'use client'; /** * 작업자 화면 메인 컴포넌트 (기획서 기반 전면 개편) * * 구조: * - 상단: 페이지 제목 * - 탭: 스크린/슬랫/절곡 (디폴트: 스크린) * - 상태 카드 4개 (할일/작업중/완료/긴급) * - 수주 정보 섹션 (읽기 전용) * - 작업 정보 섹션 (생산담당자 셀렉트 + 생산일자) * - 작업 목록 (WorkItemCard 나열) * - 하단 고정 버튼 (작업일지보기 / 중간검사하기) */ /** 품목명에서 길이(mm) 추출: "가이드레일(측면) 본체(철재) 2438mm" → 2438 */ function extractLengthFromName(name?: string | null): number { if (!name) return 0; const m = name.match(/(\d{3,5})\s*mm/i); return m ? parseInt(m[1], 10) : 0; } import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; import dynamic from 'next/dynamic'; import { useSearchParams, useRouter, usePathname } from 'next/navigation'; 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, updateWorkOrderInfo } from './actions'; import type { StepProgressItem, DepartmentOption, DepartmentUser } from './actions'; import type { InspectionTemplateData } from './types'; import { getProcessList } from '@/components/process-management/actions'; import type { InspectionSetting, InspectionScope, Process } from '@/types/process'; import type { WorkOrder } from '../ProductionDashboard/types'; import { BENDING_STEP_MAP, extractBendingTypeCode } from '../WorkOrders/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 })), ); interface SidebarOrder { id: string; siteName: string; date: string; quantity: number; shutterCount: number; priority: 'urgent' | 'priority' | 'normal'; subType?: 'slat' | 'jointbar' | 'bending' | 'wip'; bdCode?: string; // 재공품 BD- 코드 (예: BD-ST-24) status?: string; // 작업지시 상태 (waiting/in_progress/completed) } const WO_STATUS_BADGE: Record = { completed: { label: '완료', className: 'bg-gray-500 text-white' }, in_progress: { label: '진행중', className: 'bg-green-100 text-green-700' }, waiting: { label: '대기', className: 'bg-yellow-100 text-yellow-700' }, pending: { label: '대기', className: 'bg-gray-100 text-gray-600' }, }; 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 searchParams = useSearchParams(); const router = useRouter(); const pathname = usePathname(); const [workOrders, setWorkOrders] = useState([]); const [isLoading, setIsLoading] = useState(true); const [activeTab, setActiveTabState] = useState(searchParams.get('tab') || ''); // 탭 변경 시 URL query parameter 동기화 (새로고침 시 탭 유지) const setActiveTab = useCallback((tab: string) => { setActiveTabState(tab); const params = new URLSearchParams(searchParams.toString()); params.set('tab', tab); router.replace(`${pathname}?${params.toString()}`, { scroll: false }); }, [searchParams, router, pathname]); 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 [workOrderResult, processResult, deptResult] = await Promise.all([ getMyWorkOrders(), getProcessList({ size: 100 }), getDepartments(), ]); if (workOrderResult.success) { setWorkOrders(workOrderResult.data); } else { toast.error(workOrderResult.error || '작업 목록 조회에 실패했습니다.'); } if (processResult.success && processResult.data?.items) { setProcessListCache(processResult.data.items); } if (deptResult.success) { setDepartmentList(deptResult.data); } } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[WorkerScreen] loadData error:', error); toast.error('데이터 로드 중 오류가 발생했습니다.'); } finally { setIsLoading(false); } }, []); useEffect(() => { loadData(); }, [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(); // 공정의 검사 범위 설정 const [currentInspectionScope, setCurrentInspectionScope] = 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([]); // 활성 공정 목록 const activeProcesses = useMemo(() => { return processListCache.filter((p) => p.status === '사용중'); }, [processListCache]); // 그룹별 탭 구성 (parent_id 기반 트리 구조) const groupedTabs = useMemo(() => { const groupMap = new Map(); // 루트 공정 기준 그룹 구성 activeProcesses.forEach((p) => { if (p.parentId) return; // 자식은 건너뜀 const group = p.processName; if (!groupMap.has(group)) groupMap.set(group, []); groupMap.get(group)!.push(p); // 자식 공정 추가 const children = activeProcesses.filter((c) => c.parentId === p.id); children.forEach((c) => groupMap.get(group)!.push(c)); }); return Array.from(groupMap.entries()).map(([group, processes]) => ({ group, processes, defaultProcessId: processes[0].id, })); }, [activeProcesses]); // processTabs 호환 (기존 코드에서 참조) const processTabs = activeProcesses; // 선택된 그룹 내 하위 공정 필터 const [subProcessId, setSubProcessId] = useState('all'); // 현재 탭의 그룹 정보 const activeGroup = useMemo(() => { const process = processListCache.find((p) => p.id === activeTab); if (!process) return null; // 자식이면 부모 공정명, 루트면 자기 공정명 const groupName = process.parentProcessName || process.processName; return groupedTabs.find((g) => g.group === groupName) || null; }, [activeTab, processListCache, groupedTabs]); // 공정 목록 로드 후 탭 선택 (URL 파라미터 우선, 없으면 첫 번째 그룹) useEffect(() => { if (groupedTabs.length === 0 && !isLoading) { if (!activeTab) setActiveTabState('screen'); return; } if (groupedTabs.length === 0) return; // URL에 tab이 있고 유효한 탭이면 유지 if (activeTab) { const isValid = groupedTabs.some((g) => g.defaultProcessId === activeTab); if (isValid) return; } // 없으면 첫 번째 그룹 선택 setActiveTabState(groupedTabs[0].defaultProcessId); }, [groupedTabs, activeTab, isLoading]); // 선택된 공정의 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]); // 선택된 공정의 작업일지/검사성적서 설정 // subProcessId가 선택되어 있으면 자식 공정의 설정 사용 const activeProcessSettings = useMemo(() => { const effectiveId = subProcessId !== 'all' ? subProcessId : activeTab; const process = processListCache.find((p) => p.id === effectiveId); // 자식 공정에 설정이 없으면 부모 공정 폴백 const parentProcess = processListCache.find((p) => p.id === activeTab); return { needsWorkLog: process?.needsWorkLog ?? parentProcess?.needsWorkLog ?? false, hasDocumentTemplate: !!(process?.documentTemplateId ?? parentProcess?.documentTemplateId), workLogTemplateId: process?.workLogTemplateId ?? parentProcess?.workLogTemplateId, workLogTemplateName: process?.workLogTemplateName ?? parentProcess?.workLogTemplateName, }; }, [activeTab, subProcessId, 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); setCurrentInspectionScope(inspectionStep?.inspectionScope); } else { setCurrentInspectionSetting(undefined); setCurrentInspectionScope(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(); }, [selectedSidebarOrderId]); // ===== 탭별 필터링된 작업 (그룹 + 하위 공정 필터) ===== const filteredWorkOrders = useMemo(() => { // 하위 공정이 특정되면 해당 공정만 필터 if (subProcessId !== 'all') { const subProcess = processListCache.find((p) => p.id === subProcessId); if (subProcess) { const subName = subProcess.processName.toLowerCase(); return workOrders.filter((order) => { const orderProcessName = (order.processName || '').toLowerCase(); return orderProcessName.includes(subName) || subName.includes(orderProcessName); }); } } // 그룹 내 모든 공정 매칭 if (activeGroup) { const groupProcessNames = activeGroup.processes.map((p) => p.processName.toLowerCase()); return workOrders.filter((order) => { const orderProcessName = (order.processName || '').toLowerCase(); return groupProcessNames.some((gpn) => orderProcessName.includes(gpn) || gpn.includes(orderProcessName) ); }); } // 폴백: 기존 방식 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, activeGroup, subProcessId, processListCache]); // ===== API WorkOrders → SidebarOrder 변환 ===== const apiSidebarOrders: SidebarOrder[] = useMemo(() => { return filteredWorkOrders.map((wo) => { const isWip = wo.projectName === '재고생산' || wo.salesOrderNo?.startsWith('STK'); // 재공품: 첫 번째 item의 BD- 코드 추출 const bdCode = isWip ? wo.nodeGroups?.flatMap(g => g.items).find(it => it.itemCode?.startsWith('BD-'))?.itemCode ?? undefined : undefined; return { 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'], subType: isWip ? 'wip' as const : undefined, bdCode, status: wo.status, }; }); }, [filteredWorkOrders]); // ===== 탭 변경/데이터 로드 시 최상위 우선순위 작업 자동 선택 ===== useEffect(() => { if (isLoading) return; const allOrders: SidebarOrder[] = [...apiSidebarOrders]; // 현재 선택이 유효하면 자동 전환하지 않음 (데이터 새로고침 시 선택 유지) 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; // 절곡 공정: BD 코드에 따라 필요한 단계만 필터링 let itemStepsTemplate = stepsTemplate; if (activeProcessTabKey === 'bending') { const itemCodes = group.items.map((it) => it.itemCode).filter(Boolean) as string[]; if (itemCodes.length > 0) { const neededKeys = new Set(); let hasNonBd = false; for (const code of itemCodes) { const typeCode = extractBendingTypeCode(code); if (typeCode && BENDING_STEP_MAP[typeCode]) { BENDING_STEP_MAP[typeCode].forEach((k) => neededKeys.add(k)); } else { hasNonBd = true; } } if (!hasNonBd && neededKeys.size > 0) { // 단계명 매핑: guide_rail→가이드레일 제작, case→케이스 제작, bottom_finish→하단마감재 제작, inspection→검사/중간검사 const stepNameMap: Record = { guide_rail: ['가이드레일 제작', '가이드레일'], case: ['케이스 제작', '케이스'], bottom_finish: ['하단마감재 제작', '하단마감재'], inspection: ['검사', '중간검사'], }; const allowedNames = new Set(); // 자재투입은 항상 포함 allowedNames.add('자재투입'); for (const key of neededKeys) { (stepNameMap[key] || []).forEach((n) => allowedNames.add(n)); } itemStepsTemplate = stepsTemplate.filter((st) => allowedNames.has(st.name)); } } } const steps: WorkStepData[] = itemStepsTemplate.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.bending_width as number) || (opts.width as number) || 0, height: (opts.height as number) || extractLengthFromName(firstItem?.itemName) || 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 })), }; } // BD- 코드 추출 (재공품/절곡품 참조용) const bdItemCode = firstItem?.itemCode; if (bdItemCode?.startsWith('BD-')) { workItem.bdCode = bdItemCode; } if (opts.is_wip) { workItem.isWip = true; const wi = opts.wip_info as { specification: string; length_quantity: string } | undefined; if (wi) { workItem.wipInfo = { specification: wi.specification, lengthQuantity: wi.length_quantity }; } } 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, }); } return apiItems; }, [filteredWorkOrders, selectedSidebarOrderId, activeProcessTabKey, stepCompletionMap, bendingSubMode, slatSubMode, activeProcessSteps, inputMaterialsMap, stepProgressMap]); // ===== 검사 범위(scope) 기반 검사 단계 활성화/비활성화 ===== // 수주 단위로 적용: API 아이템(실제 수주 개소)에만 scope 적용 // 목업 아이템은 각각 독립 1개소이므로 항상 검사 버튼 유지 const scopedWorkItems: WorkItemData[] = useMemo(() => { if (!currentInspectionScope || currentInspectionScope.type === 'all') { return workItems; } // 실제 수주 아이템만 분리 (목업 제외) const apiItems = workItems.filter((item) => !item.id.startsWith('mock-')); const apiCount = apiItems.length; if (apiCount === 0) return workItems; // 검사 단계를 아예 제거하는 헬퍼 const removeInspectionSteps = (item: WorkItemData): WorkItemData => ({ ...item, steps: item.steps.filter((step) => !step.isInspection && !step.needsInspection), }); let realIdx = 0; if (currentInspectionScope.type === 'sampling') { const sampleSize = currentInspectionScope.sampleSize || 1; return workItems.map((item) => { // 목업은 독립 1개소 → 검사 유지 if (item.id.startsWith('mock-')) return item; const isInSampleRange = realIdx >= apiCount - sampleSize; realIdx++; return isInSampleRange ? item : removeInspectionSteps(item); }); } if (currentInspectionScope.type === 'group') { return workItems.map((item) => { if (item.id.startsWith('mock-')) return item; const isLast = realIdx === apiCount - 1; realIdx++; return isLast ? item : removeInspectionSteps(item); }); } return workItems; }, [workItems, currentInspectionScope]); // ===== 작업지시 선택 시 기존 검사 데이터 로드 ===== // 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); // 절곡 공정: 수주 단위 검사 → 어떤 item이든 inspection_data 있으면 모든 개소가 공유 const isBendingProcess = workItems.some(w => w.processType === 'bending'); if (isBendingProcess) { const bendingItem = result.data!.items.find(i => i.inspection_data); if (bendingItem?.inspection_data) { for (const w of workItems) { next.set(w.id, bendingItem.inspection_data as unknown as InspectionData); const inspStep = w.steps.find((s) => s.isInspection || s.needsInspection); if (inspStep) { const stepKey = `${w.id.replace('-node-', '-')}-${inspStep.name}`; completionUpdates[stepKey] = true; } } } } else { // 기존: item별 개별 매칭 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(); }, [selectedSidebarOrderId, workItems.length]); // ===== 작업지시 변경 시 작업 정보 자동 세팅 (선택 변경 시에만) ===== const prevSidebarOrderIdRef = useRef(null); useEffect(() => { // 같은 작업지시를 다시 선택한 경우 덮어쓰지 않음 (사용자 수정값 보존) if (prevSidebarOrderIdRef.current === selectedSidebarOrderId) return; prevSidebarOrderIdRef.current = selectedSidebarOrderId; 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(''); } // 생산담당자 세팅 setProductionManagerId(apiOrder.assigneeId ? String(apiOrder.assigneeId) : ''); // 생산일자 세팅 setProductionDate(apiOrder.scheduledDate || ''); } else { setDepartmentId(''); setProductionManagerId(''); setProductionDate(''); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedSidebarOrderId]); // ===== 수주 정보 (사이드바 선택 항목 기반) ===== 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') : '-', status: apiOrder.status || '-', }; } // 2. 폴백: 첫 번째 작업 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') : '-', status: first.status || '-', }; }, [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.stepProgressId) { // stepProgressId가 있으면 서버 토글 API 호출 (모든 일반 step 포함) 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} 완료 취소`); // 단계 완료 시 생산일자가 비어있으면 오늘로 자동 저장 if (result.data.is_completed && !productionDate) { const today = new Date().toISOString().slice(0, 10); setProductionDate(today); updateWorkOrderInfo(orderId, { scheduled_date: today }); } // 모든 단계 완료 시 작업지시 자동 완료 → 생산일자 저장 + 목록 새로고침 if (result.data.work_order_status_changed) { toast.success('모든 공정 단계 완료 — 작업지시가 자동 완료되었습니다.'); const today = new Date().toISOString().slice(0, 10); await updateWorkOrderInfo(orderId, { scheduled_date: today }); setProductionDate(today); try { const refreshResult = await getMyWorkOrders(); if (refreshResult.success) setWorkOrders(refreshResult.data); } catch { /* refresh failed */ } } } else { toast.error(result.error || '단계 완료 처리에 실패했습니다.'); } } catch { toast.error('단계 완료 처리 중 오류가 발생했습니다.'); } } } else { // stepProgressId 없는 경우 → 로컬 토글 (폴백) 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 { /* refresh failed silently */ } } 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 { /* refresh failed silently */ } } 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 완료 후 → API 완료 처리 → 결과 팝업 const handleWorkCompletion = useCallback(async () => { if (!selectedOrder) return; setIsCompletionFlow(false); 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) { // 백엔드에서 받은 실제 LOT 번호로 결과 팝업 표시 setCompletionLotNo(result.lotNo || ''); setIsCompletionResultOpen(true); 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] handleWorkCompletion error:', error); toast.error('작업 완료 중 오류가 발생했습니다.'); } }, [selectedOrder, inputMaterialsMap]); // 완료 결과 팝업 확인 → 상태 정리 const handleCompletionResultConfirm = useCallback(() => { setSelectedOrder(null); setCompletionLotNo(''); }, []); // 하단 버튼용 합성 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}`; // 메모리에 즉시 반영 // 절곡: 수주 단위 검사 → 모든 개소가 동일한 검사 데이터 공유 const inspProcessType = getInspectionProcessType(); const isBendingInsp = inspProcessType === 'bending' || inspProcessType === 'bending_wip'; setInspectionDataMap((prev) => { const next = new Map(prev); if (isBendingInsp) { for (const w of workItems) { next.set(w.id, data); } } else { 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 })); if (toggleResult.data?.work_order_status_changed) { toast.success('모든 공정 단계 완료 — 작업지시가 자동 완료되었습니다.'); } } } catch { // 단계 완료 실패 시 로컬만 업데이트 setStepCompletionMap((prev) => ({ ...prev, [buildStepKey(stepName)]: true })); } } else if (stepName) { // stepProgressId 없으면 로컬만 완료 처리 setStepCompletionMap((prev) => ({ ...prev, [buildStepKey(stepName)]: true })); } // 4. 생산일자 자동 저장 (검사 완료 시점 = 생산일자) if (selectedOrder) { const today = new Date().toISOString().slice(0, 10); await updateWorkOrderInfo(selectedOrder.id, { scheduled_date: today }); setProductionDate(today); } // 5. 작업 목록 리프레시 (상태 변경 반영 → 사이드바 대기/완료 탭 갱신) const refreshResult = await getMyWorkOrders(); if (refreshResult.success) setWorkOrders(refreshResult.data); } catch { toast.error('검사 데이터 저장 중 오류가 발생했습니다.'); } } else if (inspectionStepName) { } }, [selectedOrder, workItems, getInspectionProcessType, inspectionStepName]); // ===== 재공품 감지 ===== const hasWipItems = useMemo(() => { if (activeProcessTabKey !== 'bending') return false; // 1. workItems에서 isWip 체크 if (workItems.some(item => item.isWip)) return true; // 2. Fallback: 선택된 작업지시의 프로젝트명/수주번호로 WIP 판별 const selectedWo = filteredWorkOrders.find(wo => wo.id === selectedSidebarOrderId); return !!(selectedWo && (selectedWo.projectName === '재고생산' || selectedWo.salesOrderNo?.startsWith('STK'))); }, [activeProcessTabKey, workItems, filteredWorkOrders, selectedSidebarOrderId]); // ===== 조인트바 감지 ===== 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 기반 동적 생성) */} {isLoading ? ( ) : ( setActiveTab(v)} >
{groupedTabs.length > 0 ? ( groupedTabs.map((g) => ( { setSubProcessId('all'); }} > {g.group} )) ) : ( (['screen', 'slat', 'bending'] as ProcessTab[]).map((tab) => ( {PROCESS_TAB_LABELS[tab]} )) )}
{/* 하위 공정 필터 제거 — 항상 '전체' 선택 상태로 동작 */} {(groupedTabs.length > 0 ? groupedTabs.map((g) => g.defaultProcessId) : ['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" />
{/* 수주 정보 */}

수주 정보

{orderInfo?.status && WO_STATUS_BADGE[orderInfo.status] && ( {WO_STATUS_BADGE[orderInfo.status].label} )}
{/* 작업 정보 - API 연동 */}

작업 정보

{ setProductionDate(date); if (selectedOrder && date) { updateWorkOrderInfo(selectedOrder.id, { scheduled_date: date }); } }} />
{/* 작업 목록 */}

작업 목록

{isLoading ? ( ) : workItems.length === 0 ? ( 해당 공정에 배정된 작업이 없습니다. ) : (
{scopedWorkItems.map((item) => (
))}
)}
))}
)}
{/* 하단 고정 버튼 */} {(hasWipItems || activeProcessSettings.needsWorkLog || activeProcessSettings.hasDocumentTemplate) && (
{(hasWipItems || activeProcessSettings.needsWorkLog) && ( )} {(hasWipItems || 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(); }} />
w.id === selectedOrder?.id)?.workOrderId} />
); } // ===== 사이드바 컨텐츠 ===== interface SidebarContentProps { tab: ProcessTab; selectedOrderId: string; apiOrders: SidebarOrder[]; onSelectOrder: (id: string, subType?: SidebarOrder['subType']) => void; } function SidebarContent({ tab, selectedOrderId, onSelectOrder, apiOrders, }: SidebarContentProps) { const [sidebarTab, setSidebarTab] = useState<'orders' | 'wip'>('orders'); const [showCompleted, setShowCompleted] = useState(false); const [searchTerm, setSearchTerm] = useState(''); // 수주목록 / 재공품 분리 const regularOrders = useMemo(() => apiOrders.filter((o) => o.subType !== 'wip'), [apiOrders]); const wipOrders = useMemo(() => apiOrders.filter((o) => o.subType === 'wip'), [apiOrders]); // 완료/미완료 필터링 const pendingRegular = useMemo(() => regularOrders.filter(o => o.status !== 'completed'), [regularOrders]); const completedRegular = useMemo(() => regularOrders.filter(o => o.status === 'completed'), [regularOrders]); const pendingWip = useMemo(() => wipOrders.filter(o => o.status !== 'completed'), [wipOrders]); const completedWip = useMemo(() => wipOrders.filter(o => o.status === 'completed'), [wipOrders]); // 검색 필터링 const displayOrders = useMemo(() => { const baseOrders = sidebarTab === 'orders' ? (showCompleted ? completedRegular : pendingRegular) : (showCompleted ? completedWip : pendingWip); if (!searchTerm.trim()) return baseOrders; const q = searchTerm.toLowerCase(); return baseOrders.filter((o) => o.siteName.toLowerCase().includes(q) || o.date.includes(q) ); }, [sidebarTab, showCompleted, pendingRegular, completedRegular, pendingWip, completedWip, searchTerm]); 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 (
{/* 탭: 수주목록 / 재공품 */}
{/* 대기/완료 토글 */}
{/* 검색 */} setSearchTerm(e.target.value)} className="h-8 text-xs" /> {displayOrders.length > 0 ? ( renderOrders(displayOrders) ) : (

{searchTerm ? '검색 결과가 없습니다.' : '데이터가 없습니다.'}

)}
); } // ===== 하위 컴포넌트 ===== 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 || '-'}

); }