diff --git a/src/components/production/WorkerScreen/InspectionInputModal.tsx b/src/components/production/WorkerScreen/InspectionInputModal.tsx index 3d3d2602..a9c93570 100644 --- a/src/components/production/WorkerScreen/InspectionInputModal.tsx +++ b/src/components/production/WorkerScreen/InspectionInputModal.tsx @@ -487,6 +487,7 @@ export function InspectionInputModal({ ...initialData, productName: initialData.productName || productName, specification: initialData.specification || specification, + nonConformingContent: initialData.nonConformingContent ?? '', }); if (initialData.gapPoints) { setGapPoints(initialData.gapPoints); diff --git a/src/components/production/WorkerScreen/WorkItemCard.tsx b/src/components/production/WorkerScreen/WorkItemCard.tsx index 0a87dc05..7019af1d 100644 --- a/src/components/production/WorkerScreen/WorkItemCard.tsx +++ b/src/components/production/WorkerScreen/WorkItemCard.tsx @@ -134,31 +134,39 @@ export const WorkItemCard = memo(function WorkItemCard({

- {/* 공정 단계 버튼 - 중간검사 포함 인라인 */} + {/* 공정 단계 버튼 - 순차 진행 (이전 단계 완료 후 다음 단계 활성화) */}
- {item.steps.map((step) => ( - - ))} + {item.steps.map((step, idx) => { + const prevCompleted = idx === 0 || item.steps[idx - 1].isCompleted; + const isLocked = !step.isCompleted && !prevCompleted; + return ( + + ); + })}
{/* 자재 투입 목록 (토글) */} diff --git a/src/components/production/WorkerScreen/actions.ts b/src/components/production/WorkerScreen/actions.ts index 76f0ccc3..a710dfd7 100644 --- a/src/components/production/WorkerScreen/actions.ts +++ b/src/components/production/WorkerScreen/actions.ts @@ -501,6 +501,7 @@ export async function requestInspection( export interface StepProgressItem { id: number; process_step_id: number; + work_order_item_id: number | null; step_code: string; step_name: string; sort_order: number; @@ -762,14 +763,13 @@ export async function getInspectionTemplate( return { success: result.success, data: result.data, error: result.error }; } -// ===== 검사 문서 저장 (Document + DocumentData) ===== +// ===== 검사 문서 동기화 (원본: work_order_items → document_data) ===== export async function saveInspectionDocument( workOrderId: string, data: { title?: string; - data: Record[]; approvers?: { role_name: string; user_id?: number }[]; - } + } = {} ): Promise<{ success: boolean; data?: { document_id: number; document_no: string; status: string }; @@ -779,7 +779,7 @@ export async function saveInspectionDocument( url: `${API_URL}/api/v1/work-orders/${workOrderId}/inspection-document`, method: 'POST', body: data, - errorMessage: '검사 문서 저장에 실패했습니다.', + errorMessage: '검사 문서 동기화에 실패했습니다.', }); return { success: result.success, data: result.data, error: result.error }; } \ No newline at end of file diff --git a/src/components/production/WorkerScreen/index.tsx b/src/components/production/WorkerScreen/index.tsx index 98b544fa..6c4ca6b5 100644 --- a/src/components/production/WorkerScreen/index.tsx +++ b/src/components/production/WorkerScreen/index.tsx @@ -40,7 +40,8 @@ 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, toggleStepProgress, deleteMaterialInput, updateMaterialInput } from './actions'; +import { getMyWorkOrders, completeWorkOrder, saveItemInspection, getWorkOrderInspectionData, saveInspectionDocument, getInspectionTemplate, getStepProgress, toggleStepProgress, deleteMaterialInput, updateMaterialInput } from './actions'; +import type { StepProgressItem } from './actions'; import type { InspectionTemplateData } from './types'; import { getProcessList } from '@/components/process-management/actions'; import type { InspectionSetting, Process } from '@/types/process'; @@ -327,6 +328,9 @@ export default function WorkerScreen() { // 공정별 step 완료 상태: { [itemId-stepName]: boolean } const [stepCompletionMap, setStepCompletionMap] = useState>({}); + // 작업지시별 단계 진행 캐시: { [workOrderId]: StepProgressItem[] } + const [stepProgressMap, setStepProgressMap] = useState>({}); + // 데이터 로드 const loadData = useCallback(async () => { setIsLoading(true); @@ -473,46 +477,25 @@ export default function WorkerScreen() { } }, [activeTab, processListCache]); - // ===== 작업지시 선택 시 기존 검사 데이터 로드 ===== + // ===== 작업지시 선택 시 단계 진행 데이터 로드 ===== useEffect(() => { if (!selectedSidebarOrderId) return; - // 목업 ID면 건너뛰기 if (selectedSidebarOrderId.startsWith('order-')) return; - const loadInspectionData = async () => { + const loadStepProgress = 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 완료 처리 - const stepKey = match.id.replace('-node-', '-') + '-중간검사'; - completionUpdates[stepKey] = true; - } - } - return next; - }); - // stepCompletionMap 일괄 업데이트 - if (Object.keys(completionUpdates).length > 0) { - setStepCompletionMap((prev) => ({ ...prev, ...completionUpdates })); - } + const result = await getStepProgress(selectedSidebarOrderId); + if (result.success) { + setStepProgressMap((prev) => ({ ...prev, [selectedSidebarOrderId]: result.data })); } } catch { - // 검사 데이터 로드 실패는 무시 (새 작업일 수 있음) + // step progress 로드 실패 시 무시 } }; - loadInspectionData(); + loadStepProgress(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedSidebarOrderId]); - // ===== 탭별 필터링된 작업 ===== const filteredWorkOrders = useMemo(() => { const selectedProcess = processListCache.find((p) => p.id === activeTab); @@ -602,14 +585,21 @@ export default function WorkerScreen() { : hardcodedSteps; // step에 API 설정 속성을 매칭하는 헬퍼 - const enrichStep = (st: { name: string; isMaterialInput: boolean; isInspection?: boolean }, stepId: string, stepKey: string) => { + 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: stepCompletionMap[stepKey] || false, + isCompleted: stepKey in stepCompletionMap ? stepCompletionMap[stepKey] : (spMatch?.is_completed || false), + stepProgressId: spMatch?.id, needsInspection: matched?.needsInspection, connectionType: matched?.connectionType, connectionTarget: matched?.connectionTarget, @@ -623,17 +613,16 @@ export default function WorkerScreen() { // 개소별로 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); + 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(', ') : '-'; - - // 첫 번째 아이템의 options에서 사이즈/공정별 정보 추출 - const firstItem = group.items[0]; const opts = (firstItem?.options || {}) as Record; // 개소별 투입 자재 매핑 (로컬 오버라이드 > API 초기 데이터) @@ -761,7 +750,51 @@ export default function WorkerScreen() { })); return [...apiItems, ...mockItems]; - }, [filteredWorkOrders, selectedSidebarOrderId, activeProcessTabKey, stepCompletionMap, bendingSubMode, slatSubMode, activeProcessSteps, inputMaterialsMap]); + }, [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 완료 처리 + const stepKey = match.id.replace('-node-', '-') + '-중간검사'; + 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]); // ===== 수주 정보 (사이드바 선택 항목 기반) ===== const orderInfo = useMemo(() => { @@ -858,7 +891,7 @@ export default function WorkerScreen() { // pill 클릭 핸들러 const handleStepClick = useCallback( - (itemId: string, step: WorkStepData) => { + async (itemId: string, step: WorkStepData) => { if (step.isMaterialInput) { // 자재투입 → 자재 투입 모달 열기 (개소별) const order = workOrders.find((o) => o.id === itemId || itemId.startsWith(`${o.id}-node-`)); @@ -901,9 +934,31 @@ export default function WorkerScreen() { } else if (step.needsInspection || step.isInspection) { // 검사 단계 (processListCache 설정 또는 하드코딩 폴백) → 중간검사 모달 열기 handleInspectionClick(itemId); + } 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 { - // 기타 → 완료/미완료 토글 - const stepKey = `${itemId}-${step.name}`; + // 기타 → 완료/미완료 토글 (로컬) - enrichStep stepKey 형식 일치 + const stepKey = `${itemId.replace('-node-', '-')}-${step.name}`; setStepCompletionMap((prev) => ({ ...prev, [stepKey]: !prev[stepKey], @@ -1171,18 +1226,16 @@ export default function WorkerScreen() { toast.error(result.error || '검사 데이터 저장에 실패했습니다.'); } - // 2. 추가: Document + DocumentData로 저장 (document_template 연결된 경우) + // 2. Document 동기화: 백엔드가 원본(work_order_items)에서 전체 수집하여 document_data 갱신 try { - await saveInspectionDocument(targetItem.workOrderId, { - data: [data as unknown as Record], - }); + await saveInspectionDocument(targetItem.workOrderId, {}); } catch { - // Document 저장 실패는 무시 (template 미연결 시 404 가능) + // Document 동기화 실패는 무시 (template 미연결 시 404 가능) } - // 3. completionType='검사완료 시 완료'인 단계 자동 완료 처리 + // 3. completionType='inspection_complete'인 단계 자동 완료 처리 const inspectionStep = targetItem.steps.find( - (s) => (s.completionType === '검사완료 시 완료') || s.needsInspection || s.isInspection + (s) => (s.completionType === 'inspection_complete') || s.needsInspection || s.isInspection ); if (inspectionStep?.stepProgressId) { // 서버에 단계 완료 토글 diff --git a/src/components/production/WorkerScreen/types.ts b/src/components/production/WorkerScreen/types.ts index 4e53aee8..9b0846e2 100644 --- a/src/components/production/WorkerScreen/types.ts +++ b/src/components/production/WorkerScreen/types.ts @@ -117,7 +117,7 @@ export interface WorkStepData { needsInspection?: boolean; // 검사여부 (ProcessStep.needs_inspection) connectionType?: string; // 연결 유형: '팝업' | '없음' connectionTarget?: string; // 도달: '중간검사' 등 - completionType?: string; // 완료 유형: '검사완료 시 완료' | '클릭 시 완료' | '선택 완료 시 완료' + completionType?: string; // 완료 유형: 'inspection_complete' | 'click_complete' | 'selection_complete' } // ===== 자재 투입 목록 항목 =====