feat(WEB):작업자 화면 단계 진행 API 연동 및 순차 진행 UI
- stepProgressMap: 작업지시별 단계 진행 데이터 캐시 및 API 로드 - 개소별 step 완료 상태를 서버 데이터 기반으로 표시 - WorkItemCard: 이전 단계 완료 후 다음 단계 활성화 (순차 진행 잠금) - click_complete 타입 단계 클릭 시 서버 토글 API 호출 - 검사 데이터 로드 타이밍을 workItems 의존성으로 보장 - InspectionInputModal: nonConformingContent 초기값 보정
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
{/* 자재 투입 목록 (토글) */}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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) {
|
||||
// 서버에 단계 완료 토글
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
// ===== 자재 투입 목록 항목 =====
|
||||
|
||||
Reference in New Issue
Block a user