'use client'; /** * 작업자 화면 메인 컴포넌트 (기획서 기반 전면 개편) * * 구조: * - 상단: 페이지 제목 * - 탭: 스크린/슬랫/절곡 (디폴트: 스크린) * - 상태 카드 4개 (할일/작업중/완료/긴급) * - 수주 정보 섹션 (읽기 전용) * - 작업 정보 섹션 (생산담당자 셀렉트 + 생산일자) * - 작업 목록 (WorkItemCard 나열) * - 하단 고정 버튼 (작업일지보기 / 중간검사하기) */ import { useState, useMemo, useCallback, useEffect } from 'react'; import dynamic from 'next/dynamic'; import { useSidebarCollapsed } from '@/stores/menuStore'; import { ClipboardList, PlayCircle, CheckCircle2, AlertTriangle, ChevronDown, ChevronUp, List } from 'lucide-react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from '@/components/ui/dialog'; import { ContentSkeleton } from '@/components/ui/skeleton'; import { Card, CardContent } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { DatePicker } from '@/components/ui/date-picker'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { Button } from '@/components/ui/button'; import { PageLayout } from '@/components/organisms/PageLayout'; import { cn } from '@/lib/utils'; import { toast } from 'sonner'; import { getMyWorkOrders, completeWorkOrder, saveItemInspection, getWorkOrderInspectionData, saveInspectionDocument, getInspectionTemplate, getStepProgress, toggleStepProgress, deleteMaterialInput, updateMaterialInput, getDepartments, getDepartmentUsers } from './actions'; import type { StepProgressItem, DepartmentOption, DepartmentUser } from './actions'; import type { InspectionTemplateData } from './types'; import { getProcessList } from '@/components/process-management/actions'; import type { InspectionSetting, Process } from '@/types/process'; import type { WorkOrder } from '../ProductionDashboard/types'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; import type { WorkerStats, CompletionToastInfo, MaterialInput, ProcessTab, WorkItemData, WorkStepData, MaterialListItem, } from './types'; import { PROCESS_TAB_LABELS } from './types'; import { WorkItemCard } from './WorkItemCard'; import { CompletionConfirmDialog } from './CompletionConfirmDialog'; import { CompletionToast } from './CompletionToast'; import type { InspectionProcessType, InspectionData } from './InspectionInputModal'; const InspectionInputModal = dynamic( () => import('./InspectionInputModal').then(mod => ({ default: mod.InspectionInputModal })), ); const MaterialInputModal = dynamic( () => import('./MaterialInputModal').then(mod => ({ default: mod.MaterialInputModal })), ); const WorkLogModal = dynamic( () => import('./WorkLogModal').then(mod => ({ default: mod.WorkLogModal })), ); const IssueReportModal = dynamic( () => import('./IssueReportModal').then(mod => ({ default: mod.IssueReportModal })), ); const WorkCompletionResultDialog = dynamic( () => import('./WorkCompletionResultDialog').then(mod => ({ default: mod.WorkCompletionResultDialog })), ); const InspectionReportModal = dynamic( () => import('../WorkOrders/documents').then(mod => ({ default: mod.InspectionReportModal })), ); // ===== 목업 데이터 ===== const MOCK_ITEMS: Record = { screen: [ { id: 'mock-s1', itemNo: 1, itemCode: 'KWWS03', itemName: '와이어', floor: '1층', code: 'FSS-01', width: 8260, height: 8350, quantity: 2, processType: 'screen', cuttingInfo: { width: 1210, sheets: 8 }, steps: [ { id: 's1-1', name: '자재투입', isMaterialInput: true, isCompleted: true }, { id: 's1-2', name: '절단', isMaterialInput: false, isCompleted: true }, { id: 's1-3', name: '미싱', isMaterialInput: false, isCompleted: false }, { id: 's1-4', name: '중간검사', isMaterialInput: false, isInspection: true, isCompleted: false }, { id: 's1-5', name: '포장완료', isMaterialInput: false, isCompleted: false }, ], materialInputs: [ { id: 'm1', lotNo: 'LOT-2026-001', itemName: '스크린 원단 A', quantity: 500, unit: 'm' }, { id: 'm2', lotNo: 'LOT-2026-002', itemName: '와이어 B', quantity: 120, unit: 'EA' }, ], }, { id: 'mock-s2', itemNo: 2, itemCode: 'KWWS05', itemName: '메쉬', floor: '2층', code: 'FSS-03', width: 6400, height: 5200, quantity: 4, processType: 'screen', cuttingInfo: { width: 1600, sheets: 4 }, steps: [ { id: 's2-1', name: '자재투입', isMaterialInput: true, isCompleted: false }, { id: 's2-2', name: '절단', isMaterialInput: false, isCompleted: false }, { id: 's2-3', name: '미싱', isMaterialInput: false, isCompleted: false }, { id: 's2-4', name: '중간검사', isMaterialInput: false, isInspection: true, isCompleted: false }, { id: 's2-5', name: '포장완료', isMaterialInput: false, isCompleted: false }, ], }, { id: 'mock-s3', itemNo: 3, itemCode: 'KWWS08', itemName: '와이어(광폭)', floor: '3층', code: 'FSS-05', width: 12000, height: 4500, quantity: 1, processType: 'screen', cuttingInfo: { width: 2400, sheets: 5 }, steps: [ { id: 's3-1', name: '자재투입', isMaterialInput: true, isCompleted: true }, { id: 's3-2', name: '절단', isMaterialInput: false, isCompleted: true }, { id: 's3-3', name: '미싱', isMaterialInput: false, isCompleted: true }, { id: 's3-4', name: '중간검사', isMaterialInput: false, isInspection: true, isCompleted: false }, { id: 's3-5', name: '포장완료', isMaterialInput: false, isCompleted: false }, ], materialInputs: [ { id: 'm3', lotNo: 'LOT-2026-005', itemName: '광폭 원단', quantity: 300, unit: 'm' }, ], }, ], slat: [ { id: 'mock-l1', itemNo: 1, itemCode: 'KQTS01', itemName: '슬랫코일', floor: '1층', code: 'FSS-01', width: 8260, height: 8350, quantity: 2, processType: 'slat', slatInfo: { length: 3910, slatCount: 40, jointBar: 4, glassQty: 2 }, steps: [ { id: 'l1-1', name: '자재투입', isMaterialInput: true, isCompleted: true }, { id: 'l1-2', name: '포밍/절단', isMaterialInput: false, isCompleted: false }, { id: 'l1-3', name: '중간검사', isMaterialInput: false, isInspection: true, isCompleted: false }, { id: 'l1-4', name: '포장완료', isMaterialInput: false, isCompleted: false }, ], materialInputs: [ { id: 'm4', lotNo: 'LOT-2026-010', itemName: '슬랫 코일 A', quantity: 200, unit: 'kg' }, ], }, { id: 'mock-l2', itemNo: 2, itemCode: 'KQTS03', itemName: '슬랫코일(광폭)', floor: '2층', code: 'FSS-02', width: 10500, height: 6200, quantity: 3, processType: 'slat', slatInfo: { length: 5200, slatCount: 55, jointBar: 6, glassQty: 3 }, steps: [ { id: 'l2-1', name: '자재투입', isMaterialInput: true, isCompleted: false }, { id: 'l2-2', name: '포밍/절단', isMaterialInput: false, isCompleted: false }, { id: 'l2-3', name: '중간검사', isMaterialInput: false, isInspection: true, isCompleted: false }, { id: 'l2-4', name: '포장완료', isMaterialInput: false, isCompleted: false }, ], }, ], bending: [ { id: 'mock-b1', itemNo: 1, itemCode: 'KWWS03', itemName: '가이드레일', floor: '1층', code: 'FSS-01', width: 0, height: 0, quantity: 6, processType: 'bending', bendingInfo: { common: { kind: '혼합형 120X70', type: '혼합형', lengthQuantities: [{ length: 4000, quantity: 6 }, { length: 3000, quantity: 6 }], }, detailParts: [ { partName: '엘바', material: 'EGI 1.6T', barcyInfo: '16 I 75' }, { partName: '하장바', material: 'EGI 1.6T', barcyInfo: '16|75|16|75|16(A각)' }, ], }, steps: [ { id: 'b1-1', name: '자재투입', isMaterialInput: true, isCompleted: true }, { id: 'b1-2', name: '절단', isMaterialInput: false, isCompleted: true }, { id: 'b1-3', name: '절곡', isMaterialInput: false, isCompleted: false }, { id: 'b1-4', name: '중간검사', isMaterialInput: false, isInspection: true, isCompleted: false }, ], }, ], }; // 절곡 재공품 전용 목업 데이터 (토글로 전환) const MOCK_ITEMS_BENDING_WIP: WorkItemData[] = [ { id: 'mock-bw1', itemNo: 1, itemCode: 'KWWS03', itemName: '케이스 - 전면부', floor: '-', code: '-', width: 0, height: 0, quantity: 6, processType: 'bending', isWip: true, wipInfo: { specification: 'EGI 1.55T (W576)', lengthQuantity: '4,000mm X 6개' }, steps: [ { id: 'bw1-1', name: '자재투입', isMaterialInput: true, isCompleted: false }, { id: 'bw1-2', name: '절단', isMaterialInput: false, isCompleted: false }, { id: 'bw1-3', name: '중간검사', isMaterialInput: false, isInspection: true, isCompleted: false }, ], }, { id: 'mock-bw2', itemNo: 2, itemCode: 'KWWS04', itemName: '케이스 - 후면부', floor: '-', code: '-', width: 0, height: 0, quantity: 4, processType: 'bending', isWip: true, wipInfo: { specification: 'EGI 1.55T (W576)', lengthQuantity: '3,500mm X 4개' }, steps: [ { id: 'bw2-1', name: '자재투입', isMaterialInput: true, isCompleted: false }, { id: 'bw2-2', name: '절단', isMaterialInput: false, isCompleted: false }, { id: 'bw2-3', name: '중간검사', isMaterialInput: false, isInspection: true, isCompleted: false }, ], }, { id: 'mock-bw3', itemNo: 3, itemCode: 'KWWS05', itemName: '하단마감재', floor: '-', code: '-', width: 0, height: 0, quantity: 10, processType: 'bending', isWip: true, wipInfo: { specification: 'EGI 1.2T (W400)', lengthQuantity: '2,800mm X 10개' }, steps: [ { id: 'bw3-1', name: '자재투입', isMaterialInput: true, isCompleted: false }, { id: 'bw3-2', name: '절단', isMaterialInput: false, isCompleted: false }, { id: 'bw3-3', name: '중간검사', isMaterialInput: false, isInspection: true, isCompleted: false }, ], }, ]; // 슬랫 조인트바 전용 목업 데이터 (토글로 전환) const MOCK_ITEMS_SLAT_JOINTBAR: WorkItemData[] = [ { id: 'mock-jb1', itemNo: 1, itemCode: 'KQJB01', itemName: '조인트바 A', floor: '1층', code: 'FSS-01', width: 0, height: 0, quantity: 8, processType: 'slat', isJointBar: true, slatJointBarInfo: { specification: 'EGI 1.6T', length: 3910, quantity: 8 }, steps: [ { id: 'jb1-1', name: '자재투입', isMaterialInput: true, isCompleted: true }, { id: 'jb1-2', name: '포밍/절단', isMaterialInput: false, isCompleted: false }, { id: 'jb1-3', name: '중간검사', isMaterialInput: false, isInspection: true, isCompleted: false }, { id: 'jb1-4', name: '포장완료', isMaterialInput: false, isCompleted: false }, ], materialInputs: [ { id: 'mjb1', lotNo: 'LOT-2026-020', itemName: '조인트바 코일', quantity: 100, unit: 'kg' }, ], }, { id: 'mock-jb2', itemNo: 2, itemCode: 'KQJB02', itemName: '조인트바 B', floor: '2층', code: 'FSS-02', width: 0, height: 0, quantity: 12, processType: 'slat', isJointBar: true, slatJointBarInfo: { specification: 'EGI 1.6T', length: 5200, quantity: 12 }, steps: [ { id: 'jb2-1', name: '자재투입', isMaterialInput: true, isCompleted: false }, { id: 'jb2-2', name: '포밍/절단', isMaterialInput: false, isCompleted: false }, { id: 'jb2-3', name: '중간검사', isMaterialInput: false, isInspection: true, isCompleted: false }, { id: 'jb2-4', name: '포장완료', isMaterialInput: false, isCompleted: false }, ], }, ]; // 사이드바 작업지시 목업 데이터 interface SidebarOrder { id: string; siteName: string; date: string; quantity: number; shutterCount: number; priority: 'urgent' | 'priority' | 'normal'; subType?: 'slat' | 'jointbar' | 'bending' | 'wip'; } // 스크린: subType 없음 / 슬랫: slat|jointbar / 절곡: bending|wip const MOCK_SIDEBAR_ORDERS: Record = { screen: [ { id: 'order-s1', siteName: '현장명', date: '2024-09-24', quantity: 7, shutterCount: 5, priority: 'urgent' }, { id: 'order-s2', siteName: '현장명', date: '2024-09-24', quantity: 7, shutterCount: 5, priority: 'priority' }, { id: 'order-s3', siteName: '현장명', date: '2024-09-24', quantity: 7, shutterCount: 5, priority: 'normal' }, { id: 'order-s4', siteName: '현장명', date: '2024-09-24', quantity: 7, shutterCount: 5, priority: 'normal' }, ], slat: [ { id: 'order-l1', siteName: '현장명A', date: '2024-09-24', quantity: 7, shutterCount: 5, priority: 'urgent', subType: 'slat' }, { id: 'order-l2', siteName: '현장명B', date: '2024-09-24', quantity: 3, shutterCount: 2, priority: 'priority', subType: 'jointbar' }, { id: 'order-l3', siteName: '현장명C', date: '2024-09-24', quantity: 5, shutterCount: 4, priority: 'normal', subType: 'slat' }, { id: 'order-l4', siteName: '현장명D', date: '2024-09-24', quantity: 4, shutterCount: 3, priority: 'normal', subType: 'jointbar' }, ], bending: [ { id: 'order-b1', siteName: '현장명A', date: '2024-09-24', quantity: 7, shutterCount: 5, priority: 'urgent', subType: 'bending' }, { id: 'order-b2', siteName: '현장명B', date: '2024-09-24', quantity: 3, shutterCount: 2, priority: 'priority', subType: 'wip' }, { id: 'order-b3', siteName: '현장명C', date: '2024-09-24', quantity: 5, shutterCount: 4, priority: 'normal', subType: 'bending' }, { id: 'order-b4', siteName: '현장명D', date: '2024-09-24', quantity: 4, shutterCount: 3, priority: 'normal', subType: 'wip' }, ], }; const SUB_TYPE_TAGS: Record = { slat: { label: '슬랫', className: 'bg-blue-100 text-blue-700' }, jointbar: { label: '조인트바', className: 'bg-purple-100 text-purple-700' }, bending: { label: '절곡', className: 'bg-amber-100 text-amber-700' }, wip: { label: '재공품', className: 'bg-orange-100 text-orange-700' }, }; const PRIORITY_GROUPS = [ { key: 'urgent' as const, label: '긴급', color: 'text-red-600' }, { key: 'priority' as const, label: '우선', color: 'text-orange-600' }, { key: 'normal' as const, label: '일반', color: 'text-gray-600' }, ]; // 하드코딩된 공정별 단계 폴백 const PROCESS_STEPS: Record = { screen: [ { name: '자재투입', isMaterialInput: true }, { name: '절단', isMaterialInput: false }, { name: '미싱', isMaterialInput: false }, { name: '중간검사', isMaterialInput: false, isInspection: true }, { name: '포장완료', isMaterialInput: false }, ], slat: [ { name: '자재투입', isMaterialInput: true }, { name: '포밍/절단', isMaterialInput: false }, { name: '중간검사', isMaterialInput: false, isInspection: true }, { name: '포장완료', isMaterialInput: false }, ], bending: [ { name: '자재투입', isMaterialInput: true }, { name: '절단', isMaterialInput: false }, { name: '절곡', isMaterialInput: false }, { name: '중간검사', isMaterialInput: false, isInspection: true }, ], bending_wip: [ { name: '자재투입', isMaterialInput: true }, { name: '절단', isMaterialInput: false }, { name: '중간검사', isMaterialInput: false, isInspection: true }, ], }; export default function WorkerScreen() { // ===== 상태 관리 ===== const sidebarCollapsed = useSidebarCollapsed(); const [workOrders, setWorkOrders] = useState([]); const [isLoading, setIsLoading] = useState(true); const [activeTab, setActiveTab] = useState(''); const [bendingSubMode, setBendingSubMode] = useState<'normal' | 'wip'>('normal'); const [slatSubMode, setSlatSubMode] = useState<'normal' | 'jointbar'>('normal'); // 작업 정보 const [departmentId, setDepartmentId] = useState(''); const [productionManagerId, setProductionManagerId] = useState(''); const [productionDate, setProductionDate] = useState(''); const [departmentList, setDepartmentList] = useState([]); const [departmentUsers, setDepartmentUsers] = useState([]); // 좌측 사이드바 const [selectedSidebarOrderId, setSelectedSidebarOrderId] = useState(''); const [isSidebarOpen, setIsSidebarOpen] = useState(false); // 공정별 step 완료 상태: { [itemId-stepName]: boolean } const [stepCompletionMap, setStepCompletionMap] = useState>({}); // 작업지시별 단계 진행 캐시: { [workOrderId]: StepProgressItem[] } const [stepProgressMap, setStepProgressMap] = useState>({}); // 데이터 로드 const loadData = useCallback(async () => { setIsLoading(true); try { const result = await getMyWorkOrders(); if (result.success) { setWorkOrders(result.data); } else { toast.error(result.error || '작업 목록 조회에 실패했습니다.'); } } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[WorkerScreen] loadData error:', error); toast.error('데이터 로드 중 오류가 발생했습니다.'); } finally { setIsLoading(false); } }, []); useEffect(() => { loadData(); // 부서 목록 로드 getDepartments().then((res) => { if (res.success) setDepartmentList(res.data); }); }, [loadData]); // 부서 선택 시 해당 부서 사용자 목록 로드 useEffect(() => { if (!departmentId) { setDepartmentUsers([]); setProductionManagerId(''); return; } getDepartmentUsers(Number(departmentId)).then((res) => { if (res.success) { setDepartmentUsers(res.data); } else { setDepartmentUsers([]); } setProductionManagerId(''); }); }, [departmentId]); // PC에서 사이드바 sticky 동작을 위해 main의 overflow 임시 해제 useEffect(() => { const mainEl = document.querySelector('main'); if (!mainEl) return; const original = mainEl.style.overflow; mainEl.style.overflow = 'visible'; return () => { mainEl.style.overflow = original; }; }, []); // 모달/다이얼로그 상태 const [selectedOrder, setSelectedOrder] = useState(null); const [isCompletionDialogOpen, setIsCompletionDialogOpen] = useState(false); const [isMaterialModalOpen, setIsMaterialModalOpen] = useState(false); const [selectedWorkOrderItemId, setSelectedWorkOrderItemId] = useState(); const [selectedWorkOrderItemIds, setSelectedWorkOrderItemIds] = useState(); const [selectedWorkOrderItemName, setSelectedWorkOrderItemName] = useState(); const [isWorkLogModalOpen, setIsWorkLogModalOpen] = useState(false); const [isInspectionModalOpen, setIsInspectionModalOpen] = useState(false); const [isIssueReportModalOpen, setIsIssueReportModalOpen] = useState(false); const [isInspectionInputModalOpen, setIsInspectionInputModalOpen] = useState(false); // 공정의 중간검사 설정 const [currentInspectionSetting, setCurrentInspectionSetting] = useState(); // 문서 템플릿 데이터 (document_template 기반 동적 검사용) const [inspectionTemplateData, setInspectionTemplateData] = useState(); const [inspectionDimensions, setInspectionDimensions] = useState<{ width?: number; height?: number }>({}); // 검사 클릭 시 해당 step name 추적 (하드코딩 '중간검사' 제거용) const [inspectionStepName, setInspectionStepName] = useState(''); // 중간검사 체크 상태 관리: { [itemId]: boolean } const [inspectionCheckedMap, setInspectionCheckedMap] = useState>({}); // 체크된 검사 항목 수 계산 const checkedInspectionCount = useMemo(() => { return Object.values(inspectionCheckedMap).filter(Boolean).length; }, [inspectionCheckedMap]); // 중간검사 완료 데이터 관리 const [inspectionDataMap, setInspectionDataMap] = useState>(new Map()); // 전량완료 흐름 상태 const [isCompletionFlow, setIsCompletionFlow] = useState(false); const [isCompletionResultOpen, setIsCompletionResultOpen] = useState(false); const [completionLotNo, setCompletionLotNo] = useState(''); // 투입된 자재 관리 (orderId -> MaterialInput[]) const [inputMaterialsMap, setInputMaterialsMap] = useState>( new Map() ); // 자재 수정 Dialog 상태 const [editMaterialTarget, setEditMaterialTarget] = useState<{ itemId: string; material: MaterialListItem } | null>(null); const [editMaterialQty, setEditMaterialQty] = useState(''); // 완료 토스트 상태 const [toastInfo, setToastInfo] = useState(null); // 공정 목록 캐시 const [processListCache, setProcessListCache] = useState([]); // 공정 목록 조회 (최초 1회) useEffect(() => { const fetchProcessList = async () => { try { const result = await getProcessList({ size: 100 }); if (result.success && result.data?.items) { setProcessListCache(result.data.items); } } catch (error) { console.error('Failed to fetch process list:', error); } }; fetchProcessList(); }, []); // 활성 공정 목록 (탭용) - 공정관리에서 등록된 활성 공정만 const processTabs = useMemo(() => { return processListCache.filter((p) => p.status === '사용중'); }, [processListCache]); // 공정 목록 로드 후 첫 번째 공정을 기본 선택 useEffect(() => { if (processTabs.length > 0 && !activeTab) { setActiveTab(processTabs[0].id); } }, [processTabs, activeTab]); // 선택된 공정의 ProcessTab 키 (mock 데이터 및 기존 로직 호환용) const activeProcessTabKey: ProcessTab = useMemo(() => { const process = processListCache.find((p) => p.id === activeTab); if (!process) return 'screen'; const name = process.processName.toLowerCase(); if (name.includes('스크린')) return 'screen'; if (name.includes('슬랫')) return 'slat'; if (name.includes('절곡')) return 'bending'; return 'screen'; }, [activeTab, processListCache]); // 선택된 공정의 작업일지/검사성적서 설정 const activeProcessSettings = useMemo(() => { const process = processListCache.find((p) => p.id === activeTab); return { needsWorkLog: process?.needsWorkLog ?? false, hasDocumentTemplate: !!process?.documentTemplateId, workLogTemplateId: process?.workLogTemplateId, workLogTemplateName: process?.workLogTemplateName, }; }, [activeTab, processListCache]); // activeTab 변경 시 해당 공정의 중간검사 설정 조회 useEffect(() => { if (processListCache.length === 0 || !activeTab) return; const matchedProcess = processListCache.find((p) => p.id === activeTab); if (matchedProcess?.steps) { const inspectionStep = matchedProcess.steps.find( (step) => step.needsInspection && step.inspectionSetting ); setCurrentInspectionSetting(inspectionStep?.inspectionSetting); } else { setCurrentInspectionSetting(undefined); } }, [activeTab, processListCache]); // ===== 작업지시 선택 시 단계 진행 데이터 로드 ===== useEffect(() => { if (!selectedSidebarOrderId) return; if (selectedSidebarOrderId.startsWith('order-')) return; const loadStepProgress = async () => { try { const result = await getStepProgress(selectedSidebarOrderId); if (result.success) { setStepProgressMap((prev) => ({ ...prev, [selectedSidebarOrderId]: result.data })); } } catch { // step progress 로드 실패 시 무시 } }; loadStepProgress(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedSidebarOrderId]); // ===== 탭별 필터링된 작업 ===== const filteredWorkOrders = useMemo(() => { const selectedProcess = processListCache.find((p) => p.id === activeTab); if (!selectedProcess) return workOrders; const selectedName = selectedProcess.processName.toLowerCase(); return workOrders.filter((order) => { const orderProcessName = (order.processName || '').toLowerCase(); return orderProcessName.includes(selectedName) || selectedName.includes(orderProcessName); }); }, [workOrders, activeTab, processListCache]); // ===== API WorkOrders → SidebarOrder 변환 ===== const apiSidebarOrders: SidebarOrder[] = useMemo(() => { return filteredWorkOrders.map((wo) => ({ id: wo.id, siteName: wo.projectName || wo.client || '-', date: wo.dueDate || (wo.createdAt ? wo.createdAt.slice(0, 10) : '-'), quantity: wo.quantity, shutterCount: wo.shutterCount || 0, priority: (wo.isUrgent ? 'urgent' : (wo.priority <= 3 ? 'priority' : 'normal')) as SidebarOrder['priority'], })); }, [filteredWorkOrders]); // ===== 탭 변경/데이터 로드 시 최상위 우선순위 작업 자동 선택 ===== useEffect(() => { if (isLoading) return; const allOrders: SidebarOrder[] = [...apiSidebarOrders, ...MOCK_SIDEBAR_ORDERS[activeProcessTabKey]]; // 현재 선택이 유효하면 자동 전환하지 않음 (데이터 새로고침 시 선택 유지) if (selectedSidebarOrderId && allOrders.some((o) => o.id === selectedSidebarOrderId)) { return; } // API 작업지시 우선 선택 (부서/담당자 연동을 위해) if (apiSidebarOrders.length > 0) { const firstApi = apiSidebarOrders[0]; setSelectedSidebarOrderId(firstApi.id); if (activeProcessTabKey === 'slat') { setSlatSubMode(firstApi.subType === 'jointbar' ? 'jointbar' : 'normal'); } if (activeProcessTabKey === 'bending') { setBendingSubMode(firstApi.subType === 'wip' ? 'wip' : 'normal'); } return; } // API 작업지시 없으면 목업에서 우선순위 순서: urgent → priority → normal for (const group of PRIORITY_GROUPS) { const first = allOrders.find((o) => o.priority === group.key); if (first) { setSelectedSidebarOrderId(first.id); if (activeProcessTabKey === 'slat') { setSlatSubMode(first.subType === 'jointbar' ? 'jointbar' : 'normal'); } if (activeProcessTabKey === 'bending') { setBendingSubMode(first.subType === 'wip' ? 'wip' : 'normal'); } return; } } }, [isLoading, apiSidebarOrders, activeProcessTabKey, selectedSidebarOrderId]); // ===== 통계 계산 (탭별) ===== const stats: WorkerStats = useMemo(() => { return { assigned: filteredWorkOrders.length, inProgress: filteredWorkOrders.filter((o) => o.status === 'inProgress').length, completed: 0, urgent: filteredWorkOrders.filter((o) => o.isUrgent).length, }; }, [filteredWorkOrders]); // ===== 선택된 작업지시의 개소별 WorkItemData 변환 + 목업 ===== // 현재 활성 공정의 단계 설정 (processListCache 기반) const activeProcessSteps = useMemo(() => { const process = processListCache.find((p) => p.id === activeTab); return process?.steps || []; }, [activeTab, processListCache]); const workItems: WorkItemData[] = useMemo(() => { const selectedOrder = filteredWorkOrders.find((wo) => wo.id === selectedSidebarOrderId); const stepsKey = (activeProcessTabKey === 'bending' && bendingSubMode === 'wip') ? 'bending_wip' : activeProcessTabKey; const hardcodedSteps = PROCESS_STEPS[stepsKey]; // 공정관리 API에서 가져온 단계가 있으면 우선 사용, 없으면 하드코딩 폴백 const useApiSteps = activeProcessSteps.length > 0; const stepsTemplate: { name: string; isMaterialInput: boolean; isInspection?: boolean }[] = useApiSteps ? activeProcessSteps .filter((ps) => ps.isActive) .sort((a, b) => a.order - b.order) .map((ps) => ({ name: ps.stepName, isMaterialInput: ps.stepName.includes('자재투입'), isInspection: ps.needsInspection, })) : hardcodedSteps; // step에 API 설정 속성을 매칭하는 헬퍼 const orderProgress = stepProgressMap[selectedSidebarOrderId || ''] || []; const enrichStep = (st: { name: string; isMaterialInput: boolean; isInspection?: boolean }, stepId: string, stepKey: string, apiItemId?: number) => { const matched = activeProcessSteps.find((ps) => ps.stepName === st.name); // stepProgress 매칭 (개소별: work_order_item_id로 필터) const spMatch = orderProgress.find((sp) => sp.step_name === st.name && (sp.work_order_item_id === null || sp.work_order_item_id === (apiItemId ?? null)) ); return { id: stepId, name: st.name, isMaterialInput: st.isMaterialInput, isInspection: matched ? matched.needsInspection : (st.isInspection || false), isCompleted: stepKey in stepCompletionMap ? stepCompletionMap[stepKey] : (spMatch?.is_completed || false), stepProgressId: spMatch?.id, needsInspection: matched?.needsInspection, connectionType: matched?.connectionType, connectionTarget: matched?.connectionTarget, completionType: matched?.completionType, }; }; const apiItems: WorkItemData[] = []; if (selectedOrder && selectedOrder.nodeGroups && selectedOrder.nodeGroups.length > 0) { // 개소별로 WorkItemData 생성 selectedOrder.nodeGroups.forEach((group, index) => { const nodeKey = group.nodeId != null ? String(group.nodeId) : `unassigned-${index}`; const firstItem = group.items[0]; const firstItemId = firstItem?.id as number | undefined; const steps: WorkStepData[] = stepsTemplate.map((st, si) => { const stepKey = `${selectedOrder.id}-${nodeKey}-${st.name}`; return enrichStep(st, `${selectedOrder.id}-${nodeKey}-step-${si}`, stepKey, firstItemId); }); // 개소 내 아이템 이름 요약 const itemNames = group.items.map((it) => it.itemName).filter(Boolean); const itemSummary = itemNames.length > 0 ? itemNames.join(', ') : '-'; const opts = (firstItem?.options || {}) as Record; // 개소별 투입 자재 매핑 (로컬 오버라이드 > API 초기 데이터) const itemMapKey = firstItem?.id ? `${selectedOrder.id}-item-${firstItem.id}` : ''; const savedMats = inputMaterialsMap.get(itemMapKey) || inputMaterialsMap.get(selectedOrder.id); let materialInputsList: MaterialListItem[]; if (savedMats) { materialInputsList = savedMats.map((m) => ({ id: m.id, lotNo: m.lotNo, itemName: m.materialName, quantity: m.inputQuantity, unit: m.unit })); } else { // API 초기 데이터에서 투입 이력 추출 const apiMaterialInputs = group.items.flatMap((it) => it.materialInputs || []); materialInputsList = apiMaterialInputs.map((mi) => ({ id: String(mi.id), lotNo: mi.lotNo || '', itemName: mi.materialName || '', quantity: mi.qty, unit: mi.unit, })); } // API 데이터에서 자재투입 이력이 있으면 자재투입 step 완료 처리 if (materialInputsList.length > 0) { const matStep = steps.find((s) => s.isMaterialInput); if (matStep && !matStep.isCompleted) { matStep.isCompleted = true; } } // 개소 내 모든 item IDs 수집 (절곡 공정 등에서 복수 items) const allItemIds = group.items.map((it) => it.id as number).filter(Boolean); const workItem: WorkItemData = { id: `${selectedOrder.id}-node-${nodeKey}`, apiItemId: firstItem?.id as number | undefined, apiItemIds: allItemIds.length > 0 ? allItemIds : undefined, workOrderId: selectedOrder.id, itemNo: index + 1, itemCode: selectedOrder.orderNo || '-', itemName: selectedOrder.productCode !== '-' ? selectedOrder.productCode : itemSummary, floor: (opts.floor as string) || '-', code: (opts.code as string) || '-', width: (opts.width as number) || 0, height: (opts.height as number) || 0, quantity: group.totalQuantity, processType: activeProcessTabKey, steps, materialInputs: materialInputsList, }; // 공정별 추가 정보 추출 if (opts.cutting_info) { const ci = opts.cutting_info as { width: number; sheets: number }; workItem.cuttingInfo = { width: ci.width, sheets: ci.sheets }; } if (opts.slat_info) { const si = opts.slat_info as { length: number; slat_count: number; joint_bar: number; glass_qty: number }; workItem.slatInfo = { length: si.length, slatCount: si.slat_count, jointBar: si.joint_bar, glassQty: si.glass_qty || 0 }; } if (opts.bending_info) { const bi = opts.bending_info as { common: { kind: string; type: string; length_quantities: { length: number; quantity: number }[] }; detail_parts: { part_name: string; material: string; barcy_info: string }[]; }; workItem.bendingInfo = { common: { kind: bi.common?.kind || '', type: bi.common?.type || '', lengthQuantities: bi.common?.length_quantities || [] }, detailParts: (bi.detail_parts || []).map(dp => ({ partName: dp.part_name, material: dp.material, barcyInfo: dp.barcy_info })), }; } if (opts.is_wip) { workItem.isWip = true; const wi = opts.wip_info as { specification: string; length_quantity: string; drawing_url?: string } | undefined; if (wi) { workItem.wipInfo = { specification: wi.specification, lengthQuantity: wi.length_quantity, drawingUrl: wi.drawing_url }; } } if (opts.is_joint_bar) { workItem.isJointBar = true; const jb = opts.slat_joint_bar_info as { specification: string; length: number; quantity: number } | undefined; if (jb) workItem.slatJointBarInfo = jb; } apiItems.push(workItem); }); } else if (selectedOrder) { // nodeGroups가 없는 경우 폴백 (단일 항목) const steps: WorkStepData[] = stepsTemplate.map((st, si) => { const stepKey = `${selectedOrder.id}-${st.name}`; return enrichStep(st, `${selectedOrder.id}-step-${si}`, stepKey); }); const fallbackMats = inputMaterialsMap.get(selectedOrder.id); const fallbackMaterialsList: MaterialListItem[] = fallbackMats ? fallbackMats.map((m) => ({ id: m.id, lotNo: m.lotNo, itemName: m.materialName, quantity: m.inputQuantity, unit: m.unit })) : []; apiItems.push({ id: selectedOrder.id, workOrderId: selectedOrder.id, itemNo: 1, itemCode: selectedOrder.orderNo || '-', itemName: selectedOrder.productCode !== '-' ? selectedOrder.productCode : (selectedOrder.productName || '-'), floor: '-', code: '-', width: 0, height: 0, quantity: selectedOrder.quantity || 0, processType: activeProcessTabKey, steps, materialInputs: fallbackMaterialsList, }); } // 목업 데이터 합치기 (API 데이터 뒤에 번호 이어서) // 절곡 탭에서 재공품 서브모드면 WIP 전용 목업 사용 // 슬랫 탭에서 조인트바 서브모드면 조인트바 전용 목업 사용 const baseMockItems = (activeProcessTabKey === 'bending' && bendingSubMode === 'wip') ? MOCK_ITEMS_BENDING_WIP : (activeProcessTabKey === 'slat' && slatSubMode === 'jointbar') ? MOCK_ITEMS_SLAT_JOINTBAR : MOCK_ITEMS[activeProcessTabKey]; const mockItems = baseMockItems.map((item, i) => ({ ...item, itemNo: apiItems.length + i + 1, steps: item.steps.map((step) => { const stepKey = `${item.id}-${step.name}`; return { ...step, isCompleted: stepCompletionMap[stepKey] ?? step.isCompleted, }; }), })); return [...apiItems, ...mockItems]; }, [filteredWorkOrders, selectedSidebarOrderId, activeProcessTabKey, stepCompletionMap, bendingSubMode, slatSubMode, activeProcessSteps, inputMaterialsMap, stepProgressMap]); // ===== 작업지시 선택 시 기존 검사 데이터 로드 ===== // workItems 선언 이후에 위치해야 workItems.length 참조 가능 // workItems.length 의존성: selectedSidebarOrderId 변경 시점에 workItems가 아직 비어있을 수 있음 // (filteredWorkOrders API 응답이 늦은 경우). workItems가 채워지면 재실행하여 매칭 보장. useEffect(() => { if (!selectedSidebarOrderId) return; // 목업 ID면 건너뛰기 if (selectedSidebarOrderId.startsWith('order-')) return; // workItems가 아직 비어있으면 대기 (채워지면 재실행됨) if (workItems.length === 0) return; const loadInspectionData = async () => { try { const result = await getWorkOrderInspectionData(selectedSidebarOrderId); if (result.success && result.data?.items) { const completionUpdates: Record = {}; setInspectionDataMap((prev) => { const next = new Map(prev); for (const apiItem of result.data!.items) { if (!apiItem.inspection_data) continue; // workItems에서 apiItemId가 일치하는 항목 찾기 const match = workItems.find((w) => w.apiItemId === apiItem.item_id); if (match) { next.set(match.id, apiItem.inspection_data as unknown as InspectionData); // 검사 step 완료 처리 (실제 step name 사용) const inspStep = match.steps.find((s) => s.isInspection || s.needsInspection); if (inspStep) { const stepKey = `${match.id.replace('-node-', '-')}-${inspStep.name}`; completionUpdates[stepKey] = true; } } } return next; }); // stepCompletionMap 일괄 업데이트 if (Object.keys(completionUpdates).length > 0) { setStepCompletionMap((prev) => ({ ...prev, ...completionUpdates })); } } } catch { // 검사 데이터 로드 실패는 무시 (새 작업일 수 있음) } }; loadInspectionData(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedSidebarOrderId, workItems.length]); // ===== 작업지시 변경 시 작업 정보 자동 세팅 ===== useEffect(() => { const apiOrder = filteredWorkOrders.find((wo) => wo.id === selectedSidebarOrderId); if (apiOrder) { // 부서 세팅: 1순위 work_orders.team_id → 2순위 process.department(부서명 매칭) if (apiOrder.teamId) { setDepartmentId(String(apiOrder.teamId)); } else if (apiOrder.processDepartment && departmentList.length > 0) { const matched = departmentList.find((d) => d.name === apiOrder.processDepartment); setDepartmentId(matched ? String(matched.id) : ''); } else { setDepartmentId(''); } // 생산일자 세팅 setProductionDate(apiOrder.scheduledDate || ''); } else { setDepartmentId(''); setProductionManagerId(''); setProductionDate(''); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedSidebarOrderId, filteredWorkOrders, departmentList]); // ===== 수주 정보 (사이드바 선택 항목 기반) ===== const orderInfo = useMemo(() => { // 1. 선택된 API 작업지시에서 찾기 const apiOrder = filteredWorkOrders.find((wo) => wo.id === selectedSidebarOrderId); if (apiOrder) { return { orderDate: apiOrder.createdAt ? new Date(apiOrder.createdAt).toLocaleDateString('ko-KR') : '-', salesOrderNo: apiOrder.salesOrderNo || '-', siteName: apiOrder.projectName || '-', client: apiOrder.client || '-', salesManager: apiOrder.salesManager || '-', managerPhone: apiOrder.managerPhone || '-', shippingDate: apiOrder.dueDate ? new Date(apiOrder.dueDate).toLocaleDateString('ko-KR') : '-', }; } // 2. 목업 사이드바에서 찾기 const mockOrder = MOCK_SIDEBAR_ORDERS[activeProcessTabKey].find((o) => o.id === selectedSidebarOrderId); if (mockOrder) { return { orderDate: mockOrder.date, salesOrderNo: 'SO-2024-0001', siteName: mockOrder.siteName, client: '-', salesManager: '-', managerPhone: '-', shippingDate: '-', }; } // 3. 폴백: 첫 번째 작업 const first = filteredWorkOrders[0]; if (!first) return null; return { orderDate: first.createdAt ? new Date(first.createdAt).toLocaleDateString('ko-KR') : '-', salesOrderNo: first.salesOrderNo || '-', siteName: first.projectName || '-', client: first.client || '-', salesManager: first.salesManager || '-', managerPhone: first.managerPhone || '-', shippingDate: first.dueDate ? new Date(first.dueDate).toLocaleDateString('ko-KR') : '-', }; }, [filteredWorkOrders, selectedSidebarOrderId, activeProcessTabKey]); // ===== 핸들러 ===== // 중간검사 버튼 클릭 핸들러 - 템플릿 로드 후 모달 열기 const handleInspectionClick = useCallback(async (itemId: string, stepName?: string) => { // 해당 아이템 찾기 const item = workItems.find((w) => w.id === itemId); if (item) { // 클릭된 검사 step name 저장 (stepName 미전달 시 item.steps에서 검사 step 탐색) const resolvedStepName = stepName || item.steps.find((s) => s.isInspection || s.needsInspection)?.name || ''; setInspectionStepName(resolvedStepName); // 합성 WorkOrder 생성 const syntheticOrder: WorkOrder = { id: item.id, orderNo: item.itemCode, productCode: item.itemCode, productName: item.itemName, processCode: item.processType, processName: PROCESS_TAB_LABELS[item.processType], client: '-', projectName: '-', assignees: [], quantity: item.quantity, shutterCount: 0, dueDate: '', priority: 5, status: 'waiting', isUrgent: false, isDelayed: false, createdAt: '', }; setSelectedOrder(syntheticOrder); setInspectionDimensions({ width: item.width, height: item.height }); // 실제 API 아이템인 경우 검사 템플릿 로딩 시도 if (item.workOrderId && !item.id.startsWith('mock-')) { try { const tplResult = await getInspectionTemplate(item.workOrderId); if (tplResult.success && tplResult.data?.has_template) { setInspectionTemplateData(tplResult.data); } else { setInspectionTemplateData(undefined); } } catch { setInspectionTemplateData(undefined); } } else { setInspectionTemplateData(undefined); } setIsInspectionInputModalOpen(true); } }, [workItems]); // pill 클릭 핸들러 const handleStepClick = useCallback( async (itemId: string, step: WorkStepData) => { if (step.isMaterialInput) { // 자재투입 → 자재 투입 모달 열기 (개소별) const order = workOrders.find((o) => o.id === itemId || itemId.startsWith(`${o.id}-node-`)); const workItem = workItems.find((item) => item.id === itemId); if (order) { setSelectedOrder(order); // 개소별 API 호출을 위한 apiItemId(s) 설정 setSelectedWorkOrderItemId(workItem?.apiItemId); setSelectedWorkOrderItemIds(workItem?.apiItemIds); setSelectedWorkOrderItemName(workItem ? `${workItem.itemName} (${workItem.code})` : undefined); setIsMaterialModalOpen(true); } else { // 목업 아이템인 경우 합성 WorkOrder 생성 const mockItem = workItems.find((item) => item.id === itemId); if (mockItem) { const syntheticOrder: WorkOrder = { id: mockItem.id, orderNo: mockItem.itemCode, productCode: mockItem.itemCode, productName: mockItem.itemName, processCode: mockItem.processType, processName: PROCESS_TAB_LABELS[mockItem.processType], client: '-', projectName: '-', assignees: [], quantity: mockItem.quantity, shutterCount: 0, dueDate: '', priority: 5, status: 'waiting', isUrgent: false, isDelayed: false, createdAt: '', }; setSelectedOrder(syntheticOrder); setIsMaterialModalOpen(true); } } } else if (step.connectionType === '팝업' && step.connectionTarget === '중간검사') { // 연결정보: 팝업 + 중간검사 → 중간검사 모달 열기 handleInspectionClick(itemId, step.name); } else if (step.needsInspection || step.isInspection) { // 검사 단계 (processListCache 설정 또는 하드코딩 폴백) → 중간검사 모달 열기 handleInspectionClick(itemId, step.name); } else if (step.completionType === 'click_complete' && step.stepProgressId) { // 클릭 시 완료 → 서버 토글 API 호출 const workItem = workItems.find((item) => item.id === itemId); const orderId = workItem?.workOrderId; if (orderId) { // enrichStep의 stepKey와 동일한 형식 사용 (-node- 제거) const stepKey = `${itemId.replace('-node-', '-')}-${step.name}`; try { const result = await toggleStepProgress(orderId, step.stepProgressId); if (result.success && result.data) { setStepCompletionMap((prev) => ({ ...prev, [stepKey]: result.data!.is_completed, })); toast.success(result.data.is_completed ? `${step.name} 완료` : `${step.name} 완료 취소`); } else { toast.error(result.error || '단계 완료 처리에 실패했습니다.'); } } catch { toast.error('단계 완료 처리 중 오류가 발생했습니다.'); } } } else { // 기타 → 완료/미완료 토글 (로컬) - enrichStep stepKey 형식 일치 const stepKey = `${itemId.replace('-node-', '-')}-${step.name}`; setStepCompletionMap((prev) => ({ ...prev, [stepKey]: !prev[stepKey], })); } }, [workOrders, workItems, handleInspectionClick] ); // 자재 수정 핸들러 - Dialog 열기 const handleEditMaterial = useCallback( (itemId: string, material: MaterialListItem) => { setEditMaterialTarget({ itemId, material }); setEditMaterialQty(String(material.quantity)); }, [] ); // 자재 수정 확정 const handleEditMaterialConfirm = useCallback(async () => { if (!editMaterialTarget) return; const { itemId, material } = editMaterialTarget; const newQty = parseFloat(editMaterialQty); if (isNaN(newQty) || newQty <= 0) { toast.error('올바른 수량을 입력해주세요.'); return; } const workItem = workItems.find((w) => w.id === itemId); const orderId = workItem?.workOrderId; if (!orderId) return; const result = await updateMaterialInput(orderId, parseInt(material.id), newQty); if (result.success) { toast.success('투입 수량이 수정되었습니다.'); setEditMaterialTarget(null); // 데이터 새로고침 try { const refreshResult = await getMyWorkOrders(); if (refreshResult.success) setWorkOrders(refreshResult.data); } catch {} } else { toast.error(result.error || '수정에 실패했습니다.'); } }, [editMaterialTarget, editMaterialQty, workItems]); // 자재 삭제 핸들러 const handleDeleteMaterial = useCallback( async (itemId: string, materialId: string) => { const workItem = workItems.find((w) => w.id === itemId); const orderId = workItem?.workOrderId; if (!orderId) return; const result = await deleteMaterialInput(orderId, parseInt(materialId)); if (result.success) { toast.success('자재 투입이 삭제되었습니다.'); // 해당 개소에 더이상 투입 이력이 없으면 자재투입 step 완료 해제 const nodeMatch = itemId.match(/-node-(.+)$/); if (nodeMatch) { const stepKey = `${orderId}-${nodeMatch[1]}-자재투입`; // 현재 해당 노드의 materialInputs에서 삭제 대상 제외 후 남은 것 확인 const currentInputs = workItem.materialInputs?.filter((m) => m.id !== materialId); if (!currentInputs || currentInputs.length === 0) { setStepCompletionMap((prev) => { const next = { ...prev }; delete next[stepKey]; return next; }); } } // 데이터 새로고침 try { const refreshResult = await getMyWorkOrders(); if (refreshResult.success) { setWorkOrders(refreshResult.data); // 로컬 오버라이드 모두 제거 (API 데이터가 최신) setInputMaterialsMap(new Map()); } } catch {} } else { toast.error(result.error || '삭제에 실패했습니다.'); } }, [workItems] ); // 자재 저장 핸들러 const handleSaveMaterials = useCallback(async (orderId: string, materials: MaterialInput[]) => { // 개소별 키: workOrderItemId가 있으면 개소별, 없으면 orderId 기준 const mapKey = selectedWorkOrderItemId ? `${orderId}-item-${selectedWorkOrderItemId}` : orderId; setInputMaterialsMap((prev) => { const next = new Map(prev); next.set(mapKey, materials); return next; }); // 자재투입 step 완료로 마킹 - workItem의 id 기반으로 stepKey 생성 // workItems에서 해당 개소를 찾아 정확한 stepKey 사용 const matchedItem = workItems.find((item) => selectedWorkOrderItemId ? item.apiItemId === selectedWorkOrderItemId : item.workOrderId === orderId || item.id === orderId ); if (matchedItem) { // workItems의 step 생성 시 stepKey = `${orderId}-${nodeKey}-자재투입` 형식 // matchedItem.id에서 nodeKey 추출: `${orderId}-node-${nodeKey}` const nodeMatch = matchedItem.id.match(/-node-(.+)$/); const stepKey = nodeMatch ? `${orderId}-${nodeMatch[1]}-자재투입` : `${orderId}-자재투입`; setStepCompletionMap((prev) => ({ ...prev, [stepKey]: true, })); } // 로컬 오버라이드로 즉시 반영 (전체 새로고침 없이 현재 선택 수주 유지) }, [selectedWorkOrderItemId, workItems]); // 완료 확인 → MaterialInputModal 열기 const handleCompletionConfirm = useCallback(() => { setIsCompletionFlow(true); setIsMaterialModalOpen(true); }, []); // MaterialInputModal 완료 후 → 작업 완료 결과 팝업 const handleWorkCompletion = useCallback(() => { if (!selectedOrder) return; const lotNo = `KD-SA-${new Date().toISOString().slice(2, 10).replace(/-/g, '')}-01`; setCompletionLotNo(lotNo); setIsCompletionResultOpen(true); setIsCompletionFlow(false); }, [selectedOrder]); // 완료 결과 팝업 확인 → API 완료 처리 const handleCompletionResultConfirm = useCallback(async () => { if (!selectedOrder) return; try { const materials = inputMaterialsMap.get(selectedOrder.id); const result = await completeWorkOrder( selectedOrder.id, materials?.map((m) => ({ materialId: parseInt(m.id), quantity: m.inputQuantity, lotNo: m.lotNo, })) ); if (result.success) { toast.success('작업이 완료되었습니다.'); setInputMaterialsMap((prev) => { const next = new Map(prev); next.delete(selectedOrder.id); return next; }); setWorkOrders((prev) => prev.filter((o) => o.id !== selectedOrder.id)); } else { toast.error(result.error || '작업 완료 처리에 실패했습니다.'); } } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[WorkerScreen] handleCompletionResultConfirm error:', error); toast.error('작업 완료 중 오류가 발생했습니다.'); } finally { setSelectedOrder(null); setCompletionLotNo(''); } }, [selectedOrder, inputMaterialsMap]); // 하단 버튼용 합성 WorkOrder (사이드바 선택 우선, 없으면 첫 번째, 그래도 없으면 목업 폴백) const getTargetOrder = useCallback((): WorkOrder | null => { // 사이드바에서 선택된 작업지시 우선 const selected = selectedSidebarOrderId ? filteredWorkOrders.find((wo) => wo.id === selectedSidebarOrderId) : null; if (selected) return selected; // 선택 없으면 첫 번째 작업지시 const apiTarget = filteredWorkOrders[0]; if (apiTarget) return apiTarget; // 목업 아이템으로 폴백 const mockItem = workItems[0]; if (!mockItem) return null; return { id: mockItem.id, orderNo: mockItem.itemCode, productCode: mockItem.itemCode, productName: mockItem.itemName, processCode: mockItem.processType, processName: PROCESS_TAB_LABELS[mockItem.processType], client: '-', projectName: '-', assignees: [], quantity: mockItem.quantity, shutterCount: 0, dueDate: '', priority: 5, status: 'waiting', isUrgent: false, isDelayed: false, createdAt: '', }; }, [filteredWorkOrders, workItems, selectedSidebarOrderId]); // 현재 공정에 맞는 중간검사 타입 결정 const getInspectionProcessType = useCallback((): InspectionProcessType => { if (activeProcessTabKey === 'bending' && bendingSubMode === 'wip') { return 'bending_wip'; } if (activeProcessTabKey === 'slat' && slatSubMode === 'jointbar') { return 'slat_jointbar'; } return activeProcessTabKey as InspectionProcessType; }, [activeProcessTabKey, bendingSubMode, slatSubMode]); // 하단 버튼 핸들러 const handleWorkLog = useCallback(() => { const target = getTargetOrder(); if (target) { setSelectedOrder(target); setIsWorkLogModalOpen(true); } else { toast.error('표시할 작업이 없습니다.'); } }, [getTargetOrder]); const handleInspection = useCallback(() => { const target = getTargetOrder(); if (target) { setSelectedOrder(target); setIsInspectionModalOpen(true); } else { toast.error('표시할 작업이 없습니다.'); } }, [getTargetOrder]); // 검사 완료 핸들러 (API 저장 + 메모리 업데이트 + 공정 단계 완료 처리) const handleInspectionComplete = useCallback(async (data: InspectionData) => { if (!selectedOrder) return; // stepCompletionMap 키 생성 헬퍼 (enrichStep 형식 일치: -node- 제거 + 실제 step name) const buildStepKey = (stepName: string) => `${selectedOrder.id.replace('-node-', '-')}-${stepName}`; // 메모리에 즉시 반영 setInspectionDataMap((prev) => { const next = new Map(prev); next.set(selectedOrder.id, data); return next; }); // 실제 API item인 경우 서버에 저장 const targetItem = workItems.find((w) => w.id === selectedOrder.id); if (targetItem?.apiItemId && targetItem?.workOrderId) { try { // 1. 기존: work_order_items.options에 저장 const result = await saveItemInspection( targetItem.workOrderId, targetItem.apiItemId, getInspectionProcessType(), data as unknown as Record ); if (result.success) { toast.success('검사가 저장되었습니다.'); } else { toast.error(result.error || '검사 데이터 저장에 실패했습니다.'); } // 2. Document 동기화: 백엔드가 원본(work_order_items)에서 전체 수집하여 document_data 갱신 try { await saveInspectionDocument(targetItem.workOrderId, {}); } catch { // Document 동기화 실패는 무시 (template 미연결 시 404 가능) } // 3. 검사 단계 자동 완료 처리 (inspectionStepName 우선, 없으면 steps에서 탐색) const stepName = inspectionStepName || targetItem.steps.find((s) => (s.completionType === 'inspection_complete') || s.needsInspection || s.isInspection)?.name || ''; const inspectionStep = stepName ? targetItem.steps.find((s) => s.name === stepName) : undefined; if (inspectionStep?.stepProgressId) { // 서버에 단계 완료 토글 try { const toggleResult = await toggleStepProgress(targetItem.workOrderId, inspectionStep.stepProgressId); if (toggleResult.success) { setStepCompletionMap((prev) => ({ ...prev, [buildStepKey(stepName)]: true })); } } catch { // 단계 완료 실패 시 로컬만 업데이트 setStepCompletionMap((prev) => ({ ...prev, [buildStepKey(stepName)]: true })); } } else if (stepName) { // stepProgressId 없으면 로컬만 완료 처리 setStepCompletionMap((prev) => ({ ...prev, [buildStepKey(stepName)]: true })); } } catch { toast.error('검사 데이터 저장 중 오류가 발생했습니다.'); } } else if (inspectionStepName) { // 목업 데이터는 메모리만 저장 + 로컬 완료 처리 setStepCompletionMap((prev) => ({ ...prev, [buildStepKey(inspectionStepName)]: true })); toast.success('중간검사가 완료되었습니다.'); } }, [selectedOrder, workItems, getInspectionProcessType, inspectionStepName]); // ===== 재공품 감지 ===== const hasWipItems = useMemo(() => { return activeProcessTabKey === 'bending' && workItems.some(item => item.isWip); }, [activeProcessTabKey, workItems]); // ===== 조인트바 감지 ===== const hasJointBarItems = useMemo(() => { return activeProcessTabKey === 'slat' && slatSubMode === 'jointbar'; }, [activeProcessTabKey, slatSubMode]); // 재공품 통합 문서 (작업일지 + 중간검사) 핸들러 const handleWipInspection = useCallback(() => { const target = getTargetOrder(); if (target) { setSelectedOrder(target); setIsInspectionModalOpen(true); } else { toast.error('표시할 작업이 없습니다.'); } }, [getTargetOrder]); return (
{/* 완료 토스트 */} {toastInfo && } {/* 헤더 */}

작업자 화면

작업을 관리합니다

{/* 공정별 탭 (공정관리 API 기반 동적 생성) */} setActiveTab(v)} >
{processTabs.length > 0 ? ( processTabs.map((proc) => ( {proc.processName} )) ) : ( (['screen', 'slat', 'bending'] as ProcessTab[]).map((tab) => ( {PROCESS_TAB_LABELS[tab]} )) )}
{(processTabs.length > 0 ? processTabs.map((p) => p.id) : ['screen', 'slat', 'bending'] ).map((tabValue) => ( {/* 모바일: 사이드바 토글 */}
{isSidebarOpen && (
{ setSelectedSidebarOrderId(id); if (subType === 'slat' || subType === 'jointbar') setSlatSubMode(subType === 'jointbar' ? 'jointbar' : 'normal'); if (subType === 'bending' || subType === 'wip') setBendingSubMode(subType === 'wip' ? 'wip' : 'normal'); setIsSidebarOpen(false); }} />
)}
{/* 2-패널 레이아웃 */}
{/* 좌측 사이드바 - 데스크탑만 (스크롤 따라다님) */} {/* 우측 메인 패널 */}
{/* 상태 카드 */}
} variant="default" /> } variant="blue" /> } variant="green" /> } variant="red" />
{/* 수주 정보 */}

