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:
유병철
2026-02-02 11:14:05 +09:00
parent e684c495ee
commit 1a69324d59
41 changed files with 4134 additions and 1440 deletions

View File

@@ -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 (배차차량관리 추가)

View File

@@ -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} />;
}

View File

@@ -0,0 +1,12 @@
'use client';
/**
* 배차차량관리 - 목록 페이지
* URL: /outbound/vehicle-dispatches
*/
import { VehicleDispatchList } from '@/components/outbound/VehicleDispatchManagement';
export default function VehicleDispatchesPage() {
return <VehicleDispatchList />;
}

View File

@@ -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':

View File

@@ -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 },
],
},
{

View File

@@ -40,7 +40,7 @@ export interface DocumentItem {
title: string;
date: string;
code?: string;
// 중간검사 성적서 서브타입 (report 타입일 때만 사용)
// 중간검사 성적서 및 작업일지 서브타입 (report, log 타입에서 사용)
subType?: 'screen' | 'bending' | 'slat' | 'jointbar';
}

View File

@@ -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`);
}
};

View File

@@ -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 && (

View File

@@ -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>

View File

@@ -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}

View 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>
);
}

View File

@@ -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';

View File

@@ -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
*/

View File

@@ -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>

View File

@@ -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>
{/* 상품명 / 제품명 / 로트번호 / 인정번호 */}

View File

@@ -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

View File

@@ -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>

View File

@@ -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>
)}

View File

@@ -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} />;

View File

@@ -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 };

View File

@@ -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">&nbsp;</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} />;
}

View File

@@ -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">&nbsp;</td>
<td className="px-2 py-1">&nbsp;</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">&nbsp;</td>
<td className="px-2 py-1">&nbsp;</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">&nbsp;</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>
);
}

View File

@@ -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} />;
}

View File

@@ -1,5 +1,5 @@
/**
* 출관리 컴포넌트 Export
* 출관리 컴포넌트 Export
*/
export { ShipmentList } from './ShipmentList';

View File

@@ -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 선택 옵션 (등록 시)

View File

@@ -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: '출 정보를 수정합니다',
};

View File

@@ -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;
}
}

View File

@@ -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}
/>
);
}

View File

@@ -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}
/>
);
}

View File

@@ -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} />;
}

View 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: '서버 오류가 발생했습니다.' };
}
}

View File

@@ -0,0 +1,10 @@
/**
* 배차차량관리 컴포넌트 Export
*/
export { VehicleDispatchList } from './VehicleDispatchList';
export { VehicleDispatchDetail } from './VehicleDispatchDetail';
export { VehicleDispatchEdit } from './VehicleDispatchEdit';
// Types
export * from './types';

View 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,
};

View 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; // 합계 금액
}

View File

@@ -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: '저장',
},
};

View File

@@ -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>

View File

@@ -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>
{/* ===== 신청업체 / 신청내용 ===== */}

View File

@@ -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>
{/* ===== 신청업체 / 신청내용 ===== */}

View File

@@ -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}

View File

@@ -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}

View File

@@ -382,6 +382,8 @@ export interface UniversalListConfig<T> {
}) => ReactNode);
/** 테이블 하단 푸터 */
tableFooter?: ReactNode;
/** 테이블 뒤에 표시될 컨텐츠 (캘린더 등) */
afterTableContent?: ReactNode | ((props: { data: T[], selectedItems: Set<string> }) => ReactNode);
/** 경고 배너 */
alertBanner?: ReactNode;
/** 헤더 액션 영역 아래, 검색 위 커스텀 탭 */