From 14af77ca65afc3ac37eae1ad67290ddfb0104bca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 12 Feb 2026 20:07:31 +0900 Subject: [PATCH] =?UTF-8?q?fix(WEB):=20=EC=9E=90=EC=9E=AC=ED=88=AC?= =?UTF-8?q?=EC=9E=85=20=EB=AA=A8=EB=8B=AC=20UX=20=EA=B0=9C=EC=84=A0=20-=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=EC=9C=A0=EC=A7=80/=EC=A4=91=EB=B3=B5?= =?UTF-8?q?=ED=88=AC=EC=9E=85=20=EC=B0=A8=EB=8B=A8/=EB=B2=84=ED=8A=BC=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 자재 투입 후 전체 새로고침 제거, 로컬 오버라이드로 현재 수주 선택 유지 - 자동선택 useEffect에 현재 선택 유효 가드 추가 - API remainingRequiredQty 활용하여 이미 충족된 품목 추가 선택 차단 - 기투입 수량 표시 및 '투입 완료' 뱃지 표시 - 체크박스 → 버튼 형태(선택/선택됨)로 변경 - 수량 소수점 불필요 자릿수 제거 (parseFloat 래핑) --- .../production/ProductionDashboard/types.ts | 9 + .../documents/TemplateInspectionContent.tsx | 2 +- .../WorkerScreen/MaterialInputModal.tsx | 461 ++++++++++++------ .../production/WorkerScreen/WorkItemCard.tsx | 2 +- .../production/WorkerScreen/WorkLogModal.tsx | 52 +- .../production/WorkerScreen/actions.ts | 197 ++++++++ .../production/WorkerScreen/index.tsx | 228 ++++++++- 7 files changed, 770 insertions(+), 181 deletions(-) diff --git a/src/components/production/ProductionDashboard/types.ts b/src/components/production/ProductionDashboard/types.ts index 1e348dfd..7b832501 100644 --- a/src/components/production/ProductionDashboard/types.ts +++ b/src/components/production/ProductionDashboard/types.ts @@ -55,6 +55,15 @@ export interface WorkOrderNodeItem { quantity: number; specification?: string | null; options?: Record | null; + materialInputs?: { + id: number; + stockLotId: number; + lotNo: string | null; + itemId: number; + materialName: string | null; + qty: number; + unit: string; + }[]; } // 작업자 현황 diff --git a/src/components/production/WorkOrders/documents/TemplateInspectionContent.tsx b/src/components/production/WorkOrders/documents/TemplateInspectionContent.tsx index 91af866d..6618b071 100644 --- a/src/components/production/WorkOrders/documents/TemplateInspectionContent.tsx +++ b/src/components/production/WorkOrders/documents/TemplateInspectionContent.tsx @@ -374,7 +374,7 @@ export const TemplateInspectionContent = forwardRef void; order: WorkOrder | null; + workOrderItemId?: number; // 개소(작업지시품목) ID + workOrderItemName?: string; // 개소명 (모달 헤더 표시용) onComplete?: () => void; isCompletionFlow?: boolean; onSaveMaterials?: (orderId: string, materials: MaterialInput[]) => void; savedMaterials?: MaterialInput[]; } +interface MaterialGroup { + itemId: number; + materialName: string; + materialCode: string; + requiredQty: number; + effectiveRequiredQty: number; // 남은 필요수량 (이미 투입분 차감) + alreadyInputted: number; // 이미 투입된 수량 + unit: string; + lots: MaterialForInput[]; +} + +const fmtQty = (v: number) => parseFloat(String(v)).toLocaleString(); + export function MaterialInputModal({ open, onOpenChange, order, + workOrderItemId, + workOrderItemName, onComplete, isCompletionFlow = false, onSaveMaterials, }: MaterialInputModalProps) { const [materials, setMaterials] = useState([]); - const [inputQuantities, setInputQuantities] = useState>({}); + const [selectedLotKeys, setSelectedLotKeys] = useState>(new Set()); const [isLoading, setIsLoading] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); @@ -70,6 +86,83 @@ export function MaterialInputModal({ fifoRank: i + 1, })); + // 로트 키 생성 + const getLotKey = (material: MaterialForInput) => + String(material.stockLotId ?? `item-${material.itemId}`); + + // 품목별 그룹핑 + const materialGroups: MaterialGroup[] = useMemo(() => { + const groups = new Map(); + for (const m of materials) { + const existing = groups.get(m.itemId) || []; + existing.push(m); + groups.set(m.itemId, existing); + } + return Array.from(groups.entries()).map(([itemId, lots]) => { + const first = lots[0]; + const itemInput = first as unknown as MaterialForItemInput; + const alreadyInputted = itemInput.alreadyInputted ?? 0; + const effectiveRequiredQty = Math.max(0, itemInput.remainingRequiredQty ?? first.requiredQty); + return { + itemId, + materialName: first.materialName, + materialCode: first.materialCode, + requiredQty: first.requiredQty, + effectiveRequiredQty, + alreadyInputted, + unit: first.unit, + lots: lots.sort((a, b) => a.fifoRank - b.fifoRank), + }; + }); + }, [materials]); + + // 선택된 로트에 FIFO 순서로 자동 배분 계산 + const allocations = useMemo(() => { + const result = new Map(); + for (const group of materialGroups) { + let remaining = group.effectiveRequiredQty; + for (const lot of group.lots) { + const lotKey = getLotKey(lot); + if (selectedLotKeys.has(lotKey) && lot.stockLotId && remaining > 0) { + const alloc = Math.min(lot.lotAvailableQty, remaining); + result.set(lotKey, alloc); + remaining -= alloc; + } + } + } + return result; + }, [materialGroups, selectedLotKeys]); + + // 전체 배정 완료 여부 + const allGroupsFulfilled = useMemo(() => { + if (materialGroups.length === 0) return false; + return materialGroups.every((group) => { + const allocated = group.lots.reduce( + (sum, lot) => sum + (allocations.get(getLotKey(lot)) || 0), + 0 + ); + return group.effectiveRequiredQty <= 0 || allocated >= group.effectiveRequiredQty; + }); + }, [materialGroups, allocations]); + + // 배정된 항목 존재 여부 + const hasAnyAllocation = useMemo(() => { + return Array.from(allocations.values()).some((v) => v > 0); + }, [allocations]); + + // 로트 선택/해제 + const toggleLot = useCallback((lotKey: string) => { + setSelectedLotKeys((prev) => { + const next = new Set(prev); + if (next.has(lotKey)) { + next.delete(lotKey); + } else { + next.add(lotKey); + } + return next; + }); + }, []); + // API로 자재 목록 로드 const loadMaterials = useCallback(async () => { if (!order) return; @@ -79,23 +172,17 @@ export function MaterialInputModal({ // 목업 아이템인 경우 목업 자재 데이터 사용 if (order.id.startsWith('mock-')) { setMaterials(MOCK_MATERIALS); - const initialQuantities: Record = {}; - MOCK_MATERIALS.forEach((m) => { - initialQuantities[String(m.stockLotId)] = ''; - }); - setInputQuantities(initialQuantities); setIsLoading(false); return; } - const result = await getMaterialsForWorkOrder(order.id); + // 개소별 API vs 전체 API 분기 + const result = workOrderItemId + ? await getMaterialsForItem(order.id, workOrderItemId) + : await getMaterialsForWorkOrder(order.id); + if (result.success) { setMaterials(result.data); - const initialQuantities: Record = {}; - result.data.forEach((m) => { - initialQuantities[String(m.stockLotId ?? `item-${m.itemId}`)] = ''; - }); - setInputQuantities(initialQuantities); } else { toast.error(result.error || '자재 목록 조회에 실패했습니다.'); } @@ -106,74 +193,63 @@ export function MaterialInputModal({ } finally { setIsLoading(false); } - }, [order]); + }, [order, workOrderItemId]); - // 모달이 열릴 때 데이터 로드 + // 모달이 열릴 때 데이터 로드 + 선택 초기화 useEffect(() => { if (open && order) { loadMaterials(); + setSelectedLotKeys(new Set()); } }, [open, order, loadMaterials]); - // 투입 수량 변경 핸들러 (숫자만 허용) - const handleQuantityChange = (key: string, value: string) => { - const numericValue = value.replace(/[^0-9]/g, ''); - setInputQuantities((prev) => ({ - ...prev, - [key]: numericValue, - })); - }; - - // 로트 키 생성 - const getLotKey = (material: MaterialForInput) => - String(material.stockLotId ?? `item-${material.itemId}`); - // 투입 등록 const handleSubmit = async () => { if (!order) return; - // 투입 수량이 입력된 항목 필터 (재고 있는 로트만) - const materialsWithQuantity = materials.filter((m) => { - if (!m.stockLotId) return false; - const qty = inputQuantities[getLotKey(m)]; - return qty && parseInt(qty) > 0; - }); - - if (materialsWithQuantity.length === 0) { - toast.error('투입 수량을 입력해주세요.'); - return; + // 배분된 로트만 추출 + const inputs: { stock_lot_id: number; qty: number }[] = []; + for (const [lotKey, allocQty] of allocations) { + if (allocQty > 0) { + const material = materials.find((m) => getLotKey(m) === lotKey); + if (material?.stockLotId) { + inputs.push({ stock_lot_id: material.stockLotId, qty: allocQty }); + } + } } - // 가용수량 초과 검증 - for (const m of materialsWithQuantity) { - const inputQty = parseInt(inputQuantities[getLotKey(m)] || '0'); - if (inputQty > m.lotAvailableQty) { - toast.error(`${m.lotNo}: 가용수량(${m.lotAvailableQty})을 초과할 수 없습니다.`); - return; - } + if (inputs.length === 0) { + toast.error('투입할 로트를 선택해주세요.'); + return; } setIsSubmitting(true); try { - const inputs = materialsWithQuantity.map((m) => ({ - stock_lot_id: m.stockLotId!, - qty: parseInt(inputQuantities[getLotKey(m)] || '0'), - })); - - const result = await registerMaterialInput(order.id, inputs); + // 개소별 API vs 전체 API 분기 + const result = workOrderItemId + ? await registerMaterialInputForItem(order.id, workOrderItemId, inputs) + : await registerMaterialInput(order.id, inputs); if (result.success) { toast.success('자재 투입이 등록되었습니다.'); if (onSaveMaterials) { - const savedList: MaterialInput[] = materialsWithQuantity.map((m) => ({ - id: String(m.stockLotId), - lotNo: m.lotNo || '', - materialName: m.materialName, - quantity: m.lotAvailableQty, - unit: m.unit, - inputQuantity: parseInt(inputQuantities[getLotKey(m)] || '0'), - })); + const savedList: MaterialInput[] = []; + for (const [lotKey, allocQty] of allocations) { + if (allocQty > 0) { + const material = materials.find((m) => getLotKey(m) === lotKey); + if (material) { + savedList.push({ + id: String(material.stockLotId), + lotNo: material.lotNo || '', + materialName: material.materialName, + quantity: material.lotAvailableQty, + unit: material.unit, + inputQuantity: allocQty, + }); + } + } + } onSaveMaterials(order.id, savedList); } @@ -194,12 +270,8 @@ export function MaterialInputModal({ } }; - const handleCancel = () => { - resetAndClose(); - }; - const resetAndClose = () => { - setInputQuantities({}); + setSelectedLotKeys(new Set()); onOpenChange(false); }; @@ -210,94 +282,177 @@ export function MaterialInputModal({ {/* 헤더 */} - 자재 투입 + + 자재 투입{workOrderItemName ? ` - ${workOrderItemName}` : ''} + +

