From 841c28458623a2e40eef9fde83a97559b83cf144 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?=
Date: Fri, 13 Feb 2026 03:41:59 +0900
Subject: [PATCH] =?UTF-8?q?feat(WEB):=EC=9E=91=EC=97=85=EC=9E=90=20?=
=?UTF-8?q?=ED=99=94=EB=A9=B4=20=EB=8B=A8=EA=B3=84=20=EC=A7=84=ED=96=89=20?=
=?UTF-8?q?API=20=EC=97=B0=EB=8F=99=20=EB=B0=8F=20=EC=88=9C=EC=B0=A8=20?=
=?UTF-8?q?=EC=A7=84=ED=96=89=20UI?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- stepProgressMap: 작업지시별 단계 진행 데이터 캐시 및 API 로드
- 개소별 step 완료 상태를 서버 데이터 기반으로 표시
- WorkItemCard: 이전 단계 완료 후 다음 단계 활성화 (순차 진행 잠금)
- click_complete 타입 단계 클릭 시 서버 토글 API 호출
- 검사 데이터 로드 타이밍을 workItems 의존성으로 보장
- InspectionInputModal: nonConformingContent 초기값 보정
---
.../WorkerScreen/InspectionInputModal.tsx | 1 +
.../production/WorkerScreen/WorkItemCard.tsx | 56 ++++---
.../production/WorkerScreen/actions.ts | 8 +-
.../production/WorkerScreen/index.tsx | 145 ++++++++++++------
.../production/WorkerScreen/types.ts | 2 +-
5 files changed, 137 insertions(+), 75 deletions(-)
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'
}
// ===== 자재 투입 목록 항목 =====