From 1a69324d5933f36c6928ef06f6f21dbfd729da6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Mon, 2 Feb 2026 11:14:05 +0900 Subject: [PATCH] =?UTF-8?q?feat(WEB):=20=EC=B6=9C=EA=B3=A0=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EB=8C=80=ED=8F=AD=20=EA=B0=9C=EC=84=A0,=20?= =?UTF-8?q?=EC=B0=A8=EB=9F=89=EB=B0=B0=EC=B0=A8=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EC=8B=A0=EA=B7=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20QMS/?= =?UTF-8?q?=EC=BA=98=EB=A6=B0=EB=8D=94=20=EA=B8=B0=EB=8A=A5=20=EA=B0=95?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 출고관리: ShipmentCreate/Detail/Edit/List 개선, ShipmentOrderDocument 신규 추가 - 차량배차관리: VehicleDispatchManagement 모듈 신규 추가 - QMS: InspectionModalV2 개선 - 캘린더: WeekTimeView 신규 추가, CalendarHeader/types 확장 - 문서: ConstructionApprovalTable/SalesOrderDocument/DeliveryConfirmation/ShippingSlip 개선 - 작업지시서: 검사보고서/작업일지 문서 개선 - 템플릿: IntegratedListTemplateV2/UniversalListPage 기능 확장 Co-Authored-By: Claude Opus 4.5 --- claudedocs/dev/[REF] all-pages-test-urls.md | 10 +- .../outbound/vehicle-dispatches/[id]/page.tsx | 28 + .../outbound/vehicle-dispatches/page.tsx | 12 + .../qms/components/InspectionModalV2.tsx | 210 ++--- .../(protected)/quality/qms/mockData.ts | 7 +- .../[locale]/(protected)/quality/qms/types.ts | 2 +- .../order-management-sales/[id]/page.tsx | 2 +- src/components/auth/LoginPage.tsx | 2 +- .../ScheduleCalendar/CalendarHeader.tsx | 13 +- .../ScheduleCalendar/ScheduleCalendar.tsx | 14 + .../common/ScheduleCalendar/WeekTimeView.tsx | 216 ++++++ .../common/ScheduleCalendar/index.ts | 2 + .../common/ScheduleCalendar/types.ts | 26 +- .../components/ConstructionApprovalTable.tsx | 28 +- .../orders/documents/SalesOrderDocument.tsx | 34 +- .../ShipmentManagement/ShipmentCreate.tsx | 727 +++++++++++++----- .../ShipmentManagement/ShipmentDetail.tsx | 406 +++++----- .../ShipmentManagement/ShipmentEdit.tsx | 639 ++++++++++----- .../ShipmentManagement/ShipmentList.tsx | 326 ++++---- .../outbound/ShipmentManagement/actions.ts | 48 +- .../documents/DeliveryConfirmation.tsx | 189 +---- .../documents/ShipmentOrderDocument.tsx | 561 ++++++++++++++ .../documents/ShippingSlip.tsx | 218 +----- .../outbound/ShipmentManagement/index.ts | 2 +- .../outbound/ShipmentManagement/mockData.ts | 123 ++- .../ShipmentManagement/shipmentConfig.ts | 22 +- .../outbound/ShipmentManagement/types.ts | 193 +++-- .../VehicleDispatchDetail.tsx | 158 ++++ .../VehicleDispatchEdit.tsx | 397 ++++++++++ .../VehicleDispatchList.tsx | 338 ++++++++ .../VehicleDispatchManagement/actions.ts | 162 ++++ .../VehicleDispatchManagement/index.ts | 10 + .../VehicleDispatchManagement/mockData.ts | 198 +++++ .../VehicleDispatchManagement/types.ts | 104 +++ .../vehicleDispatchConfig.ts | 41 + .../documents/BendingInspectionContent.tsx | 31 +- .../documents/BendingWorkLogContent.tsx | 29 +- .../documents/ScreenWorkLogContent.tsx | 29 +- .../templates/IntegratedListTemplateV2.tsx | 9 + .../templates/UniversalListPage/index.tsx | 6 + .../templates/UniversalListPage/types.ts | 2 + 41 files changed, 4134 insertions(+), 1440 deletions(-) create mode 100644 src/app/[locale]/(protected)/outbound/vehicle-dispatches/[id]/page.tsx create mode 100644 src/app/[locale]/(protected)/outbound/vehicle-dispatches/page.tsx create mode 100644 src/components/common/ScheduleCalendar/WeekTimeView.tsx create mode 100644 src/components/outbound/ShipmentManagement/documents/ShipmentOrderDocument.tsx create mode 100644 src/components/outbound/VehicleDispatchManagement/VehicleDispatchDetail.tsx create mode 100644 src/components/outbound/VehicleDispatchManagement/VehicleDispatchEdit.tsx create mode 100644 src/components/outbound/VehicleDispatchManagement/VehicleDispatchList.tsx create mode 100644 src/components/outbound/VehicleDispatchManagement/actions.ts create mode 100644 src/components/outbound/VehicleDispatchManagement/index.ts create mode 100644 src/components/outbound/VehicleDispatchManagement/mockData.ts create mode 100644 src/components/outbound/VehicleDispatchManagement/types.ts create mode 100644 src/components/outbound/VehicleDispatchManagement/vehicleDispatchConfig.ts diff --git a/claudedocs/dev/[REF] all-pages-test-urls.md b/claudedocs/dev/[REF] all-pages-test-urls.md index eaeb7a3a..73dc2b84 100644 --- a/claudedocs/dev/[REF] all-pages-test-urls.md +++ b/claudedocs/dev/[REF] all-pages-test-urls.md @@ -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 (배차차량관리 추가) diff --git a/src/app/[locale]/(protected)/outbound/vehicle-dispatches/[id]/page.tsx b/src/app/[locale]/(protected)/outbound/vehicle-dispatches/[id]/page.tsx new file mode 100644 index 00000000..27c8bd49 --- /dev/null +++ b/src/app/[locale]/(protected)/outbound/vehicle-dispatches/[id]/page.tsx @@ -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 ; + } + + return ; +} diff --git a/src/app/[locale]/(protected)/outbound/vehicle-dispatches/page.tsx b/src/app/[locale]/(protected)/outbound/vehicle-dispatches/page.tsx new file mode 100644 index 00000000..89f5d9f8 --- /dev/null +++ b/src/app/[locale]/(protected)/outbound/vehicle-dispatches/page.tsx @@ -0,0 +1,12 @@ +'use client'; + +/** + * 배차차량관리 - 목록 페이지 + * URL: /outbound/vehicle-dispatches + */ + +import { VehicleDispatchList } from '@/components/outbound/VehicleDispatchManagement'; + +export default function VehicleDispatchesPage() { + return ; +} diff --git a/src/app/[locale]/(protected)/quality/qms/components/InspectionModalV2.tsx b/src/app/[locale]/(protected)/quality/qms/components/InspectionModalV2.tsx index 8bb16a60..ed300c2f 100644 --- a/src/app/[locale]/(protected)/quality/qms/components/InspectionModalV2.tsx +++ b/src/app/[locale]/(protected)/quality/qms/components/InspectionModalV2.tsx @@ -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 ( -
- {/* 헤더 */} -
-
- KD - 경동기업 -
-
-

작 업 일 지

-

{documentNo}

-

스크린 생산부서

-
- - - - - - - - - - - - - - - - - - - -
-
-
작성검토승인
-
{order.assignees[0] || '-'}
-
판매생산품질
-
- - {/* 기본 정보 */} -
-
-
-
발주처
-
{order.client}
-
-
-
현장명
-
{order.projectName}
-
-
-
-
-
작업일자
-
{today}
-
-
-
LOT NO.
-
{lotNo}
-
-
-
-
-
납기일
-
{order.dueDate}
-
-
-
지시수량
-
{order.quantity} EA
-
-
-
- - {/* 품목 테이블 */} -
-
-
No
-
품목명
-
출/부호
-
규격
-
수량
-
상태
-
- {items.map((item, index) => ( -
-
{item.no}
-
{item.name}
-
{item.location}
-
{item.spec}
-
{item.qty}
-
- {item.status} -
-
- ))} -
- - {/* 특이사항 */} -
-
특이사항
-
{order.instruction || '-'}
-
-
- ); -}; +// 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 ; - case 'bending': - return ; + return ; case 'slat': - return ; + return ; + case 'bending': + return ; + default: + // subType 미지정 시 스크린 기본 + return ; + } + }; + + // 중간검사 성적서 서브타입에 따른 렌더링 (신규 버전 통일) + const renderReportDocument = () => { + const subType = documentItem?.subType; + const mockOrder = createQmsMockWorkOrder(subType || 'screen'); + switch (subType) { + case 'screen': + return ; + case 'bending': + return ; + case 'slat': + return ; case 'jointbar': return ; default: - return ; + return ; } }; @@ -463,7 +405,7 @@ export const InspectionModalV2 = ({ case 'order': return ; case 'log': - return ; + return renderWorkLogDocument(); case 'confirmation': return ; case 'shipping': diff --git a/src/app/[locale]/(protected)/quality/qms/mockData.ts b/src/app/[locale]/(protected)/quality/qms/mockData.ts index 172ee9c0..e209947f 100644 --- a/src/app/[locale]/(protected)/quality/qms/mockData.ts +++ b/src/app/[locale]/(protected)/quality/qms/mockData.ts @@ -247,10 +247,11 @@ export const MOCK_DOCUMENTS: Record = { 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 }, ], }, { diff --git a/src/app/[locale]/(protected)/quality/qms/types.ts b/src/app/[locale]/(protected)/quality/qms/types.ts index d3da2363..6d33f08d 100644 --- a/src/app/[locale]/(protected)/quality/qms/types.ts +++ b/src/app/[locale]/(protected)/quality/qms/types.ts @@ -40,7 +40,7 @@ export interface DocumentItem { title: string; date: string; code?: string; - // 중간검사 성적서 서브타입 (report 타입일 때만 사용) + // 중간검사 성적서 및 작업일지 서브타입 (report, log 타입에서 사용) subType?: 'screen' | 'bending' | 'slat' | 'jointbar'; } diff --git a/src/app/[locale]/(protected)/sales/order-management-sales/[id]/page.tsx b/src/app/[locale]/(protected)/sales/order-management-sales/[id]/page.tsx index adce1dea..1724d3f2 100644 --- a/src/app/[locale]/(protected)/sales/order-management-sales/[id]/page.tsx +++ b/src/app/[locale]/(protected)/sales/order-management-sales/[id]/page.tsx @@ -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`); } }; diff --git a/src/components/auth/LoginPage.tsx b/src/components/auth/LoginPage.tsx index 1b7eb1f2..dece0bf6 100644 --- a/src/components/auth/LoginPage.tsx +++ b/src/components/auth/LoginPage.tsx @@ -209,7 +209,7 @@ export function LoginPage() {