수주 정보

{/* 작업 정보 - API 연동 */}

작업 정보

setProductionDate(date)} />
{/* 작업 목록 */}

작업 목록

{isLoading ? ( ) : workItems.length === 0 ? ( 해당 공정에 배정된 작업이 없습니다. ) : (
{(() => { const apiCount = workItems.filter((i) => !i.id.startsWith('mock-')).length; return apiCount > 0 ? ( 실제 데이터 ({apiCount}건) ) : null; })()} {workItems.map((item, index) => { const isFirstMock = item.id.startsWith('mock-') && (index === 0 || !workItems[index - 1]?.id.startsWith('mock-')); return (
{isFirstMock && (
{workItems.some((i) => !i.id.startsWith('mock-')) &&
} 목업 데이터
)}
); })}
)}
))}
{/* 하단 고정 버튼 */} {(hasWipItems || activeProcessSettings.needsWorkLog || activeProcessSettings.hasDocumentTemplate) && (
{hasWipItems ? ( ) : ( <> {activeProcessSettings.needsWorkLog && ( )} {activeProcessSettings.hasDocumentTemplate && ( )} )}
)} {/* 모달/다이얼로그 */} { setIsMaterialModalOpen(open); if (!open) { setSelectedWorkOrderItemId(undefined); setSelectedWorkOrderItemIds(undefined); setSelectedWorkOrderItemName(undefined); } }} order={selectedOrder} workOrderItemId={selectedWorkOrderItemId} workOrderItemIds={selectedWorkOrderItemIds} workOrderItemName={selectedWorkOrderItemName} isCompletionFlow={isCompletionFlow} onComplete={handleWorkCompletion} onSaveMaterials={handleSaveMaterials} savedMaterials={selectedOrder ? inputMaterialsMap.get(selectedOrder.id) : undefined} /> {/* 자재 투입 수량 수정 Dialog */} { if (!open) setEditMaterialTarget(null); }}> 투입 수량 수정