+ 로트를 선택하면 필요수량만큼 자동 배분됩니다. +

-
- {/* 자재 목록 테이블 */} +
+ {/* 자재 목록 */} {isLoading ? ( ) : materials.length === 0 ? ( -
- - - - 로트번호 - 품목명 - 필요수량 - 가용수량 - 단위 - 투입 수량 - - - - - - 이 공정에 배정된 자재가 없습니다. - - - -
+
+ 이 공정에 배정된 자재가 없습니다.
) : ( -
- - - - 로트번호 - 품목명 - 필요수량 - 가용수량 - 단위 - 투입 수량 - - - - {materials.map((material, index) => { - const lotKey = getLotKey(material); - const hasStock = material.stockLotId !== null; - return ( - - - {material.lotNo || ( - 재고 없음 - )} - - - {material.materialName} - - - {material.requiredQty.toLocaleString()} - - - {hasStock ? material.lotAvailableQty.toLocaleString() : ( - 0 - )} - - - {material.unit} - - - {hasStock ? ( - - handleQuantityChange(lotKey, e.target.value) - } - className="w-20 mx-auto text-center h-8 text-sm" - /> +
+ {materialGroups.map((group) => { + const groupAllocated = group.lots.reduce( + (sum, lot) => sum + (allocations.get(getLotKey(lot)) || 0), + 0 + ); + const isAlreadyComplete = group.effectiveRequiredQty <= 0; + const isFulfilled = isAlreadyComplete || groupAllocated >= group.effectiveRequiredQty; + + return ( +
+ {/* 품목 그룹 헤더 */} +
+
+ + {group.materialName} + + {group.materialCode && ( + + {group.materialCode} + + )} +
+
+ + {group.alreadyInputted > 0 ? ( + <> + 필요:{' '} + + {fmtQty(group.effectiveRequiredQty)} + {' '} + {group.unit} + + (기투입: {fmtQty(group.alreadyInputted)}) + + ) : ( - - + <> + 필요:{' '} + + {fmtQty(group.requiredQty)} + {' '} + {group.unit} + )} - - - ); - })} - -
+ + 0 + ? 'bg-amber-100 text-amber-700' + : 'bg-gray-100 text-gray-500' + }`} + > + {isAlreadyComplete ? ( + <> + + 투입 완료 + + ) : isFulfilled ? ( + <> + + 배정 완료 + + ) : ( + `${fmtQty(groupAllocated)} / ${fmtQty(group.effectiveRequiredQty)}` + )} + +
+
+ + {/* 로트 테이블 */} + + + + 선택 + 로트번호 + 가용수량 + 단위 + 배정수량 + + + + {group.lots.map((lot, idx) => { + const lotKey = getLotKey(lot); + const hasStock = lot.stockLotId !== null; + const isSelected = selectedLotKeys.has(lotKey); + const allocated = allocations.get(lotKey) || 0; + const canSelect = hasStock && !isAlreadyComplete && (!isFulfilled || isSelected); + + return ( + 0 + ? 'bg-blue-50/50' + : '' + } + > + + {hasStock ? ( + + ) : null} + + + {lot.lotNo || ( + + 재고 없음 + + )} + + + {hasStock ? ( + fmtQty(lot.lotAvailableQty) + ) : ( + 0 + )} + + + {lot.unit} + + + {allocated > 0 ? ( + + {fmtQty(allocated)} + + ) : ( + - + )} + + + ); + })} + +
+
+ ); + })}
)} @@ -305,7 +460,7 @@ export function MaterialInputModal({
+ ); + return ( {isLoading ? (
diff --git a/src/components/production/WorkerScreen/actions.ts b/src/components/production/WorkerScreen/actions.ts index 7294153d..76f0ccc3 100644 --- a/src/components/production/WorkerScreen/actions.ts +++ b/src/components/production/WorkerScreen/actions.ts @@ -54,6 +54,16 @@ interface WorkOrderApiItem { symbol_code?: string | null; node?: { id: number; name: string; code: string } | null; } | null; + material_inputs?: { + id: number; + stock_lot_id: number; + item_id: number; + qty: number; + input_by: number | null; + input_at: string | null; + stock_lot?: { id: number; lot_no: string } | null; + item?: { id: number; code: string; name: string; unit: string } | null; + }[]; }[]; } @@ -141,6 +151,15 @@ function transformToWorkerScreenFormat(api: WorkOrderApiItem): WorkOrder { quantity: Number(it.quantity), specification: it.specification, options: it.options, + materialInputs: (it.material_inputs || []).map((mi) => ({ + id: mi.id, + stockLotId: mi.stock_lot_id, + lotNo: mi.stock_lot?.lot_no || null, + itemId: mi.item_id, + materialName: mi.item?.name || null, + qty: Number(mi.qty), + unit: mi.item?.unit || 'EA', + })), })), totalQuantity: (g.items || []).reduce((sum, it) => sum + Number(it.quantity), 0), })); @@ -265,6 +284,136 @@ export async function registerMaterialInput( return { success: result.success, error: result.error }; } +// ===== 개소별 자재 목록 조회 ===== +export interface MaterialForItemInput extends MaterialForInput { + alreadyInputted: number; // 이미 투입된 수량 + remainingRequiredQty: number; // 남은 필요 수량 +} + +export async function getMaterialsForItem( + workOrderId: string, + itemId: number +): Promise<{ + success: boolean; + data: MaterialForItemInput[]; + error?: string; +}> { + interface MaterialItemApiItem { + stock_lot_id: number | null; item_id: number; lot_no: string | null; + material_code: string; material_name: string; specification: string; + unit: string; bom_qty: number; required_qty: number; + already_inputted: number; remaining_required_qty: number; + lot_available_qty: number; fifo_rank: number; + lot_qty: number; lot_reserved_qty: number; + receipt_date: string | null; supplier: string | null; + } + const result = await executeServerAction({ + url: `${API_URL}/api/v1/work-orders/${workOrderId}/items/${itemId}/materials`, + errorMessage: '개소별 자재 목록 조회에 실패했습니다.', + }); + if (!result.success || !result.data) return { success: false, data: [], error: result.error }; + return { + success: true, + data: result.data.map((item) => ({ + stockLotId: item.stock_lot_id, itemId: item.item_id, lotNo: item.lot_no, + materialCode: item.material_code, materialName: item.material_name, + specification: item.specification ?? '', unit: item.unit, + requiredQty: item.required_qty, lotAvailableQty: item.lot_available_qty, + fifoRank: item.fifo_rank, + alreadyInputted: item.already_inputted, + remainingRequiredQty: item.remaining_required_qty, + })), + }; +} + +// ===== 개소별 자재 투입 등록 ===== +export async function registerMaterialInputForItem( + workOrderId: string, + itemId: number, + inputs: { stock_lot_id: number; qty: number }[] +): Promise<{ success: boolean; error?: string }> { + const result = await executeServerAction({ + url: `${API_URL}/api/v1/work-orders/${workOrderId}/items/${itemId}/material-inputs`, + method: 'POST', + body: { inputs }, + errorMessage: '개소별 자재 투입 등록에 실패했습니다.', + }); + return { success: result.success, error: result.error }; +} + +// ===== 개소별 자재 투입 이력 조회 ===== +export interface MaterialInputHistoryItem { + id: number; + stockLotId: number; + lotNo: string | null; + itemId: number; + materialCode: string | null; + materialName: string | null; + qty: number; + unit: string; + inputBy: number | null; + inputByName: string | null; + inputAt: string | null; +} + +export async function getMaterialInputsForItem( + workOrderId: string, + itemId: number +): Promise<{ + success: boolean; + data: MaterialInputHistoryItem[]; + error?: string; +}> { + interface HistoryApiItem { + id: number; stock_lot_id: number; lot_no: string | null; + item_id: number; material_code: string | null; material_name: string | null; + qty: number; unit: string; + input_by: number | null; input_by_name: string | null; input_at: string | null; + } + const result = await executeServerAction({ + url: `${API_URL}/api/v1/work-orders/${workOrderId}/items/${itemId}/material-inputs`, + errorMessage: '개소별 투입 이력 조회에 실패했습니다.', + }); + if (!result.success || !result.data) return { success: false, data: [], error: result.error }; + return { + success: true, + data: result.data.map((item) => ({ + id: item.id, stockLotId: item.stock_lot_id, lotNo: item.lot_no, + itemId: item.item_id, materialCode: item.material_code, + materialName: item.material_name, qty: item.qty, unit: item.unit, + inputBy: item.input_by, inputByName: item.input_by_name, inputAt: item.input_at, + })), + }; +} + +// ===== 자재 투입 삭제 (재고 복원) ===== +export async function deleteMaterialInput( + workOrderId: string, + inputId: number +): Promise<{ success: boolean; error?: string }> { + const result = await executeServerAction({ + url: `${API_URL}/api/v1/work-orders/${workOrderId}/material-inputs/${inputId}`, + method: 'DELETE', + errorMessage: '자재 투입 삭제에 실패했습니다.', + }); + return { success: result.success, error: result.error }; +} + +// ===== 자재 투입 수량 수정 ===== +export async function updateMaterialInput( + workOrderId: string, + inputId: number, + qty: number +): Promise<{ success: boolean; data?: { id: number; qty: number; changed: boolean }; error?: string }> { + const result = await executeServerAction<{ id: number; qty: number; changed: boolean }>({ + url: `${API_URL}/api/v1/work-orders/${workOrderId}/material-inputs/${inputId}`, + method: 'PATCH', + body: { qty }, + errorMessage: '자재 투입 수정에 실패했습니다.', + }); + return { success: result.success, data: result.data, error: result.error }; +} + // ===== 이슈 보고 ===== export async function reportIssue( workOrderId: string, @@ -548,6 +697,54 @@ export async function getWorkOrderInspectionData( return { success: result.success, data: result.data, error: result.error }; } +// ===== 작업일지 저장 ===== +export async function saveWorkLog( + workOrderId: string, + data: { + basic_data?: Record; + table_data?: Array>; + remarks?: string; + title?: string; + } +): Promise<{ + success: boolean; + data?: { document_id: number; document_no: string; status: string }; + error?: string; +}> { + const result = await executeServerAction<{ document_id: number; document_no: string; status: string }>({ + url: `${API_URL}/api/v1/work-orders/${workOrderId}/work-log`, + method: 'POST', + body: data, + errorMessage: '작업일지 저장에 실패했습니다.', + }); + return { success: result.success, data: result.data, error: result.error }; +} + +// ===== 작업일지 조회 ===== +export async function getWorkLog( + workOrderId: string +): Promise<{ + success: boolean; + data?: { + template: Record; + document: Record | null; + auto_values: Record; + work_stats: Record; + }; + error?: string; +}> { + const result = await executeServerAction<{ + template: Record; + document: Record | null; + auto_values: Record; + work_stats: Record; + }>({ + url: `${API_URL}/api/v1/work-orders/${workOrderId}/work-log`, + errorMessage: '작업일지 조회에 실패했습니다.', + }); + return { success: result.success, data: result.data, error: result.error }; +} + // ===== 검사 문서 템플릿 타입 (types.ts에서 import) ===== import type { InspectionTemplateData } from './types'; diff --git a/src/components/production/WorkerScreen/index.tsx b/src/components/production/WorkerScreen/index.tsx index 976888a2..98b544fa 100644 --- a/src/components/production/WorkerScreen/index.tsx +++ b/src/components/production/WorkerScreen/index.tsx @@ -16,6 +16,13 @@ import { useState, useMemo, useCallback, useEffect } from 'react'; import { useMenuStore } 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'; @@ -33,7 +40,7 @@ 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 } from './actions'; +import { getMyWorkOrders, completeWorkOrder, saveItemInspection, getWorkOrderInspectionData, saveInspectionDocument, getInspectionTemplate, toggleStepProgress, deleteMaterialInput, updateMaterialInput } from './actions'; import type { InspectionTemplateData } from './types'; import { getProcessList } from '@/components/process-management/actions'; import type { InspectionSetting, Process } from '@/types/process'; @@ -358,6 +365,8 @@ export default function WorkerScreen() { const [selectedOrder, setSelectedOrder] = useState(null); const [isCompletionDialogOpen, setIsCompletionDialogOpen] = useState(false); const [isMaterialModalOpen, setIsMaterialModalOpen] = useState(false); + const [selectedWorkOrderItemId, setSelectedWorkOrderItemId] = useState(); + const [selectedWorkOrderItemName, setSelectedWorkOrderItemName] = useState(); const [isWorkLogModalOpen, setIsWorkLogModalOpen] = useState(false); const [isInspectionModalOpen, setIsInspectionModalOpen] = useState(false); const [isIssueReportModalOpen, setIsIssueReportModalOpen] = useState(false); @@ -389,6 +398,10 @@ export default function WorkerScreen() { new Map() ); + // 자재 수정 Dialog 상태 + const [editMaterialTarget, setEditMaterialTarget] = useState<{ itemId: string; material: MaterialListItem } | null>(null); + const [editMaterialQty, setEditMaterialQty] = useState(''); + // 완료 토스트 상태 const [toastInfo, setToastInfo] = useState(null); @@ -499,6 +512,7 @@ export default function WorkerScreen() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedSidebarOrderId]); + // ===== 탭별 필터링된 작업 ===== const filteredWorkOrders = useMemo(() => { const selectedProcess = processListCache.find((p) => p.id === activeTab); @@ -528,6 +542,12 @@ export default function WorkerScreen() { if (isLoading) return; const allOrders: SidebarOrder[] = [...apiSidebarOrders, ...MOCK_SIDEBAR_ORDERS[activeProcessTabKey]]; + + // 현재 선택이 유효하면 자동 전환하지 않음 (데이터 새로고침 시 선택 유지) + if (selectedSidebarOrderId && allOrders.some((o) => o.id === selectedSidebarOrderId)) { + return; + } + // 우선순위 순서: urgent → priority → normal for (const group of PRIORITY_GROUPS) { const first = allOrders.find((o) => o.priority === group.key); @@ -543,7 +563,7 @@ export default function WorkerScreen() { return; } } - }, [isLoading, apiSidebarOrders, activeProcessTabKey]); + }, [isLoading, apiSidebarOrders, activeProcessTabKey, selectedSidebarOrderId]); // ===== 통계 계산 (탭별) ===== const stats: WorkerStats = useMemo(() => { @@ -566,11 +586,23 @@ export default function WorkerScreen() { const workItems: WorkItemData[] = useMemo(() => { const selectedOrder = filteredWorkOrders.find((wo) => wo.id === selectedSidebarOrderId); const stepsKey = (activeProcessTabKey === 'bending' && bendingSubMode === 'wip') ? 'bending_wip' : activeProcessTabKey; - const stepsTemplate = PROCESS_STEPS[stepsKey]; + const hardcodedSteps = PROCESS_STEPS[stepsKey]; - // PROCESS_STEPS 폴백 step에 processListCache 설정 매칭하는 헬퍼 + // 공정관리 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 enrichStep = (st: { name: string; isMaterialInput: boolean; isInspection?: boolean }, stepId: string, stepKey: string) => { - // 단계명으로 processListCache의 단계 설정 매칭 const matched = activeProcessSteps.find((ps) => ps.stepName === st.name); return { id: stepId, @@ -604,6 +636,32 @@ export default function WorkerScreen() { const firstItem = group.items[0]; 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; + } + } + const workItem: WorkItemData = { id: `${selectedOrder.id}-node-${nodeKey}`, apiItemId: firstItem?.id as number | undefined, @@ -618,7 +676,7 @@ export default function WorkerScreen() { quantity: group.totalQuantity, processType: activeProcessTabKey, steps, - materialInputs: [], + materialInputs: materialInputsList, }; // 공정별 추가 정보 추출 @@ -661,6 +719,10 @@ export default function WorkerScreen() { 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, @@ -674,7 +736,7 @@ export default function WorkerScreen() { quantity: selectedOrder.quantity || 0, processType: activeProcessTabKey, steps, - materialInputs: [], + materialInputs: fallbackMaterialsList, }); } @@ -699,7 +761,7 @@ export default function WorkerScreen() { })); return [...apiItems, ...mockItems]; - }, [filteredWorkOrders, selectedSidebarOrderId, activeProcessTabKey, stepCompletionMap, bendingSubMode, slatSubMode, activeProcessSteps]); + }, [filteredWorkOrders, selectedSidebarOrderId, activeProcessTabKey, stepCompletionMap, bendingSubMode, slatSubMode, activeProcessSteps, inputMaterialsMap]); // ===== 수주 정보 (사이드바 선택 항목 기반) ===== const orderInfo = useMemo(() => { @@ -798,10 +860,14 @@ export default function WorkerScreen() { const handleStepClick = useCallback( (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 설정 + setSelectedWorkOrderItemId(workItem?.apiItemId); + setSelectedWorkOrderItemName(workItem ? `${workItem.itemName} (${workItem.code})` : undefined); setIsMaterialModalOpen(true); } else { // 목업 아이템인 경우 합성 WorkOrder 생성 @@ -847,37 +913,117 @@ export default function WorkerScreen() { [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 {} + } else { + toast.error(result.error || '수정에 실패했습니다.'); + } + }, [editMaterialTarget, editMaterialQty, workItems]); + // 자재 삭제 핸들러 const handleDeleteMaterial = useCallback( - (itemId: string, materialId: string) => { - // 추후 구현 + 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 {} + } else { + toast.error(result.error || '삭제에 실패했습니다.'); + } }, - [] + [workItems] ); // 자재 저장 핸들러 - const handleSaveMaterials = useCallback((orderId: string, materials: MaterialInput[]) => { + 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(orderId, materials); + next.set(mapKey, materials); return next; }); - // 자재투입 step 완료로 마킹 - const stepKey = `${orderId}-자재투입`; - setStepCompletionMap((prev) => ({ - ...prev, - [stepKey]: true, - })); - }, []); + // 자재투입 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(() => { @@ -1381,8 +1527,16 @@ export default function WorkerScreen() { { + setIsMaterialModalOpen(open); + if (!open) { + setSelectedWorkOrderItemId(undefined); + setSelectedWorkOrderItemName(undefined); + } + }} order={selectedOrder} + workOrderItemId={selectedWorkOrderItemId} + workOrderItemName={selectedWorkOrderItemName} isCompletionFlow={isCompletionFlow} onComplete={handleWorkCompletion} onSaveMaterials={handleSaveMaterials} @@ -1425,6 +1579,32 @@ export default function WorkerScreen() { onConfirm={handleCompletionResultConfirm} /> + {/* 자재 투입 수량 수정 Dialog */} + { if (!open) setEditMaterialTarget(null); }}> + + + 투입 수량 수정 + +
+

{editMaterialTarget?.material.itemName}

+ setEditMaterialQty(e.target.value)} + placeholder="수량 입력" + min={1} + autoFocus + onKeyDown={(e) => { if (e.key === 'Enter') handleEditMaterialConfirm(); }} + /> +
+ + + + +
+
+