feat(WEB): 출고관리 대폭 개선, 차량배차관리 신규 추가 및 QMS/캘린더 기능 강화
- 출고관리: ShipmentCreate/Detail/Edit/List 개선, ShipmentOrderDocument 신규 추가 - 차량배차관리: VehicleDispatchManagement 모듈 신규 추가 - QMS: InspectionModalV2 개선 - 캘린더: WeekTimeView 신규 추가, CalendarHeader/types 확장 - 문서: ConstructionApprovalTable/SalesOrderDocument/DeliveryConfirmation/ShippingSlip 개선 - 작업지시서: 검사보고서/작업일지 문서 개선 - 템플릿: IntegratedListTemplateV2/UniversalListPage 기능 확장 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -159,9 +159,11 @@ http://localhost:3000/ko/vehicle-management/forklift # 🆕 지게차 관
|
||||
| 페이지 | URL | 상태 |
|
||||
|--------|-----|------|
|
||||
| **출하 목록** | `/ko/outbound/shipments` | 🆕 NEW |
|
||||
| **배차차량 목록** | `/ko/outbound/vehicle-dispatches` | 🆕 NEW |
|
||||
|
||||
```
|
||||
http://localhost:3000/ko/outbound/shipments # 🆕 출하관리
|
||||
http://localhost:3000/ko/outbound/shipments # 🆕 출하관리
|
||||
http://localhost:3000/ko/outbound/vehicle-dispatches # 🆕 배차차량관리
|
||||
```
|
||||
|
||||
---
|
||||
@@ -372,7 +374,8 @@ http://localhost:3000/ko/quality/inspections # 🆕 검사관리
|
||||
|
||||
### Outbound
|
||||
```
|
||||
http://localhost:3000/ko/outbound/shipments # 🆕 출하관리
|
||||
http://localhost:3000/ko/outbound/shipments # 🆕 출하관리
|
||||
http://localhost:3000/ko/outbound/vehicle-dispatches # 🆕 배차차량관리
|
||||
```
|
||||
|
||||
### Vehicle Management (차량/지게차)
|
||||
@@ -488,6 +491,7 @@ http://localhost:3000/ko/dev/editable-table # Editable Table 테스트
|
||||
|
||||
// Outbound (출고관리)
|
||||
'/outbound/shipments' // 출하관리 (🆕 NEW)
|
||||
'/outbound/vehicle-dispatches' // 배차차량관리 (🆕 NEW)
|
||||
|
||||
// Vehicle Management (차량/지게차)
|
||||
'/vehicle-management/vehicle' // 차량관리 (🆕 NEW)
|
||||
@@ -551,4 +555,4 @@ http://localhost:3000/ko/dev/editable-table # Editable Table 테스트
|
||||
## 작성일
|
||||
|
||||
- 최초 작성: 2025-12-06
|
||||
- 최종 업데이트: 2026-01-28 (차량/지게차 메뉴 추가)
|
||||
- 최종 업데이트: 2026-02-02 (배차차량관리 추가)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 배차차량관리 - 상세/수정 페이지
|
||||
* URL: /outbound/vehicle-dispatches/[id]
|
||||
* ?mode=edit로 수정 모드 전환
|
||||
*/
|
||||
|
||||
import { use } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { VehicleDispatchDetail, VehicleDispatchEdit } from '@/components/outbound/VehicleDispatchManagement';
|
||||
|
||||
interface VehicleDispatchDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default function VehicleDispatchDetailPage({ params }: VehicleDispatchDetailPageProps) {
|
||||
const { id } = use(params);
|
||||
const searchParams = useSearchParams();
|
||||
const mode = searchParams.get('mode');
|
||||
const isEditMode = mode === 'edit';
|
||||
|
||||
if (isEditMode) {
|
||||
return <VehicleDispatchEdit id={id} />;
|
||||
}
|
||||
|
||||
return <VehicleDispatchDetail id={id} />;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 배차차량관리 - 목록 페이지
|
||||
* URL: /outbound/vehicle-dispatches
|
||||
*/
|
||||
|
||||
import { VehicleDispatchList } from '@/components/outbound/VehicleDispatchManagement';
|
||||
|
||||
export default function VehicleDispatchesPage() {
|
||||
return <VehicleDispatchList />;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { DocumentViewer } from '@/components/document-system';
|
||||
import { Document, DocumentItem } from '../types';
|
||||
import { MOCK_ORDER_DATA, MOCK_WORK_ORDER, MOCK_SHIPMENT_DETAIL } from '../mockData';
|
||||
import { MOCK_ORDER_DATA, MOCK_SHIPMENT_DETAIL } from '../mockData';
|
||||
|
||||
// 기존 문서 컴포넌트 import
|
||||
import { DeliveryConfirmation } from '@/components/outbound/ShipmentManagement/documents/DeliveryConfirmation';
|
||||
@@ -14,14 +14,22 @@ import { ShippingSlip } from '@/components/outbound/ShipmentManagement/documents
|
||||
import {
|
||||
ImportInspectionDocument,
|
||||
ProductInspectionDocument,
|
||||
ScreenInspectionDocument,
|
||||
BendingInspectionDocument,
|
||||
SlatInspectionDocument,
|
||||
JointbarInspectionDocument,
|
||||
QualityDocumentUploader,
|
||||
} from './documents';
|
||||
import type { ImportInspectionTemplate } from './documents/ImportInspectionDocument';
|
||||
|
||||
// 작업일지 + 중간검사 성적서 문서 컴포넌트 import (공정별 신규 버전)
|
||||
import {
|
||||
ScreenWorkLogContent,
|
||||
SlatWorkLogContent,
|
||||
BendingWorkLogContent,
|
||||
ScreenInspectionContent,
|
||||
SlatInspectionContent,
|
||||
BendingInspectionContent,
|
||||
} from '@/components/production/WorkOrders/documents';
|
||||
import type { WorkOrder } from '@/components/production/WorkOrders/types';
|
||||
|
||||
// 검사 템플릿 API
|
||||
import { getInspectionTemplate } from '@/components/material/ReceivingManagement/actions';
|
||||
|
||||
@@ -203,128 +211,43 @@ const OrderDocument = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// 작업일지 문서 컴포넌트 (간소화 버전)
|
||||
const WorkLogDocument = () => {
|
||||
const order = MOCK_WORK_ORDER;
|
||||
const today = new Date().toLocaleDateString('ko-KR').replace(/\. /g, '-').replace('.', '');
|
||||
const documentNo = `WL-${order.processCode.toUpperCase().slice(0, 3)}`;
|
||||
const lotNo = `KD-TS-${new Date().toISOString().slice(2, 10).replace(/-/g, '')}-01`;
|
||||
|
||||
const items = [
|
||||
{ no: 1, name: order.productName, location: '1층/A-01', spec: '3000×2500', qty: 1, status: '완료' },
|
||||
{ no: 2, name: order.productName, location: '2층/A-02', spec: '3000×2500', qty: 1, status: '작업중' },
|
||||
{ no: 3, name: order.productName, location: '3층/A-03', spec: '-', qty: 1, status: '대기' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bg-white p-8 w-full text-sm shadow-sm">
|
||||
{/* 헤더 */}
|
||||
<div className="flex justify-between items-start mb-6 border border-gray-300">
|
||||
<div className="w-24 border-r border-gray-300 flex flex-col items-center justify-center p-3">
|
||||
<span className="text-2xl font-bold">KD</span>
|
||||
<span className="text-xs text-gray-500">경동기업</span>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-3 border-r border-gray-300">
|
||||
<h1 className="text-xl font-bold tracking-widest mb-1">작 업 일 지</h1>
|
||||
<p className="text-xs text-gray-500">{documentNo}</p>
|
||||
<p className="text-sm font-medium mt-1">스크린 생산부서</p>
|
||||
</div>
|
||||
<table className="text-xs shrink-0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td rowSpan={3} className="w-8 text-center font-medium bg-gray-100 border-r border-b border-gray-300">
|
||||
<div className="flex flex-col items-center"><span>결</span><span>재</span></div>
|
||||
</td>
|
||||
<td className="w-16 p-2 text-center font-medium bg-gray-100 border-r border-b border-gray-300">작성</td>
|
||||
<td className="w-16 p-2 text-center font-medium bg-gray-100 border-r border-b border-gray-300">검토</td>
|
||||
<td className="w-16 p-2 text-center font-medium bg-gray-100 border-b border-gray-300">승인</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="w-16 p-2 text-center border-r border-b border-gray-300">
|
||||
<div>{order.assignees[0] || '-'}</div>
|
||||
</td>
|
||||
<td className="w-16 p-2 text-center border-r border-b border-gray-300"></td>
|
||||
<td className="w-16 p-2 text-center border-b border-gray-300"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="w-16 p-2 text-center bg-gray-50 border-r border-gray-300">판매</td>
|
||||
<td className="w-16 p-2 text-center bg-gray-50 border-r border-gray-300">생산</td>
|
||||
<td className="w-16 p-2 text-center bg-gray-50">품질</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<div className="border border-gray-300 mb-6">
|
||||
<div className="grid grid-cols-2 border-b border-gray-300">
|
||||
<div className="flex border-r border-gray-300">
|
||||
<div className="w-24 bg-gray-100 p-3 text-sm font-medium border-r border-gray-300">발주처</div>
|
||||
<div className="flex-1 p-3 text-sm">{order.client}</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="w-24 bg-gray-100 p-3 text-sm font-medium border-r border-gray-300">현장명</div>
|
||||
<div className="flex-1 p-3 text-sm">{order.projectName}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 border-b border-gray-300">
|
||||
<div className="flex border-r border-gray-300">
|
||||
<div className="w-24 bg-gray-100 p-3 text-sm font-medium border-r border-gray-300">작업일자</div>
|
||||
<div className="flex-1 p-3 text-sm">{today}</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="w-24 bg-gray-100 p-3 text-sm font-medium border-r border-gray-300">LOT NO.</div>
|
||||
<div className="flex-1 p-3 text-sm">{lotNo}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2">
|
||||
<div className="flex border-r border-gray-300">
|
||||
<div className="w-24 bg-gray-100 p-3 text-sm font-medium border-r border-gray-300">납기일</div>
|
||||
<div className="flex-1 p-3 text-sm">{order.dueDate}</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="w-24 bg-gray-100 p-3 text-sm font-medium border-r border-gray-300">지시수량</div>
|
||||
<div className="flex-1 p-3 text-sm">{order.quantity} EA</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 품목 테이블 */}
|
||||
<div className="border border-gray-300 mb-6">
|
||||
<div className="grid grid-cols-12 border-b border-gray-300 bg-gray-100">
|
||||
<div className="col-span-1 p-2 text-sm font-medium text-center border-r border-gray-300">No</div>
|
||||
<div className="col-span-4 p-2 text-sm font-medium text-center border-r border-gray-300">품목명</div>
|
||||
<div className="col-span-2 p-2 text-sm font-medium text-center border-r border-gray-300">출/부호</div>
|
||||
<div className="col-span-2 p-2 text-sm font-medium text-center border-r border-gray-300">규격</div>
|
||||
<div className="col-span-1 p-2 text-sm font-medium text-center border-r border-gray-300">수량</div>
|
||||
<div className="col-span-2 p-2 text-sm font-medium text-center">상태</div>
|
||||
</div>
|
||||
{items.map((item, index) => (
|
||||
<div key={item.no} className={`grid grid-cols-12 ${index < items.length - 1 ? 'border-b border-gray-300' : ''}`}>
|
||||
<div className="col-span-1 p-2 text-sm text-center border-r border-gray-300">{item.no}</div>
|
||||
<div className="col-span-4 p-2 text-sm border-r border-gray-300">{item.name}</div>
|
||||
<div className="col-span-2 p-2 text-sm text-center border-r border-gray-300">{item.location}</div>
|
||||
<div className="col-span-2 p-2 text-sm text-center border-r border-gray-300">{item.spec}</div>
|
||||
<div className="col-span-1 p-2 text-sm text-center border-r border-gray-300">{item.qty}</div>
|
||||
<div className="col-span-2 p-2 text-sm text-center">
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${
|
||||
item.status === '완료' ? 'bg-green-100 text-green-700' :
|
||||
item.status === '작업중' ? 'bg-blue-100 text-blue-700' :
|
||||
'bg-gray-100 text-gray-700'
|
||||
}`}>{item.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 특이사항 */}
|
||||
<div className="border border-gray-300">
|
||||
<div className="bg-gray-800 text-white p-2.5 text-sm font-medium text-center">특이사항</div>
|
||||
<div className="p-4 min-h-[60px] text-sm">{order.instruction || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
// QMS용 작업일지 Mock WorkOrder 생성
|
||||
const createQmsMockWorkOrder = (subType?: string): WorkOrder => ({
|
||||
id: 'qms-wo-1',
|
||||
workOrderNo: 'KD-WO-240924-01',
|
||||
lotNo: 'KD-SS-240924-19',
|
||||
processId: 1,
|
||||
processName: subType === 'slat' ? '슬랫' : subType === 'bending' ? '절곡' : '스크린',
|
||||
processCode: subType || 'screen',
|
||||
processType: (subType || 'screen') as 'screen' | 'slat' | 'bending',
|
||||
status: 'in_progress',
|
||||
client: '삼성물산(주)',
|
||||
projectName: '강남 아파트 단지',
|
||||
dueDate: '2024-10-05',
|
||||
assignee: '김작업',
|
||||
assignees: [
|
||||
{ id: '1', name: '김작업', isPrimary: true },
|
||||
{ id: '2', name: '이생산', isPrimary: false },
|
||||
],
|
||||
orderDate: '2024-09-20',
|
||||
scheduledDate: '2024-09-24',
|
||||
shipmentDate: '2024-10-04',
|
||||
salesOrderDate: '2024-09-18',
|
||||
isAssigned: true,
|
||||
isStarted: true,
|
||||
priority: 3,
|
||||
priorityLabel: '긴급',
|
||||
shutterCount: 5,
|
||||
department: '생산부',
|
||||
items: [
|
||||
{ id: '1', no: 1, status: 'completed', productName: '와이어 스크린', floorCode: '1층/FSS-01', specification: '3000×2500', quantity: 2, unit: 'EA' },
|
||||
{ id: '2', no: 2, status: 'in_progress', productName: '메쉬 스크린', floorCode: '2층/FSS-03', specification: '3000×2500', quantity: 3, unit: 'EA' },
|
||||
{ id: '3', no: 3, status: 'waiting', productName: '광폭 와이어', floorCode: '3층/FSS-05', specification: '12000×4500', quantity: 1, unit: 'EA' },
|
||||
],
|
||||
currentStep: 2,
|
||||
issues: [],
|
||||
note: '품질 검수 철저히 진행',
|
||||
});
|
||||
|
||||
// 로딩 컴포넌트
|
||||
const LoadingDocument = () => (
|
||||
@@ -426,20 +349,39 @@ export const InspectionModalV2 = ({
|
||||
console.log('[InspectionModalV2] 품질관리서 PDF 삭제');
|
||||
};
|
||||
|
||||
// 중간검사 성적서 서브타입에 따른 렌더링
|
||||
const renderReportDocument = () => {
|
||||
// 작업일지 공정별 렌더링
|
||||
const renderWorkLogDocument = () => {
|
||||
const subType = documentItem?.subType;
|
||||
const mockOrder = createQmsMockWorkOrder(subType);
|
||||
|
||||
switch (subType) {
|
||||
case 'screen':
|
||||
return <ScreenInspectionDocument />;
|
||||
case 'bending':
|
||||
return <BendingInspectionDocument />;
|
||||
return <ScreenWorkLogContent data={mockOrder} />;
|
||||
case 'slat':
|
||||
return <SlatInspectionDocument />;
|
||||
return <SlatWorkLogContent data={mockOrder} />;
|
||||
case 'bending':
|
||||
return <BendingWorkLogContent data={mockOrder} />;
|
||||
default:
|
||||
// subType 미지정 시 스크린 기본
|
||||
return <ScreenWorkLogContent data={mockOrder} />;
|
||||
}
|
||||
};
|
||||
|
||||
// 중간검사 성적서 서브타입에 따른 렌더링 (신규 버전 통일)
|
||||
const renderReportDocument = () => {
|
||||
const subType = documentItem?.subType;
|
||||
const mockOrder = createQmsMockWorkOrder(subType || 'screen');
|
||||
switch (subType) {
|
||||
case 'screen':
|
||||
return <ScreenInspectionContent data={mockOrder} readOnly />;
|
||||
case 'bending':
|
||||
return <BendingInspectionContent data={mockOrder} readOnly />;
|
||||
case 'slat':
|
||||
return <SlatInspectionContent data={mockOrder} readOnly />;
|
||||
case 'jointbar':
|
||||
return <JointbarInspectionDocument />;
|
||||
default:
|
||||
return <ScreenInspectionDocument />;
|
||||
return <ScreenInspectionContent data={mockOrder} readOnly />;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -463,7 +405,7 @@ export const InspectionModalV2 = ({
|
||||
case 'order':
|
||||
return <OrderDocument />;
|
||||
case 'log':
|
||||
return <WorkLogDocument />;
|
||||
return renderWorkLogDocument();
|
||||
case 'confirmation':
|
||||
return <DeliveryConfirmation data={MOCK_SHIPMENT_DETAIL} />;
|
||||
case 'shipping':
|
||||
|
||||
@@ -247,10 +247,11 @@ export const MOCK_DOCUMENTS: Record<string, Document[]> = {
|
||||
id: 'doc-3',
|
||||
type: 'log',
|
||||
title: '작업일지',
|
||||
count: 2,
|
||||
count: 3,
|
||||
items: [
|
||||
{ id: 'doc-3-1', title: '생산 작업일지', date: '2024-09-25', code: 'WL-2024-0925' },
|
||||
{ id: 'doc-3-2', title: '후가공 작업일지', date: '2024-09-26', code: 'WL-2024-0926' },
|
||||
{ id: 'doc-3-1', title: '스크린 작업일지', date: '2024-09-25', code: 'WL-2024-0925', subType: 'screen' as const },
|
||||
{ id: 'doc-3-2', title: '슬랫 작업일지', date: '2024-09-26', code: 'WL-2024-0926', subType: 'slat' as const },
|
||||
{ id: 'doc-3-3', title: '절곡 작업일지', date: '2024-09-27', code: 'WL-2024-0927', subType: 'bending' as const },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -40,7 +40,7 @@ export interface DocumentItem {
|
||||
title: string;
|
||||
date: string;
|
||||
code?: string;
|
||||
// 중간검사 성적서 서브타입 (report 타입일 때만 사용)
|
||||
// 중간검사 성적서 및 작업일지 서브타입 (report, log 타입에서 사용)
|
||||
subType?: 'screen' | 'bending' | 'slat' | 'jointbar';
|
||||
}
|
||||
|
||||
|
||||
@@ -754,7 +754,7 @@ export default function OrderDetailPage() {
|
||||
// 견적 수정 핸들러
|
||||
const handleEditQuote = () => {
|
||||
if (order?.quoteId) {
|
||||
router.push(`/sales/quotes/${order.quoteId}?mode=edit`);
|
||||
router.push(`/sales/quote-management/${order.quoteId}?mode=edit`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -209,7 +209,7 @@ export function LoginPage() {
|
||||
<div className="clean-glass rounded-2xl p-8 clean-shadow space-y-6">
|
||||
<div className="text-center">
|
||||
<h2 className="mb-2 text-foreground">{t('login')}</h2>
|
||||
<p className="text-muted-foreground">{tCommon('welcome')} SAM MES</p>
|
||||
<p className="text-muted-foreground">{tCommon('welcome')} SAM ERP/MES</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -20,8 +20,9 @@ export function CalendarHeader({
|
||||
onViewChange,
|
||||
titleSlot,
|
||||
filterSlot,
|
||||
availableViews,
|
||||
}: CalendarHeaderProps) {
|
||||
const views: { value: CalendarView; label: string }[] = [
|
||||
const views: { value: CalendarView; label: string }[] = availableViews || [
|
||||
{ value: 'week', label: '주' },
|
||||
{ value: 'month', label: '월' },
|
||||
];
|
||||
@@ -84,9 +85,11 @@ export function CalendarHeader({
|
||||
</div>
|
||||
|
||||
{/* 2줄: 뷰 전환 탭 */}
|
||||
<div className="flex justify-center">
|
||||
{renderViewTabs()}
|
||||
</div>
|
||||
{views.length > 1 && (
|
||||
<div className="flex justify-center">
|
||||
{renderViewTabs()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* PC: 타이틀 + 네비게이션 | 뷰전환 + 필터 (한 줄) */}
|
||||
@@ -125,7 +128,7 @@ export function CalendarHeader({
|
||||
|
||||
{/* 우측: 뷰 전환 + 필터 */}
|
||||
<div className="flex items-center gap-3">
|
||||
{renderViewTabs('px-4 py-1.5')}
|
||||
{views.length > 1 && renderViewTabs('px-4 py-1.5')}
|
||||
{filterSlot && <div className="flex items-center gap-2">{filterSlot}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { cn } from '@/components/ui/utils';
|
||||
import { CalendarHeader } from './CalendarHeader';
|
||||
import { MonthView } from './MonthView';
|
||||
import { WeekView } from './WeekView';
|
||||
import { WeekTimeView } from './WeekTimeView';
|
||||
import type { ScheduleCalendarProps, CalendarView } from './types';
|
||||
import { getNextMonth, getPrevMonth } from './utils';
|
||||
|
||||
@@ -44,6 +45,8 @@ export function ScheduleCalendar({
|
||||
weekStartsOn = 0,
|
||||
isLoading = false,
|
||||
className,
|
||||
availableViews,
|
||||
timeRange,
|
||||
}: ScheduleCalendarProps) {
|
||||
// 내부 상태 (controlled/uncontrolled 지원)
|
||||
const [internalDate, setInternalDate] = useState(() => new Date());
|
||||
@@ -118,6 +121,7 @@ export function ScheduleCalendar({
|
||||
onViewChange={handleViewChange}
|
||||
titleSlot={titleSlot}
|
||||
filterSlot={filterSlot}
|
||||
availableViews={availableViews}
|
||||
/>
|
||||
|
||||
{/* 본문 */}
|
||||
@@ -126,6 +130,16 @@ export function ScheduleCalendar({
|
||||
<div className="flex items-center justify-center min-h-[300px]">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
) : view === 'week-time' ? (
|
||||
<WeekTimeView
|
||||
currentDate={currentDate}
|
||||
events={events}
|
||||
selectedDate={selectedDate}
|
||||
weekStartsOn={weekStartsOn}
|
||||
timeRange={timeRange}
|
||||
onDateClick={handleDateClick}
|
||||
onEventClick={handleEventClick}
|
||||
/>
|
||||
) : view === 'month' ? (
|
||||
<MonthView
|
||||
currentDate={currentDate}
|
||||
|
||||
216
src/components/common/ScheduleCalendar/WeekTimeView.tsx
Normal file
216
src/components/common/ScheduleCalendar/WeekTimeView.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { cn } from '@/components/ui/utils';
|
||||
import { getWeekDays, getWeekdayHeaders, formatDate, checkIsToday, isSameDate } from './utils';
|
||||
import { EVENT_COLORS } from './types';
|
||||
import type { WeekTimeViewProps, ScheduleEvent } from './types';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
|
||||
/**
|
||||
* 시간축 주간 뷰 (week-time)
|
||||
*
|
||||
* 좌측에 시간 라벨, 상단에 요일+날짜, 시간 슬롯에 이벤트 블록 표시
|
||||
* startTime이 없는 이벤트는 all-day 영역에 표시
|
||||
*/
|
||||
export function WeekTimeView({
|
||||
currentDate,
|
||||
events,
|
||||
selectedDate,
|
||||
weekStartsOn,
|
||||
timeRange = { start: 1, end: 12 },
|
||||
onDateClick,
|
||||
onEventClick,
|
||||
}: WeekTimeViewProps) {
|
||||
const weekDays = useMemo(() => getWeekDays(currentDate, weekStartsOn), [currentDate, weekStartsOn]);
|
||||
const weekdayHeaders = useMemo(() => getWeekdayHeaders(weekStartsOn), [weekStartsOn]);
|
||||
|
||||
// 시간 슬롯 생성 (AM 시간대)
|
||||
const timeSlots = useMemo(() => {
|
||||
const slots: { hour: number; label: string }[] = [];
|
||||
for (let h = timeRange.start; h <= timeRange.end; h++) {
|
||||
slots.push({
|
||||
hour: h,
|
||||
label: `AM ${h}시`,
|
||||
});
|
||||
}
|
||||
return slots;
|
||||
}, [timeRange]);
|
||||
|
||||
// 이벤트를 날짜별로 분류
|
||||
const eventsByDate = useMemo(() => {
|
||||
const map = new Map<string, { allDay: ScheduleEvent[]; timed: ScheduleEvent[] }>();
|
||||
|
||||
weekDays.forEach((day) => {
|
||||
const dateStr = formatDate(day, 'yyyy-MM-dd');
|
||||
map.set(dateStr, { allDay: [], timed: [] });
|
||||
});
|
||||
|
||||
events.forEach((event) => {
|
||||
const eventStartDate = parseISO(event.startDate);
|
||||
const eventEndDate = parseISO(event.endDate);
|
||||
|
||||
weekDays.forEach((day) => {
|
||||
const dateStr = formatDate(day, 'yyyy-MM-dd');
|
||||
// 이벤트가 이 날짜를 포함하는지 확인
|
||||
if (day >= eventStartDate && day <= eventEndDate) {
|
||||
const bucket = map.get(dateStr);
|
||||
if (bucket) {
|
||||
if (event.startTime) {
|
||||
bucket.timed.push(event);
|
||||
} else {
|
||||
bucket.allDay.push(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return map;
|
||||
}, [events, weekDays]);
|
||||
|
||||
// all-day 이벤트가 있는지 확인
|
||||
const hasAllDayEvents = useMemo(() => {
|
||||
for (const [, bucket] of eventsByDate) {
|
||||
if (bucket.allDay.length > 0) return true;
|
||||
}
|
||||
return false;
|
||||
}, [eventsByDate]);
|
||||
|
||||
// 시간 문자열에서 hour 추출
|
||||
const getHourFromTime = (time: string): number => {
|
||||
const [hours] = time.split(':').map(Number);
|
||||
return hours;
|
||||
};
|
||||
|
||||
// 이벤트 색상 클래스
|
||||
const getEventColorClasses = (event: ScheduleEvent): string => {
|
||||
const color = event.color || 'blue';
|
||||
return EVENT_COLORS[color] || EVENT_COLORS.blue;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="min-w-[700px]">
|
||||
{/* 헤더: 시간라벨컬럼 + 요일/날짜 */}
|
||||
<div className="grid grid-cols-[60px_repeat(7,1fr)] border-b">
|
||||
{/* 빈 코너셀 */}
|
||||
<div className="border-r bg-muted/30" />
|
||||
{/* 요일 + 날짜 */}
|
||||
{weekDays.map((day, i) => {
|
||||
const today = checkIsToday(day);
|
||||
const selected = isSameDate(selectedDate, day);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'text-center py-2 border-r last:border-r-0 cursor-pointer transition-colors',
|
||||
today && 'bg-primary/5',
|
||||
selected && 'bg-primary/10',
|
||||
)}
|
||||
onClick={() => onDateClick(day)}
|
||||
>
|
||||
<div className={cn(
|
||||
'text-xs text-muted-foreground',
|
||||
today && 'text-primary font-semibold',
|
||||
)}>
|
||||
{weekdayHeaders[i]}
|
||||
</div>
|
||||
<div className={cn(
|
||||
'text-sm font-medium',
|
||||
today && 'text-primary',
|
||||
)}>
|
||||
{format(day, 'd')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* All-day 영역 */}
|
||||
{hasAllDayEvents && (
|
||||
<div className="grid grid-cols-[60px_repeat(7,1fr)] border-b">
|
||||
<div className="border-r bg-muted/30 flex items-center justify-center">
|
||||
<span className="text-[10px] text-muted-foreground">종일</span>
|
||||
</div>
|
||||
{weekDays.map((day, i) => {
|
||||
const dateStr = formatDate(day, 'yyyy-MM-dd');
|
||||
const allDayEvents = eventsByDate.get(dateStr)?.allDay || [];
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="border-r last:border-r-0 p-0.5 min-h-[28px]"
|
||||
>
|
||||
{allDayEvents.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className={cn(
|
||||
'text-[10px] px-1 py-0.5 rounded truncate cursor-pointer hover:opacity-80 mb-0.5',
|
||||
getEventColorClasses(event),
|
||||
)}
|
||||
title={event.title}
|
||||
onClick={() => onEventClick(event)}
|
||||
>
|
||||
{event.title}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 시간 그리드 */}
|
||||
{timeSlots.map((slot) => (
|
||||
<div
|
||||
key={slot.hour}
|
||||
className="grid grid-cols-[60px_repeat(7,1fr)] border-b last:border-b-0"
|
||||
>
|
||||
{/* 시간 라벨 */}
|
||||
<div className="border-r bg-muted/30 flex items-start justify-center pt-1">
|
||||
<span className="text-[10px] text-muted-foreground whitespace-nowrap">
|
||||
{slot.label}
|
||||
</span>
|
||||
</div>
|
||||
{/* 요일별 셀 */}
|
||||
{weekDays.map((day, i) => {
|
||||
const dateStr = formatDate(day, 'yyyy-MM-dd');
|
||||
const timedEvents = eventsByDate.get(dateStr)?.timed || [];
|
||||
const slotEvents = timedEvents.filter((event) => {
|
||||
if (!event.startTime) return false;
|
||||
const eventHour = getHourFromTime(event.startTime);
|
||||
return eventHour === slot.hour;
|
||||
});
|
||||
|
||||
const today = checkIsToday(day);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'border-r last:border-r-0 p-0.5 min-h-[40px] relative',
|
||||
today && 'bg-primary/[0.02]',
|
||||
)}
|
||||
>
|
||||
{slotEvents.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className={cn(
|
||||
'text-[10px] px-1.5 py-1 rounded cursor-pointer hover:opacity-80 mb-0.5 leading-tight',
|
||||
getEventColorClasses(event),
|
||||
)}
|
||||
title={event.title}
|
||||
onClick={() => onEventClick(event)}
|
||||
>
|
||||
<span className="line-clamp-2">{event.title}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export { ScheduleCalendar } from './ScheduleCalendar';
|
||||
export { CalendarHeader } from './CalendarHeader';
|
||||
export { MonthView } from './MonthView';
|
||||
export { WeekView } from './WeekView';
|
||||
export { WeekTimeView } from './WeekTimeView';
|
||||
export { DayCell } from './DayCell';
|
||||
export { ScheduleBar } from './ScheduleBar';
|
||||
export { MorePopover } from './MorePopover';
|
||||
@@ -23,6 +24,7 @@ export type {
|
||||
MorePopoverProps,
|
||||
MonthViewProps,
|
||||
WeekViewProps,
|
||||
WeekTimeViewProps,
|
||||
} from './types';
|
||||
|
||||
export { EVENT_COLORS, BADGE_COLORS } from './types';
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
/**
|
||||
* 달력 뷰 모드
|
||||
*/
|
||||
export type CalendarView = 'week' | 'month';
|
||||
export type CalendarView = 'week' | 'month' | 'week-time';
|
||||
|
||||
/**
|
||||
* 일정 이벤트
|
||||
@@ -23,6 +23,10 @@ export interface ScheduleEvent {
|
||||
color?: string;
|
||||
/** 이벤트 상태 */
|
||||
status?: string;
|
||||
/** 시작 시간 (HH:mm 형식, week-time 뷰용) */
|
||||
startTime?: string;
|
||||
/** 종료 시간 (HH:mm 형식, week-time 뷰용) */
|
||||
endTime?: string;
|
||||
/** 추가 데이터 */
|
||||
data?: unknown;
|
||||
}
|
||||
@@ -73,6 +77,10 @@ export interface ScheduleCalendarProps {
|
||||
isLoading?: boolean;
|
||||
/** 추가 클래스 */
|
||||
className?: string;
|
||||
/** 사용 가능한 뷰 목록 (기본: 주/월) */
|
||||
availableViews?: { value: CalendarView; label: string }[];
|
||||
/** 시간축 뷰 시간 범위 (기본: 1~12) */
|
||||
timeRange?: { start: number; end: number };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,6 +95,8 @@ export interface CalendarHeaderProps {
|
||||
/** 타이틀 영역 (년월 네비게이션 왼쪽) */
|
||||
titleSlot?: React.ReactNode;
|
||||
filterSlot?: React.ReactNode;
|
||||
/** 사용 가능한 뷰 목록 (기본: [{value:'week',label:'주'},{value:'month',label:'월'}]) */
|
||||
availableViews?: { value: CalendarView; label: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,6 +156,20 @@ export interface MonthViewProps {
|
||||
onEventClick: (event: ScheduleEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 시간축 주간 뷰 Props (week-time)
|
||||
*/
|
||||
export interface WeekTimeViewProps {
|
||||
currentDate: Date;
|
||||
events: ScheduleEvent[];
|
||||
selectedDate: Date | null;
|
||||
weekStartsOn: 0 | 1;
|
||||
/** 표시할 시간 범위 (기본: 1~12) */
|
||||
timeRange?: { start: number; end: number };
|
||||
onDateClick: (date: Date) => void;
|
||||
onEventClick: (event: ScheduleEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 주간 뷰 Props
|
||||
*/
|
||||
|
||||
@@ -54,58 +54,58 @@ export function ConstructionApprovalTable({
|
||||
};
|
||||
|
||||
return (
|
||||
<table className={cn('border-collapse border border-gray-300 text-sm', className)}>
|
||||
<table className={cn('border-collapse border border-gray-400 text-sm', className)}>
|
||||
<tbody>
|
||||
{/* 헤더 행 */}
|
||||
<tr>
|
||||
<th
|
||||
rowSpan={3}
|
||||
className="border border-gray-300 px-2 py-1 bg-gray-50 text-center w-8 align-middle"
|
||||
className="border border-gray-400 px-2 py-1 bg-gray-50 text-center w-8 align-middle"
|
||||
>
|
||||
<span className="writing-vertical">결<br />재</span>
|
||||
</th>
|
||||
<th className="border border-gray-300 px-3 py-1 bg-gray-50 text-center w-16">
|
||||
<th className="border border-gray-400 px-3 py-1 bg-gray-50 text-center w-16">
|
||||
{labels.writer}
|
||||
</th>
|
||||
<th className="border border-gray-300 px-3 py-1 bg-gray-50 text-center w-16">
|
||||
<th className="border border-gray-400 px-3 py-1 bg-gray-50 text-center w-16">
|
||||
{labels.approver1}
|
||||
</th>
|
||||
<th className="border border-gray-300 px-3 py-1 bg-gray-50 text-center w-16">
|
||||
<th className="border border-gray-400 px-3 py-1 bg-gray-50 text-center w-16">
|
||||
{labels.approver2}
|
||||
</th>
|
||||
<th className="border border-gray-300 px-3 py-1 bg-gray-50 text-center w-16">
|
||||
<th className="border border-gray-400 px-3 py-1 bg-gray-50 text-center w-16">
|
||||
{labels.approver3}
|
||||
</th>
|
||||
</tr>
|
||||
|
||||
{/* 이름 행 */}
|
||||
<tr>
|
||||
<td className="border border-gray-300 px-3 py-2 text-center h-10">
|
||||
<td className="border border-gray-400 px-3 py-2 text-center h-10">
|
||||
{approvers.writer?.name || ''}
|
||||
</td>
|
||||
<td className="border border-gray-300 px-3 py-2 text-center h-10">
|
||||
<td className="border border-gray-400 px-3 py-2 text-center h-10">
|
||||
{approvers.approver1?.name || ''}
|
||||
</td>
|
||||
<td className="border border-gray-300 px-3 py-2 text-center h-10">
|
||||
<td className="border border-gray-400 px-3 py-2 text-center h-10">
|
||||
{approvers.approver2?.name || ''}
|
||||
</td>
|
||||
<td className="border border-gray-300 px-3 py-2 text-center h-10">
|
||||
<td className="border border-gray-400 px-3 py-2 text-center h-10">
|
||||
{approvers.approver3?.name || ''}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* 부서 행 */}
|
||||
<tr>
|
||||
<td className="border border-gray-300 px-3 py-1 text-center text-xs text-gray-500 whitespace-nowrap">
|
||||
<td className="border border-gray-400 px-3 py-1 text-center text-xs text-gray-500 whitespace-nowrap">
|
||||
{approvers.writer?.department || '부서명'}
|
||||
</td>
|
||||
<td className="border border-gray-300 px-3 py-1 text-center text-xs text-gray-500 whitespace-nowrap">
|
||||
<td className="border border-gray-400 px-3 py-1 text-center text-xs text-gray-500 whitespace-nowrap">
|
||||
{approvers.approver1?.department || '부서명'}
|
||||
</td>
|
||||
<td className="border border-gray-300 px-3 py-1 text-center text-xs text-gray-500 whitespace-nowrap">
|
||||
<td className="border border-gray-400 px-3 py-1 text-center text-xs text-gray-500 whitespace-nowrap">
|
||||
{approvers.approver2?.department || '부서명'}
|
||||
</td>
|
||||
<td className="border border-gray-300 px-3 py-1 text-center text-xs text-gray-500 whitespace-nowrap">
|
||||
<td className="border border-gray-400 px-3 py-1 text-center text-xs text-gray-500 whitespace-nowrap">
|
||||
{approvers.approver3?.department || '부서명'}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { getTodayString } from "@/utils/date";
|
||||
import { OrderItem } from "../actions";
|
||||
import { ProductInfo } from "./OrderDocumentModal";
|
||||
import { ConstructionApprovalTable } from "@/components/document-system";
|
||||
|
||||
interface SalesOrderDocumentProps {
|
||||
documentNumber?: string;
|
||||
@@ -130,36 +131,9 @@ export function SalesOrderDocument({
|
||||
</div>
|
||||
|
||||
{/* 결재란 (우측) */}
|
||||
<table className="border border-gray-400 text-[10px]">
|
||||
<thead>
|
||||
<tr className="bg-gray-100">
|
||||
<th className="border-r border-gray-400 px-3 py-1">작성</th>
|
||||
<th className="border-r border-gray-400 px-3 py-1">승인</th>
|
||||
<th className="border-r border-gray-400 px-3 py-1">승인</th>
|
||||
<th className="px-3 py-1">승인</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-t border-gray-400">
|
||||
<td className="border-r border-gray-400 h-6 w-12 text-center text-[9px] text-gray-500">과장</td>
|
||||
<td className="border-r border-gray-400 h-6 w-12 text-center"></td>
|
||||
<td className="border-r border-gray-400 h-6 w-12 text-center"></td>
|
||||
<td className="h-6 w-12 text-center"></td>
|
||||
</tr>
|
||||
<tr className="border-t border-gray-400">
|
||||
<td className="border-r border-gray-400 h-6 w-12 text-center text-[9px]">홍길동</td>
|
||||
<td className="border-r border-gray-400 h-6 w-12 text-center text-[9px]">이름</td>
|
||||
<td className="border-r border-gray-400 h-6 w-12 text-center text-[9px]">이름</td>
|
||||
<td className="h-6 w-12 text-center text-[9px]">이름</td>
|
||||
</tr>
|
||||
<tr className="border-t border-gray-400">
|
||||
<td className="border-r border-gray-400 h-6 w-12 text-center text-[9px] text-gray-500">부서명</td>
|
||||
<td className="border-r border-gray-400 h-6 w-12 text-center text-[9px] text-gray-500">부서명</td>
|
||||
<td className="border-r border-gray-400 h-6 w-12 text-center text-[9px] text-gray-500">부서명</td>
|
||||
<td className="h-6 w-12 text-center text-[9px] text-gray-500">부서명</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<ConstructionApprovalTable
|
||||
approvers={{ writer: { name: '홍길동' } }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 상품명 / 제품명 / 로트번호 / 인정번호 */}
|
||||
|
||||
@@ -1,19 +1,28 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 출하 등록 페이지
|
||||
* API 연동 완료 (2025-12-26)
|
||||
* IntegratedDetailTemplate 마이그레이션 (2025-01-20)
|
||||
* 출고 등록 페이지
|
||||
* 4개 섹션 구조: 기본정보, 수주/배송정보, 배차정보, 제품내용
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Plus, X as XIcon, ChevronDown, Search } from 'lucide-react';
|
||||
import { getTodayString } from '@/utils/date';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -21,6 +30,18 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@/components/ui/accordion';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { shipmentCreateConfig } from './shipmentConfig';
|
||||
import {
|
||||
@@ -29,32 +50,57 @@ import {
|
||||
getLogisticsOptions,
|
||||
getVehicleTonnageOptions,
|
||||
} from './actions';
|
||||
import {
|
||||
FREIGHT_COST_LABELS,
|
||||
DELIVERY_METHOD_LABELS,
|
||||
} from './types';
|
||||
import type {
|
||||
ShipmentCreateFormData,
|
||||
ShipmentPriority,
|
||||
DeliveryMethod,
|
||||
FreightCostType,
|
||||
VehicleDispatch,
|
||||
LotOption,
|
||||
LogisticsOption,
|
||||
VehicleTonnageOption,
|
||||
ProductGroup,
|
||||
ProductPart,
|
||||
} from './types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { toast } from 'sonner';
|
||||
import { useDaumPostcode } from '@/hooks/useDaumPostcode';
|
||||
import { useDevFill } from '@/components/dev';
|
||||
import { generateShipmentData } from '@/components/dev/generators/shipmentData';
|
||||
import { mockProductGroups, mockOtherParts } from './mockData';
|
||||
|
||||
// 고정 옵션 (클라이언트에서 관리)
|
||||
const priorityOptions: { value: ShipmentPriority; label: string }[] = [
|
||||
{ value: 'urgent', label: '긴급' },
|
||||
{ value: 'normal', label: '일반' },
|
||||
{ value: 'low', label: '낮음' },
|
||||
];
|
||||
|
||||
// 배송방식 옵션
|
||||
const deliveryMethodOptions: { value: DeliveryMethod; label: string }[] = [
|
||||
{ value: 'pickup', label: '상차 (물류업체)' },
|
||||
{ value: 'direct', label: '직접배송 (자체)' },
|
||||
{ value: 'logistics', label: '물류사' },
|
||||
{ value: 'direct_dispatch', label: '직접배차' },
|
||||
{ value: 'loading', label: '상차' },
|
||||
{ value: 'kyungdong_delivery', label: '경동택배' },
|
||||
{ value: 'daesin_delivery', label: '대신택배' },
|
||||
{ value: 'kyungdong_freight', label: '경동화물' },
|
||||
{ value: 'daesin_freight', label: '대신화물' },
|
||||
{ value: 'self_pickup', label: '직접수령' },
|
||||
];
|
||||
|
||||
// 운임비용 옵션
|
||||
const freightCostOptions: { value: FreightCostType; label: string }[] = Object.entries(
|
||||
FREIGHT_COST_LABELS
|
||||
).map(([value, label]) => ({ value: value as FreightCostType, label }));
|
||||
|
||||
// 빈 배차 행 생성
|
||||
function createEmptyDispatch(): VehicleDispatch {
|
||||
return {
|
||||
id: `vd-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
logisticsCompany: '',
|
||||
arrivalDateTime: '',
|
||||
tonnage: '',
|
||||
vehicleNo: '',
|
||||
driverContact: '',
|
||||
remarks: '',
|
||||
};
|
||||
}
|
||||
|
||||
export function ShipmentCreate() {
|
||||
const router = useRouter();
|
||||
|
||||
@@ -63,7 +109,15 @@ export function ShipmentCreate() {
|
||||
lotNo: '',
|
||||
scheduledDate: getTodayString(),
|
||||
priority: 'normal',
|
||||
deliveryMethod: 'pickup',
|
||||
deliveryMethod: 'direct_dispatch',
|
||||
shipmentDate: '',
|
||||
freightCost: undefined,
|
||||
receiver: '',
|
||||
receiverContact: '',
|
||||
zipCode: '',
|
||||
address: '',
|
||||
addressDetail: '',
|
||||
vehicleDispatches: [createEmptyDispatch()],
|
||||
logisticsCompany: '',
|
||||
vehicleTonnage: '',
|
||||
loadingTime: '',
|
||||
@@ -76,12 +130,30 @@ export function ShipmentCreate() {
|
||||
const [logisticsOptions, setLogisticsOptions] = useState<LogisticsOption[]>([]);
|
||||
const [vehicleTonnageOptions, setVehicleTonnageOptions] = useState<VehicleTonnageOption[]>([]);
|
||||
|
||||
// 제품 데이터 (LOT 선택 시 표시)
|
||||
const [productGroups, setProductGroups] = useState<ProductGroup[]>([]);
|
||||
const [otherParts, setOtherParts] = useState<ProductPart[]>([]);
|
||||
|
||||
// 로딩/에러 상태
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||
|
||||
// 아코디언 상태
|
||||
const [accordionValue, setAccordionValue] = useState<string[]>([]);
|
||||
|
||||
// 우편번호 찾기
|
||||
const { openPostcode } = useDaumPostcode({
|
||||
onComplete: (result) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
zipCode: result.zonecode,
|
||||
address: result.address,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
// 옵션 데이터 로드
|
||||
const loadOptions = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
@@ -97,11 +169,9 @@ export function ShipmentCreate() {
|
||||
if (lotsResult.success && lotsResult.data) {
|
||||
setLotOptions(lotsResult.data);
|
||||
}
|
||||
|
||||
if (logisticsResult.success && logisticsResult.data) {
|
||||
setLogisticsOptions(logisticsResult.data);
|
||||
}
|
||||
|
||||
if (tonnageResult.success && tonnageResult.data) {
|
||||
setVehicleTonnageOptions(tonnageResult.data);
|
||||
}
|
||||
@@ -114,7 +184,6 @@ export function ShipmentCreate() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 옵션 로드
|
||||
useEffect(() => {
|
||||
loadOptions();
|
||||
}, [loadOptions]);
|
||||
@@ -123,85 +192,108 @@ export function ShipmentCreate() {
|
||||
useDevFill(
|
||||
'shipment',
|
||||
useCallback(() => {
|
||||
// lotOptions를 generateShipmentData에 전달하기 위해 변환
|
||||
const lotOptionsForGenerator = lotOptions.map(o => ({
|
||||
lotNo: o.value,
|
||||
customerName: o.customerName,
|
||||
siteName: o.siteName,
|
||||
}));
|
||||
|
||||
const logisticsOptionsForGenerator = logisticsOptions.map(o => ({
|
||||
id: o.value,
|
||||
name: o.label,
|
||||
}));
|
||||
|
||||
const tonnageOptionsForGenerator = vehicleTonnageOptions.map(o => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
}));
|
||||
|
||||
const sampleData = generateShipmentData({
|
||||
lotOptions: lotOptionsForGenerator as unknown as LotOption[],
|
||||
logisticsOptions: logisticsOptionsForGenerator as unknown as LogisticsOption[],
|
||||
tonnageOptions: tonnageOptionsForGenerator,
|
||||
});
|
||||
|
||||
setFormData(sampleData);
|
||||
toast.success('[Dev] 출하 폼이 자동으로 채워졌습니다.');
|
||||
setFormData(prev => ({ ...prev, ...sampleData }));
|
||||
toast.success('[Dev] 출고 폼이 자동으로 채워졌습니다.');
|
||||
}, [lotOptions, logisticsOptions, vehicleTonnageOptions])
|
||||
);
|
||||
|
||||
// LOT 선택 시 현장명/수주처 자동 매핑 + 목데이터 제품 표시
|
||||
const handleLotChange = useCallback((lotNo: string) => {
|
||||
setFormData(prev => ({ ...prev, lotNo }));
|
||||
if (lotNo) {
|
||||
// 목데이터로 제품 그룹 표시
|
||||
setProductGroups(mockProductGroups);
|
||||
setOtherParts(mockOtherParts);
|
||||
} else {
|
||||
setProductGroups([]);
|
||||
setOtherParts([]);
|
||||
}
|
||||
if (validationErrors.length > 0) setValidationErrors([]);
|
||||
}, [validationErrors]);
|
||||
|
||||
// 폼 입력 핸들러
|
||||
const handleInputChange = (field: keyof ShipmentCreateFormData, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
// 입력 시 에러 클리어
|
||||
if (validationErrors.length > 0) {
|
||||
setValidationErrors([]);
|
||||
}
|
||||
if (validationErrors.length > 0) setValidationErrors([]);
|
||||
};
|
||||
|
||||
// 취소
|
||||
// 배차 정보 핸들러
|
||||
const handleDispatchChange = (index: number, field: keyof VehicleDispatch, value: string) => {
|
||||
setFormData(prev => {
|
||||
const newDispatches = [...prev.vehicleDispatches];
|
||||
newDispatches[index] = { ...newDispatches[index], [field]: value };
|
||||
return { ...prev, vehicleDispatches: newDispatches };
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddDispatch = () => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
vehicleDispatches: [...prev.vehicleDispatches, createEmptyDispatch()],
|
||||
}));
|
||||
};
|
||||
|
||||
const handleRemoveDispatch = (index: number) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
vehicleDispatches: prev.vehicleDispatches.filter((_, i) => i !== index),
|
||||
}));
|
||||
};
|
||||
|
||||
// 아코디언 제어
|
||||
const handleExpandAll = useCallback(() => {
|
||||
const allIds = [
|
||||
...productGroups.map(g => g.id),
|
||||
...(otherParts.length > 0 ? ['other-parts'] : []),
|
||||
];
|
||||
setAccordionValue(allIds);
|
||||
}, [productGroups, otherParts]);
|
||||
|
||||
const handleCollapseAll = useCallback(() => {
|
||||
setAccordionValue([]);
|
||||
}, []);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
router.push('/ko/outbound/shipments');
|
||||
}, [router]);
|
||||
|
||||
// validation 체크
|
||||
const validateForm = (): boolean => {
|
||||
const errors: string[] = [];
|
||||
|
||||
// 필수 필드 체크
|
||||
if (!formData.lotNo) {
|
||||
errors.push('로트번호는 필수 선택 항목입니다.');
|
||||
}
|
||||
if (!formData.scheduledDate) {
|
||||
errors.push('출고예정일은 필수 입력 항목입니다.');
|
||||
}
|
||||
if (!formData.priority) {
|
||||
errors.push('출고 우선순위는 필수 선택 항목입니다.');
|
||||
}
|
||||
if (!formData.deliveryMethod) {
|
||||
errors.push('배송방식은 필수 선택 항목입니다.');
|
||||
}
|
||||
|
||||
if (!formData.lotNo) errors.push('로트번호는 필수 선택 항목입니다.');
|
||||
if (!formData.scheduledDate) errors.push('출고예정일은 필수 입력 항목입니다.');
|
||||
if (!formData.deliveryMethod) errors.push('배송방식은 필수 선택 항목입니다.');
|
||||
setValidationErrors(errors);
|
||||
return errors.length === 0;
|
||||
};
|
||||
|
||||
// 저장
|
||||
const handleSubmit = useCallback(async () => {
|
||||
// validation 체크
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
if (!validateForm()) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await createShipment(formData);
|
||||
|
||||
if (result.success) {
|
||||
router.push('/ko/outbound/shipments');
|
||||
} else {
|
||||
setValidationErrors([result.error || '출하 등록에 실패했습니다.']);
|
||||
setValidationErrors([result.error || '출고 등록에 실패했습니다.']);
|
||||
}
|
||||
} catch (err) {
|
||||
if (isNextRedirectError(err)) throw err;
|
||||
@@ -212,44 +304,80 @@ export function ShipmentCreate() {
|
||||
}
|
||||
}, [formData, router]);
|
||||
|
||||
// 제품 부품 테이블 렌더링
|
||||
const renderPartsTable = (parts: ProductPart[]) => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-16 text-center">순번</TableHead>
|
||||
<TableHead>품목명</TableHead>
|
||||
<TableHead className="w-32">규격</TableHead>
|
||||
<TableHead className="w-20 text-center">수량</TableHead>
|
||||
<TableHead className="w-20 text-center">단위</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{parts.map((part) => (
|
||||
<TableRow key={part.id}>
|
||||
<TableCell className="text-center">{part.seq}</TableCell>
|
||||
<TableCell>{part.itemName}</TableCell>
|
||||
<TableCell>{part.specification}</TableCell>
|
||||
<TableCell className="text-center">{part.quantity}</TableCell>
|
||||
<TableCell className="text-center">{part.unit}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
|
||||
// LOT에서 선택한 정보 표시
|
||||
const selectedLot = lotOptions.find(o => o.value === formData.lotNo);
|
||||
|
||||
// 폼 컨텐츠 렌더링
|
||||
const renderFormContent = useCallback((_props: { formData: Record<string, unknown>; onChange: (key: string, value: unknown) => void; mode: string; errors: Record<string, string> }) => (
|
||||
<div className="space-y-6">
|
||||
{/* Validation 에러 표시 */}
|
||||
{validationErrors.length > 0 && (
|
||||
<Alert className="bg-red-50 border-red-200">
|
||||
<AlertDescription className="text-red-900">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg">⚠️</span>
|
||||
<div className="flex-1">
|
||||
<strong className="block mb-2">
|
||||
입력 내용을 확인해주세요 ({validationErrors.length}개 오류)
|
||||
</strong>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{validationErrors.map((error, index) => (
|
||||
<li key={index} className="flex items-start gap-1">
|
||||
<span>•</span>
|
||||
<span>{error}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{validationErrors.length > 0 && (
|
||||
<Alert className="bg-red-50 border-red-200">
|
||||
<AlertDescription className="text-red-900">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg">⚠️</span>
|
||||
<div className="flex-1">
|
||||
<strong className="block mb-2">
|
||||
입력 내용을 확인해주세요 ({validationErrors.length}개 오류)
|
||||
</strong>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{validationErrors.map((err, index) => (
|
||||
<li key={index} className="flex items-start gap-1">
|
||||
<span>•</span>
|
||||
<span>{err}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 수주 선택 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">수주 선택</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Label>로트번호 *</Label>
|
||||
{/* 카드 1: 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
{/* 출고번호 - 자동생성 */}
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground mb-1">출고번호</div>
|
||||
<div className="font-medium text-muted-foreground">자동생성</div>
|
||||
</div>
|
||||
{/* 로트번호 - Select */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-muted-foreground">로트번호 *</div>
|
||||
<Select
|
||||
value={formData.lotNo}
|
||||
onValueChange={(value) => handleInputChange('lotNo', value)}
|
||||
onValueChange={handleLotChange}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -264,50 +392,50 @@ export function ShipmentCreate() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* 현장명 - LOT 선택 시 자동 매핑 */}
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground mb-1">현장명</div>
|
||||
<div className="font-medium">{selectedLot?.siteName || '-'}</div>
|
||||
</div>
|
||||
{/* 수주처 - LOT 선택 시 자동 매핑 */}
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground mb-1">수주처</div>
|
||||
<div className="font-medium">{selectedLot?.customerName || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 출고 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">출고 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>출고예정일 *</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.scheduledDate}
|
||||
onChange={(e) => handleInputChange('scheduledDate', e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>출고 우선순위 *</Label>
|
||||
<Select
|
||||
value={formData.priority}
|
||||
onValueChange={(value) => handleInputChange('priority', value as ShipmentPriority)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="우선순위 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{priorityOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* 카드 2: 수주/배송 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">수주/배송 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>출고 예정일 *</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.scheduledDate}
|
||||
onChange={(e) => handleInputChange('scheduledDate', e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>출고일</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.shipmentDate || ''}
|
||||
onChange={(e) => handleInputChange('shipmentDate', e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>배송방식 *</Label>
|
||||
<Select
|
||||
value={formData.deliveryMethod}
|
||||
onValueChange={(value) => handleInputChange('deliveryMethod', value as DeliveryMethod)}
|
||||
onValueChange={(value) => handleInputChange('deliveryMethod', value)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -322,97 +450,286 @@ export function ShipmentCreate() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>운임비용</Label>
|
||||
<Select
|
||||
value={formData.freightCost || ''}
|
||||
onValueChange={(value) => handleInputChange('freightCost', value)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{freightCostOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>수신자</Label>
|
||||
<Input
|
||||
value={formData.receiver || ''}
|
||||
onChange={(e) => handleInputChange('receiver', e.target.value)}
|
||||
placeholder="수신자명"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>수신처</Label>
|
||||
<Input
|
||||
value={formData.receiverContact || ''}
|
||||
onChange={(e) => handleInputChange('receiverContact', e.target.value)}
|
||||
placeholder="수신처"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* 주소 */}
|
||||
<div className="space-y-2">
|
||||
<Label>주소</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={formData.zipCode || ''}
|
||||
placeholder="우편번호"
|
||||
className="w-32"
|
||||
readOnly
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={openPostcode}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<Search className="w-4 h-4 mr-1" />
|
||||
우편번호 찾기
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
value={formData.address || ''}
|
||||
placeholder="주소"
|
||||
readOnly
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<Input
|
||||
value={formData.addressDetail || ''}
|
||||
onChange={(e) => handleInputChange('addressDetail', e.target.value)}
|
||||
placeholder="상세주소"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 상차 (물류업체) - 배송방식이 상차 또는 물류사일 때 표시 */}
|
||||
{(formData.deliveryMethod === 'pickup' || formData.deliveryMethod === 'logistics') && (
|
||||
<Card className="bg-muted/30">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">상차 (물류업체)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>물류사</Label>
|
||||
<Select
|
||||
value={formData.logisticsCompany || ''}
|
||||
onValueChange={(value) => handleInputChange('logisticsCompany', value)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{logisticsOptions.filter(o => o.value).map((option, index) => (
|
||||
<SelectItem key={`${option.value}-${index}`} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>차량 톤수 (물량)</Label>
|
||||
<Select
|
||||
value={formData.vehicleTonnage || ''}
|
||||
onValueChange={(value) => handleInputChange('vehicleTonnage', value)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{vehicleTonnageOptions.filter(o => o.value).map((option, index) => (
|
||||
<SelectItem key={`${option.value}-${index}`} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>상차시간 (입차예정)</Label>
|
||||
{/* 카드 3: 배차 정보 */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base">배차 정보</CardTitle>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleAddDispatch}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>물류업체</TableHead>
|
||||
<TableHead>입차일시</TableHead>
|
||||
<TableHead>톤수</TableHead>
|
||||
<TableHead>차량번호</TableHead>
|
||||
<TableHead>기사연락처</TableHead>
|
||||
<TableHead>비고</TableHead>
|
||||
<TableHead className="w-12"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{formData.vehicleDispatches.map((dispatch, index) => (
|
||||
<TableRow key={dispatch.id}>
|
||||
<TableCell className="p-1">
|
||||
<Select
|
||||
value={dispatch.logisticsCompany}
|
||||
onValueChange={(value) => handleDispatchChange(index, 'logisticsCompany', value)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{logisticsOptions.filter(o => o.value).map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={formData.loadingTime || ''}
|
||||
onChange={(e) => handleInputChange('loadingTime', e.target.value)}
|
||||
placeholder="연도. 월. 일. -- --:--"
|
||||
value={dispatch.arrivalDateTime}
|
||||
onChange={(e) => handleDispatchChange(index, 'arrivalDateTime', e.target.value)}
|
||||
className="h-8"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
물류업체와 입차시간 확정 후 입력하세요.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Select
|
||||
value={dispatch.tonnage}
|
||||
onValueChange={(value) => handleDispatchChange(index, 'tonnage', value)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{vehicleTonnageOptions.filter(o => o.value).map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input
|
||||
value={dispatch.vehicleNo}
|
||||
onChange={(e) => handleDispatchChange(index, 'vehicleNo', e.target.value)}
|
||||
placeholder="차량번호"
|
||||
className="h-8"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input
|
||||
value={dispatch.driverContact}
|
||||
onChange={(e) => handleDispatchChange(index, 'driverContact', e.target.value)}
|
||||
placeholder="연락처"
|
||||
className="h-8"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input
|
||||
value={dispatch.remarks}
|
||||
onChange={(e) => handleDispatchChange(index, 'remarks', e.target.value)}
|
||||
placeholder="비고"
|
||||
className="h-8"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-1 text-center">
|
||||
{formData.vehicleDispatches.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleRemoveDispatch(index)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<XIcon className="w-4 h-4 text-red-500" />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>상차담당자</Label>
|
||||
<Input
|
||||
value={formData.loadingManager || ''}
|
||||
onChange={(e) => handleInputChange('loadingManager', e.target.value)}
|
||||
placeholder="상차 작업 담당자명"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
{/* 카드 4: 제품내용 (읽기전용 - LOT 선택 시 표시) */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base">제품내용</CardTitle>
|
||||
{productGroups.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
펼치기/접기
|
||||
<ChevronDown className="w-4 h-4 ml-1" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handleExpandAll}>
|
||||
모두 펼치기
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleCollapseAll}>
|
||||
모두 접기
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{productGroups.length > 0 || otherParts.length > 0 ? (
|
||||
<Accordion
|
||||
type="multiple"
|
||||
value={accordionValue}
|
||||
onValueChange={setAccordionValue}
|
||||
>
|
||||
{productGroups.map((group: ProductGroup) => (
|
||||
<AccordionItem key={group.id} value={group.id}>
|
||||
<AccordionTrigger className="hover:no-underline">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-medium">{group.productName}</span>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
({group.specification})
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{group.partCount}개 부품
|
||||
</Badge>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
{renderPartsTable(group.parts)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
{otherParts.length > 0 && (
|
||||
<AccordionItem value="other-parts">
|
||||
<AccordionTrigger className="hover:no-underline">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-medium">기타부품</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{otherParts.length}개 부품
|
||||
</Badge>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
{renderPartsTable(otherParts)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)}
|
||||
</Accordion>
|
||||
) : (
|
||||
<div className="text-center text-muted-foreground text-sm py-4">
|
||||
{formData.lotNo ? '제품 정보를 불러오는 중...' : '로트를 선택하면 제품 목록이 표시됩니다.'}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
), [
|
||||
formData, validationErrors, isSubmitting, lotOptions, logisticsOptions,
|
||||
vehicleTonnageOptions, selectedLot, productGroups, otherParts, accordionValue,
|
||||
handleLotChange, handleExpandAll, handleCollapseAll, openPostcode,
|
||||
]);
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>비고</Label>
|
||||
<Textarea
|
||||
value={formData.remarks || ''}
|
||||
onChange={(e) => handleInputChange('remarks', e.target.value)}
|
||||
placeholder="특이사항 입력"
|
||||
rows={4}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
), [formData, validationErrors, isSubmitting, lotOptions, logisticsOptions, vehicleTonnageOptions]);
|
||||
|
||||
// 로딩 또는 에러 상태 처리
|
||||
if (error) {
|
||||
return (
|
||||
<IntegratedDetailTemplate
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 출하 상세 페이지
|
||||
* API 연동 완료 (2025-12-26)
|
||||
* IntegratedDetailTemplate 마이그레이션 (2025-01-20)
|
||||
* 출고 상세 페이지
|
||||
* 4개 섹션 구조: 기본정보, 수주/배송정보, 배차정보, 제품내용
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
FileText,
|
||||
Receipt,
|
||||
ClipboardList,
|
||||
Check,
|
||||
Printer,
|
||||
X,
|
||||
ArrowRight,
|
||||
Loader2,
|
||||
Trash2,
|
||||
ArrowRight,
|
||||
X,
|
||||
Printer,
|
||||
ChevronDown,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -45,19 +43,35 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { PhoneInput } from '@/components/ui/phone-input';
|
||||
import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@/components/ui/accordion';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { shipmentConfig } from './shipmentConfig';
|
||||
import { getShipmentById, deleteShipment, updateShipmentStatus } from './actions';
|
||||
import {
|
||||
SHIPMENT_STATUS_LABELS,
|
||||
SHIPMENT_STATUS_STYLES,
|
||||
PRIORITY_LABELS,
|
||||
PRIORITY_STYLES,
|
||||
DELIVERY_METHOD_LABELS,
|
||||
FREIGHT_COST_LABELS,
|
||||
} from './types';
|
||||
import type {
|
||||
ShipmentDetail as ShipmentDetailType,
|
||||
ShipmentStatus,
|
||||
FreightCostType,
|
||||
ProductGroup,
|
||||
ProductPart,
|
||||
} from './types';
|
||||
import type { ShipmentDetail as ShipmentDetailType, ShipmentStatus } from './types';
|
||||
import { ShippingSlip } from './documents/ShippingSlip';
|
||||
import { TransactionStatement } from './documents/TransactionStatement';
|
||||
import { DeliveryConfirmation } from './documents/DeliveryConfirmation';
|
||||
import { printArea } from '@/lib/print-utils';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
@@ -66,17 +80,17 @@ interface ShipmentDetailProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
// 상태 전이 맵: 현재 상태 → 다음 가능한 상태
|
||||
// 상태 전이 맵: 현재 상태 -> 다음 가능한 상태
|
||||
const STATUS_TRANSITIONS: Record<ShipmentStatus, ShipmentStatus | null> = {
|
||||
scheduled: 'ready',
|
||||
ready: 'shipping',
|
||||
shipping: 'completed',
|
||||
completed: null, // 최종 상태
|
||||
completed: null,
|
||||
};
|
||||
|
||||
export function ShipmentDetail({ id }: ShipmentDetailProps) {
|
||||
const router = useRouter();
|
||||
const [previewDocument, setPreviewDocument] = useState<'shipping' | 'transaction' | 'delivery' | null>(null);
|
||||
const [previewDocument, setPreviewDocument] = useState<'shipping' | 'delivery' | null>(null);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
@@ -97,6 +111,9 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [_error, setError] = useState<string | null>(null);
|
||||
|
||||
// 아코디언 상태
|
||||
const [accordionValue, setAccordionValue] = useState<string[]>([]);
|
||||
|
||||
// API 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
@@ -108,7 +125,7 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
|
||||
if (result.success && result.data) {
|
||||
setDetail(result.data);
|
||||
} else {
|
||||
setError(result.error || '출하 정보를 찾을 수 없습니다.');
|
||||
setError(result.error || '출고 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
} catch (err) {
|
||||
if (isNextRedirectError(err)) throw err;
|
||||
@@ -119,22 +136,18 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
// 데이터 로드
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// 목록으로 이동
|
||||
const handleGoBack = useCallback(() => {
|
||||
router.push('/ko/outbound/shipments');
|
||||
}, [router]);
|
||||
|
||||
// 수정 페이지로 이동
|
||||
const handleEdit = useCallback(() => {
|
||||
router.push(`/ko/outbound/shipments/${id}?mode=edit`);
|
||||
}, [id, router]);
|
||||
|
||||
// 삭제 처리
|
||||
const handleDelete = useCallback(async () => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
@@ -154,15 +167,11 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
|
||||
}
|
||||
}, [id, router]);
|
||||
|
||||
// 인쇄
|
||||
const handlePrint = useCallback(() => {
|
||||
const docName = previewDocument === 'shipping' ? '출고증'
|
||||
: previewDocument === 'transaction' ? '거래명세서'
|
||||
: '납품확인서';
|
||||
const docName = previewDocument === 'shipping' ? '출고증' : '납품확인서';
|
||||
printArea({ title: `${docName} 인쇄` });
|
||||
}, [previewDocument]);
|
||||
|
||||
// 상태 변경 다이얼로그 열기
|
||||
const handleOpenStatusDialog = useCallback((status: ShipmentStatus) => {
|
||||
setTargetStatus(status);
|
||||
setStatusFormData({
|
||||
@@ -175,7 +184,6 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
|
||||
setShowStatusDialog(true);
|
||||
}, []);
|
||||
|
||||
// 상태 변경 처리
|
||||
const handleStatusChange = useCallback(async () => {
|
||||
if (!targetStatus) return;
|
||||
|
||||
@@ -183,7 +191,6 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
|
||||
try {
|
||||
const additionalData: Record<string, string> = {};
|
||||
|
||||
// 상태별 추가 데이터 설정
|
||||
if (targetStatus === 'ready' && statusFormData.loadingTime) {
|
||||
additionalData.loadingTime = statusFormData.loadingTime;
|
||||
}
|
||||
@@ -217,40 +224,70 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
|
||||
}
|
||||
}, [id, targetStatus, statusFormData]);
|
||||
|
||||
// 정보 영역 렌더링
|
||||
// 모두 펼치기/접기 처리
|
||||
const handleExpandAll = useCallback(() => {
|
||||
if (!detail) return;
|
||||
const allIds = [
|
||||
...detail.productGroups.map(g => g.id),
|
||||
...(detail.otherParts.length > 0 ? ['other-parts'] : []),
|
||||
];
|
||||
setAccordionValue(allIds);
|
||||
}, [detail]);
|
||||
|
||||
const handleCollapseAll = useCallback(() => {
|
||||
setAccordionValue([]);
|
||||
}, []);
|
||||
|
||||
// 정보 필드 렌더링 헬퍼
|
||||
const renderInfoField = (label: string, value: React.ReactNode, className?: string) => (
|
||||
<div className={className}>
|
||||
<div className="text-sm text-muted-foreground mb-1">{label}</div>
|
||||
<div className="font-medium">{value}</div>
|
||||
<div className="font-medium">{value || '-'}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 수정/삭제 가능 여부 (scheduled, ready 상태에서만)
|
||||
const canEdit = detail ? (detail.status === 'scheduled' || detail.status === 'ready') : false;
|
||||
const canDelete = detail ? (detail.status === 'scheduled' || detail.status === 'ready') : false;
|
||||
|
||||
// 제품 부품 테이블 렌더링
|
||||
const renderPartsTable = (parts: ProductPart[]) => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-16 text-center">순번</TableHead>
|
||||
<TableHead>품목명</TableHead>
|
||||
<TableHead className="w-32">규격</TableHead>
|
||||
<TableHead className="w-20 text-center">수량</TableHead>
|
||||
<TableHead className="w-20 text-center">단위</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{parts.map((part) => (
|
||||
<TableRow key={part.id}>
|
||||
<TableCell className="text-center">{part.seq}</TableCell>
|
||||
<TableCell>{part.itemName}</TableCell>
|
||||
<TableCell>{part.specification}</TableCell>
|
||||
<TableCell className="text-center">{part.quantity}</TableCell>
|
||||
<TableCell className="text-center">{part.unit}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
|
||||
// 헤더 액션 버튼 렌더링
|
||||
const renderHeaderActions = useCallback(() => {
|
||||
if (!detail) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 문서 미리보기 버튼 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPreviewDocument('shipping')}
|
||||
>
|
||||
<FileText className="w-4 h-4 mr-1" />
|
||||
출고증
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPreviewDocument('transaction')}
|
||||
>
|
||||
<Receipt className="w-4 h-4 mr-1" />
|
||||
거래명세서
|
||||
출고증 보기
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -258,8 +295,13 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
|
||||
onClick={() => setPreviewDocument('delivery')}
|
||||
>
|
||||
<ClipboardList className="w-4 h-4 mr-1" />
|
||||
납품확인서
|
||||
납품확인서 보기
|
||||
</Button>
|
||||
{/* 거래명세서 - 추후 활성화 */}
|
||||
{/* <Button variant="outline" size="sm">
|
||||
<Receipt className="w-4 h-4 mr-1" />
|
||||
거래명세서 보기
|
||||
</Button> */}
|
||||
{canDelete && (
|
||||
<>
|
||||
<div className="w-px h-6 bg-border mx-2" />
|
||||
@@ -273,7 +315,6 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{/* 상태 변경 버튼 */}
|
||||
{STATUS_TRANSITIONS[detail.status] && (
|
||||
<>
|
||||
<div className="w-px h-6 bg-border mx-2" />
|
||||
@@ -298,28 +339,38 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 출고 정보 */}
|
||||
{/* 카드 1: 기본 정보 (읽기전용) */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">출고 정보</CardTitle>
|
||||
<CardTitle className="text-base">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
{renderInfoField('출고번호', detail.shipmentNo)}
|
||||
{renderInfoField('출고예정일', detail.scheduledDate)}
|
||||
{renderInfoField('로트번호', detail.lotNo)}
|
||||
{renderInfoField('현장명', detail.siteName)}
|
||||
{renderInfoField('수주처', detail.customerName)}
|
||||
{renderInfoField('거래등급', detail.customerGrade)}
|
||||
{renderInfoField(
|
||||
'출고상태',
|
||||
'상태',
|
||||
<Badge className={SHIPMENT_STATUS_STYLES[detail.status]}>
|
||||
{SHIPMENT_STATUS_LABELS[detail.status]}
|
||||
</Badge>
|
||||
)}
|
||||
{renderInfoField(
|
||||
'출고 우선순위',
|
||||
<Badge className={PRIORITY_STYLES[detail.priority]}>
|
||||
{PRIORITY_LABELS[detail.priority]}
|
||||
</Badge>
|
||||
)}
|
||||
{renderInfoField('작성자', detail.registrant)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 카드 2: 수주/배송 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">수주/배송 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
{renderInfoField('출고 예정일', detail.scheduledDate)}
|
||||
{renderInfoField('출고일', detail.shipmentDate)}
|
||||
{renderInfoField(
|
||||
'배송방식',
|
||||
<Badge variant="outline">
|
||||
@@ -327,136 +378,138 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
|
||||
</Badge>
|
||||
)}
|
||||
{renderInfoField(
|
||||
'입금확인',
|
||||
detail.depositConfirmed ? (
|
||||
<span className="flex items-center gap-1 text-green-600">
|
||||
<Check className="w-4 h-4" />
|
||||
확인됨
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">미확인</span>
|
||||
)
|
||||
'운임비용',
|
||||
detail.freightCost
|
||||
? FREIGHT_COST_LABELS[detail.freightCost as FreightCostType] || detail.freightCostLabel
|
||||
: undefined
|
||||
)}
|
||||
{renderInfoField(
|
||||
'세금계산서',
|
||||
detail.invoiceIssued ? (
|
||||
<span className="flex items-center gap-1 text-green-600">
|
||||
<Check className="w-4 h-4" />
|
||||
발행됨
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">미발행</span>
|
||||
)
|
||||
)}
|
||||
{renderInfoField('거래처 등급', detail.customerGrade)}
|
||||
{renderInfoField(
|
||||
'출하가능',
|
||||
detail.canShip ? (
|
||||
<span className="flex items-center gap-1 text-green-600">
|
||||
<Check className="w-4 h-4" />
|
||||
가능
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-red-600">
|
||||
<X className="w-4 h-4" />
|
||||
불가
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
{renderInfoField('상차담당자', detail.loadingManager || '-')}
|
||||
{renderInfoField(
|
||||
'상차완료',
|
||||
detail.loadingCompleted ? (
|
||||
<span className="flex items-center gap-1 text-green-600">
|
||||
<Check className="w-4 h-4" />
|
||||
완료 ({detail.loadingCompleted})
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)
|
||||
)}
|
||||
{renderInfoField('등록자', detail.registrant || '-')}
|
||||
{renderInfoField('수신자', detail.receiver)}
|
||||
{renderInfoField('수신처', detail.receiverContact)}
|
||||
</div>
|
||||
{/* 주소 영역 */}
|
||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<div className="md:col-span-4">
|
||||
<div className="text-sm text-muted-foreground mb-1">주소</div>
|
||||
<div className="font-medium">
|
||||
{detail.zipCode && (
|
||||
<span className="text-muted-foreground mr-2">[{detail.zipCode}]</span>
|
||||
)}
|
||||
{detail.address || detail.deliveryAddress || '-'}
|
||||
{detail.addressDetail && (
|
||||
<span className="ml-2">{detail.addressDetail}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 발주처/배송 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">발주처/배송 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
{renderInfoField('발주처', detail.customerName)}
|
||||
{renderInfoField('현장명', detail.siteName)}
|
||||
{renderInfoField('배송주소', detail.deliveryAddress, 'md:col-span-2')}
|
||||
{renderInfoField('인수자', detail.receiver || '-')}
|
||||
{renderInfoField('연락처', detail.receiverContact || '-')}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 출고 품목 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">출고 품목</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12 text-center">No</TableHead>
|
||||
<TableHead className="w-24">품목코드</TableHead>
|
||||
<TableHead>품목명</TableHead>
|
||||
<TableHead className="w-24">층/M호</TableHead>
|
||||
<TableHead className="w-28">규격</TableHead>
|
||||
<TableHead className="w-16 text-center">수량</TableHead>
|
||||
<TableHead className="w-36">LOT번호</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{detail.products.map((product) => (
|
||||
<TableRow key={product.id}>
|
||||
<TableCell className="text-center">{product.no}</TableCell>
|
||||
<TableCell>{product.itemCode}</TableCell>
|
||||
<TableCell>{product.itemName}</TableCell>
|
||||
<TableCell>{product.floorUnit}</TableCell>
|
||||
<TableCell>{product.specification}</TableCell>
|
||||
<TableCell className="text-center">{product.quantity}</TableCell>
|
||||
<TableCell>{product.lotNo}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 배차 정보 */}
|
||||
{/* 카드 3: 배차 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">배차 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
{renderInfoField('배송방식', DELIVERY_METHOD_LABELS[detail.deliveryMethod])}
|
||||
{renderInfoField('물류사', detail.logisticsCompany || '-')}
|
||||
{renderInfoField('차량 톤수', detail.vehicleTonnage || '-')}
|
||||
{renderInfoField('운송비', detail.shippingCost !== undefined ? `${detail.shippingCost.toLocaleString()}원` : '-')}
|
||||
</div>
|
||||
<CardContent className="p-0">
|
||||
{detail.vehicleDispatches.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>물류업체</TableHead>
|
||||
<TableHead>입차일시</TableHead>
|
||||
<TableHead>톤수</TableHead>
|
||||
<TableHead>차량번호</TableHead>
|
||||
<TableHead>기사연락처</TableHead>
|
||||
<TableHead>비고</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{detail.vehicleDispatches.map((dispatch) => (
|
||||
<TableRow key={dispatch.id}>
|
||||
<TableCell>{dispatch.logisticsCompany || '-'}</TableCell>
|
||||
<TableCell>{dispatch.arrivalDateTime || '-'}</TableCell>
|
||||
<TableCell>{dispatch.tonnage || '-'}</TableCell>
|
||||
<TableCell>{dispatch.vehicleNo || '-'}</TableCell>
|
||||
<TableCell>{dispatch.driverContact || '-'}</TableCell>
|
||||
<TableCell>{dispatch.remarks || '-'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="p-6 text-center text-muted-foreground text-sm">
|
||||
등록된 배차 정보가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 차량/운전자 정보 */}
|
||||
{/* 카드 4: 제품내용 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">차량/운전자 정보</CardTitle>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base">제품내용</CardTitle>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
펼치기/접기
|
||||
<ChevronDown className="w-4 h-4 ml-1" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handleExpandAll}>
|
||||
모두 펼치기
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleCollapseAll}>
|
||||
모두 접기
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
{renderInfoField('차량번호', detail.vehicleNo || '-')}
|
||||
{renderInfoField('운전자', detail.driverName || '-')}
|
||||
{renderInfoField('운전자 연락처', detail.driverContact || '-')}
|
||||
</div>
|
||||
{detail.productGroups.length > 0 || detail.otherParts.length > 0 ? (
|
||||
<Accordion
|
||||
type="multiple"
|
||||
value={accordionValue}
|
||||
onValueChange={setAccordionValue}
|
||||
>
|
||||
{detail.productGroups.map((group: ProductGroup) => (
|
||||
<AccordionItem key={group.id} value={group.id}>
|
||||
<AccordionTrigger className="hover:no-underline">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-medium">{group.productName}</span>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
({group.specification})
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{group.partCount}개 부품
|
||||
</Badge>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
{renderPartsTable(group.parts)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
{detail.otherParts.length > 0 && (
|
||||
<AccordionItem value="other-parts">
|
||||
<AccordionTrigger className="hover:no-underline">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-medium">기타부품</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{detail.otherParts.length}개 부품
|
||||
</Badge>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
{renderPartsTable(detail.otherParts)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)}
|
||||
</Accordion>
|
||||
) : (
|
||||
<div className="text-center text-muted-foreground text-sm py-4">
|
||||
등록된 제품이 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -473,7 +526,7 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}, [detail]);
|
||||
}, [detail, accordionValue, handleExpandAll, handleCollapseAll]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -488,24 +541,20 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
|
||||
headerActions={renderHeaderActions()}
|
||||
/>
|
||||
|
||||
{/* 문서 미리보기 다이얼로그 - 작업일지 모달 패턴 적용 */}
|
||||
{/* 문서 미리보기 다이얼로그 */}
|
||||
<Dialog open={previewDocument !== null} onOpenChange={() => setPreviewDocument(null)}>
|
||||
<DialogContent className="sm:max-w-5xl max-h-[90vh] overflow-y-auto p-0 gap-0 bg-gray-100">
|
||||
{/* 접근성을 위한 숨겨진 타이틀 */}
|
||||
<VisuallyHidden>
|
||||
<DialogTitle>
|
||||
{previewDocument === 'shipping' && '출고증'}
|
||||
{previewDocument === 'transaction' && '거래명세서'}
|
||||
{previewDocument === 'delivery' && '납품확인서'}
|
||||
</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
|
||||
{/* 모달 헤더 - 작업일지 스타일 (인쇄 시 숨김) */}
|
||||
<div className="print-hidden flex items-center justify-between px-6 py-4 border-b bg-white sticky top-0 z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-semibold text-lg">
|
||||
{previewDocument === 'shipping' && '출고증 미리보기'}
|
||||
{previewDocument === 'transaction' && '거래명세서 미리보기'}
|
||||
{previewDocument === 'delivery' && '납품확인서'}
|
||||
</span>
|
||||
{detail && (
|
||||
@@ -535,11 +584,9 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 문서 본문 - 흰색 카드 형태 (인쇄 시 이 영역만 출력) */}
|
||||
{detail && (
|
||||
<div className="print-area m-6 p-6 bg-white rounded-lg shadow-sm">
|
||||
{previewDocument === 'shipping' && <ShippingSlip data={detail} />}
|
||||
{previewDocument === 'transaction' && <TransactionStatement data={detail} />}
|
||||
{previewDocument === 'delivery' && <DeliveryConfirmation data={detail} />}
|
||||
</div>
|
||||
)}
|
||||
@@ -551,10 +598,10 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
|
||||
open={showDeleteDialog}
|
||||
onOpenChange={setShowDeleteDialog}
|
||||
onConfirm={handleDelete}
|
||||
title="출하 정보 삭제"
|
||||
title="출고 정보 삭제"
|
||||
description={
|
||||
<>
|
||||
출하번호 {detail?.shipmentNo}을(를) 삭제하시겠습니까?
|
||||
출고번호 {detail?.shipmentNo}을(를) 삭제하시겠습니까?
|
||||
<br />
|
||||
이 작업은 되돌릴 수 없습니다.
|
||||
</>
|
||||
@@ -566,7 +613,7 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
|
||||
<Dialog open={showStatusDialog} onOpenChange={setShowStatusDialog}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>출하 상태 변경</DialogTitle>
|
||||
<DialogTitle>출고 상태 변경</DialogTitle>
|
||||
<DialogDescription>
|
||||
{detail?.status && targetStatus && (
|
||||
<span className="flex items-center gap-2 mt-2">
|
||||
@@ -583,7 +630,6 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* 출하대기로 변경 시 - 상차 시간 */}
|
||||
{targetStatus === 'ready' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="loadingTime">상차 시간 (선택)</Label>
|
||||
@@ -598,7 +644,6 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 배송중으로 변경 시 - 차량/운전자 정보 */}
|
||||
{targetStatus === 'shipping' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
@@ -637,7 +682,6 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 배송완료로 변경 시 - 도착 확인 시간 */}
|
||||
{targetStatus === 'completed' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmedArrival">도착 확인 시간 (선택)</Label>
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 출하 수정 페이지
|
||||
* API 연동 완료 (2025-12-26)
|
||||
* IntegratedDetailTemplate 마이그레이션 (2025-01-20)
|
||||
* 출고 수정 페이지
|
||||
* 4개 섹션 구조: 기본정보(읽기전용), 수주/배송정보, 배차정보, 제품내용(읽기전용)
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Plus, X as XIcon, ChevronDown, Search } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { PhoneInput } from '@/components/ui/phone-input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -23,6 +29,18 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@/components/ui/accordion';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { shipmentEditConfig } from './shipmentConfig';
|
||||
import {
|
||||
@@ -34,30 +52,51 @@ import {
|
||||
import {
|
||||
SHIPMENT_STATUS_LABELS,
|
||||
SHIPMENT_STATUS_STYLES,
|
||||
FREIGHT_COST_LABELS,
|
||||
} from './types';
|
||||
import type {
|
||||
ShipmentDetail,
|
||||
ShipmentEditFormData,
|
||||
ShipmentPriority,
|
||||
DeliveryMethod,
|
||||
FreightCostType,
|
||||
VehicleDispatch,
|
||||
LogisticsOption,
|
||||
VehicleTonnageOption,
|
||||
ProductGroup,
|
||||
ProductPart,
|
||||
} from './types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { useDaumPostcode } from '@/hooks/useDaumPostcode';
|
||||
|
||||
// 고정 옵션 (클라이언트에서 관리)
|
||||
const priorityOptions: { value: ShipmentPriority; label: string }[] = [
|
||||
{ value: 'urgent', label: '긴급' },
|
||||
{ value: 'normal', label: '일반' },
|
||||
{ value: 'low', label: '낮음' },
|
||||
];
|
||||
|
||||
// 배송방식 옵션
|
||||
const deliveryMethodOptions: { value: DeliveryMethod; label: string }[] = [
|
||||
{ value: 'pickup', label: '상차 (물류업체)' },
|
||||
{ value: 'direct', label: '직접배송 (자체)' },
|
||||
{ value: 'logistics', label: '물류사' },
|
||||
{ value: 'direct_dispatch', label: '직접배차' },
|
||||
{ value: 'loading', label: '상차' },
|
||||
{ value: 'kyungdong_delivery', label: '경동택배' },
|
||||
{ value: 'daesin_delivery', label: '대신택배' },
|
||||
{ value: 'kyungdong_freight', label: '경동화물' },
|
||||
{ value: 'daesin_freight', label: '대신화물' },
|
||||
{ value: 'self_pickup', label: '직접수령' },
|
||||
];
|
||||
|
||||
// 운임비용 옵션
|
||||
const freightCostOptions: { value: FreightCostType; label: string }[] = Object.entries(
|
||||
FREIGHT_COST_LABELS
|
||||
).map(([value, label]) => ({ value: value as FreightCostType, label }));
|
||||
|
||||
// 빈 배차 행 생성
|
||||
function createEmptyDispatch(): VehicleDispatch {
|
||||
return {
|
||||
id: `vd-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
logisticsCompany: '',
|
||||
arrivalDateTime: '',
|
||||
tonnage: '',
|
||||
vehicleNo: '',
|
||||
driverContact: '',
|
||||
remarks: '',
|
||||
};
|
||||
}
|
||||
|
||||
interface ShipmentEditProps {
|
||||
id: string;
|
||||
}
|
||||
@@ -71,8 +110,16 @@ export function ShipmentEdit({ id }: ShipmentEditProps) {
|
||||
// 폼 상태
|
||||
const [formData, setFormData] = useState<ShipmentEditFormData>({
|
||||
scheduledDate: '',
|
||||
shipmentDate: '',
|
||||
priority: 'normal',
|
||||
deliveryMethod: 'pickup',
|
||||
deliveryMethod: 'direct_dispatch',
|
||||
freightCost: undefined,
|
||||
receiver: '',
|
||||
receiverContact: '',
|
||||
zipCode: '',
|
||||
address: '',
|
||||
addressDetail: '',
|
||||
vehicleDispatches: [createEmptyDispatch()],
|
||||
loadingManager: '',
|
||||
logisticsCompany: '',
|
||||
vehicleTonnage: '',
|
||||
@@ -96,6 +143,20 @@ export function ShipmentEdit({ id }: ShipmentEditProps) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||
|
||||
// 아코디언 상태
|
||||
const [accordionValue, setAccordionValue] = useState<string[]>([]);
|
||||
|
||||
// 우편번호 찾기
|
||||
const { openPostcode } = useDaumPostcode({
|
||||
onComplete: (result) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
zipCode: result.zonecode,
|
||||
address: result.address,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
@@ -115,8 +176,18 @@ export function ShipmentEdit({ id }: ShipmentEditProps) {
|
||||
// 폼 초기값 설정
|
||||
setFormData({
|
||||
scheduledDate: shipmentDetail.scheduledDate,
|
||||
shipmentDate: shipmentDetail.shipmentDate || '',
|
||||
priority: shipmentDetail.priority,
|
||||
deliveryMethod: shipmentDetail.deliveryMethod,
|
||||
freightCost: shipmentDetail.freightCost,
|
||||
receiver: shipmentDetail.receiver || '',
|
||||
receiverContact: shipmentDetail.receiverContact || '',
|
||||
zipCode: shipmentDetail.zipCode || '',
|
||||
address: shipmentDetail.address || shipmentDetail.deliveryAddress || '',
|
||||
addressDetail: shipmentDetail.addressDetail || '',
|
||||
vehicleDispatches: shipmentDetail.vehicleDispatches.length > 0
|
||||
? shipmentDetail.vehicleDispatches
|
||||
: [createEmptyDispatch()],
|
||||
loadingManager: shipmentDetail.loadingManager || '',
|
||||
logisticsCompany: shipmentDetail.logisticsCompany || '',
|
||||
vehicleTonnage: shipmentDetail.vehicleTonnage || '',
|
||||
@@ -130,13 +201,12 @@ export function ShipmentEdit({ id }: ShipmentEditProps) {
|
||||
remarks: shipmentDetail.remarks || '',
|
||||
});
|
||||
} else {
|
||||
setError(detailResult.error || '출하 정보를 불러오는 데 실패했습니다.');
|
||||
setError(detailResult.error || '출고 정보를 불러오는 데 실패했습니다.');
|
||||
}
|
||||
|
||||
if (logisticsResult.success && logisticsResult.data) {
|
||||
setLogisticsOptions(logisticsResult.data);
|
||||
}
|
||||
|
||||
if (tonnageResult.success && tonnageResult.data) {
|
||||
setVehicleTonnageOptions(tonnageResult.data);
|
||||
}
|
||||
@@ -149,7 +219,6 @@ export function ShipmentEdit({ id }: ShipmentEditProps) {
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
// 데이터 로드
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
@@ -157,54 +226,69 @@ export function ShipmentEdit({ id }: ShipmentEditProps) {
|
||||
// 폼 입력 핸들러
|
||||
const handleInputChange = (field: keyof ShipmentEditFormData, value: string | number | undefined) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
// 입력 시 에러 클리어
|
||||
if (validationErrors.length > 0) {
|
||||
setValidationErrors([]);
|
||||
}
|
||||
if (validationErrors.length > 0) setValidationErrors([]);
|
||||
};
|
||||
|
||||
// 취소
|
||||
// 배차 정보 핸들러
|
||||
const handleDispatchChange = (index: number, field: keyof VehicleDispatch, value: string) => {
|
||||
setFormData(prev => {
|
||||
const newDispatches = [...prev.vehicleDispatches];
|
||||
newDispatches[index] = { ...newDispatches[index], [field]: value };
|
||||
return { ...prev, vehicleDispatches: newDispatches };
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddDispatch = () => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
vehicleDispatches: [...prev.vehicleDispatches, createEmptyDispatch()],
|
||||
}));
|
||||
};
|
||||
|
||||
const handleRemoveDispatch = (index: number) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
vehicleDispatches: prev.vehicleDispatches.filter((_, i) => i !== index),
|
||||
}));
|
||||
};
|
||||
|
||||
// 아코디언 제어
|
||||
const handleExpandAll = useCallback(() => {
|
||||
if (!detail) return;
|
||||
const allIds = [
|
||||
...detail.productGroups.map(g => g.id),
|
||||
...(detail.otherParts.length > 0 ? ['other-parts'] : []),
|
||||
];
|
||||
setAccordionValue(allIds);
|
||||
}, [detail]);
|
||||
|
||||
const handleCollapseAll = useCallback(() => {
|
||||
setAccordionValue([]);
|
||||
}, []);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
router.push(`/ko/outbound/shipments/${id}?mode=view`);
|
||||
}, [router, id]);
|
||||
|
||||
// validation 체크
|
||||
const validateForm = (): boolean => {
|
||||
const errors: string[] = [];
|
||||
|
||||
// 필수 필드 체크
|
||||
if (!formData.scheduledDate) {
|
||||
errors.push('출고예정일은 필수 입력 항목입니다.');
|
||||
}
|
||||
if (!formData.priority) {
|
||||
errors.push('출고 우선순위는 필수 선택 항목입니다.');
|
||||
}
|
||||
if (!formData.deliveryMethod) {
|
||||
errors.push('배송방식은 필수 선택 항목입니다.');
|
||||
}
|
||||
if (!formData.changeReason.trim()) {
|
||||
errors.push('변경 사유는 필수 입력 항목입니다.');
|
||||
}
|
||||
|
||||
if (!formData.scheduledDate) errors.push('출고예정일은 필수 입력 항목입니다.');
|
||||
if (!formData.deliveryMethod) errors.push('배송방식은 필수 선택 항목입니다.');
|
||||
if (!formData.changeReason.trim()) errors.push('변경 사유는 필수 입력 항목입니다.');
|
||||
setValidationErrors(errors);
|
||||
return errors.length === 0;
|
||||
};
|
||||
|
||||
// 저장
|
||||
const handleSubmit = useCallback(async () => {
|
||||
// validation 체크
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
if (!validateForm()) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await updateShipment(id, formData);
|
||||
|
||||
if (result.success) {
|
||||
router.push(`/ko/outbound/shipments/${id}?mode=view`);
|
||||
} else {
|
||||
setValidationErrors([result.error || '출하 수정에 실패했습니다.']);
|
||||
setValidationErrors([result.error || '출고 수정에 실패했습니다.']);
|
||||
}
|
||||
} catch (err) {
|
||||
if (isNextRedirectError(err)) throw err;
|
||||
@@ -215,11 +299,36 @@ export function ShipmentEdit({ id }: ShipmentEditProps) {
|
||||
}
|
||||
}, [id, formData, router]);
|
||||
|
||||
// 동적 config (로트번호 + 상태 표시)
|
||||
// Note: IntegratedDetailTemplate이 edit 모드에서 '수정' 접미사 자동 추가
|
||||
// 제품 부품 테이블 렌더링
|
||||
const renderPartsTable = (parts: ProductPart[]) => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-16 text-center">순번</TableHead>
|
||||
<TableHead>품목명</TableHead>
|
||||
<TableHead className="w-32">규격</TableHead>
|
||||
<TableHead className="w-20 text-center">수량</TableHead>
|
||||
<TableHead className="w-20 text-center">단위</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{parts.map((part) => (
|
||||
<TableRow key={part.id}>
|
||||
<TableCell className="text-center">{part.seq}</TableCell>
|
||||
<TableCell>{part.itemName}</TableCell>
|
||||
<TableCell>{part.specification}</TableCell>
|
||||
<TableCell className="text-center">{part.quantity}</TableCell>
|
||||
<TableCell className="text-center">{part.unit}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
|
||||
// 동적 config
|
||||
const dynamicConfig = {
|
||||
...shipmentEditConfig,
|
||||
title: detail?.lotNo ? `출하 (${detail.lotNo})` : '출하',
|
||||
title: detail?.lotNo ? `출고 (${detail.lotNo})` : '출고',
|
||||
};
|
||||
|
||||
// 폼 컨텐츠 렌더링
|
||||
@@ -246,10 +355,10 @@ export function ShipmentEdit({ id }: ShipmentEditProps) {
|
||||
입력 내용을 확인해주세요 ({validationErrors.length}개 오류)
|
||||
</strong>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{validationErrors.map((error, index) => (
|
||||
{validationErrors.map((err, index) => (
|
||||
<li key={index} className="flex items-start gap-1">
|
||||
<span>•</span>
|
||||
<span>{error}</span>
|
||||
<span>{err}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -259,7 +368,7 @@ export function ShipmentEdit({ id }: ShipmentEditProps) {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 기본 정보 (읽기 전용) */}
|
||||
{/* 카드 1: 기본 정보 (읽기전용) */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">기본 정보</CardTitle>
|
||||
@@ -274,31 +383,35 @@ export function ShipmentEdit({ id }: ShipmentEditProps) {
|
||||
<Label className="text-muted-foreground">로트번호</Label>
|
||||
<div className="font-medium">{detail.lotNo}</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground">발주처</Label>
|
||||
<div className="font-medium">{detail.customerName}</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground">현장명</Label>
|
||||
<div className="font-medium">{detail.siteName}</div>
|
||||
</div>
|
||||
<div className="space-y-1 md:col-span-4">
|
||||
<Label className="text-muted-foreground">배송주소</Label>
|
||||
<div className="font-medium">{detail.deliveryAddress}</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground">수주처</Label>
|
||||
<div className="font-medium">{detail.customerName}</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground">거래등급</Label>
|
||||
<div className="font-medium">{detail.customerGrade || '-'}</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground">작성자</Label>
|
||||
<div className="font-medium">{detail.registrant || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 출고 정보 */}
|
||||
{/* 카드 2: 수주/배송 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">출고 정보</CardTitle>
|
||||
<CardTitle className="text-base">수주/배송 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>출고예정일 *</Label>
|
||||
<Label>출고 예정일 *</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.scheduledDate}
|
||||
@@ -307,29 +420,18 @@ export function ShipmentEdit({ id }: ShipmentEditProps) {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>출고 우선순위 *</Label>
|
||||
<Select
|
||||
value={formData.priority}
|
||||
onValueChange={(value) => handleInputChange('priority', value as ShipmentPriority)}
|
||||
<Label>출고일</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.shipmentDate || ''}
|
||||
onChange={(e) => handleInputChange('shipmentDate', e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="우선순위 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{priorityOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>배송방식 *</Label>
|
||||
<Select
|
||||
key={`delivery-${formData.deliveryMethod}`}
|
||||
value={formData.deliveryMethod}
|
||||
onValueChange={(value) => handleInputChange('deliveryMethod', value as DeliveryMethod)}
|
||||
disabled={isSubmitting}
|
||||
@@ -347,124 +449,277 @@ export function ShipmentEdit({ id }: ShipmentEditProps) {
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>상차담당자</Label>
|
||||
<Input
|
||||
value={formData.loadingManager || ''}
|
||||
onChange={(e) => handleInputChange('loadingManager', e.target.value)}
|
||||
placeholder="상차담당자명"
|
||||
<Label>운임비용</Label>
|
||||
<Select
|
||||
key={`freight-${formData.freightCost}`}
|
||||
value={formData.freightCost || ''}
|
||||
onValueChange={(value) => handleInputChange('freightCost', value)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{freightCostOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>물류사</Label>
|
||||
<Select
|
||||
value={formData.logisticsCompany || ''}
|
||||
onValueChange={(value) => handleInputChange('logisticsCompany', value)}
|
||||
<Label>수신자</Label>
|
||||
<Input
|
||||
value={formData.receiver || ''}
|
||||
onChange={(e) => handleInputChange('receiver', e.target.value)}
|
||||
placeholder="수신자명"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{logisticsOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>차량 톤수</Label>
|
||||
<Select
|
||||
value={formData.vehicleTonnage || ''}
|
||||
onValueChange={(value) => handleInputChange('vehicleTonnage', value)}
|
||||
<Label>수신처</Label>
|
||||
<Input
|
||||
value={formData.receiverContact || ''}
|
||||
onChange={(e) => handleInputChange('receiverContact', e.target.value)}
|
||||
placeholder="수신처"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* 주소 */}
|
||||
<div className="space-y-2">
|
||||
<Label>주소</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={formData.zipCode || ''}
|
||||
placeholder="우편번호"
|
||||
className="w-32"
|
||||
readOnly
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={openPostcode}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{vehicleTonnageOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Search className="w-4 h-4 mr-1" />
|
||||
우편번호 찾기
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
value={formData.address || ''}
|
||||
placeholder="주소"
|
||||
readOnly
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<Input
|
||||
value={formData.addressDetail || ''}
|
||||
onChange={(e) => handleInputChange('addressDetail', e.target.value)}
|
||||
placeholder="상세주소"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 배차 정보 */}
|
||||
{/* 카드 3: 배차 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base">배차 정보</CardTitle>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleAddDispatch}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>차량번호</Label>
|
||||
<Input
|
||||
value={formData.vehicleNo || ''}
|
||||
onChange={(e) => handleInputChange('vehicleNo', e.target.value)}
|
||||
placeholder="예: 12가 3456"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>물류업체</TableHead>
|
||||
<TableHead>입차일시</TableHead>
|
||||
<TableHead>톤수</TableHead>
|
||||
<TableHead>차량번호</TableHead>
|
||||
<TableHead>기사연락처</TableHead>
|
||||
<TableHead>비고</TableHead>
|
||||
<TableHead className="w-12"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{formData.vehicleDispatches.map((dispatch, index) => (
|
||||
<TableRow key={dispatch.id}>
|
||||
<TableCell className="p-1">
|
||||
<Select
|
||||
value={dispatch.logisticsCompany}
|
||||
onValueChange={(value) => handleDispatchChange(index, 'logisticsCompany', value)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{logisticsOptions.filter(o => o.value).map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={dispatch.arrivalDateTime}
|
||||
onChange={(e) => handleDispatchChange(index, 'arrivalDateTime', e.target.value)}
|
||||
className="h-8"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Select
|
||||
value={dispatch.tonnage}
|
||||
onValueChange={(value) => handleDispatchChange(index, 'tonnage', value)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{vehicleTonnageOptions.filter(o => o.value).map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input
|
||||
value={dispatch.vehicleNo}
|
||||
onChange={(e) => handleDispatchChange(index, 'vehicleNo', e.target.value)}
|
||||
placeholder="차량번호"
|
||||
className="h-8"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input
|
||||
value={dispatch.driverContact}
|
||||
onChange={(e) => handleDispatchChange(index, 'driverContact', e.target.value)}
|
||||
placeholder="연락처"
|
||||
className="h-8"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input
|
||||
value={dispatch.remarks}
|
||||
onChange={(e) => handleDispatchChange(index, 'remarks', e.target.value)}
|
||||
placeholder="비고"
|
||||
className="h-8"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-1 text-center">
|
||||
{formData.vehicleDispatches.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleRemoveDispatch(index)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<XIcon className="w-4 h-4 text-red-500" />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 카드 4: 제품내용 (읽기전용) */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base">제품내용</CardTitle>
|
||||
{(detail.productGroups.length > 0 || detail.otherParts.length > 0) && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
펼치기/접기
|
||||
<ChevronDown className="w-4 h-4 ml-1" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handleExpandAll}>
|
||||
모두 펼치기
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleCollapseAll}>
|
||||
모두 접기
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{detail.productGroups.length > 0 || detail.otherParts.length > 0 ? (
|
||||
<Accordion
|
||||
type="multiple"
|
||||
value={accordionValue}
|
||||
onValueChange={setAccordionValue}
|
||||
>
|
||||
{detail.productGroups.map((group: ProductGroup) => (
|
||||
<AccordionItem key={group.id} value={group.id}>
|
||||
<AccordionTrigger className="hover:no-underline">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-medium">{group.productName}</span>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
({group.specification})
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{group.partCount}개 부품
|
||||
</Badge>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
{renderPartsTable(group.parts)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
{detail.otherParts.length > 0 && (
|
||||
<AccordionItem value="other-parts">
|
||||
<AccordionTrigger className="hover:no-underline">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-medium">기타부품</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{detail.otherParts.length}개 부품
|
||||
</Badge>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
{renderPartsTable(detail.otherParts)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)}
|
||||
</Accordion>
|
||||
) : (
|
||||
<div className="text-center text-muted-foreground text-sm py-4">
|
||||
등록된 제품이 없습니다.
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>운송비</Label>
|
||||
<CurrencyInput
|
||||
value={formData.shippingCost || 0}
|
||||
onChange={(value) => handleInputChange('shippingCost', value ?? undefined)}
|
||||
placeholder="0"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>운전자명</Label>
|
||||
<Input
|
||||
value={formData.driverName || ''}
|
||||
onChange={(e) => handleInputChange('driverName', e.target.value)}
|
||||
placeholder="운전자명"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>운전자 연락처</Label>
|
||||
<PhoneInput
|
||||
value={formData.driverContact || ''}
|
||||
onChange={(value) => handleInputChange('driverContact', value)}
|
||||
placeholder="010-0000-0000"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>입차예정시간</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={formData.expectedArrival || ''}
|
||||
onChange={(e) => handleInputChange('expectedArrival', e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>입차확정시간</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={formData.confirmedArrival || ''}
|
||||
onChange={(e) => handleInputChange('confirmedArrival', e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -483,23 +738,15 @@ export function ShipmentEdit({ id }: ShipmentEditProps) {
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>비고</Label>
|
||||
<Textarea
|
||||
value={formData.remarks || ''}
|
||||
onChange={(e) => handleInputChange('remarks', e.target.value)}
|
||||
placeholder="특이사항 입력"
|
||||
rows={4}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}, [detail, formData, validationErrors, isSubmitting, logisticsOptions, vehicleTonnageOptions]);
|
||||
}, [
|
||||
detail, formData, validationErrors, isSubmitting, logisticsOptions,
|
||||
vehicleTonnageOptions, accordionValue, handleExpandAll, handleCollapseAll, openPostcode,
|
||||
]);
|
||||
|
||||
// 에러 상태 표시
|
||||
if (error && !isLoading) {
|
||||
return (
|
||||
<IntegratedDetailTemplate
|
||||
@@ -510,7 +757,7 @@ export function ShipmentEdit({ id }: ShipmentEditProps) {
|
||||
renderForm={(_props: { formData: Record<string, unknown>; onChange: (key: string, value: unknown) => void; mode: string; errors: Record<string, string> }) => (
|
||||
<Alert className="bg-red-50 border-red-200">
|
||||
<AlertDescription className="text-red-900">
|
||||
{error || '출하 정보를 찾을 수 없습니다.'}
|
||||
{error || '출고 정보를 찾을 수 없습니다.'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 출하 목록 - UniversalListPage 마이그레이션
|
||||
* 출고 목록 - 리뉴얼 버전
|
||||
*
|
||||
* 기존 IntegratedListTemplateV2 → UniversalListPage config 기반으로 변환
|
||||
* - 서버 사이드 페이지네이션 (getShipments API)
|
||||
* - 통계 카드 (getShipmentStats API)
|
||||
* - 상태별 탭 필터 (getShipmentStatsByStatus API)
|
||||
* - 경고 배너 (출고불가 건수)
|
||||
* 변경 사항:
|
||||
* - 제목: 출고 목록 → 출고 목록
|
||||
* - DateRangeSelector 추가
|
||||
* - 통계 카드: 3개 (당일 출고대기, 출고대기, 출고완료)
|
||||
* - 탭 삭제 → 배송방식 필터로 대체
|
||||
* - 테이블 컬럼: 19개
|
||||
* - 하단 출고 스케줄 캘린더 (시간축 주간 뷰)
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
@@ -16,33 +18,32 @@ import {
|
||||
Truck,
|
||||
Package,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Eye,
|
||||
Plus,
|
||||
Check,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import {
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
type SelectionHandlers,
|
||||
type RowClickHandlers,
|
||||
type TabOption,
|
||||
type StatCard,
|
||||
type ListParams,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||
import { getShipments, getShipmentStats, getShipmentStatsByStatus } from './actions';
|
||||
import { ScheduleCalendar } from '@/components/common/ScheduleCalendar/ScheduleCalendar';
|
||||
import type { ScheduleEvent } from '@/components/common/ScheduleCalendar/types';
|
||||
import { getShipments, getShipmentStats } from './actions';
|
||||
import {
|
||||
SHIPMENT_STATUS_LABELS,
|
||||
SHIPMENT_STATUS_STYLES,
|
||||
DELIVERY_METHOD_LABELS,
|
||||
} from './types';
|
||||
import type { ShipmentItem, ShipmentStatus, ShipmentStats, ShipmentStatusStats } from './types';
|
||||
import type { ShipmentItem, ShipmentStatus, ShipmentStats } from './types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
// 페이지당 항목 수
|
||||
@@ -51,25 +52,44 @@ const ITEMS_PER_PAGE = 20;
|
||||
export function ShipmentList() {
|
||||
const router = useRouter();
|
||||
|
||||
// ===== 통계 및 상태별 통계 (외부 관리) =====
|
||||
// ===== 통계 (외부 관리) =====
|
||||
const [shipmentStats, setShipmentStats] = useState<ShipmentStats | null>(null);
|
||||
const [statusStats, setStatusStats] = useState<ShipmentStatusStats>({});
|
||||
const [cannotShipCount, setCannotShipCount] = useState(0);
|
||||
|
||||
// ===== 날짜 범위 =====
|
||||
const today = new Date();
|
||||
const [startDate, setStartDate] = useState(() => {
|
||||
const d = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
return d.toISOString().split('T')[0];
|
||||
});
|
||||
const [endDate, setEndDate] = useState(() => {
|
||||
const d = new Date(today.getFullYear(), today.getMonth() + 1, 0);
|
||||
return d.toISOString().split('T')[0];
|
||||
});
|
||||
|
||||
// ===== 캘린더 상태 =====
|
||||
const [calendarDate, setCalendarDate] = useState(new Date());
|
||||
const [calendarDateInitialized, setCalendarDateInitialized] = useState(false);
|
||||
const [shipmentData, setShipmentData] = useState<ShipmentItem[]>([]);
|
||||
|
||||
// 데이터 로드 후 캘린더를 데이터 날짜로 이동
|
||||
useEffect(() => {
|
||||
if (!calendarDateInitialized && shipmentData.length > 0) {
|
||||
const firstDate = shipmentData[0].scheduledDate;
|
||||
if (firstDate) {
|
||||
setCalendarDate(new Date(firstDate));
|
||||
setCalendarDateInitialized(true);
|
||||
}
|
||||
}
|
||||
}, [shipmentData, calendarDateInitialized]);
|
||||
|
||||
// 초기 통계 로드
|
||||
useEffect(() => {
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const [statsResult, statusStatsResult] = await Promise.all([
|
||||
getShipmentStats(),
|
||||
getShipmentStatsByStatus(),
|
||||
]);
|
||||
const statsResult = await getShipmentStats();
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setShipmentStats(statsResult.data);
|
||||
}
|
||||
if (statusStatsResult.success && statusStatsResult.data) {
|
||||
setStatusStats(statusStatsResult.data);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[ShipmentList] loadStats error:', error);
|
||||
@@ -91,77 +111,78 @@ export function ShipmentList() {
|
||||
router.push('/ko/outbound/shipments?mode=new');
|
||||
}, [router]);
|
||||
|
||||
// ===== 통계 카드 =====
|
||||
// ===== 통계 카드 (3개: 당일 출고대기, 출고대기, 출고완료) =====
|
||||
const stats: StatCard[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: '당일 출하',
|
||||
value: `${shipmentStats?.todayShipmentCount || 0}건`,
|
||||
label: '당일 출고대기',
|
||||
value: `${shipmentStats?.todayPendingCount || shipmentStats?.todayShipmentCount || 0}건`,
|
||||
icon: Package,
|
||||
iconColor: 'text-green-600',
|
||||
iconColor: 'text-orange-600',
|
||||
},
|
||||
{
|
||||
label: '출고 대기',
|
||||
value: `${shipmentStats?.scheduledCount || 0}건`,
|
||||
label: '출고대기',
|
||||
value: `${shipmentStats?.pendingCount || shipmentStats?.scheduledCount || 0}건`,
|
||||
icon: Clock,
|
||||
iconColor: 'text-yellow-600',
|
||||
},
|
||||
{
|
||||
label: '배송중',
|
||||
value: `${shipmentStats?.shippingCount || 0}건`,
|
||||
icon: Truck,
|
||||
iconColor: 'text-blue-600',
|
||||
},
|
||||
{
|
||||
label: '긴급 출하',
|
||||
value: `${shipmentStats?.urgentCount || 0}건`,
|
||||
icon: AlertTriangle,
|
||||
iconColor: 'text-red-600',
|
||||
label: '출고완료',
|
||||
value: `${shipmentStats?.completedCount || 0}건`,
|
||||
icon: CheckCircle2,
|
||||
iconColor: 'text-green-600',
|
||||
},
|
||||
],
|
||||
[shipmentStats]
|
||||
);
|
||||
|
||||
// ===== 탭 옵션 (기본 탭 + 상태별 통계) =====
|
||||
const tabs: TabOption[] = useMemo(() => {
|
||||
// 기본 탭 정의 (API 데이터 없어도 항상 표시)
|
||||
const defaultTabs: { value: string; label: string }[] = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'scheduled', label: '출고예정' },
|
||||
{ value: 'ready', label: '출하대기' },
|
||||
{ value: 'shipping', label: '배송중' },
|
||||
{ value: 'delivered', label: '배송완료' },
|
||||
];
|
||||
// ===== 캘린더 이벤트 변환 =====
|
||||
const scheduleEvents: ScheduleEvent[] = useMemo(() => {
|
||||
return shipmentData.map((item) => {
|
||||
const deliveryLabel = item.deliveryMethodLabel || DELIVERY_METHOD_LABELS[item.deliveryMethod] || '';
|
||||
const statusLabel = SHIPMENT_STATUS_LABELS[item.status] || '';
|
||||
const title = `${deliveryLabel} ${item.siteName} / ${statusLabel}`;
|
||||
|
||||
return defaultTabs.map((tab) => {
|
||||
if (tab.value === 'all') {
|
||||
return { ...tab, count: shipmentStats?.totalCount || 0 };
|
||||
}
|
||||
const stat = statusStats[tab.value];
|
||||
const count = typeof stat?.count === 'number' ? stat.count : 0;
|
||||
return { ...tab, count };
|
||||
// 색상 매핑: 상태별
|
||||
const colorMap: Record<ShipmentStatus, string> = {
|
||||
scheduled: 'gray',
|
||||
ready: 'yellow',
|
||||
shipping: 'blue',
|
||||
completed: 'green',
|
||||
};
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
title,
|
||||
startDate: item.scheduledDate,
|
||||
endDate: item.shipmentDate || item.scheduledDate,
|
||||
startTime: item.shipmentTime,
|
||||
color: colorMap[item.status] || 'blue',
|
||||
status: item.status,
|
||||
data: item,
|
||||
};
|
||||
});
|
||||
}, [statusStats, shipmentStats?.totalCount]);
|
||||
}, [shipmentData]);
|
||||
|
||||
// ===== 경고 배너 =====
|
||||
const alertBanner = useMemo(() => {
|
||||
if (cannotShipCount <= 0) return undefined;
|
||||
return (
|
||||
<Alert className="mb-4 bg-orange-50 border-orange-200">
|
||||
<AlertTriangle className="h-4 w-4 text-orange-600" />
|
||||
<AlertDescription className="text-orange-800">
|
||||
출고불가 {cannotShipCount}건 - 입금확인 및 세금계산서 발행 완료 후 출고 진행이 가능합니다.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}, [cannotShipCount]);
|
||||
// ===== 캘린더 이벤트 클릭 =====
|
||||
const handleCalendarEventClick = useCallback((event: ScheduleEvent) => {
|
||||
if (event.data) {
|
||||
const item = event.data as ShipmentItem;
|
||||
router.push(`/ko/outbound/shipments/${item.id}?mode=view`);
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
// ===== 캘린더 날짜 클릭 =====
|
||||
const handleCalendarDateClick = useCallback((date: Date) => {
|
||||
setCalendarDate(date);
|
||||
}, []);
|
||||
|
||||
// ===== UniversalListPage Config =====
|
||||
const config: UniversalListConfig<ShipmentItem> = useMemo(
|
||||
() => ({
|
||||
// 페이지 기본 정보
|
||||
title: '출하 목록',
|
||||
description: '출하 관리',
|
||||
title: '출고 목록',
|
||||
description: '출고 관리',
|
||||
icon: Truck,
|
||||
basePath: '/outbound/shipments',
|
||||
|
||||
@@ -175,26 +196,19 @@ export function ShipmentList() {
|
||||
const result = await getShipments({
|
||||
page: params?.page || 1,
|
||||
perPage: params?.pageSize || ITEMS_PER_PAGE,
|
||||
status: params?.tab !== 'all' ? (params?.tab as ShipmentStatus) : undefined,
|
||||
status: undefined, // 탭 삭제 - 필터로 대체
|
||||
search: params?.search || undefined,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
// 통계 및 상태별 통계 다시 로드
|
||||
const [statsResult, statusStatsResult] = await Promise.all([
|
||||
getShipmentStats(),
|
||||
getShipmentStatsByStatus(),
|
||||
]);
|
||||
// 통계 다시 로드
|
||||
const statsResult = await getShipmentStats();
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setShipmentStats(statsResult.data);
|
||||
}
|
||||
if (statusStatsResult.success && statusStatsResult.data) {
|
||||
setStatusStats(statusStatsResult.data);
|
||||
}
|
||||
|
||||
// 출고불가 건수 계산
|
||||
const cannotShip = result.data.filter((item) => !item.canShip).length;
|
||||
setCannotShipCount(cannotShip);
|
||||
// 캘린더용 데이터 저장
|
||||
setShipmentData(result.data);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -211,57 +225,85 @@ export function ShipmentList() {
|
||||
},
|
||||
},
|
||||
|
||||
// 테이블 컬럼
|
||||
// 날짜 범위 선택기
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
showPresets: true,
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
|
||||
// 등록 버튼
|
||||
createButton: {
|
||||
label: '출고 등록',
|
||||
onClick: handleCreate,
|
||||
icon: Plus,
|
||||
},
|
||||
|
||||
// 테이블 컬럼 (18개 - 출고번호/로트번호 통합)
|
||||
columns: [
|
||||
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'shipmentNo', label: '출고번호', className: 'min-w-[130px]' },
|
||||
{ key: 'lotNo', label: '로트번호', className: 'min-w-[150px]' },
|
||||
{ key: 'scheduledDate', label: '출고예정일', className: 'w-[120px] text-center' },
|
||||
{ key: 'status', label: '상태', className: 'w-[100px] text-center' },
|
||||
{ key: 'canShip', label: '출하가능', className: 'w-[80px] text-center' },
|
||||
{ key: 'deliveryMethod', label: '배송', className: 'w-[100px] text-center' },
|
||||
{ key: 'customerName', label: '발주처', className: 'min-w-[120px]' },
|
||||
{ key: 'siteName', label: '현장명', className: 'min-w-[120px]' },
|
||||
{ key: 'manager', label: '담당', className: 'w-[80px] text-center' },
|
||||
{ key: 'deliveryTime', label: '납기(수배시간)', className: 'w-[120px] text-center' },
|
||||
{ key: 'no', label: '번호', className: 'w-[50px] text-center' },
|
||||
{ key: 'shipmentNo', label: '출고번호/로트번호', className: 'min-w-[160px]' },
|
||||
{ key: 'scheduledDate', label: '출고예정일', className: 'w-[100px] text-center' },
|
||||
{ key: 'siteName', label: '현장명', className: 'min-w-[100px]' },
|
||||
{ key: 'orderCustomer', label: '수주처', className: 'min-w-[100px]' },
|
||||
{ key: 'customerGrade', label: '거래등급', className: 'w-[80px] text-center' },
|
||||
{ key: 'receiver', label: '수신자', className: 'w-[80px] text-center' },
|
||||
{ key: 'receiverAddress', label: '수신주소', className: 'min-w-[140px]' },
|
||||
{ key: 'receiverCompany', label: '수신처', className: 'min-w-[100px]' },
|
||||
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
|
||||
{ key: 'dispatch', label: '배차', className: 'w-[80px] text-center' },
|
||||
{ key: 'arrivalDateTime', label: '입차일시', className: 'w-[130px] text-center' },
|
||||
{ key: 'tonnage', label: '톤수', className: 'w-[70px] text-center' },
|
||||
{ key: 'unloadingNo', label: '하차번호', className: 'w-[90px] text-center' },
|
||||
{ key: 'driverContact', label: '기사연락처', className: 'min-w-[110px] text-center' },
|
||||
{ key: 'writer', label: '작성자', className: 'w-[80px] text-center' },
|
||||
{ key: 'shipmentDate', label: '출고일', className: 'w-[100px] text-center' },
|
||||
],
|
||||
|
||||
// 배송방식 필터
|
||||
filterConfig: [{
|
||||
key: 'deliveryMethod',
|
||||
label: '배송방식',
|
||||
type: 'single' as const,
|
||||
options: [
|
||||
{ value: 'direct_dispatch', label: '직접배차' },
|
||||
{ value: 'loading', label: '상차' },
|
||||
{ value: 'kyungdong_delivery', label: '경동택배' },
|
||||
{ value: 'daesin_delivery', label: '대신택배' },
|
||||
{ value: 'kyungdong_freight', label: '경동화물' },
|
||||
{ value: 'daesin_freight', label: '대신화물' },
|
||||
{ value: 'self_pickup', label: '직접수령' },
|
||||
],
|
||||
allOptionLabel: '전체',
|
||||
}],
|
||||
|
||||
// 서버 사이드 페이지네이션
|
||||
clientSideFiltering: false,
|
||||
itemsPerPage: ITEMS_PER_PAGE,
|
||||
|
||||
// 검색
|
||||
searchPlaceholder: '출고번호, 로트번호, 발주처, 현장명 검색...',
|
||||
searchPlaceholder: '출고번호, 로트번호, 현장명, 수주처 검색...',
|
||||
searchFilter: (item: ShipmentItem, search: string) => {
|
||||
const s = search.toLowerCase();
|
||||
return (
|
||||
item.shipmentNo?.toLowerCase().includes(s) ||
|
||||
item.lotNo?.toLowerCase().includes(s) ||
|
||||
item.customerName?.toLowerCase().includes(s) ||
|
||||
item.siteName?.toLowerCase().includes(s) ||
|
||||
item.orderCustomer?.toLowerCase().includes(s) ||
|
||||
item.customerName?.toLowerCase().includes(s) ||
|
||||
false
|
||||
);
|
||||
},
|
||||
|
||||
// 탭 설정
|
||||
tabs,
|
||||
defaultTab: 'all',
|
||||
// 탭 삭제 (tabs 미설정)
|
||||
|
||||
// 통계 카드
|
||||
stats,
|
||||
|
||||
// 경고 배너
|
||||
alertBanner,
|
||||
|
||||
// 헤더 액션 (출하 등록)
|
||||
headerActions: () => (
|
||||
<Button onClick={handleCreate}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
출하 등록
|
||||
</Button>
|
||||
),
|
||||
|
||||
// 테이블 행 렌더링
|
||||
// 테이블 행 렌더링 (19개 컬럼)
|
||||
renderTableRow: (
|
||||
item: ShipmentItem,
|
||||
index: number,
|
||||
@@ -281,26 +323,31 @@ export function ShipmentList() {
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell className="font-medium">{item.shipmentNo}</TableCell>
|
||||
<TableCell>{item.lotNo}</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
<div>{item.shipmentNo}</div>
|
||||
{item.lotNo && item.lotNo !== item.shipmentNo && (
|
||||
<div className="text-xs text-muted-foreground">{item.lotNo}</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{item.scheduledDate}</TableCell>
|
||||
<TableCell className="max-w-[100px] truncate">{item.siteName}</TableCell>
|
||||
<TableCell>{item.orderCustomer || item.customerName || '-'}</TableCell>
|
||||
<TableCell className="text-center">{item.customerGrade || '-'}</TableCell>
|
||||
<TableCell className="text-center">{item.receiver || '-'}</TableCell>
|
||||
<TableCell className="max-w-[140px] truncate">{item.receiverAddress || '-'}</TableCell>
|
||||
<TableCell>{item.receiverCompany || '-'}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge className={`text-xs ${SHIPMENT_STATUS_STYLES[item.status]}`}>
|
||||
{SHIPMENT_STATUS_LABELS[item.status]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{item.canShip ? (
|
||||
<Check className="w-4 h-4 mx-auto text-green-600" />
|
||||
) : (
|
||||
<X className="w-4 h-4 mx-auto text-red-600" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{item.deliveryMethodLabel}</TableCell>
|
||||
<TableCell>{item.customerName}</TableCell>
|
||||
<TableCell className="max-w-[120px] truncate">{item.siteName}</TableCell>
|
||||
<TableCell className="text-center">{item.manager || '-'}</TableCell>
|
||||
<TableCell className="text-center">{item.deliveryTime || '-'}</TableCell>
|
||||
<TableCell className="text-center">{item.dispatch || item.deliveryMethodLabel || '-'}</TableCell>
|
||||
<TableCell className="text-center">{item.arrivalDateTime || '-'}</TableCell>
|
||||
<TableCell className="text-center">{item.tonnage || '-'}</TableCell>
|
||||
<TableCell className="text-center">{item.unloadingNo || '-'}</TableCell>
|
||||
<TableCell className="text-center">{item.driverContact || '-'}</TableCell>
|
||||
<TableCell className="text-center">{item.writer || item.manager || '-'}</TableCell>
|
||||
<TableCell className="text-center">{item.shipmentDate || '-'}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
@@ -337,11 +384,12 @@ export function ShipmentList() {
|
||||
}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField label="로트번호" value={item.lotNo} />
|
||||
<InfoField label="발주처" value={item.customerName} />
|
||||
<InfoField label="출고번호/로트번호" value={item.shipmentNo || item.lotNo} />
|
||||
<InfoField label="수주처" value={item.orderCustomer || item.customerName} />
|
||||
<InfoField label="출고예정일" value={item.scheduledDate} />
|
||||
<InfoField label="배송방식" value={item.deliveryMethodLabel} />
|
||||
<InfoField label="출하가능" value={item.canShip ? '가능' : '불가'} />
|
||||
<InfoField label="배송방식" value={item.deliveryMethodLabel || DELIVERY_METHOD_LABELS[item.deliveryMethod]} />
|
||||
<InfoField label="수신자" value={item.receiver || '-'} />
|
||||
<InfoField label="출고일" value={item.shipmentDate || '-'} />
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
@@ -365,8 +413,24 @@ export function ShipmentList() {
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
// 하단 캘린더 (시간축 주간 뷰)
|
||||
afterTableContent: (
|
||||
<ScheduleCalendar
|
||||
events={scheduleEvents}
|
||||
currentDate={calendarDate}
|
||||
view="week-time"
|
||||
onDateClick={handleCalendarDateClick}
|
||||
onEventClick={handleCalendarEventClick}
|
||||
onMonthChange={setCalendarDate}
|
||||
titleSlot="출고 스케줄"
|
||||
weekStartsOn={0}
|
||||
availableViews={[]}
|
||||
timeRange={{ start: 1, end: 12 }}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
[tabs, stats, alertBanner, handleRowClick, handleCreate]
|
||||
[stats, startDate, endDate, scheduleEvents, calendarDate, handleRowClick, handleCreate, handleCalendarDateClick, handleCalendarEventClick]
|
||||
);
|
||||
|
||||
return <UniversalListPage config={config} />;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 출하 관리 서버 액션
|
||||
* 출고 관리 서버 액션
|
||||
*
|
||||
* API Endpoints:
|
||||
* - GET /api/v1/shipments - 목록 조회
|
||||
@@ -29,6 +29,7 @@ import type {
|
||||
ShipmentStatus,
|
||||
ShipmentPriority,
|
||||
DeliveryMethod,
|
||||
FreightCostType,
|
||||
ShipmentCreateFormData,
|
||||
ShipmentEditFormData,
|
||||
LotOption,
|
||||
@@ -177,9 +178,12 @@ function transformApiToDetail(data: ShipmentApiData): ShipmentDetail {
|
||||
shipmentNo: data.shipment_no,
|
||||
lotNo: data.lot_no || '',
|
||||
scheduledDate: data.scheduled_date,
|
||||
shipmentDate: (data as Record<string, unknown>).shipment_date as string | undefined,
|
||||
status: data.status,
|
||||
priority: data.priority,
|
||||
deliveryMethod: data.delivery_method,
|
||||
freightCost: (data as Record<string, unknown>).freight_cost as FreightCostType | undefined,
|
||||
freightCostLabel: (data as Record<string, unknown>).freight_cost_label as string | undefined,
|
||||
depositConfirmed: data.deposit_confirmed,
|
||||
invoiceIssued: data.invoice_issued,
|
||||
customerGrade: data.customer_grade || '',
|
||||
@@ -193,6 +197,14 @@ function transformApiToDetail(data: ShipmentApiData): ShipmentDetail {
|
||||
deliveryAddress: data.order_info?.delivery_address || data.delivery_address || '',
|
||||
receiver: data.receiver,
|
||||
receiverContact: data.order_info?.contact || data.receiver_contact,
|
||||
zipCode: (data as Record<string, unknown>).zip_code as string | undefined,
|
||||
address: (data as Record<string, unknown>).address as string | undefined,
|
||||
addressDetail: (data as Record<string, unknown>).address_detail as string | undefined,
|
||||
// 배차 정보 (다중 행) - API 준비 후 연동
|
||||
vehicleDispatches: [],
|
||||
// 제품내용 (그룹핑) - 프론트엔드에서 그룹핑 처리
|
||||
productGroups: [],
|
||||
otherParts: [],
|
||||
products: (data.items || []).map(transformApiToProduct),
|
||||
logisticsCompany: data.logistics_company,
|
||||
vehicleTonnage: data.vehicle_tonnage,
|
||||
@@ -219,7 +231,7 @@ function transformApiToStats(data: ShipmentApiStatsResponse & { total_count?: nu
|
||||
const STATUS_TAB_LABELS: Record<string, string> = {
|
||||
all: '전체',
|
||||
scheduled: '출고예정',
|
||||
ready: '출하대기',
|
||||
ready: '출고대기',
|
||||
shipping: '배송중',
|
||||
completed: '배송완료',
|
||||
};
|
||||
@@ -286,7 +298,7 @@ interface PaginationMeta {
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ===== 출하 목록 조회 =====
|
||||
// ===== 출고 목록 조회 =====
|
||||
export async function getShipments(params?: {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
@@ -368,7 +380,7 @@ export async function getShipments(params?: {
|
||||
success: false,
|
||||
data: [],
|
||||
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 },
|
||||
error: result.message || '출하 목록 조회에 실패했습니다.',
|
||||
error: result.message || '출고 목록 조회에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -404,7 +416,7 @@ export async function getShipments(params?: {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 출하 통계 조회 =====
|
||||
// ===== 출고 통계 조회 =====
|
||||
export async function getShipmentStats(): Promise<{
|
||||
success: boolean;
|
||||
data?: ShipmentStats;
|
||||
@@ -432,7 +444,7 @@ export async function getShipmentStats(): Promise<{
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
return { success: false, error: result.message || '출하 통계 조회에 실패했습니다.' };
|
||||
return { success: false, error: result.message || '출고 통계 조회에 실패했습니다.' };
|
||||
}
|
||||
|
||||
return { success: true, data: transformApiToStats(result.data) };
|
||||
@@ -482,7 +494,7 @@ export async function getShipmentStatsByStatus(): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 출하 상세 조회 =====
|
||||
// ===== 출고 상세 조회 =====
|
||||
export async function getShipmentById(id: string): Promise<{
|
||||
success: boolean;
|
||||
data?: ShipmentDetail;
|
||||
@@ -510,7 +522,7 @@ export async function getShipmentById(id: string): Promise<{
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
return { success: false, error: result.message || '출하 조회에 실패했습니다.' };
|
||||
return { success: false, error: result.message || '출고 조회에 실패했습니다.' };
|
||||
}
|
||||
|
||||
return { success: true, data: transformApiToDetail(result.data) };
|
||||
@@ -521,7 +533,7 @@ export async function getShipmentById(id: string): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 출하 등록 =====
|
||||
// ===== 출고 등록 =====
|
||||
export async function createShipment(
|
||||
data: ShipmentCreateFormData
|
||||
): Promise<{ success: boolean; data?: ShipmentDetail; error?: string; __authError?: boolean }> {
|
||||
@@ -542,14 +554,14 @@ export async function createShipment(
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return { success: false, error: '출하 등록에 실패했습니다.' };
|
||||
return { success: false, error: '출고 등록에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('[ShipmentActions] POST shipment response:', result);
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
return { success: false, error: result.message || '출하 등록에 실패했습니다.' };
|
||||
return { success: false, error: result.message || '출고 등록에 실패했습니다.' };
|
||||
}
|
||||
|
||||
return { success: true, data: transformApiToDetail(result.data) };
|
||||
@@ -560,7 +572,7 @@ export async function createShipment(
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 출하 수정 =====
|
||||
// ===== 출고 수정 =====
|
||||
export async function updateShipment(
|
||||
id: string,
|
||||
data: Partial<ShipmentEditFormData>
|
||||
@@ -582,14 +594,14 @@ export async function updateShipment(
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return { success: false, error: '출하 수정에 실패했습니다.' };
|
||||
return { success: false, error: '출고 수정에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('[ShipmentActions] PUT shipment response:', result);
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
return { success: false, error: result.message || '출하 수정에 실패했습니다.' };
|
||||
return { success: false, error: result.message || '출고 수정에 실패했습니다.' };
|
||||
}
|
||||
|
||||
return { success: true, data: transformApiToDetail(result.data) };
|
||||
@@ -600,7 +612,7 @@ export async function updateShipment(
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 출하 상태 변경 =====
|
||||
// ===== 출고 상태 변경 =====
|
||||
export async function updateShipmentStatus(
|
||||
id: string,
|
||||
status: ShipmentStatus,
|
||||
@@ -655,7 +667,7 @@ export async function updateShipmentStatus(
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 출하 삭제 =====
|
||||
// ===== 출고 삭제 =====
|
||||
export async function deleteShipment(
|
||||
id: string
|
||||
): Promise<{ success: boolean; error?: string; __authError?: boolean }> {
|
||||
@@ -672,14 +684,14 @@ export async function deleteShipment(
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return { success: false, error: '출하 삭제에 실패했습니다.' };
|
||||
return { success: false, error: '출고 삭제에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('[ShipmentActions] DELETE shipment response:', result);
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
return { success: false, error: result.message || '출하 삭제에 실패했습니다.' };
|
||||
return { success: false, error: result.message || '출고 삭제에 실패했습니다.' };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
|
||||
@@ -1,196 +1,17 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 납품확인서 미리보기/인쇄 문서
|
||||
*
|
||||
* 공통 컴포넌트 사용:
|
||||
* - DocumentHeader: default 레이아웃 + 4col 결재란
|
||||
* 납품확인서 문서 컴포넌트
|
||||
* - 수주서 레이아웃 기반, 제목만 "납 품 확 인 서"로 변경
|
||||
*/
|
||||
|
||||
import type { ShipmentDetail } from '../types';
|
||||
import { DocumentHeader } from '@/components/document-system';
|
||||
import { ShipmentOrderDocument } from './ShipmentOrderDocument';
|
||||
|
||||
interface DeliveryConfirmationProps {
|
||||
data: ShipmentDetail;
|
||||
}
|
||||
|
||||
export function DeliveryConfirmation({ data }: DeliveryConfirmationProps) {
|
||||
return (
|
||||
<div className="bg-white p-8 max-w-3xl mx-auto text-sm print:p-0 print:max-w-none">
|
||||
{/* 문서 헤더 (공통 컴포넌트) */}
|
||||
<DocumentHeader
|
||||
title="납 품 확 인 서"
|
||||
logo={{ text: 'KD', subtext: '경동기업' }}
|
||||
layout="default"
|
||||
approval={{
|
||||
type: '4col',
|
||||
showDepartment: true,
|
||||
departmentLabels: { writer: '판매/전산', reviewer: '출하', approver: '품질' },
|
||||
}}
|
||||
className="mb-6"
|
||||
/>
|
||||
|
||||
{/* 출하 관리부서 */}
|
||||
<div className="text-xs text-muted-foreground mb-2">출하 관리부서</div>
|
||||
|
||||
{/* 연락처 */}
|
||||
<div className="text-xs mb-4">
|
||||
전화 : 031-938-5130 | 팩스 : 02-6911-6315 | 이메일 : kd5130@naver.com
|
||||
</div>
|
||||
|
||||
{/* 발주정보 / 납품정보 */}
|
||||
<div className="grid grid-cols-2 gap-0 mb-6">
|
||||
<div className="border">
|
||||
<div className="bg-muted px-3 py-2 font-medium text-center border-b">발주정보</div>
|
||||
<table className="w-full text-xs">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border-r border-b px-3 py-2 bg-muted w-24">발주일</td>
|
||||
<td className="border-b px-3 py-2">-</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border-r border-b px-3 py-2 bg-muted">발주처</td>
|
||||
<td className="border-b px-3 py-2">{data.customerName}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border-r border-b px-3 py-2 bg-muted">담당자</td>
|
||||
<td className="border-b px-3 py-2">-</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border-r border-b px-3 py-2 bg-muted">연락처</td>
|
||||
<td className="border-b px-3 py-2">-</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border-r px-3 py-2 bg-muted">제품 LOT NO.</td>
|
||||
<td className="px-3 py-2">{data.lotNo}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="border border-l-0">
|
||||
<div className="bg-muted px-3 py-2 font-medium text-center border-b">납품정보</div>
|
||||
<table className="w-full text-xs">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border-r border-b px-3 py-2 bg-muted w-24">납품일</td>
|
||||
<td className="border-b px-3 py-2">{data.scheduledDate}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border-r border-b px-3 py-2 bg-muted">현장명</td>
|
||||
<td className="border-b px-3 py-2">{data.siteName}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border-r border-b px-3 py-2 bg-muted">인수업체명</td>
|
||||
<td className="border-b px-3 py-2">현판업</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border-r border-b px-3 py-2 bg-muted">인수자연락처</td>
|
||||
<td className="border-b px-3 py-2">{data.receiverContact}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border-r px-3 py-2 bg-muted">납품지 주소</td>
|
||||
<td className="px-3 py-2">{data.deliveryAddress}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 납품 품목 */}
|
||||
<h3 className="font-medium mb-2">납품 품목</h3>
|
||||
<table className="w-full border-collapse mb-6 text-xs">
|
||||
<thead>
|
||||
<tr className="bg-muted">
|
||||
<th className="border px-2 py-2 text-center w-12">No</th>
|
||||
<th className="border px-2 py-2 text-left">품 명</th>
|
||||
<th className="border px-2 py-2 text-center w-24">규 격</th>
|
||||
<th className="border px-2 py-2 text-center w-16">단위</th>
|
||||
<th className="border px-2 py-2 text-center w-16">수량</th>
|
||||
<th className="border px-2 py-2 text-center w-20">비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.products.map((product, index) => (
|
||||
<tr key={product.id}>
|
||||
<td className="border px-2 py-2 text-center">{product.no}</td>
|
||||
<td className="border px-2 py-2">{product.itemName}</td>
|
||||
<td className="border px-2 py-2 text-center">{product.specification}</td>
|
||||
<td className="border px-2 py-2 text-center">SET</td>
|
||||
<td className="border px-2 py-2 text-center">{product.quantity}</td>
|
||||
<td className="border px-2 py-2 text-center">{product.floorUnit}</td>
|
||||
</tr>
|
||||
))}
|
||||
{/* 빈 행 채우기 (최소 10행) */}
|
||||
{Array.from({ length: Math.max(0, 10 - data.products.length) }).map((_, i) => (
|
||||
<tr key={`empty-${i}`}>
|
||||
<td className="border px-2 py-2 text-center">{data.products.length + i + 1}</td>
|
||||
<td className="border px-2 py-2"> </td>
|
||||
<td className="border px-2 py-2"></td>
|
||||
<td className="border px-2 py-2"></td>
|
||||
<td className="border px-2 py-2"></td>
|
||||
<td className="border px-2 py-2"></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* 특기사항 */}
|
||||
<div className="border p-4 mb-6">
|
||||
<h3 className="font-medium mb-2">특기사항</h3>
|
||||
<div className="min-h-16 text-xs">
|
||||
위 물품을 상기와 같이 납품합니다.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 서명 영역 */}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* 납품자 */}
|
||||
<div className="border p-4">
|
||||
<h3 className="font-medium mb-3 text-center">납 품 자</h3>
|
||||
<table className="w-full text-xs">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="py-2 w-20">회 사 명</td>
|
||||
<td className="py-2 font-medium">경동기업</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2">담 당 자</td>
|
||||
<td className="py-2">전진</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2">서명/날인</td>
|
||||
<td className="py-2"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 인수자 */}
|
||||
<div className="border p-4">
|
||||
<h3 className="font-medium mb-3 text-center">인 수 자</h3>
|
||||
<table className="w-full text-xs">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="py-2 w-20">회 사 명</td>
|
||||
<td className="py-2"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2">담 당 자</td>
|
||||
<td className="py-2"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2">서명/날인</td>
|
||||
<td className="py-2"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 확인 문구 */}
|
||||
<p className="text-center text-xs text-muted-foreground mt-6">
|
||||
상기 물품을 정히 인수하였음을 확인합니다.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <ShipmentOrderDocument title="납 품 확 인 서" data={data} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,561 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 출고 문서 공통 컴포넌트 (수주서 레이아웃 기반)
|
||||
* - 출고증, 납품확인서에서 제목만 변경하여 사용
|
||||
* - 수주서(SalesOrderDocument)와 동일한 레이아웃
|
||||
*/
|
||||
|
||||
import type { ShipmentDetail } from '../types';
|
||||
import { DELIVERY_METHOD_LABELS } from '../types';
|
||||
import { ConstructionApprovalTable } from '@/components/document-system';
|
||||
|
||||
interface ShipmentOrderDocumentProps {
|
||||
title: string;
|
||||
data: ShipmentDetail;
|
||||
}
|
||||
|
||||
export function ShipmentOrderDocument({ title, data }: ShipmentOrderDocumentProps) {
|
||||
// 스크린 제품 필터링 (productGroups 기반)
|
||||
const screenProducts = data.productGroups.filter(g =>
|
||||
g.productName?.includes('스크린') ||
|
||||
g.productName?.includes('방화') ||
|
||||
g.productName?.includes('셔터')
|
||||
);
|
||||
|
||||
// 전체 부품 목록
|
||||
const allParts = [
|
||||
...data.productGroups.flatMap(g => g.parts),
|
||||
...data.otherParts,
|
||||
];
|
||||
|
||||
// 모터 아이템 필터링
|
||||
const motorItems = allParts.filter(part =>
|
||||
part.itemName?.includes('모터')
|
||||
);
|
||||
|
||||
// 브라켓 아이템 필터링
|
||||
const bracketItems = allParts.filter(part =>
|
||||
part.itemName?.includes('브라켓')
|
||||
);
|
||||
|
||||
// 가이드레일 아이템 필터링
|
||||
const guideRailItems = allParts.filter(part =>
|
||||
part.itemName?.includes('가이드') ||
|
||||
part.itemName?.includes('레일')
|
||||
);
|
||||
|
||||
// 케이스 아이템 필터링
|
||||
const caseItems = allParts.filter(part =>
|
||||
part.itemName?.includes('케이스') ||
|
||||
part.itemName?.includes('셔터박스')
|
||||
);
|
||||
|
||||
// 하단마감재 아이템 필터링
|
||||
const bottomFinishItems = allParts.filter(part =>
|
||||
part.itemName?.includes('하단') ||
|
||||
part.itemName?.includes('마감')
|
||||
);
|
||||
|
||||
const deliveryMethodLabel = DELIVERY_METHOD_LABELS[data.deliveryMethod] || '-';
|
||||
const fullAddress = [data.address, data.addressDetail].filter(Boolean).join(' ') || data.deliveryAddress || '-';
|
||||
|
||||
return (
|
||||
<div className="bg-white p-8 min-h-full text-[11px]">
|
||||
{/* 헤더: 제목 (좌측) + 결재란 (우측) */}
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-widest mb-2">{title}</h1>
|
||||
<div className="text-[10px] space-y-1">
|
||||
<div className="flex gap-4">
|
||||
<span>문서번호: <strong>{data.shipmentNo}</strong></span>
|
||||
<span>작성일자: <strong>{data.scheduledDate}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 결재란 (우측) */}
|
||||
<ConstructionApprovalTable
|
||||
approvers={{ writer: { name: '홍길동' } }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 상품명 / 제품명 / 로트번호 / 인정번호 */}
|
||||
<table className="border border-gray-400 w-full mb-3 text-[10px]">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="bg-gray-100 px-2 py-1 font-medium border-r border-gray-400 whitespace-nowrap">상품명</td>
|
||||
<td className="px-2 py-1 border-r border-gray-400">{data.productGroups[0]?.productName || '-'}</td>
|
||||
<td className="bg-gray-100 px-2 py-1 font-medium border-r border-gray-400 whitespace-nowrap">제품명</td>
|
||||
<td className="px-2 py-1 border-r border-gray-400">{data.products[0]?.itemName || '-'}</td>
|
||||
<td className="bg-gray-100 px-2 py-1 font-medium border-r border-gray-400 whitespace-nowrap">로트번호</td>
|
||||
<td className="px-2 py-1 border-r border-gray-400">{data.lotNo}</td>
|
||||
<td className="bg-gray-100 px-2 py-1 font-medium border-r border-gray-400 whitespace-nowrap">인정번호</td>
|
||||
<td className="px-2 py-1">-</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* 3열 섹션: 신청업체 | 신청내용 | 납품정보 */}
|
||||
<div className="border border-gray-400 mb-4">
|
||||
<div className="grid grid-cols-3">
|
||||
{/* 신청업체 */}
|
||||
<div className="border-r border-gray-400">
|
||||
<div className="bg-gray-200 text-center py-1 font-bold border-b border-gray-400">신청업체</div>
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="bg-gray-100 px-2 py-1 w-20 font-medium">수주일</td>
|
||||
<td className="px-2 py-1">{data.scheduledDate}</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="bg-gray-100 px-2 py-1 font-medium">업체명</td>
|
||||
<td className="px-2 py-1">{data.customerName}</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="bg-gray-100 px-2 py-1 font-medium">수주 담당자</td>
|
||||
<td className="px-2 py-1">{data.registrant || '-'}</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="bg-gray-100 px-2 py-1 font-medium">담당자 연락처</td>
|
||||
<td className="px-2 py-1">{data.driverContact || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="bg-gray-100 px-2 py-1 font-medium">배송지 주소</td>
|
||||
<td className="px-2 py-1">{fullAddress}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 신청내용 */}
|
||||
<div className="border-r border-gray-400">
|
||||
<div className="bg-gray-200 text-center py-1 font-bold border-b border-gray-400">신청내용</div>
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="bg-gray-100 px-2 py-1 w-20 font-medium">현장명</td>
|
||||
<td className="px-2 py-1">{data.siteName}</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="bg-gray-100 px-2 py-1 font-medium">납기요청일</td>
|
||||
<td className="px-2 py-1">{data.scheduledDate}</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="bg-gray-100 px-2 py-1 font-medium">출고일</td>
|
||||
<td className="px-2 py-1">{data.shipmentDate || data.scheduledDate}</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="bg-gray-100 px-2 py-1 font-medium">셔터출수량</td>
|
||||
<td className="px-2 py-1">{data.productGroups.length}개소</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="bg-gray-100 px-2 py-1 font-medium"> </td>
|
||||
<td className="px-2 py-1"> </td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 납품정보 */}
|
||||
<div>
|
||||
<div className="bg-gray-200 text-center py-1 font-bold border-b border-gray-400">납품정보</div>
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="bg-gray-100 px-2 py-1 w-20 font-medium">현장명</td>
|
||||
<td className="px-2 py-1">{data.siteName}</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="bg-gray-100 px-2 py-1 font-medium">인수담당자</td>
|
||||
<td className="px-2 py-1">{data.receiver || '-'}</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="bg-gray-100 px-2 py-1 font-medium">인수자연락처</td>
|
||||
<td className="px-2 py-1">{data.receiverContact || '-'}</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="bg-gray-100 px-2 py-1 font-medium">배송방법</td>
|
||||
<td className="px-2 py-1">{deliveryMethodLabel}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="bg-gray-100 px-2 py-1 font-medium"> </td>
|
||||
<td className="px-2 py-1"> </td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] mb-4">아래와 같이 주문하오니 품질 및 납기일을 준수하여 주시기 바랍니다.</p>
|
||||
|
||||
{/* 1. 스크린 테이블 */}
|
||||
<div className="mb-4">
|
||||
<p className="font-bold mb-2">1. 스크린</p>
|
||||
<div className="border border-gray-400">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-100 border-b border-gray-400">
|
||||
<th className="border-r border-gray-400 px-1 py-1 w-8" rowSpan={2}>No</th>
|
||||
<th className="border-r border-gray-400 px-1 py-1 w-16" rowSpan={2}>품류</th>
|
||||
<th className="border-r border-gray-400 px-1 py-1 w-14" rowSpan={2}>부호</th>
|
||||
<th className="border-r border-gray-400 px-1 py-1" colSpan={2}>오픈사이즈</th>
|
||||
<th className="border-r border-gray-400 px-1 py-1" colSpan={2}>제작사이즈</th>
|
||||
<th className="border-r border-gray-400 px-1 py-1 w-24" rowSpan={2}>가이드레일<br/>유형</th>
|
||||
<th className="border-r border-gray-400 px-1 py-1 w-14" rowSpan={2}>샤프트<br/>(인치)</th>
|
||||
<th className="border-r border-gray-400 px-1 py-1 w-14" rowSpan={2}>케이스<br/>(인치)</th>
|
||||
<th className="border-r border-gray-400 px-1 py-1" colSpan={2}>모터</th>
|
||||
<th className="px-1 py-1 w-16" rowSpan={2}>마감</th>
|
||||
</tr>
|
||||
<tr className="bg-gray-50 border-b border-gray-400 text-[9px]">
|
||||
<th className="border-r border-gray-400 px-1">가로</th>
|
||||
<th className="border-r border-gray-400 px-1">세로</th>
|
||||
<th className="border-r border-gray-400 px-1">가로</th>
|
||||
<th className="border-r border-gray-400 px-1">세로</th>
|
||||
<th className="border-r border-gray-400 px-1">브라켓트</th>
|
||||
<th className="border-r border-gray-400 px-1">용량Kg</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{screenProducts.length > 0 ? (
|
||||
screenProducts.map((group, index) => {
|
||||
// specification에서 가로x세로 파싱 (예: "2000 x 2500 mm")
|
||||
const specMatch = group.specification?.match(/(\d+)\s*[xX×]\s*(\d+)/);
|
||||
const width = specMatch ? specMatch[1] : '-';
|
||||
const height = specMatch ? specMatch[2] : '-';
|
||||
|
||||
return (
|
||||
<tr key={group.id} className="border-b border-gray-300">
|
||||
<td className="border-r border-gray-300 px-1 py-1 text-center">{index + 1}</td>
|
||||
<td className="border-r border-gray-300 px-1 py-1 text-center">{group.productName || '-'}</td>
|
||||
<td className="border-r border-gray-300 px-1 py-1 text-center">-</td>
|
||||
<td className="border-r border-gray-300 px-1 py-1 text-center">{width}</td>
|
||||
<td className="border-r border-gray-300 px-1 py-1 text-center">{height}</td>
|
||||
<td className="border-r border-gray-300 px-1 py-1 text-center">{width}</td>
|
||||
<td className="border-r border-gray-300 px-1 py-1 text-center">{height}</td>
|
||||
<td className="border-r border-gray-300 px-1 py-1 text-center text-[9px]">백면형<br/>(120X70)</td>
|
||||
<td className="border-r border-gray-300 px-1 py-1 text-center">5</td>
|
||||
<td className="border-r border-gray-300 px-1 py-1 text-center">5</td>
|
||||
<td className="border-r border-gray-300 px-1 py-1 text-center">380X180</td>
|
||||
<td className="border-r border-gray-300 px-1 py-1 text-center">300</td>
|
||||
<td className="px-1 py-1 text-center">SUS마감</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={13} className="px-2 py-3 text-center text-gray-400">
|
||||
등록된 스크린 제품이 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. 모터 테이블 */}
|
||||
<div className="mb-4">
|
||||
<p className="font-bold mb-2">2. 모터</p>
|
||||
<div className="border border-gray-400">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-100 border-b border-gray-400">
|
||||
<th className="border-r border-gray-400 px-2 py-1 w-28">항목</th>
|
||||
<th className="border-r border-gray-400 px-2 py-1 w-20">구분</th>
|
||||
<th className="border-r border-gray-400 px-2 py-1 w-28">규격</th>
|
||||
<th className="border-r border-gray-400 px-2 py-1 w-14">수량</th>
|
||||
<th className="border-r border-gray-400 px-2 py-1 w-28">항목</th>
|
||||
<th className="border-r border-gray-400 px-2 py-1 w-20">구분</th>
|
||||
<th className="border-r border-gray-400 px-2 py-1 w-28">규격</th>
|
||||
<th className="px-2 py-1 w-14">수량</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(motorItems.length > 0 || bracketItems.length > 0) ? (
|
||||
<>
|
||||
{/* 모터 행 */}
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="border-r border-gray-300 px-2 py-1">모터(380V 단상)</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1">모터 용량</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1">{motorItems[0]?.specification || 'KD-150K'}</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1 text-center">{motorItems[0]?.quantity ?? '-'}</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1">모터(380V 단상)</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1">모터 용량</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1">{motorItems[1]?.specification || 'KD-150K'}</td>
|
||||
<td className="px-2 py-1 text-center">{motorItems[1]?.quantity ?? '-'}</td>
|
||||
</tr>
|
||||
{/* 브라켓트 행 */}
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="border-r border-gray-300 px-2 py-1">브라켓트</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1">브라켓트</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1">{bracketItems[0]?.specification || '380X180 [2-4"]'}</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1 text-center">{bracketItems[0]?.quantity ?? '-'}</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1">브라켓트</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1">브라켓트</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1">{bracketItems[1]?.specification || '380X180 [2-4"]'}</td>
|
||||
<td className="px-2 py-1 text-center">{bracketItems[1]?.quantity ?? '-'}</td>
|
||||
</tr>
|
||||
{/* 브라켓트 추가 행 */}
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="border-r border-gray-300 px-2 py-1">브라켓트</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1">밑침통 영금</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1">{bracketItems[2]?.specification || '∠40-40 L380'}</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1 text-center">{bracketItems[2]?.quantity ?? '-'}</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1"></td>
|
||||
<td className="border-r border-gray-300 px-2 py-1"></td>
|
||||
<td className="border-r border-gray-300 px-2 py-1"></td>
|
||||
<td className="px-2 py-1 text-center"></td>
|
||||
</tr>
|
||||
</>
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-2 py-3 text-center text-gray-400">
|
||||
등록된 모터/브라켓 품목이 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. 절곡물 */}
|
||||
<div className="mb-4">
|
||||
<p className="font-bold mb-2">3. 절곡물</p>
|
||||
|
||||
{/* 3-1. 가이드레일 */}
|
||||
<div className="mb-3">
|
||||
<p className="text-[10px] font-medium mb-1">3-1. 가이드레일 - EGI 1.5ST + 마감재 EGI 1.1ST + 별도마감재 SUS 1.1ST</p>
|
||||
<div className="border border-gray-400">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-100 border-b border-gray-400">
|
||||
<th className="border-r border-gray-400 px-2 py-1 w-24">백면형 (120X70)</th>
|
||||
<th className="border-r border-gray-400 px-2 py-1 w-16">길이</th>
|
||||
<th className="border-r border-gray-400 px-2 py-1 w-12">수량</th>
|
||||
<th className="border-r border-gray-400 px-2 py-1 w-24">측면형 (120X120)</th>
|
||||
<th className="border-r border-gray-400 px-2 py-1 w-16">길이</th>
|
||||
<th className="px-2 py-1 w-12">수량</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{guideRailItems.length > 0 ? (
|
||||
<>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="border-r border-gray-300 px-2 py-1 text-center text-gray-400" rowSpan={4}>
|
||||
<div className="flex items-center justify-center h-20 border border-dashed border-gray-300">
|
||||
IMG
|
||||
</div>
|
||||
</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1 text-center">L: 3,000</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1 text-center">{guideRailItems[0]?.quantity ?? 22}</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1 text-center text-gray-400" rowSpan={4}>
|
||||
<div className="flex items-center justify-center h-20 border border-dashed border-gray-300">
|
||||
IMG
|
||||
</div>
|
||||
</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1 text-center">L: 3,000</td>
|
||||
<td className="px-2 py-1 text-center">{guideRailItems[1]?.quantity ?? 22}</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="border-r border-gray-300 px-2 py-1 text-center text-[9px]">하부BASE<br/>[130X80]</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1 text-center">22</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1 text-center"></td>
|
||||
<td className="px-2 py-1 text-center"></td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="border-r border-gray-300 px-2 py-1 text-center"></td>
|
||||
<td className="border-r border-gray-300 px-2 py-1 text-center"></td>
|
||||
<td className="border-r border-gray-300 px-2 py-1 text-center"></td>
|
||||
<td className="px-2 py-1 text-center"></td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="border-r border-gray-300 px-2 py-1 text-center bg-gray-100">제품명</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1 text-center">KSS01</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1 text-center bg-gray-100">제품명</td>
|
||||
<td className="px-2 py-1 text-center">KSS01</td>
|
||||
</tr>
|
||||
</>
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-2 py-2 text-center text-gray-400">
|
||||
등록된 가이드레일이 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 연기차단재 정보 */}
|
||||
<div className="mt-2 border border-gray-400">
|
||||
<table className="w-full text-[10px]">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border-r border-gray-300 px-2 py-1 w-32" rowSpan={2}>
|
||||
<div className="font-medium">연기차단재(W50)</div>
|
||||
<div>• 가이드레일 마감재</div>
|
||||
<div className="text-red-600 font-medium">양측에 설치</div>
|
||||
</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1 w-32" rowSpan={2}>
|
||||
<div>EGI 0.8T +</div>
|
||||
<div>화이버글라스코팅직물</div>
|
||||
</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1 text-center text-gray-400 w-20" rowSpan={2}>
|
||||
<div className="flex items-center justify-center h-10 border border-dashed border-gray-300">
|
||||
IMG
|
||||
</div>
|
||||
</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1 bg-gray-100">규격</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1 text-center">3,000</td>
|
||||
<td className="px-2 py-1 text-center">4,000</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border-r border-gray-300 px-2 py-1 bg-gray-100">수량</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1 text-center">44</td>
|
||||
<td className="px-2 py-1 text-center">1</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-[10px]">
|
||||
<span className="font-medium">• 별도 추가사항</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3-2. 케이스(셔터박스) */}
|
||||
<div className="mb-3">
|
||||
<p className="text-[10px] font-medium mb-1">3-2. 케이스(셔터박스) - EGI 1.5ST</p>
|
||||
<div className="border border-gray-400">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-100 border-b border-gray-400">
|
||||
<th className="border-r border-gray-400 px-2 py-1 w-24"> </th>
|
||||
<th className="border-r border-gray-400 px-2 py-1 w-24">규격</th>
|
||||
<th className="border-r border-gray-400 px-2 py-1 w-20">길이</th>
|
||||
<th className="border-r border-gray-400 px-2 py-1 w-12">수량</th>
|
||||
<th className="border-r border-gray-400 px-2 py-1 w-20">측면부</th>
|
||||
<th className="px-2 py-1 w-12">수량</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{caseItems.length > 0 ? (
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="border-r border-gray-300 px-2 py-1 text-center text-gray-400" rowSpan={3}>
|
||||
<div className="flex items-center justify-center h-16 border border-dashed border-gray-300">
|
||||
IMG
|
||||
</div>
|
||||
</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1 text-center text-[9px]">
|
||||
500X330<br/>(150X300,<br/>400K원)
|
||||
</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1 text-center text-[9px]">
|
||||
L: 4,000<br/>L: 5,000<br/>상부덮개<br/>(1219X389)
|
||||
</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1 text-center text-[9px]">
|
||||
3<br/>4<br/>55
|
||||
</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1 text-center">500X355</td>
|
||||
<td className="px-2 py-1 text-center">22</td>
|
||||
</tr>
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-2 py-2 text-center text-gray-400">
|
||||
등록된 케이스가 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 연기차단재 정보 */}
|
||||
<div className="mt-2 border border-gray-400">
|
||||
<table className="w-full text-[10px]">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border-r border-gray-300 px-2 py-1 w-32" rowSpan={2}>
|
||||
<div className="font-medium">연기차단재(W50)</div>
|
||||
<div>• 판넬부, 전면부</div>
|
||||
<div className="text-red-600 font-medium">감싸에 설치</div>
|
||||
</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1 w-32" rowSpan={2}>
|
||||
<div>EGI 0.8T +</div>
|
||||
<div>화이버글라스코팅직물</div>
|
||||
</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1 text-center text-gray-400 w-20" rowSpan={2}>
|
||||
<div className="flex items-center justify-center h-10 border border-dashed border-gray-300">
|
||||
IMG
|
||||
</div>
|
||||
</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1 bg-gray-100">규격</td>
|
||||
<td className="px-2 py-1 text-center">3,000</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border-r border-gray-300 px-2 py-1 bg-gray-100">수량</td>
|
||||
<td className="px-2 py-1 text-center">44</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3-3. 하단마감재 */}
|
||||
<div className="mb-3">
|
||||
<p className="text-[10px] font-medium mb-1">3-3. 하단마감재 - 하단마감재(EGI 1.5ST) + 하단보강앨비(EGI 1.5ST) + 하단 보강철(EGI 1.1ST) + 하단 무게형 철(50X12T)</p>
|
||||
<div className="border border-gray-400">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-100 border-b border-gray-400">
|
||||
<th className="border-r border-gray-400 px-2 py-1 w-20">구성품</th>
|
||||
<th className="border-r border-gray-400 px-2 py-1 w-16">길이</th>
|
||||
<th className="border-r border-gray-400 px-2 py-1 w-12">수량</th>
|
||||
<th className="border-r border-gray-400 px-2 py-1 w-20">구성품</th>
|
||||
<th className="border-r border-gray-400 px-2 py-1 w-16">길이</th>
|
||||
<th className="border-r border-gray-400 px-2 py-1 w-12">수량</th>
|
||||
<th className="border-r border-gray-400 px-2 py-1 w-20">구성품</th>
|
||||
<th className="border-r border-gray-400 px-2 py-1 w-16">길이</th>
|
||||
<th className="px-2 py-1 w-12">수량</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bottomFinishItems.length > 0 ? (
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="border-r border-gray-300 px-2 py-1 text-[9px]">하단마감재<br/>(60X40)</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1 text-center">L: 4,000</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1 text-center">11</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1 text-[9px]">하단보강<br/>(60X17)</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1 text-center">L: 4,000</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1 text-center">11</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1 text-[9px]">하단무게<br/>[50X12T]</td>
|
||||
<td className="border-r border-gray-300 px-2 py-1 text-center">L: 4,000</td>
|
||||
<td className="px-2 py-1 text-center">11</td>
|
||||
</tr>
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={9} className="px-2 py-2 text-center text-gray-400">
|
||||
등록된 하단마감재가 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 특이사항 */}
|
||||
{data.remarks && (
|
||||
<div className="mb-4">
|
||||
<p className="font-bold mb-2">【 특이사항 】</p>
|
||||
<div className="border border-gray-400 p-3 min-h-[40px]">
|
||||
{data.remarks}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,225 +1,17 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 출고증 미리보기/인쇄 문서
|
||||
*
|
||||
* 공통 컴포넌트 사용:
|
||||
* - DocumentHeader: default 레이아웃 + 4col 결재란
|
||||
* 출고증 문서 컴포넌트
|
||||
* - 수주서 레이아웃 기반, 제목만 "출 고 증"으로 변경
|
||||
*/
|
||||
|
||||
import type { ShipmentDetail } from '../types';
|
||||
import { DocumentHeader } from '@/components/document-system';
|
||||
import { ShipmentOrderDocument } from './ShipmentOrderDocument';
|
||||
|
||||
interface ShippingSlipProps {
|
||||
data: ShipmentDetail;
|
||||
}
|
||||
|
||||
export function ShippingSlip({ data }: ShippingSlipProps) {
|
||||
return (
|
||||
<div className="bg-white p-8 max-w-4xl mx-auto text-sm print:p-0 print:max-w-none">
|
||||
{/* 문서 헤더 (공통 컴포넌트) */}
|
||||
<DocumentHeader
|
||||
title="출 고 증"
|
||||
logo={{ text: 'KD', subtext: '경동기업' }}
|
||||
layout="default"
|
||||
approval={{
|
||||
type: '4col',
|
||||
writer: { name: '판매1팀 임', date: '12-20' },
|
||||
showDepartment: true,
|
||||
departmentLabels: { writer: '판매/전진', reviewer: '출하', approver: '생산관리' },
|
||||
}}
|
||||
className="mb-6 border-b pb-4"
|
||||
/>
|
||||
|
||||
{/* 출하 관리 */}
|
||||
<div className="text-xs text-muted-foreground mb-2">출하 관리</div>
|
||||
|
||||
{/* LOT 및 연락처 정보 */}
|
||||
<div className="grid grid-cols-5 gap-2 mb-4 text-xs">
|
||||
<div className="border px-2 py-1 bg-muted font-medium">제품 LOT NO.</div>
|
||||
<div className="border px-2 py-1">{data.lotNo}</div>
|
||||
<div className="border px-2 py-1 col-span-3">
|
||||
전화 : 031-938-5130 | 팩스 : 02-6911-6315 | 이메일 : kd5130@naver.com
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 품목 정보 */}
|
||||
<div className="grid grid-cols-5 gap-0 mb-4 text-xs border">
|
||||
<div className="border px-2 py-1 bg-muted font-medium">상품명</div>
|
||||
<div className="border px-2 py-1">국산철물스크린폴리스페서</div>
|
||||
<div className="border px-2 py-1 bg-muted font-medium">제품코드</div>
|
||||
<div className="border px-2 py-1">KWE01</div>
|
||||
<div className="border px-2 py-1 bg-muted font-medium">단발호</div>
|
||||
<div className="border px-2 py-1 col-span-4">FDS-0T523-0117-4</div>
|
||||
</div>
|
||||
|
||||
{/* 신청업체 / 신청내용 / 납품정보 */}
|
||||
<table className="w-full border-collapse mb-6 text-xs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="border px-2 py-1 bg-muted text-center" colSpan={2}>신청업체</th>
|
||||
<th className="border px-2 py-1 bg-muted text-center" colSpan={2}>신청내용</th>
|
||||
<th className="border px-2 py-1 bg-muted text-center" colSpan={2}>납품정보</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border px-2 py-1 bg-muted">발주일</td>
|
||||
<td className="border px-2 py-1">-</td>
|
||||
<td className="border px-2 py-1 bg-muted">현장</td>
|
||||
<td className="border px-2 py-1">위브 청라</td>
|
||||
<td className="border px-2 py-1 bg-muted">인수업체</td>
|
||||
<td className="border px-2 py-1">{data.customerName}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border px-2 py-1 bg-muted">발주처</td>
|
||||
<td className="border px-2 py-1">{data.customerName}</td>
|
||||
<td className="border px-2 py-1 bg-muted">납기일정일</td>
|
||||
<td className="border px-2 py-1">-</td>
|
||||
<td className="border px-2 py-1 bg-muted">인수자연락처</td>
|
||||
<td className="border px-2 py-1">{data.receiverContact}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border px-2 py-1 bg-muted">발주 담당자</td>
|
||||
<td className="border px-2 py-1">-</td>
|
||||
<td className="border px-2 py-1 bg-muted">출고일</td>
|
||||
<td className="border px-2 py-1">{data.scheduledDate}</td>
|
||||
<td className="border px-2 py-1 bg-muted">인수자</td>
|
||||
<td className="border px-2 py-1">{data.receiver || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border px-2 py-1 bg-muted">담당자 연락처</td>
|
||||
<td className="border px-2 py-1">-</td>
|
||||
<td className="border px-2 py-1 bg-muted">세미물류수</td>
|
||||
<td className="border px-2 py-1">-</td>
|
||||
<td className="border px-2 py-1 bg-muted">출고 방법</td>
|
||||
<td className="border px-2 py-1">직접배차 직접</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border px-2 py-1 bg-muted" colSpan={2}>배송지 주소</td>
|
||||
<td className="border px-2 py-1" colSpan={4}>{data.deliveryAddress}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* 1. 부자재 */}
|
||||
<div className="mb-4">
|
||||
<h3 className="font-medium mb-2">1. 부자재 - 강기샤프트, 라파이프, 앵글</h3>
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="border px-2 py-1 bg-muted">강기샤프트</th>
|
||||
<th className="border px-2 py-1 bg-muted">수량</th>
|
||||
<th className="border px-2 py-1 bg-muted">강기샤프트</th>
|
||||
<th className="border px-2 py-1 bg-muted">수량</th>
|
||||
<th className="border px-2 py-1 bg-muted">라파이프</th>
|
||||
<th className="border px-2 py-1 bg-muted">수량</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border px-2 py-1">4인치<br />L : 3,000<br />L : 4,500</td>
|
||||
<td className="border px-2 py-1 text-center">1</td>
|
||||
<td className="border px-2 py-1">5인치<br />L : 6,000<br />L : 7,000</td>
|
||||
<td className="border px-2 py-1 text-center">0</td>
|
||||
<td className="border px-2 py-1">(50*30*1.4T)<br />L : 6,000</td>
|
||||
<td className="border px-2 py-1 text-center">5</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border px-2 py-1" colSpan={4}>※ 별도 추가사항 - 부자재</td>
|
||||
<td className="border px-2 py-1 bg-muted">앵글<br />(40*40*3T)</td>
|
||||
<td className="border px-2 py-1 text-center">4</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 2. 모터 */}
|
||||
<div className="mb-6">
|
||||
<h3 className="font-medium mb-2">2. 모터</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="border px-2 py-1 bg-muted" colSpan={3}>2-1. 모터(220V 단상)</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="border px-2 py-1 bg-muted">모터 용량</th>
|
||||
<th className="border px-2 py-1 bg-muted">수량</th>
|
||||
<th className="border px-2 py-1 bg-muted">입고 LOT NO.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border px-2 py-1">KD-300K</td>
|
||||
<td className="border px-2 py-1 text-center">0</td>
|
||||
<td className="border px-2 py-1"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="border px-2 py-1 bg-muted" colSpan={3}>2-2. 모터(380V 삼상)</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="border px-2 py-1 bg-muted">모터 용량</th>
|
||||
<th className="border px-2 py-1 bg-muted">수량</th>
|
||||
<th className="border px-2 py-1 bg-muted">입고 LOT NO.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border px-2 py-1">KD-300K<br />KD-400K</td>
|
||||
<td className="border px-2 py-1 text-center">0</td>
|
||||
<td className="border px-2 py-1"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 mt-4">
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="border px-2 py-1 bg-muted" colSpan={2}>2-3. 브라켓트</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border px-2 py-1">380*180 (2-4")</td>
|
||||
<td className="border px-2 py-1 text-center">0</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border px-2 py-1">380*180 (2-5")</td>
|
||||
<td className="border px-2 py-1 text-center">0</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="border px-2 py-1 bg-muted" colSpan={2}>2-4. 연동파이프</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border px-2 py-1">매립형</td>
|
||||
<td className="border px-2 py-1 text-center">1</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border px-2 py-1">노출형</td>
|
||||
<td className="border px-2 py-1 text-center">0</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 버튼 */}
|
||||
<div className="flex justify-end gap-2 print:hidden">
|
||||
<button className="px-4 py-1 border rounded text-sm">출고 담당</button>
|
||||
<button className="px-4 py-1 border rounded text-sm">인수 확인</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <ShipmentOrderDocument title="출 고 증" data={data} />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 출하관리 컴포넌트 Export
|
||||
* 출고관리 컴포넌트 Export
|
||||
*/
|
||||
|
||||
export { ShipmentList } from './ShipmentList';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 출하관리 Mock 데이터
|
||||
* 출고관리 Mock 데이터
|
||||
*/
|
||||
|
||||
import type {
|
||||
@@ -10,6 +10,9 @@ import type {
|
||||
LotOption,
|
||||
LogisticsOption,
|
||||
VehicleTonnageOption,
|
||||
VehicleDispatch,
|
||||
ProductGroup,
|
||||
ProductPart,
|
||||
} from './types';
|
||||
|
||||
// 통계 데이터
|
||||
@@ -25,13 +28,13 @@ export const mockStats: ShipmentStats = {
|
||||
export const mockFilterTabs: ShipmentFilterTab[] = [
|
||||
{ key: 'all', label: '전체', count: 20 },
|
||||
{ key: 'scheduled', label: '출고예정', count: 5 },
|
||||
{ key: 'ready', label: '출하대기', count: 1 },
|
||||
{ key: 'ready', label: '출고대기', count: 1 },
|
||||
{ key: 'shipping', label: '배송중', count: 1 },
|
||||
{ key: 'completed', label: '배송완료', count: 12 },
|
||||
{ key: 'calendar', label: '출하일정', count: 0 },
|
||||
{ key: 'calendar', label: '출고일정', count: 0 },
|
||||
];
|
||||
|
||||
// 출하 목록 Mock 데이터
|
||||
// 출고 목록 Mock 데이터
|
||||
export const mockShipmentItems: ShipmentItem[] = [
|
||||
{
|
||||
id: 'sl-251220-01',
|
||||
@@ -185,15 +188,113 @@ export const mockShipmentItems: ShipmentItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
// 출하 상세 Mock 데이터
|
||||
// 배차 정보 Mock 데이터
|
||||
export const mockVehicleDispatches: VehicleDispatch[] = [
|
||||
{
|
||||
id: 'vd-1',
|
||||
logisticsCompany: '한진물류',
|
||||
arrivalDateTime: '2025-12-20 09:00',
|
||||
tonnage: '3.5톤',
|
||||
vehicleNo: '34사 5678',
|
||||
driverContact: '010-5656-7878',
|
||||
remarks: '1차 배차',
|
||||
},
|
||||
{
|
||||
id: 'vd-2',
|
||||
logisticsCompany: 'CJ대한통운',
|
||||
arrivalDateTime: '2025-12-20 14:00',
|
||||
tonnage: '5톤',
|
||||
vehicleNo: '56나 1234',
|
||||
driverContact: '010-1234-5678',
|
||||
remarks: '2차 배차',
|
||||
},
|
||||
];
|
||||
|
||||
// 제품 그룹 Mock 데이터
|
||||
export const mockProductGroups: ProductGroup[] = [
|
||||
{
|
||||
id: 'pg-1',
|
||||
productName: '스크린 셔터 (와이어)',
|
||||
specification: '3200 x 2500 mm',
|
||||
partCount: 5,
|
||||
parts: [
|
||||
{ id: 'pp-1-1', seq: 1, itemName: '와이어 드럼', specification: '320mm', quantity: 2, unit: 'EA' },
|
||||
{ id: 'pp-1-2', seq: 2, itemName: '가이드 레일 (좌)', specification: '2500mm', quantity: 2, unit: 'EA' },
|
||||
{ id: 'pp-1-3', seq: 3, itemName: '가이드 레일 (우)', specification: '2500mm', quantity: 2, unit: 'EA' },
|
||||
{ id: 'pp-1-4', seq: 4, itemName: '구동 모터', specification: '220V / 60Hz', quantity: 1, unit: 'EA' },
|
||||
{ id: 'pp-1-5', seq: 5, itemName: '하부 프레임', specification: '3200mm', quantity: 1, unit: 'EA' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'pg-2',
|
||||
productName: '롤스크린 (전동)',
|
||||
specification: '2800 x 2200 mm',
|
||||
partCount: 4,
|
||||
parts: [
|
||||
{ id: 'pp-2-1', seq: 1, itemName: '롤 튜브', specification: '2800mm', quantity: 1, unit: 'EA' },
|
||||
{ id: 'pp-2-2', seq: 2, itemName: '원단 스크린', specification: '2800x2200', quantity: 1, unit: 'EA' },
|
||||
{ id: 'pp-2-3', seq: 3, itemName: '구동 모터', specification: '220V / 60Hz', quantity: 1, unit: 'EA' },
|
||||
{ id: 'pp-2-4', seq: 4, itemName: '사이드 브라켓', specification: '좌/우 세트', quantity: 2, unit: 'SET' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'pg-3',
|
||||
productName: '버티컬 블라인드',
|
||||
specification: '2400 x 2100 mm',
|
||||
partCount: 3,
|
||||
parts: [
|
||||
{ id: 'pp-3-1', seq: 1, itemName: '헤드레일', specification: '2400mm', quantity: 1, unit: 'EA' },
|
||||
{ id: 'pp-3-2', seq: 2, itemName: '루버 (패브릭)', specification: '89mm x 2100mm', quantity: 27, unit: 'EA' },
|
||||
{ id: 'pp-3-3', seq: 3, itemName: '바텀 체인', specification: '2400mm', quantity: 1, unit: 'EA' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'pg-4',
|
||||
productName: '허니콤 블라인드 (더블)',
|
||||
specification: '1800 x 2000 mm',
|
||||
partCount: 4,
|
||||
parts: [
|
||||
{ id: 'pp-4-1', seq: 1, itemName: '상부 레일', specification: '1800mm', quantity: 1, unit: 'EA' },
|
||||
{ id: 'pp-4-2', seq: 2, itemName: '하부 레일', specification: '1800mm', quantity: 1, unit: 'EA' },
|
||||
{ id: 'pp-4-3', seq: 3, itemName: '허니콤 원단 (채광)', specification: '1800x1000mm', quantity: 1, unit: 'EA' },
|
||||
{ id: 'pp-4-4', seq: 4, itemName: '허니콤 원단 (암막)', specification: '1800x1000mm', quantity: 1, unit: 'EA' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'pg-5',
|
||||
productName: '방충망 (플리세)',
|
||||
specification: '2000 x 2200 mm',
|
||||
partCount: 3,
|
||||
parts: [
|
||||
{ id: 'pp-5-1', seq: 1, itemName: '플리세 망', specification: '2000x2200mm', quantity: 1, unit: 'EA' },
|
||||
{ id: 'pp-5-2', seq: 2, itemName: '프레임 (알루미늄)', specification: '2000x2200mm', quantity: 1, unit: 'SET' },
|
||||
{ id: 'pp-5-3', seq: 3, itemName: '손잡이', specification: '-', quantity: 2, unit: 'EA' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// 기타부품 Mock 데이터
|
||||
export const mockOtherParts: ProductPart[] = [
|
||||
{ id: 'op-1', seq: 1, itemName: '리모컨 (16채널)', specification: '-', quantity: 5, unit: 'EA' },
|
||||
{ id: 'op-2', seq: 2, itemName: '설치 브라켓 (벽면용)', specification: '100mm', quantity: 10, unit: 'EA' },
|
||||
{ id: 'op-3', seq: 3, itemName: '설치 브라켓 (천장용)', specification: '80mm', quantity: 8, unit: 'EA' },
|
||||
{ id: 'op-4', seq: 4, itemName: '전원 어댑터', specification: 'DC 24V', quantity: 5, unit: 'EA' },
|
||||
{ id: 'op-5', seq: 5, itemName: '연결 케이블', specification: '3m', quantity: 10, unit: 'EA' },
|
||||
{ id: 'op-6', seq: 6, itemName: '고정 나사 세트', specification: 'M4 x 30mm', quantity: 20, unit: 'SET' },
|
||||
];
|
||||
|
||||
// 출고 상세 Mock 데이터
|
||||
export const mockShipmentDetail: ShipmentDetail = {
|
||||
id: 'sl-251220-01',
|
||||
shipmentNo: 'SL-251220-01',
|
||||
lotNo: 'KD-TS-251217-10',
|
||||
scheduledDate: '2025-12-20',
|
||||
shipmentDate: '2025-12-20',
|
||||
status: 'completed',
|
||||
priority: 'urgent',
|
||||
deliveryMethod: 'direct',
|
||||
freightCost: 'prepaid',
|
||||
freightCostLabel: '선불',
|
||||
depositConfirmed: true,
|
||||
invoiceIssued: true,
|
||||
customerGrade: 'B등급',
|
||||
@@ -207,6 +308,16 @@ export const mockShipmentDetail: ShipmentDetail = {
|
||||
deliveryAddress: '인천시 서구 청라동 456',
|
||||
receiver: '임현장',
|
||||
receiverContact: '010-8901-2345',
|
||||
zipCode: '22751',
|
||||
address: '인천시 서구 청라동 456',
|
||||
addressDetail: '위브 청라 현장사무소',
|
||||
|
||||
// 배차 정보 (다중 행)
|
||||
vehicleDispatches: mockVehicleDispatches,
|
||||
|
||||
// 제품내용 (그룹핑)
|
||||
productGroups: mockProductGroups,
|
||||
otherParts: mockOtherParts,
|
||||
|
||||
products: [
|
||||
{
|
||||
@@ -229,7 +340,7 @@ export const mockShipmentDetail: ShipmentDetail = {
|
||||
driverName: '정운전',
|
||||
driverContact: '010-5656-7878',
|
||||
|
||||
remarks: '[통합테스트10] 두산건설 - 전체 플로우 완료 (견적→수주→생산→품질→출하→회계)',
|
||||
remarks: '[통합테스트10] 두산건설 - 전체 플로우 완료 (견적→수주→생산→품질→출고→회계)',
|
||||
};
|
||||
|
||||
// LOT 선택 옵션 (등록 시)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Truck } from 'lucide-react';
|
||||
import type { DetailConfig } from '@/components/templates/IntegratedDetailTemplate/types';
|
||||
|
||||
/**
|
||||
* 출하관리 상세 페이지 Config
|
||||
* 출고관리 상세 페이지 Config
|
||||
*
|
||||
* 참고: 이 config는 타이틀/버튼 영역만 정의
|
||||
* 폼 내용은 기존 ShipmentDetail의 renderView에서 처리
|
||||
@@ -16,8 +16,8 @@ import type { DetailConfig } from '@/components/templates/IntegratedDetailTempla
|
||||
* - 문서 미리보기: 출고증, 거래명세서, 납품확인서
|
||||
*/
|
||||
export const shipmentConfig: DetailConfig = {
|
||||
title: '출하 상세',
|
||||
description: '출하 정보를 조회하고 관리합니다',
|
||||
title: '출고 상세',
|
||||
description: '출고 정보를 조회하고 관리합니다',
|
||||
icon: Truck,
|
||||
basePath: '/outbound/shipments',
|
||||
fields: [], // renderView 사용으로 필드 정의 불필요
|
||||
@@ -30,19 +30,19 @@ export const shipmentConfig: DetailConfig = {
|
||||
deleteLabel: '삭제',
|
||||
editLabel: '수정',
|
||||
deleteConfirmMessage: {
|
||||
title: '출하 정보 삭제',
|
||||
description: '이 출하 정보를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.',
|
||||
title: '출고 정보 삭제',
|
||||
description: '이 출고 정보를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 출하 등록 페이지 Config
|
||||
* 출고 등록 페이지 Config
|
||||
* IntegratedDetailTemplate 마이그레이션 (2025-01-20)
|
||||
*/
|
||||
export const shipmentCreateConfig: DetailConfig = {
|
||||
title: '출하',
|
||||
description: '새로운 출하를 등록합니다',
|
||||
title: '출고',
|
||||
description: '새로운 출고를 등록합니다',
|
||||
icon: Truck,
|
||||
basePath: '/outbound/shipments',
|
||||
fields: [],
|
||||
@@ -56,10 +56,10 @@ export const shipmentCreateConfig: DetailConfig = {
|
||||
};
|
||||
|
||||
/**
|
||||
* 출하 수정 페이지 Config
|
||||
* 출고 수정 페이지 Config
|
||||
*/
|
||||
export const shipmentEditConfig: DetailConfig = {
|
||||
...shipmentCreateConfig,
|
||||
title: '출하',
|
||||
description: '출하 정보를 수정합니다',
|
||||
title: '출고',
|
||||
description: '출고 정보를 수정합니다',
|
||||
};
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
/**
|
||||
* 출하관리 타입 정의
|
||||
* 출고관리 타입 정의
|
||||
*/
|
||||
|
||||
// 출하 상태
|
||||
// 출고 상태
|
||||
export type ShipmentStatus =
|
||||
| 'scheduled' // 출고예정
|
||||
| 'ready' // 출하대기
|
||||
| 'ready' // 출고대기 (출고대기)
|
||||
| 'shipping' // 배송중
|
||||
| 'completed'; // 배송완료
|
||||
| 'completed'; // 배송완료 (출고완료)
|
||||
|
||||
// 상태 라벨
|
||||
export const SHIPMENT_STATUS_LABELS: Record<ShipmentStatus, string> = {
|
||||
scheduled: '출고예정',
|
||||
ready: '출하대기',
|
||||
ready: '출고대기',
|
||||
shipping: '배송중',
|
||||
completed: '배송완료',
|
||||
completed: '출고완료',
|
||||
};
|
||||
|
||||
// 상태 스타일
|
||||
@@ -40,16 +40,73 @@ export const PRIORITY_STYLES: Record<ShipmentPriority, string> = {
|
||||
low: 'bg-blue-100 text-blue-800',
|
||||
};
|
||||
|
||||
// 배송방식
|
||||
export type DeliveryMethod = 'pickup' | 'direct' | 'logistics';
|
||||
// 운임비용 타입
|
||||
export type FreightCostType = 'prepaid' | 'collect' | 'free' | 'negotiable';
|
||||
|
||||
export const FREIGHT_COST_LABELS: Record<FreightCostType, string> = {
|
||||
prepaid: '선불',
|
||||
collect: '착불',
|
||||
free: '무료',
|
||||
negotiable: '협의',
|
||||
};
|
||||
|
||||
// 배차 정보 (다중 행)
|
||||
export interface VehicleDispatch {
|
||||
id: string;
|
||||
logisticsCompany: string; // 물류업체 (Select)
|
||||
arrivalDateTime: string; // 입차일시
|
||||
tonnage: string; // 톤수
|
||||
vehicleNo: string; // 차량번호
|
||||
driverContact: string; // 기사연락처
|
||||
remarks: string; // 비고
|
||||
}
|
||||
|
||||
// 제품 그룹 (아코디언용)
|
||||
export interface ProductGroup {
|
||||
id: string;
|
||||
productName: string; // 제품명
|
||||
specification: string; // 규격 (예: 2000 x 2500 mm)
|
||||
partCount: number; // 부품 수
|
||||
parts: ProductPart[]; // 부품 목록
|
||||
}
|
||||
|
||||
// 제품 부품
|
||||
export interface ProductPart {
|
||||
id: string;
|
||||
seq: number; // 순번
|
||||
itemName: string; // 품목명
|
||||
specification: string; // 규격
|
||||
quantity: number; // 수량
|
||||
unit: string; // 단위
|
||||
}
|
||||
|
||||
// 배송방식 (확장)
|
||||
export type DeliveryMethod =
|
||||
| 'pickup' // 상차 (레거시)
|
||||
| 'direct' // 직접배차 (레거시)
|
||||
| 'logistics' // 물류사 (레거시)
|
||||
| 'direct_dispatch' // 직접배차
|
||||
| 'loading' // 상차
|
||||
| 'kyungdong_delivery' // 경동택배
|
||||
| 'daesin_delivery' // 대신택배
|
||||
| 'kyungdong_freight' // 경동화물
|
||||
| 'daesin_freight' // 대신화물
|
||||
| 'self_pickup'; // 직접수령
|
||||
|
||||
export const DELIVERY_METHOD_LABELS: Record<DeliveryMethod, string> = {
|
||||
pickup: '상차',
|
||||
direct: '직접배차',
|
||||
logistics: '물류사',
|
||||
direct_dispatch: '직접배차',
|
||||
loading: '상차',
|
||||
kyungdong_delivery: '경동택배',
|
||||
daesin_delivery: '대신택배',
|
||||
kyungdong_freight: '경동화물',
|
||||
daesin_freight: '대신화물',
|
||||
self_pickup: '직접수령',
|
||||
};
|
||||
|
||||
// 출하 목록 아이템
|
||||
// 출고 목록 아이템
|
||||
export interface ShipmentItem {
|
||||
id: string;
|
||||
shipmentNo: string; // 출고번호
|
||||
@@ -62,10 +119,24 @@ export interface ShipmentItem {
|
||||
customerName: string; // 발주처
|
||||
siteName: string; // 현장명
|
||||
manager?: string; // 담당
|
||||
canShip: boolean; // 출하가능
|
||||
canShip: boolean; // 출고가능
|
||||
depositConfirmed: boolean; // 입금확인
|
||||
invoiceIssued: boolean; // 세금계산서
|
||||
deliveryTime?: string; // 납기(수배시간)
|
||||
// 새 필드
|
||||
orderCustomer?: string; // 수주처
|
||||
customerGrade?: string; // 거래등급
|
||||
receiver?: string; // 수신자
|
||||
receiverAddress?: string; // 수신주소
|
||||
receiverCompany?: string; // 수신처
|
||||
dispatch?: string; // 배차
|
||||
arrivalDateTime?: string; // 입차일시
|
||||
tonnage?: string; // 톤수
|
||||
unloadingNo?: string; // 하차번호
|
||||
driverContact?: string; // 기사연락처
|
||||
writer?: string; // 작성자
|
||||
shipmentDate?: string; // 출고일
|
||||
shipmentTime?: string; // 출고시간 (캘린더용)
|
||||
}
|
||||
|
||||
// 출고 품목
|
||||
@@ -80,52 +151,69 @@ export interface ShipmentProduct {
|
||||
lotNo: string; // LOT번호
|
||||
}
|
||||
|
||||
// 출하 상세 정보
|
||||
// 출고 상세 정보
|
||||
export interface ShipmentDetail {
|
||||
// 기본 정보 (읽기전용)
|
||||
id: string;
|
||||
shipmentNo: string; // 출고번호
|
||||
lotNo: string; // 로트번호
|
||||
scheduledDate: string; // 출고예정일
|
||||
status: ShipmentStatus; // 출고상태
|
||||
priority: ShipmentPriority; // 출고 우선순위
|
||||
shipmentNo: string; // 출고번호
|
||||
lotNo: string; // 로트번호
|
||||
siteName: string; // 현장명
|
||||
customerName: string; // 수주처
|
||||
customerGrade: string; // 거래등급
|
||||
status: ShipmentStatus; // 상태
|
||||
registrant?: string; // 작성자
|
||||
|
||||
// 수주/배송 정보
|
||||
scheduledDate: string; // 출고 예정일
|
||||
shipmentDate?: string; // 출고일
|
||||
deliveryMethod: DeliveryMethod; // 배송방식
|
||||
depositConfirmed: boolean; // 입금확인
|
||||
invoiceIssued: boolean; // 세금계산서
|
||||
customerGrade: string; // 거래처 등급
|
||||
canShip: boolean; // 출하가능
|
||||
loadingManager?: string; // 상차담당자
|
||||
loadingCompleted?: string; // 상차완료
|
||||
registrant?: string; // 등록자
|
||||
freightCost?: FreightCostType; // 운임비용
|
||||
freightCostLabel?: string; // 운임비용 라벨
|
||||
receiver?: string; // 수신자
|
||||
receiverContact?: string; // 수신처
|
||||
zipCode?: string; // 우편번호
|
||||
address?: string; // 주소
|
||||
addressDetail?: string; // 상세주소
|
||||
|
||||
// 발주처/배송 정보
|
||||
customerName: string; // 발주처
|
||||
siteName: string; // 현장명
|
||||
deliveryAddress: string; // 배송주소
|
||||
receiver?: string; // 인수자
|
||||
receiverContact?: string; // 연락처
|
||||
// 배차 정보 (다중 행)
|
||||
vehicleDispatches: VehicleDispatch[];
|
||||
|
||||
// 출고 품목
|
||||
// 제품내용 (그룹핑)
|
||||
productGroups: ProductGroup[];
|
||||
otherParts: ProductPart[];
|
||||
|
||||
// 기존 필드 (하위 호환 유지)
|
||||
products: ShipmentProduct[];
|
||||
|
||||
// 배차 정보
|
||||
logisticsCompany?: string; // 물류사
|
||||
vehicleTonnage?: string; // 차량 톤수
|
||||
shippingCost?: number; // 운송비
|
||||
|
||||
// 차량/운전자 정보
|
||||
vehicleNo?: string; // 차량번호
|
||||
driverName?: string; // 운전자
|
||||
driverContact?: string; // 운전자 연락처
|
||||
|
||||
remarks?: string; // 비고
|
||||
priority: ShipmentPriority;
|
||||
deliveryAddress: string;
|
||||
depositConfirmed: boolean;
|
||||
invoiceIssued: boolean;
|
||||
canShip: boolean;
|
||||
loadingManager?: string;
|
||||
loadingCompleted?: string;
|
||||
logisticsCompany?: string;
|
||||
vehicleTonnage?: string;
|
||||
shippingCost?: number;
|
||||
vehicleNo?: string;
|
||||
driverName?: string;
|
||||
driverContact?: string;
|
||||
remarks?: string;
|
||||
}
|
||||
|
||||
// 출하 등록 폼 데이터
|
||||
// 출고 등록 폼 데이터
|
||||
export interface ShipmentCreateFormData {
|
||||
lotNo: string; // 로트번호 *
|
||||
scheduledDate: string; // 출고예정일 *
|
||||
priority: ShipmentPriority; // 출고 우선순위 *
|
||||
deliveryMethod: DeliveryMethod; // 배송방식 *
|
||||
shipmentDate?: string; // 출고일
|
||||
freightCost?: FreightCostType; // 운임비용
|
||||
receiver?: string; // 수신자
|
||||
receiverContact?: string; // 수신처
|
||||
zipCode?: string; // 우편번호
|
||||
address?: string; // 주소
|
||||
addressDetail?: string; // 상세주소
|
||||
vehicleDispatches: VehicleDispatch[]; // 배차 정보
|
||||
logisticsCompany?: string; // 물류사
|
||||
vehicleTonnage?: string; // 차량 톤수(물량)
|
||||
loadingTime?: string; // 상차시간(입차예정)
|
||||
@@ -133,11 +221,19 @@ export interface ShipmentCreateFormData {
|
||||
remarks?: string; // 비고
|
||||
}
|
||||
|
||||
// 출하 수정 폼 데이터
|
||||
// 출고 수정 폼 데이터
|
||||
export interface ShipmentEditFormData {
|
||||
scheduledDate: string; // 출고예정일 *
|
||||
shipmentDate?: string; // 출고일
|
||||
priority: ShipmentPriority; // 출고 우선순위 *
|
||||
deliveryMethod: DeliveryMethod; // 배송방식 *
|
||||
freightCost?: FreightCostType; // 운임비용
|
||||
receiver?: string; // 수신자
|
||||
receiverContact?: string; // 수신처
|
||||
zipCode?: string; // 우편번호
|
||||
address?: string; // 주소
|
||||
addressDetail?: string; // 상세주소
|
||||
vehicleDispatches: VehicleDispatch[]; // 배차 정보
|
||||
loadingManager?: string; // 상차담당자
|
||||
logisticsCompany?: string; // 물류사
|
||||
vehicleTonnage?: string; // 차량 톤수
|
||||
@@ -153,10 +249,13 @@ export interface ShipmentEditFormData {
|
||||
|
||||
// 통계 데이터
|
||||
export interface ShipmentStats {
|
||||
todayShipmentCount: number; // 당일 출하
|
||||
todayShipmentCount: number; // 당일 출고 (레거시)
|
||||
todayPendingCount?: number; // 당일 출고대기
|
||||
scheduledCount: number; // 출고 대기
|
||||
pendingCount?: number; // 출고대기 (새)
|
||||
shippingCount: number; // 배송중
|
||||
urgentCount: number; // 긴급 출하
|
||||
completedCount?: number; // 출고완료
|
||||
urgentCount: number; // 긴급 출고
|
||||
totalCount: number; // 전체 건수
|
||||
}
|
||||
|
||||
@@ -194,4 +293,4 @@ export interface LogisticsOption {
|
||||
export interface VehicleTonnageOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 배차차량 상세 페이지
|
||||
* 3개 섹션: 기본정보, 배차정보, 배송비정보
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { vehicleDispatchConfig } from './vehicleDispatchConfig';
|
||||
import { getVehicleDispatchById } from './actions';
|
||||
import {
|
||||
VEHICLE_DISPATCH_STATUS_LABELS,
|
||||
VEHICLE_DISPATCH_STATUS_STYLES,
|
||||
FREIGHT_COST_LABELS,
|
||||
FREIGHT_COST_STYLES,
|
||||
} from './types';
|
||||
import type { VehicleDispatchDetail as VehicleDispatchDetailType } from './types';
|
||||
|
||||
interface VehicleDispatchDetailProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
// 금액 포맷
|
||||
function formatAmount(amount: number): string {
|
||||
return amount.toLocaleString('ko-KR');
|
||||
}
|
||||
|
||||
export function VehicleDispatchDetail({ id }: VehicleDispatchDetailProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// API 데이터 상태
|
||||
const [detail, setDetail] = useState<VehicleDispatchDetailType | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [_error, setError] = useState<string | null>(null);
|
||||
|
||||
// API 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await getVehicleDispatchById(id);
|
||||
if (result.success && result.data) {
|
||||
setDetail(result.data);
|
||||
} else {
|
||||
setError(result.error || '배차차량 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[VehicleDispatchDetail] loadData error:', err);
|
||||
setError('데이터를 불러오는 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
router.push(`/ko/outbound/vehicle-dispatches/${id}?mode=edit`);
|
||||
}, [id, router]);
|
||||
|
||||
// 정보 필드 렌더링 헬퍼
|
||||
const renderInfoField = (label: string, value: React.ReactNode, className?: string) => (
|
||||
<div className={className}>
|
||||
<div className="text-sm text-muted-foreground mb-1">{label}</div>
|
||||
<div className="font-medium">{value || '-'}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 컨텐츠 렌더링
|
||||
const renderViewContent = useCallback((_data: Record<string, unknown>) => {
|
||||
if (!detail) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 카드 1: 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
{renderInfoField('배차번호', detail.dispatchNo)}
|
||||
{renderInfoField('출고번호', detail.shipmentNo)}
|
||||
{renderInfoField('현장명', detail.siteName)}
|
||||
{renderInfoField('수주처', detail.orderCustomer)}
|
||||
{renderInfoField(
|
||||
'운임비용',
|
||||
<Badge className={FREIGHT_COST_STYLES[detail.freightCostType]}>
|
||||
{FREIGHT_COST_LABELS[detail.freightCostType]}
|
||||
</Badge>
|
||||
)}
|
||||
{renderInfoField(
|
||||
'상태',
|
||||
<Badge className={VEHICLE_DISPATCH_STATUS_STYLES[detail.status]}>
|
||||
{VEHICLE_DISPATCH_STATUS_LABELS[detail.status]}
|
||||
</Badge>
|
||||
)}
|
||||
{renderInfoField('작성자', detail.writer)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 카드 2: 배차 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">배차 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-6">
|
||||
{renderInfoField('물류업체', detail.logisticsCompany)}
|
||||
{renderInfoField('입차일시', detail.arrivalDateTime)}
|
||||
{renderInfoField('톤수', detail.tonnage)}
|
||||
{renderInfoField('차량번호', detail.vehicleNo)}
|
||||
{renderInfoField('기사연락처', detail.driverContact)}
|
||||
{renderInfoField('비고', detail.remarks || '-')}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 카드 3: 배송비 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">배송비 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-6">
|
||||
{renderInfoField('공급가액', `${formatAmount(detail.supplyAmount)}원`)}
|
||||
{renderInfoField('부가세', `${formatAmount(detail.vat)}원`)}
|
||||
{renderInfoField(
|
||||
'합계',
|
||||
<span className="text-lg font-bold">{formatAmount(detail.totalAmount)}원</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}, [detail]);
|
||||
|
||||
return (
|
||||
<IntegratedDetailTemplate
|
||||
config={vehicleDispatchConfig}
|
||||
mode="view"
|
||||
initialData={(detail ?? undefined) as Record<string, unknown> | undefined}
|
||||
itemId={id}
|
||||
isLoading={isLoading}
|
||||
onEdit={handleEdit}
|
||||
renderView={renderViewContent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 배차차량 수정 페이지
|
||||
* 3개 섹션: 기본정보(운임비용만 편집), 배차정보(모두 편집), 배송비정보(공급가액 편집→자동계산)
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { vehicleDispatchEditConfig } from './vehicleDispatchConfig';
|
||||
import { getVehicleDispatchById, updateVehicleDispatch } from './actions';
|
||||
import {
|
||||
VEHICLE_DISPATCH_STATUS_LABELS,
|
||||
VEHICLE_DISPATCH_STATUS_STYLES,
|
||||
FREIGHT_COST_LABELS,
|
||||
} from './types';
|
||||
import type {
|
||||
VehicleDispatchDetail,
|
||||
VehicleDispatchEditFormData,
|
||||
FreightCostType,
|
||||
} from './types';
|
||||
|
||||
// 운임비용 옵션
|
||||
const freightCostOptions: { value: FreightCostType; label: string }[] = Object.entries(
|
||||
FREIGHT_COST_LABELS
|
||||
).map(([value, label]) => ({ value: value as FreightCostType, label }));
|
||||
|
||||
// 금액 포맷
|
||||
function formatAmount(amount: number): string {
|
||||
return amount.toLocaleString('ko-KR');
|
||||
}
|
||||
|
||||
interface VehicleDispatchEditProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export function VehicleDispatchEdit({ id }: VehicleDispatchEditProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// 상세 데이터
|
||||
const [detail, setDetail] = useState<VehicleDispatchDetail | null>(null);
|
||||
|
||||
// 폼 상태
|
||||
const [formData, setFormData] = useState<VehicleDispatchEditFormData>({
|
||||
freightCostType: 'prepaid',
|
||||
logisticsCompany: '',
|
||||
arrivalDateTime: '',
|
||||
tonnage: '',
|
||||
vehicleNo: '',
|
||||
driverContact: '',
|
||||
remarks: '',
|
||||
supplyAmount: 0,
|
||||
vat: 0,
|
||||
totalAmount: 0,
|
||||
});
|
||||
|
||||
// 로딩/에러
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await getVehicleDispatchById(id);
|
||||
if (result.success && result.data) {
|
||||
const d = result.data;
|
||||
setDetail(d);
|
||||
|
||||
setFormData({
|
||||
freightCostType: d.freightCostType,
|
||||
logisticsCompany: d.logisticsCompany,
|
||||
arrivalDateTime: d.arrivalDateTime,
|
||||
tonnage: d.tonnage,
|
||||
vehicleNo: d.vehicleNo,
|
||||
driverContact: d.driverContact,
|
||||
remarks: d.remarks,
|
||||
supplyAmount: d.supplyAmount,
|
||||
vat: d.vat,
|
||||
totalAmount: d.totalAmount,
|
||||
});
|
||||
} else {
|
||||
setError(result.error || '배차차량 정보를 불러오는 데 실패했습니다.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[VehicleDispatchEdit] loadData error:', err);
|
||||
setError('데이터를 불러오는 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// 공급가액 변경 → 부가세/합계 자동 계산
|
||||
const handleSupplyAmountChange = useCallback((value: string) => {
|
||||
const amount = parseInt(value.replace(/[^0-9]/g, ''), 10) || 0;
|
||||
const vat = Math.round(amount * 0.1);
|
||||
const total = amount + vat;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
supplyAmount: amount,
|
||||
vat,
|
||||
totalAmount: total,
|
||||
}));
|
||||
if (validationErrors.length > 0) setValidationErrors([]);
|
||||
}, [validationErrors]);
|
||||
|
||||
// 폼 입력 핸들러
|
||||
const handleInputChange = (field: keyof VehicleDispatchEditFormData, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
if (validationErrors.length > 0) setValidationErrors([]);
|
||||
};
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
router.push(`/ko/outbound/vehicle-dispatches/${id}?mode=view`);
|
||||
}, [router, id]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await updateVehicleDispatch(id, formData);
|
||||
if (result.success) {
|
||||
router.push(`/ko/outbound/vehicle-dispatches/${id}?mode=view`);
|
||||
} else {
|
||||
setValidationErrors([result.error || '배차차량 수정에 실패했습니다.']);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[VehicleDispatchEdit] handleSubmit error:', err);
|
||||
setValidationErrors(['저장 중 오류가 발생했습니다.']);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [id, formData, router]);
|
||||
|
||||
// 동적 config
|
||||
const dynamicConfig = {
|
||||
...vehicleDispatchEditConfig,
|
||||
title: detail?.dispatchNo ? `배차차량 수정 (${detail.dispatchNo})` : '배차차량 수정',
|
||||
};
|
||||
|
||||
// 폼 컨텐츠 렌더링
|
||||
const renderFormContent = useCallback(
|
||||
(_props: {
|
||||
formData: Record<string, unknown>;
|
||||
onChange: (key: string, value: unknown) => void;
|
||||
mode: string;
|
||||
errors: Record<string, string>;
|
||||
}) => {
|
||||
if (!detail) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 상태 배지 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={`text-xs ${VEHICLE_DISPATCH_STATUS_STYLES[detail.status]}`}>
|
||||
{VEHICLE_DISPATCH_STATUS_LABELS[detail.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Validation 에러 표시 */}
|
||||
{validationErrors.length > 0 && (
|
||||
<Alert className="bg-red-50 border-red-200">
|
||||
<AlertDescription className="text-red-900">
|
||||
<ul className="space-y-1 text-sm">
|
||||
{validationErrors.map((err, index) => (
|
||||
<li key={index}>• {err}</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 카드 1: 기본 정보 (운임비용만 편집 가능) */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground">배차번호</Label>
|
||||
<div className="font-medium">{detail.dispatchNo}</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground">출고번호</Label>
|
||||
<div className="font-medium">{detail.shipmentNo}</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground">현장명</Label>
|
||||
<div className="font-medium">{detail.siteName}</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground">수주처</Label>
|
||||
<div className="font-medium">{detail.orderCustomer}</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>운임비용</Label>
|
||||
<Select
|
||||
key={`freight-${formData.freightCostType}`}
|
||||
value={formData.freightCostType}
|
||||
onValueChange={(value) => handleInputChange('freightCostType', value)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{freightCostOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground">상태</Label>
|
||||
<div>
|
||||
<Badge className={VEHICLE_DISPATCH_STATUS_STYLES[detail.status]}>
|
||||
{VEHICLE_DISPATCH_STATUS_LABELS[detail.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-muted-foreground">작성자</Label>
|
||||
<div className="font-medium">{detail.writer}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 카드 2: 배차 정보 (모두 편집 가능) */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">배차 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>물류업체</Label>
|
||||
<Input
|
||||
value={formData.logisticsCompany}
|
||||
onChange={(e) => handleInputChange('logisticsCompany', e.target.value)}
|
||||
placeholder="물류업체명"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>입차일시</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={formData.arrivalDateTime}
|
||||
onChange={(e) => handleInputChange('arrivalDateTime', e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>톤수</Label>
|
||||
<Input
|
||||
value={formData.tonnage}
|
||||
onChange={(e) => handleInputChange('tonnage', e.target.value)}
|
||||
placeholder="예: 3.5톤"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>차량번호</Label>
|
||||
<Input
|
||||
value={formData.vehicleNo}
|
||||
onChange={(e) => handleInputChange('vehicleNo', e.target.value)}
|
||||
placeholder="예: 경기12차1234"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>기사연락처</Label>
|
||||
<Input
|
||||
value={formData.driverContact}
|
||||
onChange={(e) => handleInputChange('driverContact', e.target.value)}
|
||||
placeholder="010-0000-0000"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>비고</Label>
|
||||
<Input
|
||||
value={formData.remarks}
|
||||
onChange={(e) => handleInputChange('remarks', e.target.value)}
|
||||
placeholder="비고"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 카드 3: 배송비 정보 (공급가액 편집 → 자동계산) */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">배송비 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>공급가액</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={formatAmount(formData.supplyAmount)}
|
||||
onChange={(e) => handleSupplyAmountChange(e.target.value)}
|
||||
placeholder="0"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>부가세 (10% 자동계산)</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={formatAmount(formData.vat)}
|
||||
readOnly
|
||||
className="bg-muted"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>합계</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={formatAmount(formData.totalAmount)}
|
||||
readOnly
|
||||
className="bg-muted font-bold"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[detail, formData, validationErrors, isSubmitting, handleSupplyAmountChange]
|
||||
);
|
||||
|
||||
if (error && !isLoading) {
|
||||
return (
|
||||
<IntegratedDetailTemplate
|
||||
config={dynamicConfig}
|
||||
mode="edit"
|
||||
isLoading={false}
|
||||
onCancel={handleCancel}
|
||||
renderForm={(_props: {
|
||||
formData: Record<string, unknown>;
|
||||
onChange: (key: string, value: unknown) => void;
|
||||
mode: string;
|
||||
errors: Record<string, string>;
|
||||
}) => (
|
||||
<Alert className="bg-red-50 border-red-200">
|
||||
<AlertDescription className="text-red-900">
|
||||
{error || '배차차량 정보를 찾을 수 없습니다.'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<IntegratedDetailTemplate
|
||||
config={dynamicConfig}
|
||||
mode="edit"
|
||||
isLoading={isLoading}
|
||||
onCancel={handleCancel}
|
||||
onSubmit={async (_data: Record<string, unknown>) => {
|
||||
await handleSubmit();
|
||||
return { success: true };
|
||||
}}
|
||||
renderForm={renderFormContent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 배차차량 목록 페이지
|
||||
*
|
||||
* - DateRangeSelector
|
||||
* - 통계 카드 3개: 선불, 착불, 합계
|
||||
* - 상태 필터: 전체/작성대기/작성완료
|
||||
* - 테이블 18컬럼
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
Truck,
|
||||
CreditCard,
|
||||
Banknote,
|
||||
Calculator,
|
||||
Eye,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
type SelectionHandlers,
|
||||
type RowClickHandlers,
|
||||
type StatCard,
|
||||
type ListParams,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||
import { getVehicleDispatches, getVehicleDispatchStats } from './actions';
|
||||
import {
|
||||
VEHICLE_DISPATCH_STATUS_LABELS,
|
||||
VEHICLE_DISPATCH_STATUS_STYLES,
|
||||
FREIGHT_COST_LABELS,
|
||||
FREIGHT_COST_STYLES,
|
||||
} from './types';
|
||||
import type { VehicleDispatchItem, VehicleDispatchStats } from './types';
|
||||
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
|
||||
// 금액 포맷
|
||||
function formatAmount(amount: number): string {
|
||||
return amount.toLocaleString('ko-KR');
|
||||
}
|
||||
|
||||
export function VehicleDispatchList() {
|
||||
const router = useRouter();
|
||||
|
||||
// ===== 통계 =====
|
||||
const [dispatchStats, setDispatchStats] = useState<VehicleDispatchStats | null>(null);
|
||||
|
||||
// ===== 날짜 범위 =====
|
||||
const today = new Date();
|
||||
const [startDate, setStartDate] = useState(() => {
|
||||
const d = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
return d.toISOString().split('T')[0];
|
||||
});
|
||||
const [endDate, setEndDate] = useState(() => {
|
||||
const d = new Date(today.getFullYear(), today.getMonth() + 1, 0);
|
||||
return d.toISOString().split('T')[0];
|
||||
});
|
||||
|
||||
// 초기 통계 로드
|
||||
useEffect(() => {
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const result = await getVehicleDispatchStats();
|
||||
if (result.success && result.data) {
|
||||
setDispatchStats(result.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[VehicleDispatchList] loadStats error:', error);
|
||||
}
|
||||
};
|
||||
loadStats();
|
||||
}, []);
|
||||
|
||||
// ===== 행 클릭 핸들러 =====
|
||||
const handleRowClick = useCallback(
|
||||
(item: VehicleDispatchItem) => {
|
||||
router.push(`/ko/outbound/vehicle-dispatches/${item.id}?mode=view`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
// ===== 통계 카드 (3개: 선불, 착불, 합계) =====
|
||||
const stats: StatCard[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: '선불',
|
||||
value: `${formatAmount(dispatchStats?.prepaidAmount || 0)}원`,
|
||||
icon: CreditCard,
|
||||
iconColor: 'text-blue-600',
|
||||
},
|
||||
{
|
||||
label: '착불',
|
||||
value: `${formatAmount(dispatchStats?.collectAmount || 0)}원`,
|
||||
icon: Banknote,
|
||||
iconColor: 'text-orange-600',
|
||||
},
|
||||
{
|
||||
label: '합계',
|
||||
value: `${formatAmount(dispatchStats?.totalAmount || 0)}원`,
|
||||
icon: Calculator,
|
||||
iconColor: 'text-green-600',
|
||||
},
|
||||
],
|
||||
[dispatchStats]
|
||||
);
|
||||
|
||||
// ===== UniversalListPage Config =====
|
||||
const config: UniversalListConfig<VehicleDispatchItem> = useMemo(
|
||||
() => ({
|
||||
title: '배차차량 목록',
|
||||
description: '배차차량을 관리합니다',
|
||||
icon: Truck,
|
||||
basePath: '/outbound/vehicle-dispatches',
|
||||
|
||||
idField: 'id',
|
||||
|
||||
actions: {
|
||||
getList: async (params?: ListParams) => {
|
||||
try {
|
||||
const result = await getVehicleDispatches({
|
||||
page: params?.page || 1,
|
||||
perPage: params?.pageSize || ITEMS_PER_PAGE,
|
||||
search: params?.search || undefined,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
// 통계 다시 로드
|
||||
const statsResult = await getVehicleDispatchStats();
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setDispatchStats(statsResult.data);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
totalCount: result.pagination.total,
|
||||
totalPages: result.pagination.lastPage,
|
||||
};
|
||||
}
|
||||
return { success: false, error: result.error };
|
||||
} catch (error) {
|
||||
console.error('[VehicleDispatchList] getList error:', error);
|
||||
return { success: false, error: '데이터 로드 중 오류가 발생했습니다.' };
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// 날짜 범위 선택기
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
showPresets: true,
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
|
||||
// 테이블 컬럼
|
||||
columns: [
|
||||
{ key: 'no', label: '번호', className: 'w-[50px] text-center' },
|
||||
{ key: 'dispatchNo', label: '배차번호', className: 'min-w-[130px]' },
|
||||
{ key: 'shipmentNo', label: '출고번호', className: 'min-w-[130px]' },
|
||||
{ key: 'siteName', label: '현장명', className: 'min-w-[100px]' },
|
||||
{ key: 'orderCustomer', label: '수주처', className: 'min-w-[100px]' },
|
||||
{ key: 'logisticsCompany', label: '물류업체', className: 'min-w-[90px]' },
|
||||
{ key: 'tonnage', label: '톤수', className: 'w-[70px] text-center' },
|
||||
{ key: 'supplyAmount', label: '공급가액', className: 'w-[100px] text-right' },
|
||||
{ key: 'vat', label: '부가세', className: 'w-[90px] text-right' },
|
||||
{ key: 'totalAmount', label: '합계', className: 'w-[100px] text-right' },
|
||||
{ key: 'freightCostType', label: '선/착불', className: 'w-[70px] text-center' },
|
||||
{ key: 'vehicleNo', label: '차량번호', className: 'min-w-[100px]' },
|
||||
{ key: 'driverContact', label: '기사연락처', className: 'min-w-[110px]' },
|
||||
{ key: 'writer', label: '작성자', className: 'w-[80px] text-center' },
|
||||
{ key: 'arrivalDateTime', label: '입차일시', className: 'w-[130px] text-center' },
|
||||
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
|
||||
{ key: 'remarks', label: '비고', className: 'min-w-[100px]' },
|
||||
],
|
||||
|
||||
// 상태 필터
|
||||
filterConfig: [{
|
||||
key: 'status',
|
||||
label: '상태',
|
||||
type: 'single' as const,
|
||||
options: [
|
||||
{ value: 'draft', label: '작성대기' },
|
||||
{ value: 'completed', label: '작성완료' },
|
||||
],
|
||||
allOptionLabel: '전체',
|
||||
}],
|
||||
|
||||
// 서버 사이드 페이지네이션
|
||||
clientSideFiltering: false,
|
||||
itemsPerPage: ITEMS_PER_PAGE,
|
||||
|
||||
// 검색
|
||||
searchPlaceholder: '배차번호, 출고번호, 현장명, 수주처, 차량번호 검색...',
|
||||
searchFilter: (item: VehicleDispatchItem, search: string) => {
|
||||
const s = search.toLowerCase();
|
||||
return (
|
||||
item.dispatchNo.toLowerCase().includes(s) ||
|
||||
item.shipmentNo.toLowerCase().includes(s) ||
|
||||
item.siteName.toLowerCase().includes(s) ||
|
||||
item.orderCustomer.toLowerCase().includes(s) ||
|
||||
item.vehicleNo.toLowerCase().includes(s)
|
||||
);
|
||||
},
|
||||
|
||||
// 통계 카드
|
||||
stats,
|
||||
|
||||
// 테이블 행 렌더링
|
||||
renderTableRow: (
|
||||
item: VehicleDispatchItem,
|
||||
_index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<VehicleDispatchItem>
|
||||
) => {
|
||||
return (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className={`cursor-pointer hover:bg-muted/50 ${handlers.isSelected ? 'bg-blue-50' : ''}`}
|
||||
onClick={() => handleRowClick(item)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={handlers.isSelected}
|
||||
onCheckedChange={handlers.onToggle}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell className="font-medium">{item.dispatchNo}</TableCell>
|
||||
<TableCell>{item.shipmentNo}</TableCell>
|
||||
<TableCell className="max-w-[100px] truncate">{item.siteName}</TableCell>
|
||||
<TableCell>{item.orderCustomer}</TableCell>
|
||||
<TableCell>{item.logisticsCompany}</TableCell>
|
||||
<TableCell className="text-center">{item.tonnage}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(item.supplyAmount)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(item.vat)}</TableCell>
|
||||
<TableCell className="text-right font-medium">{formatAmount(item.totalAmount)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge className={`text-xs ${FREIGHT_COST_STYLES[item.freightCostType]}`}>
|
||||
{FREIGHT_COST_LABELS[item.freightCostType]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{item.vehicleNo}</TableCell>
|
||||
<TableCell>{item.driverContact}</TableCell>
|
||||
<TableCell className="text-center">{item.writer}</TableCell>
|
||||
<TableCell className="text-center">{item.arrivalDateTime}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge className={`text-xs ${VEHICLE_DISPATCH_STATUS_STYLES[item.status]}`}>
|
||||
{VEHICLE_DISPATCH_STATUS_LABELS[item.status]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[100px] truncate">{item.remarks || '-'}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
renderMobileCard: (
|
||||
item: VehicleDispatchItem,
|
||||
_index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<VehicleDispatchItem>
|
||||
) => {
|
||||
return (
|
||||
<ListMobileCard
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
isSelected={handlers.isSelected}
|
||||
onToggleSelection={handlers.onToggle}
|
||||
onClick={() => handleRowClick(item)}
|
||||
headerBadges={
|
||||
<>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
#{globalIndex}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{item.dispatchNo}
|
||||
</Badge>
|
||||
</>
|
||||
}
|
||||
title={item.siteName}
|
||||
statusBadge={
|
||||
<Badge className={`text-xs ${VEHICLE_DISPATCH_STATUS_STYLES[item.status]}`}>
|
||||
{VEHICLE_DISPATCH_STATUS_LABELS[item.status]}
|
||||
</Badge>
|
||||
}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField label="출고번호" value={item.shipmentNo} />
|
||||
<InfoField label="수주처" value={item.orderCustomer} />
|
||||
<InfoField label="물류업체" value={item.logisticsCompany} />
|
||||
<InfoField label="톤수" value={item.tonnage} />
|
||||
<InfoField label="합계" value={`${formatAmount(item.totalAmount)}원`} />
|
||||
<InfoField
|
||||
label="선/착불"
|
||||
value={FREIGHT_COST_LABELS[item.freightCostType]}
|
||||
/>
|
||||
<InfoField label="차량번호" value={item.vehicleNo} />
|
||||
<InfoField label="입차일시" value={item.arrivalDateTime} />
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
handlers.isSelected && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRowClick(item);
|
||||
}}
|
||||
>
|
||||
<Eye className="w-4 h-4 mr-1" />
|
||||
상세
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}),
|
||||
[stats, startDate, endDate, handleRowClick]
|
||||
);
|
||||
|
||||
return <UniversalListPage config={config} />;
|
||||
}
|
||||
162
src/components/outbound/VehicleDispatchManagement/actions.ts
Normal file
162
src/components/outbound/VehicleDispatchManagement/actions.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* 배차차량관리 서버 액션
|
||||
*
|
||||
* 현재: Mock 데이터 반환
|
||||
* 추후: API 연동 시 serverFetch 사용
|
||||
*/
|
||||
|
||||
'use server';
|
||||
|
||||
import type {
|
||||
VehicleDispatchItem,
|
||||
VehicleDispatchDetail,
|
||||
VehicleDispatchStats,
|
||||
VehicleDispatchEditFormData,
|
||||
} from './types';
|
||||
import {
|
||||
mockVehicleDispatchItems,
|
||||
mockVehicleDispatchDetail,
|
||||
mockVehicleDispatchStats,
|
||||
} from './mockData';
|
||||
|
||||
// ===== 페이지네이션 타입 =====
|
||||
interface PaginationMeta {
|
||||
currentPage: number;
|
||||
lastPage: number;
|
||||
perPage: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ===== 배차차량 목록 조회 =====
|
||||
export async function getVehicleDispatches(params?: {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
search?: string;
|
||||
status?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
data: VehicleDispatchItem[];
|
||||
pagination: PaginationMeta;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
let items = [...mockVehicleDispatchItems];
|
||||
|
||||
// 상태 필터
|
||||
if (params?.status && params.status !== 'all') {
|
||||
items = items.filter((item) => item.status === params.status);
|
||||
}
|
||||
|
||||
// 검색 필터
|
||||
if (params?.search) {
|
||||
const s = params.search.toLowerCase();
|
||||
items = items.filter(
|
||||
(item) =>
|
||||
item.dispatchNo.toLowerCase().includes(s) ||
|
||||
item.shipmentNo.toLowerCase().includes(s) ||
|
||||
item.siteName.toLowerCase().includes(s) ||
|
||||
item.orderCustomer.toLowerCase().includes(s) ||
|
||||
item.vehicleNo.toLowerCase().includes(s)
|
||||
);
|
||||
}
|
||||
|
||||
// 페이지네이션
|
||||
const page = params?.page || 1;
|
||||
const perPage = params?.perPage || 20;
|
||||
const total = items.length;
|
||||
const lastPage = Math.ceil(total / perPage);
|
||||
const startIndex = (page - 1) * perPage;
|
||||
const paginatedItems = items.slice(startIndex, startIndex + perPage);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: paginatedItems,
|
||||
pagination: {
|
||||
currentPage: page,
|
||||
lastPage,
|
||||
perPage,
|
||||
total,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[VehicleDispatchActions] getVehicleDispatches error:', error);
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 },
|
||||
error: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 배차차량 통계 조회 =====
|
||||
export async function getVehicleDispatchStats(): Promise<{
|
||||
success: boolean;
|
||||
data?: VehicleDispatchStats;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
return { success: true, data: mockVehicleDispatchStats };
|
||||
} catch (error) {
|
||||
console.error('[VehicleDispatchActions] getVehicleDispatchStats error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 배차차량 상세 조회 =====
|
||||
export async function getVehicleDispatchById(id: string): Promise<{
|
||||
success: boolean;
|
||||
data?: VehicleDispatchDetail;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// Mock: ID로 목록에서 찾아서 상세 데이터 생성
|
||||
const item = mockVehicleDispatchItems.find((i) => i.id === id);
|
||||
if (!item) {
|
||||
// fallback으로 기본 상세 데이터 반환
|
||||
return { success: true, data: { ...mockVehicleDispatchDetail, id } };
|
||||
}
|
||||
|
||||
const detail: VehicleDispatchDetail = {
|
||||
id: item.id,
|
||||
dispatchNo: item.dispatchNo,
|
||||
shipmentNo: item.shipmentNo,
|
||||
siteName: item.siteName,
|
||||
orderCustomer: item.orderCustomer,
|
||||
freightCostType: item.freightCostType,
|
||||
status: item.status,
|
||||
writer: item.writer,
|
||||
logisticsCompany: item.logisticsCompany,
|
||||
arrivalDateTime: item.arrivalDateTime,
|
||||
tonnage: item.tonnage,
|
||||
vehicleNo: item.vehicleNo,
|
||||
driverContact: item.driverContact,
|
||||
remarks: item.remarks,
|
||||
supplyAmount: item.supplyAmount,
|
||||
vat: item.vat,
|
||||
totalAmount: item.totalAmount,
|
||||
};
|
||||
|
||||
return { success: true, data: detail };
|
||||
} catch (error) {
|
||||
console.error('[VehicleDispatchActions] getVehicleDispatchById error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 배차차량 수정 =====
|
||||
export async function updateVehicleDispatch(
|
||||
id: string,
|
||||
_data: VehicleDispatchEditFormData
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
console.log('[VehicleDispatchActions] updateVehicleDispatch:', id, _data);
|
||||
// Mock: 항상 성공 반환
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[VehicleDispatchActions] updateVehicleDispatch error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
10
src/components/outbound/VehicleDispatchManagement/index.ts
Normal file
10
src/components/outbound/VehicleDispatchManagement/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 배차차량관리 컴포넌트 Export
|
||||
*/
|
||||
|
||||
export { VehicleDispatchList } from './VehicleDispatchList';
|
||||
export { VehicleDispatchDetail } from './VehicleDispatchDetail';
|
||||
export { VehicleDispatchEdit } from './VehicleDispatchEdit';
|
||||
|
||||
// Types
|
||||
export * from './types';
|
||||
198
src/components/outbound/VehicleDispatchManagement/mockData.ts
Normal file
198
src/components/outbound/VehicleDispatchManagement/mockData.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* 배차차량관리 Mock 데이터
|
||||
*/
|
||||
|
||||
import type {
|
||||
VehicleDispatchItem,
|
||||
VehicleDispatchDetail,
|
||||
VehicleDispatchStats,
|
||||
} from './types';
|
||||
|
||||
// 통계 데이터
|
||||
export const mockVehicleDispatchStats: VehicleDispatchStats = {
|
||||
prepaidAmount: 1250000,
|
||||
collectAmount: 880000,
|
||||
totalAmount: 2130000,
|
||||
};
|
||||
|
||||
// 배차차량 목록 Mock 데이터
|
||||
export const mockVehicleDispatchItems: VehicleDispatchItem[] = [
|
||||
{
|
||||
id: 'vd-001',
|
||||
dispatchNo: 'VD-260101-001',
|
||||
shipmentNo: 'SL-260101-01',
|
||||
siteName: '위브 청라',
|
||||
orderCustomer: '두산건설(주)',
|
||||
logisticsCompany: '한진물류',
|
||||
tonnage: '3.5톤',
|
||||
supplyAmount: 250000,
|
||||
vat: 25000,
|
||||
totalAmount: 275000,
|
||||
freightCostType: 'prepaid',
|
||||
vehicleNo: '경기12차1234',
|
||||
driverContact: '010-1234-5678',
|
||||
writer: '홍길동',
|
||||
arrivalDateTime: '2026-01-01 09:00',
|
||||
status: 'completed',
|
||||
remarks: '1차 배차',
|
||||
},
|
||||
{
|
||||
id: 'vd-002',
|
||||
dispatchNo: 'VD-260101-002',
|
||||
shipmentNo: 'SL-260101-02',
|
||||
siteName: '대시앙 동탄',
|
||||
orderCustomer: '태영건설(주)',
|
||||
logisticsCompany: 'CJ대한통운',
|
||||
tonnage: '5톤',
|
||||
supplyAmount: 350000,
|
||||
vat: 35000,
|
||||
totalAmount: 385000,
|
||||
freightCostType: 'collect',
|
||||
vehicleNo: '서울34나5678',
|
||||
driverContact: '010-2345-6789',
|
||||
writer: '김철수',
|
||||
arrivalDateTime: '2026-01-01 14:00',
|
||||
status: 'completed',
|
||||
remarks: '',
|
||||
},
|
||||
{
|
||||
id: 'vd-003',
|
||||
dispatchNo: 'VD-260102-001',
|
||||
shipmentNo: 'SL-260102-01',
|
||||
siteName: '버킷 광교',
|
||||
orderCustomer: '호반건설(주)',
|
||||
logisticsCompany: '롯데글로벌로지스',
|
||||
tonnage: '11톤',
|
||||
supplyAmount: 500000,
|
||||
vat: 50000,
|
||||
totalAmount: 550000,
|
||||
freightCostType: 'prepaid',
|
||||
vehicleNo: '인천56마7890',
|
||||
driverContact: '010-3456-7890',
|
||||
writer: '이영희',
|
||||
arrivalDateTime: '2026-01-02 08:30',
|
||||
status: 'draft',
|
||||
remarks: '중량물 주의',
|
||||
},
|
||||
{
|
||||
id: 'vd-004',
|
||||
dispatchNo: 'VD-260102-002',
|
||||
shipmentNo: 'SL-260102-02',
|
||||
siteName: '자이 위례',
|
||||
orderCustomer: 'GS건설(주)',
|
||||
logisticsCompany: '현대글로비스',
|
||||
tonnage: '2.5톤',
|
||||
supplyAmount: 180000,
|
||||
vat: 18000,
|
||||
totalAmount: 198000,
|
||||
freightCostType: 'collect',
|
||||
vehicleNo: '경기78바1234',
|
||||
driverContact: '010-4567-8901',
|
||||
writer: '박민수',
|
||||
arrivalDateTime: '2026-01-02 11:00',
|
||||
status: 'draft',
|
||||
remarks: '',
|
||||
},
|
||||
{
|
||||
id: 'vd-005',
|
||||
dispatchNo: 'VD-260103-001',
|
||||
shipmentNo: 'SL-260103-01',
|
||||
siteName: '푸르지오 일산',
|
||||
orderCustomer: '대우건설(주)',
|
||||
logisticsCompany: '한진물류',
|
||||
tonnage: '5톤',
|
||||
supplyAmount: 320000,
|
||||
vat: 32000,
|
||||
totalAmount: 352000,
|
||||
freightCostType: 'prepaid',
|
||||
vehicleNo: '서울90사2345',
|
||||
driverContact: '010-5678-9012',
|
||||
writer: '홍길동',
|
||||
arrivalDateTime: '2026-01-03 09:30',
|
||||
status: 'completed',
|
||||
remarks: '',
|
||||
},
|
||||
{
|
||||
id: 'vd-006',
|
||||
dispatchNo: 'VD-260103-002',
|
||||
shipmentNo: 'SL-260103-02',
|
||||
siteName: '힐스테이트 판교',
|
||||
orderCustomer: '현대건설(주)',
|
||||
logisticsCompany: 'CJ대한통운',
|
||||
tonnage: '3.5톤',
|
||||
supplyAmount: 280000,
|
||||
vat: 28000,
|
||||
totalAmount: 308000,
|
||||
freightCostType: 'collect',
|
||||
vehicleNo: '경기23아4567',
|
||||
driverContact: '010-6789-0123',
|
||||
writer: '김철수',
|
||||
arrivalDateTime: '2026-01-03 15:00',
|
||||
status: 'draft',
|
||||
remarks: '지하주차장 진입',
|
||||
},
|
||||
{
|
||||
id: 'vd-007',
|
||||
dispatchNo: 'VD-260104-001',
|
||||
shipmentNo: 'SL-260104-01',
|
||||
siteName: '래미안 강남 포레스트',
|
||||
orderCustomer: '삼성물산(주)',
|
||||
logisticsCompany: '롯데글로벌로지스',
|
||||
tonnage: '11톤',
|
||||
supplyAmount: 620000,
|
||||
vat: 62000,
|
||||
totalAmount: 682000,
|
||||
freightCostType: 'prepaid',
|
||||
vehicleNo: '서울45나6789',
|
||||
driverContact: '010-7890-1234',
|
||||
writer: '이영희',
|
||||
arrivalDateTime: '2026-01-04 08:00',
|
||||
status: 'completed',
|
||||
remarks: '',
|
||||
},
|
||||
{
|
||||
id: 'vd-008',
|
||||
dispatchNo: 'VD-260104-002',
|
||||
shipmentNo: 'SL-260104-02',
|
||||
siteName: '더샵 송도',
|
||||
orderCustomer: '포스코건설(주)',
|
||||
logisticsCompany: '한진물류',
|
||||
tonnage: '5톤',
|
||||
supplyAmount: 400000,
|
||||
vat: 40000,
|
||||
totalAmount: 440000,
|
||||
freightCostType: 'prepaid',
|
||||
vehicleNo: '인천67마8901',
|
||||
driverContact: '010-8901-2345',
|
||||
writer: '박민수',
|
||||
arrivalDateTime: '2026-01-04 13:30',
|
||||
status: 'completed',
|
||||
remarks: '2차 배차',
|
||||
},
|
||||
];
|
||||
|
||||
// 배차차량 상세 Mock 데이터
|
||||
export const mockVehicleDispatchDetail: VehicleDispatchDetail = {
|
||||
id: 'vd-001',
|
||||
// 기본 정보
|
||||
dispatchNo: 'VD-260101-001',
|
||||
shipmentNo: 'SL-260101-01',
|
||||
siteName: '위브 청라',
|
||||
orderCustomer: '두산건설(주)',
|
||||
freightCostType: 'prepaid',
|
||||
status: 'completed',
|
||||
writer: '홍길동',
|
||||
|
||||
// 배차 정보
|
||||
logisticsCompany: '한진물류',
|
||||
arrivalDateTime: '2026-01-01 09:00',
|
||||
tonnage: '3.5톤',
|
||||
vehicleNo: '경기12차1234',
|
||||
driverContact: '010-1234-5678',
|
||||
remarks: '1차 배차',
|
||||
|
||||
// 배송비 정보
|
||||
supplyAmount: 250000,
|
||||
vat: 25000,
|
||||
totalAmount: 275000,
|
||||
};
|
||||
104
src/components/outbound/VehicleDispatchManagement/types.ts
Normal file
104
src/components/outbound/VehicleDispatchManagement/types.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* 배차차량관리 타입 정의
|
||||
*/
|
||||
|
||||
// 배차 상태
|
||||
export type VehicleDispatchStatus = 'draft' | 'completed';
|
||||
|
||||
// 상태 라벨
|
||||
export const VEHICLE_DISPATCH_STATUS_LABELS: Record<VehicleDispatchStatus, string> = {
|
||||
draft: '작성대기',
|
||||
completed: '작성완료',
|
||||
};
|
||||
|
||||
// 상태 스타일
|
||||
export const VEHICLE_DISPATCH_STATUS_STYLES: Record<VehicleDispatchStatus, string> = {
|
||||
draft: 'bg-red-100 text-red-800',
|
||||
completed: 'bg-green-100 text-green-800',
|
||||
};
|
||||
|
||||
// 선/착불 타입
|
||||
export type FreightCostType = 'prepaid' | 'collect';
|
||||
|
||||
export const FREIGHT_COST_LABELS: Record<FreightCostType, string> = {
|
||||
prepaid: '선불',
|
||||
collect: '착불',
|
||||
};
|
||||
|
||||
export const FREIGHT_COST_STYLES: Record<FreightCostType, string> = {
|
||||
prepaid: 'bg-blue-100 text-blue-800',
|
||||
collect: 'bg-orange-100 text-orange-800',
|
||||
};
|
||||
|
||||
// 배차차량 목록 아이템
|
||||
export interface VehicleDispatchItem {
|
||||
id: string;
|
||||
dispatchNo: string; // 배차번호
|
||||
shipmentNo: string; // 출고번호
|
||||
siteName: string; // 현장명
|
||||
orderCustomer: string; // 수주처
|
||||
logisticsCompany: string; // 물류업체
|
||||
tonnage: string; // 톤수
|
||||
supplyAmount: number; // 공급가액
|
||||
vat: number; // 부가세
|
||||
totalAmount: number; // 합계
|
||||
freightCostType: FreightCostType; // 선/착불
|
||||
vehicleNo: string; // 차량번호
|
||||
driverContact: string; // 기사연락처
|
||||
writer: string; // 작성자
|
||||
arrivalDateTime: string; // 입차일시
|
||||
status: VehicleDispatchStatus; // 상태
|
||||
remarks: string; // 비고
|
||||
}
|
||||
|
||||
// 배차차량 상세 정보
|
||||
export interface VehicleDispatchDetail {
|
||||
id: string;
|
||||
// 기본 정보
|
||||
dispatchNo: string; // 배차번호
|
||||
shipmentNo: string; // 출고번호
|
||||
siteName: string; // 현장명
|
||||
orderCustomer: string; // 수주처
|
||||
freightCostType: FreightCostType; // 운임비용
|
||||
status: VehicleDispatchStatus; // 상태
|
||||
writer: string; // 작성자
|
||||
|
||||
// 배차 정보
|
||||
logisticsCompany: string; // 물류업체
|
||||
arrivalDateTime: string; // 입차일시
|
||||
tonnage: string; // 톤수
|
||||
vehicleNo: string; // 차량번호
|
||||
driverContact: string; // 기사연락처
|
||||
remarks: string; // 비고
|
||||
|
||||
// 배송비 정보
|
||||
supplyAmount: number; // 공급가액
|
||||
vat: number; // 부가세
|
||||
totalAmount: number; // 합계
|
||||
}
|
||||
|
||||
// 배차차량 수정 폼 데이터
|
||||
export interface VehicleDispatchEditFormData {
|
||||
// 기본 정보 (운임비용만 편집 가능)
|
||||
freightCostType: FreightCostType;
|
||||
|
||||
// 배차 정보 (모두 편집 가능)
|
||||
logisticsCompany: string;
|
||||
arrivalDateTime: string;
|
||||
tonnage: string;
|
||||
vehicleNo: string;
|
||||
driverContact: string;
|
||||
remarks: string;
|
||||
|
||||
// 배송비 정보
|
||||
supplyAmount: number;
|
||||
vat: number;
|
||||
totalAmount: number;
|
||||
}
|
||||
|
||||
// 통계 데이터
|
||||
export interface VehicleDispatchStats {
|
||||
prepaidAmount: number; // 선불 금액
|
||||
collectAmount: number; // 착불 금액
|
||||
totalAmount: number; // 합계 금액
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import { Truck } from 'lucide-react';
|
||||
import type { DetailConfig } from '@/components/templates/IntegratedDetailTemplate/types';
|
||||
|
||||
/**
|
||||
* 배차차량 상세 페이지 Config
|
||||
*/
|
||||
export const vehicleDispatchConfig: DetailConfig = {
|
||||
title: '배차차량 상세',
|
||||
description: '배차차량 정보를 조회합니다',
|
||||
icon: Truck,
|
||||
basePath: '/outbound/vehicle-dispatches',
|
||||
fields: [],
|
||||
gridColumns: 2,
|
||||
actions: {
|
||||
showBack: true,
|
||||
showEdit: true,
|
||||
showDelete: false,
|
||||
backLabel: '목록',
|
||||
editLabel: '수정',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 배차차량 수정 페이지 Config
|
||||
*/
|
||||
export const vehicleDispatchEditConfig: DetailConfig = {
|
||||
title: '배차차량 수정',
|
||||
description: '배차차량 정보를 수정합니다',
|
||||
icon: Truck,
|
||||
basePath: '/outbound/vehicle-dispatches',
|
||||
fields: [],
|
||||
actions: {
|
||||
showBack: true,
|
||||
showEdit: false,
|
||||
showDelete: false,
|
||||
showSave: true,
|
||||
submitLabel: '저장',
|
||||
},
|
||||
};
|
||||
@@ -289,19 +289,38 @@ export const BendingInspectionContent = forwardRef<InspectionContentRef, Bending
|
||||
<colgroup>
|
||||
<col style={{width: '80px'}} />
|
||||
<col style={{width: '180px'}} />
|
||||
<col style={{width: '52px'}} />
|
||||
<col style={{width: '52px'}} />
|
||||
<col style={{width: '58px'}} />
|
||||
<col style={{width: '58px'}} />
|
||||
<col />
|
||||
<col style={{width: '68px'}} />
|
||||
<col style={{width: '78px'}} />
|
||||
<col style={{width: '110px'}} />
|
||||
</colgroup>
|
||||
<tbody>
|
||||
{/* 헤더 */}
|
||||
{/* 도해 3개 (가이드레일 / 케이스 / 하단마감재) */}
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-2 py-1 font-bold text-center align-middle" rowSpan={4}>
|
||||
<td className="border border-gray-400 px-2 py-1 font-bold text-center align-middle" rowSpan={6}>
|
||||
가이드레일<br/>케이스<br/>하단마감재<br/>하단 L-BAR
|
||||
</td>
|
||||
<td className="border border-gray-400 p-0" colSpan={7}>
|
||||
<div className="grid grid-cols-3 divide-x divide-gray-400">
|
||||
<div className="text-center font-medium bg-gray-100 py-1">가이드레일 도해</div>
|
||||
<div className="text-center font-medium bg-gray-100 py-1">케이스 도해</div>
|
||||
<div className="text-center font-medium bg-gray-100 py-1">하단마감재 도해</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 p-0" colSpan={7}>
|
||||
<div className="grid grid-cols-3 divide-x divide-gray-400">
|
||||
<div className="h-28 flex items-center justify-center text-gray-300 text-xs">IMG</div>
|
||||
<div className="h-28 flex items-center justify-center text-gray-300 text-xs">IMG</div>
|
||||
<div className="h-28 flex items-center justify-center text-gray-300 text-xs">IMG</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/* 기준서 헤더 */}
|
||||
<tr>
|
||||
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center">도해</th>
|
||||
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center" colSpan={2}>검사항목</th>
|
||||
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center">검사기준</th>
|
||||
@@ -314,8 +333,8 @@ export const BendingInspectionContent = forwardRef<InspectionContentRef, Bending
|
||||
<td className="border border-gray-400 p-2 text-center text-gray-500 align-middle text-xs" rowSpan={3}>
|
||||
절곡류 중간검사<br/>상세도면 참조
|
||||
</td>
|
||||
<td className="border border-gray-400 px-2 py-1 font-medium">겉모양</td>
|
||||
<td className="border border-gray-400 px-2 py-1 font-medium">절곡상태</td>
|
||||
<td className="border border-gray-400 px-2 py-1 font-medium whitespace-nowrap">겉모양</td>
|
||||
<td className="border border-gray-400 px-2 py-1 font-medium whitespace-nowrap">절곡상태</td>
|
||||
<td className="border border-gray-400 px-2 py-1">사용상 해로운 결함이 없을것</td>
|
||||
<td className="border border-gray-400 px-2 py-1 text-center">육안검사</td>
|
||||
<td className="border border-gray-400 px-2 py-1 text-center" rowSpan={3}>n = 1, c = 0</td>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
|
||||
import type { WorkOrder } from '../types';
|
||||
import { SectionHeader } from '@/components/document-system';
|
||||
import { SectionHeader, ConstructionApprovalTable } from '@/components/document-system';
|
||||
|
||||
interface BendingWorkLogContentProps {
|
||||
data: WorkOrder;
|
||||
@@ -59,29 +59,10 @@ export function BendingWorkLogContent({ data: order }: BendingWorkLogContentProp
|
||||
</div>
|
||||
|
||||
{/* 우측: 결재란 */}
|
||||
<table className="border-collapse text-sm flex-shrink-0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-4 py-1 text-center align-middle" rowSpan={3}>결<br/>재</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center">작성</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center">승인</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center">승인</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center">승인</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-6 py-3 text-center">{primaryAssignee}</td>
|
||||
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400">이름</td>
|
||||
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400">이름</td>
|
||||
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400">이름</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<ConstructionApprovalTable
|
||||
approvers={{ writer: { name: primaryAssignee } }}
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ===== 신청업체 / 신청내용 ===== */}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
*/
|
||||
|
||||
import type { WorkOrder } from '../types';
|
||||
import { SectionHeader } from '@/components/document-system';
|
||||
import { SectionHeader, ConstructionApprovalTable } from '@/components/document-system';
|
||||
|
||||
interface ScreenWorkLogContentProps {
|
||||
data: WorkOrder;
|
||||
@@ -61,29 +61,10 @@ export function ScreenWorkLogContent({ data: order }: ScreenWorkLogContentProps)
|
||||
</div>
|
||||
|
||||
{/* 우측: 결재란 */}
|
||||
<table className="border-collapse text-sm flex-shrink-0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-4 py-1 text-center align-middle" rowSpan={3}>결<br/>재</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center">작성</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center">승인</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center">승인</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center">승인</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-6 py-3 text-center">{primaryAssignee}</td>
|
||||
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400">이름</td>
|
||||
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400">이름</td>
|
||||
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400">이름</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">부서명</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<ConstructionApprovalTable
|
||||
approvers={{ writer: { name: primaryAssignee } }}
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ===== 신청업체 / 신청내용 ===== */}
|
||||
|
||||
@@ -173,6 +173,9 @@ export interface IntegratedListTemplateV2Props<T = any> {
|
||||
// 테이블 앞에 표시될 컨텐츠 (계정과목명 + 저장 버튼 등)
|
||||
beforeTableContent?: ReactNode;
|
||||
|
||||
// 테이블 뒤에 표시될 컨텐츠 (캘린더 등)
|
||||
afterTableContent?: ReactNode;
|
||||
|
||||
// 테이블 컬럼
|
||||
tableColumns: TableColumn[];
|
||||
tableTitle?: string; // "전체 목록 (100개)" 같은 타이틀
|
||||
@@ -257,6 +260,7 @@ export function IntegratedListTemplateV2<T = any>({
|
||||
onFilterReset,
|
||||
filterTitle = "검색 필터",
|
||||
beforeTableContent,
|
||||
afterTableContent,
|
||||
tableColumns,
|
||||
tableTitle,
|
||||
sortBy,
|
||||
@@ -995,6 +999,11 @@ export function IntegratedListTemplateV2<T = any>({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 뒤 컨텐츠 (캘린더 등) */}
|
||||
{afterTableContent && (
|
||||
<div className="mt-4">{afterTableContent}</div>
|
||||
)}
|
||||
|
||||
{/* 일괄 삭제 확인 다이얼로그 */}
|
||||
<DeleteConfirmDialog
|
||||
open={showDeleteDialog}
|
||||
|
||||
@@ -938,6 +938,12 @@ export function UniversalListPage<T>({
|
||||
}
|
||||
// 테이블 푸터
|
||||
tableFooter={config.tableFooter}
|
||||
// 테이블 뒤 컨텐츠 (캘린더 등)
|
||||
afterTableContent={
|
||||
typeof config.afterTableContent === 'function'
|
||||
? config.afterTableContent({ data: displayData, selectedItems: effectiveSelectedItems })
|
||||
: config.afterTableContent
|
||||
}
|
||||
// 데이터
|
||||
data={displayData}
|
||||
totalCount={totalCount}
|
||||
|
||||
@@ -382,6 +382,8 @@ export interface UniversalListConfig<T> {
|
||||
}) => ReactNode);
|
||||
/** 테이블 하단 푸터 */
|
||||
tableFooter?: ReactNode;
|
||||
/** 테이블 뒤에 표시될 컨텐츠 (캘린더 등) */
|
||||
afterTableContent?: ReactNode | ((props: { data: T[], selectedItems: Set<string> }) => ReactNode);
|
||||
/** 경고 배너 */
|
||||
alertBanner?: ReactNode;
|
||||
/** 헤더 액션 영역 아래, 검색 위 커스텀 탭 */
|
||||
|
||||
Reference in New Issue
Block a user