{editMaterialTarget?.material.itemName}

setEditMaterialQty(e.target.value)} placeholder="수량 입력" min={1} autoFocus onKeyDown={(e) => { if (e.key === 'Enter') handleEditMaterialConfirm(); }} />
); } // ===== 사이드바 컨텐츠 ===== interface SidebarContentProps { tab: ProcessTab; selectedOrderId: string; apiOrders: SidebarOrder[]; onSelectOrder: (id: string, subType?: SidebarOrder['subType']) => void; } function SidebarContent({ tab, selectedOrderId, onSelectOrder, apiOrders, }: SidebarContentProps) { const mockOrders = MOCK_SIDEBAR_ORDERS[tab]; const renderOrders = (orders: SidebarOrder[]) => ( <> {PRIORITY_GROUPS.map((group) => { const groupOrders = orders.filter((o) => o.priority === group.key); if (groupOrders.length === 0) return null; return (

{group.label}

{groupOrders.map((order) => { const tag = order.subType ? SUB_TYPE_TAGS[order.subType] : null; return ( ); })}
); })} ); return (

수주 목록

{/* API 실제 데이터 */} {apiOrders.length > 0 && (
실제 데이터 ({apiOrders.length}건) {renderOrders(apiOrders)}
)} {/* 구분선 */} {apiOrders.length > 0 && mockOrders.length > 0 && (
)} {/* 목업 데이터 */} {mockOrders.length > 0 && (
목업 데이터 {renderOrders(mockOrders)}
)}
); } // ===== 하위 컴포넌트 ===== interface StatCardProps { title: string; value: number; icon: React.ReactNode; variant: 'default' | 'blue' | 'green' | 'red'; } function StatCard({ title, value, icon, variant }: StatCardProps) { const variantClasses = { default: 'bg-gray-50 text-gray-700', blue: 'bg-blue-50 text-blue-700', green: 'bg-green-50 text-green-700', red: 'bg-red-50 text-red-700', }; return (
{title} {icon}

{value}

); } interface InfoFieldProps { label: string; value?: string; } function InfoField({ label, value }: InfoFieldProps) { return (

{label}

{value || '-'}

); }