feat(WEB):작업자 화면 단계 진행 API 연동 및 순차 진행 UI

- stepProgressMap: 작업지시별 단계 진행 데이터 캐시 및 API 로드
- 개소별 step 완료 상태를 서버 데이터 기반으로 표시
- WorkItemCard: 이전 단계 완료 후 다음 단계 활성화 (순차 진행 잠금)
- click_complete 타입 단계 클릭 시 서버 토글 API 호출
- 검사 데이터 로드 타이밍을 workItems 의존성으로 보장
- InspectionInputModal: nonConformingContent 초기값 보정
This commit is contained in:
2026-02-13 03:41:59 +09:00
parent 4644ae298d
commit 841c284586
5 changed files with 137 additions and 75 deletions

View File

@@ -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);

View File

@@ -134,31 +134,39 @@ export const WorkItemCard = memo(function WorkItemCard({
</p>
</div>
{/* 공정 단계 버튼 - 중간검사 포함 인라인 */}
{/* 공정 단계 버튼 - 순차 진행 (이전 단계 완료 후 다음 단계 활성화) */}
<div className="flex flex-wrap gap-2">
{item.steps.map((step) => (
<button
key={step.id}
onClick={() => {
if (step.isInspection && onInspectionClick) {
onInspectionClick(item.id);
} else {
handleStepClick(step);
}
}}
className={cn(
'px-4 py-2 rounded-lg text-sm font-medium transition-colors',
step.isCompleted
? 'bg-emerald-500 text-white hover:bg-emerald-600'
: 'bg-gray-800 text-white hover:bg-gray-700'
)}
>
{step.name}
{step.isCompleted && (
<span className="ml-1.5 font-semibold"></span>
)}
</button>
))}
{item.steps.map((step, idx) => {
const prevCompleted = idx === 0 || item.steps[idx - 1].isCompleted;
const isLocked = !step.isCompleted && !prevCompleted;
return (
<button
key={step.id}
disabled={isLocked}
onClick={() => {
if (isLocked) return;
if (step.isInspection && onInspectionClick) {
onInspectionClick(item.id);
} else {
handleStepClick(step);
}
}}
className={cn(
'px-4 py-2 rounded-lg text-sm font-medium transition-colors',
step.isCompleted
? 'bg-emerald-500 text-white hover:bg-emerald-600'
: isLocked
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
: 'bg-gray-800 text-white hover:bg-gray-700'
)}
>
{step.name}
{step.isCompleted && (
<span className="ml-1.5 font-semibold"></span>
)}
</button>
);
})}
</div>
{/* 자재 투입 목록 (토글) */}

View File

@@ -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<string, unknown>[];
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 };
}

View File

@@ -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<Record<string, boolean>>({});
// 작업지시별 단계 진행 캐시: { [workOrderId]: StepProgressItem[] }
const [stepProgressMap, setStepProgressMap] = useState<Record<string, StepProgressItem[]>>({});
// 데이터 로드
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<string, boolean> = {};
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<string, unknown>;
// 개소별 투입 자재 매핑 (로컬 오버라이드 > 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<string, boolean> = {};
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<string, unknown>],
});
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) {
// 서버에 단계 완료 토글

View File

@@ -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'
}
// ===== 자재 투입 목록 항목 =====