{t('login')}

-

{tCommon('welcome')} SAM MES

+

{tCommon('welcome')} SAM ERP/MES

{error && ( diff --git a/src/components/common/ScheduleCalendar/CalendarHeader.tsx b/src/components/common/ScheduleCalendar/CalendarHeader.tsx index 35c148bb..e2827e48 100644 --- a/src/components/common/ScheduleCalendar/CalendarHeader.tsx +++ b/src/components/common/ScheduleCalendar/CalendarHeader.tsx @@ -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({
{/* 2줄: 뷰 전환 탭 */} -
- {renderViewTabs()} -
+ {views.length > 1 && ( +
+ {renderViewTabs()} +
+ )} {/* PC: 타이틀 + 네비게이션 | 뷰전환 + 필터 (한 줄) */} @@ -125,7 +128,7 @@ export function CalendarHeader({ {/* 우측: 뷰 전환 + 필터 */}
- {renderViewTabs('px-4 py-1.5')} + {views.length > 1 && renderViewTabs('px-4 py-1.5')} {filterSlot &&
{filterSlot}
}
diff --git a/src/components/common/ScheduleCalendar/ScheduleCalendar.tsx b/src/components/common/ScheduleCalendar/ScheduleCalendar.tsx index 96518c2d..cd7f74eb 100644 --- a/src/components/common/ScheduleCalendar/ScheduleCalendar.tsx +++ b/src/components/common/ScheduleCalendar/ScheduleCalendar.tsx @@ -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({
+ ) : view === 'week-time' ? ( + ) : view === 'month' ? ( 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(); + + 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 ( +
+
+ {/* 헤더: 시간라벨컬럼 + 요일/날짜 */} +
+ {/* 빈 코너셀 */} +
+ {/* 요일 + 날짜 */} + {weekDays.map((day, i) => { + const today = checkIsToday(day); + const selected = isSameDate(selectedDate, day); + return ( +
onDateClick(day)} + > +
+ {weekdayHeaders[i]} +
+
+ {format(day, 'd')} +
+
+ ); + })} +
+ + {/* All-day 영역 */} + {hasAllDayEvents && ( +
+
+ 종일 +
+ {weekDays.map((day, i) => { + const dateStr = formatDate(day, 'yyyy-MM-dd'); + const allDayEvents = eventsByDate.get(dateStr)?.allDay || []; + return ( +
+ {allDayEvents.map((event) => ( +
onEventClick(event)} + > + {event.title} +
+ ))} +
+ ); + })} +
+ )} + + {/* 시간 그리드 */} + {timeSlots.map((slot) => ( +
+ {/* 시간 라벨 */} +
+ + {slot.label} + +
+ {/* 요일별 셀 */} + {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 ( +
+ {slotEvents.map((event) => ( +
onEventClick(event)} + > + {event.title} +
+ ))} +
+ ); + })} +
+ ))} +
+
+ ); +} diff --git a/src/components/common/ScheduleCalendar/index.ts b/src/components/common/ScheduleCalendar/index.ts index c912d611..d3eee941 100644 --- a/src/components/common/ScheduleCalendar/index.ts +++ b/src/components/common/ScheduleCalendar/index.ts @@ -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'; diff --git a/src/components/common/ScheduleCalendar/types.ts b/src/components/common/ScheduleCalendar/types.ts index 987d5763..07e27d9e 100644 --- a/src/components/common/ScheduleCalendar/types.ts +++ b/src/components/common/ScheduleCalendar/types.ts @@ -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 */ diff --git a/src/components/document-system/components/ConstructionApprovalTable.tsx b/src/components/document-system/components/ConstructionApprovalTable.tsx index 5df7652d..ee06b46e 100644 --- a/src/components/document-system/components/ConstructionApprovalTable.tsx +++ b/src/components/document-system/components/ConstructionApprovalTable.tsx @@ -54,58 +54,58 @@ export function ConstructionApprovalTable({ }; return ( - +
{/* 헤더 행 */} - - - - {/* 이름 행 */} - - - - {/* 부서 행 */} - - - - diff --git a/src/components/orders/documents/SalesOrderDocument.tsx b/src/components/orders/documents/SalesOrderDocument.tsx index f571ef30..a45bf417 100644 --- a/src/components/orders/documents/SalesOrderDocument.tsx +++ b/src/components/orders/documents/SalesOrderDocument.tsx @@ -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({ {/* 결재란 (우측) */} -

+ {labels.writer} + {labels.approver1} + {labels.approver2} + {labels.approver3}
+ {approvers.writer?.name || ''} + {approvers.approver1?.name || ''} + {approvers.approver2?.name || ''} + {approvers.approver3?.name || ''}
+ {approvers.writer?.department || '부서명'} + {approvers.approver1?.department || '부서명'} + {approvers.approver2?.department || '부서명'} + {approvers.approver3?.department || '부서명'}
- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
작성승인승인승인
과장
홍길동이름이름이름
부서명부서명부서명부서명
+
{/* 상품명 / 제품명 / 로트번호 / 인정번호 */} diff --git a/src/components/outbound/ShipmentManagement/ShipmentCreate.tsx b/src/components/outbound/ShipmentManagement/ShipmentCreate.tsx index 8ef7dfb5..8d1fa265 100644 --- a/src/components/outbound/ShipmentManagement/ShipmentCreate.tsx +++ b/src/components/outbound/ShipmentManagement/ShipmentCreate.tsx @@ -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([]); const [vehicleTonnageOptions, setVehicleTonnageOptions] = useState([]); + // 제품 데이터 (LOT 선택 시 표시) + const [productGroups, setProductGroups] = useState([]); + const [otherParts, setOtherParts] = useState([]); + // 로딩/에러 상태 const [isLoading, setIsLoading] = useState(true); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); const [validationErrors, setValidationErrors] = useState([]); + // 아코디언 상태 + const [accordionValue, setAccordionValue] = useState([]); + + // 우편번호 찾기 + 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[]) => ( + + + + 순번 + 품목명 + 규격 + 수량 + 단위 + + + + {parts.map((part) => ( + + {part.seq} + {part.itemName} + {part.specification} + {part.quantity} + {part.unit} + + ))} + +
+ ); + + // LOT에서 선택한 정보 표시 + const selectedLot = lotOptions.find(o => o.value === formData.lotNo); + // 폼 컨텐츠 렌더링 const renderFormContent = useCallback((_props: { formData: Record; onChange: (key: string, value: unknown) => void; mode: string; errors: Record }) => (
{/* Validation 에러 표시 */} - {validationErrors.length > 0 && ( - - -
- ⚠️ -
- - 입력 내용을 확인해주세요 ({validationErrors.length}개 오류) - -
    - {validationErrors.map((error, index) => ( -
  • - - {error} -
  • - ))} -
-
+ {validationErrors.length > 0 && ( + + +
+ ⚠️ +
+ + 입력 내용을 확인해주세요 ({validationErrors.length}개 오류) + +
    + {validationErrors.map((err, index) => ( +
  • + + {err} +
  • + ))} +
- - - )} +
+
+
+ )} - {/* 수주 선택 */} - - - 수주 선택 - - -
- + {/* 카드 1: 기본 정보 */} + + + 기본 정보 + + +
+ {/* 출고번호 - 자동생성 */} +
+
출고번호
+
자동생성
+
+ {/* 로트번호 - Select */} +
+
로트번호 *
- - + {/* 현장명 - LOT 선택 시 자동 매핑 */} +
+
현장명
+
{selectedLot?.siteName || '-'}
+
+ {/* 수주처 - LOT 선택 시 자동 매핑 */} +
+
수주처
+
{selectedLot?.customerName || '-'}
+
+
+
+
- {/* 출고 정보 */} - - - 출고 정보 - - -
-
- - handleInputChange('scheduledDate', e.target.value)} - disabled={isSubmitting} - /> -
-
- - -
+ {/* 카드 2: 수주/배송 정보 */} + + + 수주/배송 정보 + + +
+
+ + handleInputChange('scheduledDate', e.target.value)} + disabled={isSubmitting} + /> +
+
+ + handleInputChange('shipmentDate', e.target.value)} + disabled={isSubmitting} + />
+
+ + +
+
+
+
+ + handleInputChange('receiver', e.target.value)} + placeholder="수신자명" + disabled={isSubmitting} + /> +
+
+ + handleInputChange('receiverContact', e.target.value)} + placeholder="수신처" + disabled={isSubmitting} + /> +
+
+ {/* 주소 */} +
+ +
+ + +
+ + handleInputChange('addressDetail', e.target.value)} + placeholder="상세주소" + disabled={isSubmitting} + /> +
+
+
- {/* 상차 (물류업체) - 배송방식이 상차 또는 물류사일 때 표시 */} - {(formData.deliveryMethod === 'pickup' || formData.deliveryMethod === 'logistics') && ( - - - 상차 (물류업체) - - -
-
- - -
-
- - -
-
-
- + {/* 카드 3: 배차 정보 */} + + + 배차 정보 + + + + + + + 물류업체 + 입차일시 + 톤수 + 차량번호 + 기사연락처 + 비고 + + + + + {formData.vehicleDispatches.map((dispatch, index) => ( + + + + + handleInputChange('loadingTime', e.target.value)} - placeholder="연도. 월. 일. -- --:--" + value={dispatch.arrivalDateTime} + onChange={(e) => handleDispatchChange(index, 'arrivalDateTime', e.target.value)} + className="h-8" disabled={isSubmitting} /> -

- 물류업체와 입차시간 확정 후 입력하세요. -

- - - - )} +
+ + + + + handleDispatchChange(index, 'vehicleNo', e.target.value)} + placeholder="차량번호" + className="h-8" + disabled={isSubmitting} + /> + + + handleDispatchChange(index, 'driverContact', e.target.value)} + placeholder="연락처" + className="h-8" + disabled={isSubmitting} + /> + + + handleDispatchChange(index, 'remarks', e.target.value)} + placeholder="비고" + className="h-8" + disabled={isSubmitting} + /> + + + {formData.vehicleDispatches.length > 1 && ( + + )} + +
+ ))} +
+
+
+
-
- - handleInputChange('loadingManager', e.target.value)} - placeholder="상차 작업 담당자명" - disabled={isSubmitting} - /> + {/* 카드 4: 제품내용 (읽기전용 - LOT 선택 시 표시) */} + + + 제품내용 + {productGroups.length > 0 && ( + + + + + + + 모두 펼치기 + + + 모두 접기 + + + + )} + + + {productGroups.length > 0 || otherParts.length > 0 ? ( + + {productGroups.map((group: ProductGroup) => ( + + +
+ {group.productName} + + ({group.specification}) + + + {group.partCount}개 부품 + +
+
+ + {renderPartsTable(group.parts)} + +
+ ))} + {otherParts.length > 0 && ( + + +
+ 기타부품 + + {otherParts.length}개 부품 + +
+
+ + {renderPartsTable(otherParts)} + +
+ )} +
+ ) : ( +
+ {formData.lotNo ? '제품 정보를 불러오는 중...' : '로트를 선택하면 제품 목록이 표시됩니다.'}
+ )} +
+
+
+ ), [ + formData, validationErrors, isSubmitting, lotOptions, logisticsOptions, + vehicleTonnageOptions, selectedLot, productGroups, otherParts, accordionValue, + handleLotChange, handleExpandAll, handleCollapseAll, openPostcode, + ]); -
- -