From a8591c438ee1cd1a43d8d42f8fec135e56830d8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Sat, 7 Feb 2026 03:27:23 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B2=AC=EC=A0=81=ED=99=95=EC=A0=95=20?= =?UTF-8?q?=EB=B0=B8=EB=A6=AC=EB=8D=B0=EC=9D=B4=EC=85=98,=20=EC=88=98?= =?UTF-8?q?=EC=A3=BC=EB=93=B1=EB=A1=9D=20=EA=B0=9C=EC=86=8C=EA=B7=B8?= =?UTF-8?q?=EB=A3=B9,=20=EC=9E=91=EC=97=85=EC=A7=80=EC=8B=9C=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 견적확정 시 업체명/현장명/담당자/연락처 프론트 밸리데이션 추가 - 견적확정 후 수주등록 버튼 동적 전환 - 수주등록 품목 개소별(floor+code) 그룹핑 수정 - 작업지시 상세 quantity 문자열→숫자 변환 (formatQuantity) - 작업지시 탭 카운트 초기 로딩 시 전체 표시 (by_process 활용) - 작업지시 상세 개소별/품목별 합산 테이블 추가 - 작업자 화면 API 연동 및 목업 데이터 분리 - 입고관리 완료건 수정, 재고현황 개선 --- .../ImportInspectionInputModal.tsx | 931 ++++++++++---- .../ReceivingManagement/ReceivingDetail.tsx | 230 ++-- .../ReceivingManagement/ReceivingList.tsx | 65 +- .../material/ReceivingManagement/actions.ts | 1104 ++++++++++++++++- .../material/ReceivingManagement/types.ts | 33 +- .../material/StockStatus/StockStatusList.tsx | 33 +- .../material/StockStatus/actions.ts | 46 +- .../material/StockStatus/mockData.ts | 20 - src/components/material/StockStatus/types.ts | 1 - src/components/orders/OrderRegistration.tsx | 119 +- .../production/ProductionDashboard/actions.ts | 4 +- .../production/ProductionDashboard/types.ts | 21 + .../production/WorkOrders/WorkOrderDetail.tsx | 143 ++- .../production/WorkOrders/WorkOrderList.tsx | 107 +- .../production/WorkOrders/actions.ts | 3 +- src/components/production/WorkOrders/types.ts | 38 +- .../WorkerScreen/MaterialInputModal.tsx | 4 +- .../production/WorkerScreen/WorkItemCard.tsx | 2 +- .../WorkerScreen/WorkOrderListPanel.tsx | 131 ++ .../production/WorkerScreen/actions.ts | 309 ++++- .../production/WorkerScreen/index.tsx | 354 ++++-- .../production/WorkerScreen/mockData.ts | 194 +++ src/components/quotes/ItemSearchModal.tsx | 11 +- src/components/quotes/QuoteFooterBar.tsx | 6 +- src/components/quotes/QuoteRegistration.tsx | 3 +- src/components/quotes/QuoteRegistrationV2.tsx | 18 +- src/components/quotes/actions.ts | 4 +- src/components/ui/file-list.tsx | 2 +- src/hooks/useItemList.ts | 2 +- 29 files changed, 3238 insertions(+), 700 deletions(-) create mode 100644 src/components/production/WorkerScreen/WorkOrderListPanel.tsx create mode 100644 src/components/production/WorkerScreen/mockData.ts diff --git a/src/components/material/ReceivingManagement/ImportInspectionInputModal.tsx b/src/components/material/ReceivingManagement/ImportInspectionInputModal.tsx index 79eb2ef8..f3420a6a 100644 --- a/src/components/material/ReceivingManagement/ImportInspectionInputModal.tsx +++ b/src/components/material/ReceivingManagement/ImportInspectionInputModal.tsx @@ -1,13 +1,18 @@ 'use client'; /** - * 수입검사 입력 모달 + * 수입검사 입력 모달 (패드/모바일용) * - * 작업자 화면 중간검사 모달 양식 참고 - * 기획서: 스크린샷 2026-02-05 오후 9.58.16 + * API 기반 동적 폼: + * - getInspectionTemplate()로 검사항목 로드 + * - 단일 측정 (measurementCount <= 1): 탭 없이 항상 표시 + * - 다중 측정 (measurementCount > 1): N1~Nn 탭으로 전환 + * - 사진 다중 첨부 + * - 자동 판정 (tolerance / standard_criteria 기반) + * - saveInspectionData()로 저장 */ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo, useCallback } from 'react'; import { Dialog, DialogContent, @@ -18,41 +23,110 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; +import { FileDropzone } from '@/components/ui/file-dropzone'; +import { FileList, type ExistingFile } from '@/components/ui/file-list'; +import { Loader2 } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { toast } from 'sonner'; +import { + getInspectionTemplate, + uploadInspectionFiles, + saveInspectionData, + type InspectionTemplateResponse, + type DocumentResolveResponse, +} from './actions'; -// 시료 탭 타입 -type SampleTab = 'N1' | 'N2' | 'N3'; - -// 검사 결과 데이터 타입 -export interface ImportInspectionData { - sampleTab: SampleTab; - productName: string; - specification: string; - // 겉모양 - appearanceStatus: 'ok' | 'ng' | null; - // 치수 - thickness: number | null; - width: number | null; - length: number | null; - // 판정 - judgment: 'pass' | 'fail' | null; - // 물성치 - tensileStrength: number | null; // 인장강도 (270 이상) - elongation: number | null; // 연신율 (36 이상) - zincCoating: number | null; // 아연의 최소 부착량 (17 이상) - // 내용 - content: string; -} - +// ===== Props ===== interface ImportInspectionInputModalProps { open: boolean; onOpenChange: (open: boolean) => void; - productName?: string; + itemId?: number; + itemName?: string; specification?: string; - onComplete: (data: ImportInspectionData) => void; + supplier?: string; + inspector?: string; + lotSize?: number; + materialNo?: string; + receivingId: string; + onSave?: () => void; } -// OK/NG 버튼 컴포넌트 +// ===== Raw data types for auto-judgment ===== +interface RawToleranceData { + type?: string; + value?: string | number; + plus?: string | number; + minus?: string | number; + min?: string | number; + max?: string | number; + op?: string; +} + +interface RawCriteriaData { + min?: number | null; + min_op?: 'gt' | 'gte' | null; + max?: number | null; + max_op?: 'lt' | 'lte' | null; +} + +type InspectionItem = InspectionTemplateResponse['inspectionItems'][number]; + +// ===== Auto-judgment logic ===== +function calculateAutoResult( + value: string, + rawTolerance: RawToleranceData | null | undefined, + rawCriteria: RawCriteriaData | null | undefined, + baseValue: number | null | undefined, +): 'ok' | 'ng' | null { + const num = parseFloat(value); + if (isNaN(num)) return null; + + // 1. Tolerance-based (needs base value) + if (rawTolerance && baseValue != null) { + switch (rawTolerance.type) { + case 'symmetric': { + const tol = parseFloat(String(rawTolerance.value || 0)); + return Math.abs(num - baseValue) <= tol ? 'ok' : 'ng'; + } + case 'asymmetric': { + const plus = parseFloat(String(rawTolerance.plus || 0)); + const minus = parseFloat(String(rawTolerance.minus || 0)); + return (num >= baseValue - minus && num <= baseValue + plus) ? 'ok' : 'ng'; + } + case 'range': { + const min = rawTolerance.min != null ? parseFloat(String(rawTolerance.min)) : -Infinity; + const max = rawTolerance.max != null ? parseFloat(String(rawTolerance.max)) : Infinity; + return (num >= min && num <= max) ? 'ok' : 'ng'; + } + case 'limit': { + const lv = parseFloat(String(rawTolerance.value || 0)); + switch (rawTolerance.op) { + case 'gte': return num >= lv ? 'ok' : 'ng'; + case 'gt': return num > lv ? 'ok' : 'ng'; + case 'lte': return num <= lv ? 'ok' : 'ng'; + case 'lt': return num < lv ? 'ok' : 'ng'; + default: return null; + } + } + } + } + + // 2. Criteria-based (min/max range) + if (rawCriteria && (rawCriteria.min != null || rawCriteria.max != null)) { + let pass = true; + if (rawCriteria.min != null) { + pass = pass && (rawCriteria.min_op === 'gt' ? num > rawCriteria.min : num >= rawCriteria.min); + } + if (rawCriteria.max != null) { + pass = pass && (rawCriteria.max_op === 'lt' ? num < rawCriteria.max : num <= rawCriteria.max); + } + return pass ? 'ok' : 'ng'; + } + + return null; +} + +// ===== OK/NG 토글 ===== function OkNgToggle({ value, onChange, @@ -61,15 +135,15 @@ function OkNgToggle({ onChange: (v: 'ok' | 'ng') => void; }) { return ( -
+
- ))} -
- - {/* 겉모양: OK/NG */} -
- - setFormData((prev) => ({ ...prev, appearanceStatus: v }))} - /> -
- - {/* 두께 / 너비 */} -
-
- - handleNumberChange('thickness', e.target.value)} - className="" - /> + ) : ( +
+ {/* 기본 정보 (읽기전용 인풋 스타일) */} +
+
+ 제품명 + +
+
+ 규격 + +
+ + {/* N탭 측정 항목 (measurementCount > 1) — 그룹 카드 */} + {multiItems.length > 0 && maxN > 0 && ( +
+ {/* N 탭 (밑줄 스타일) */} +
+ {Array.from({ length: maxN }, (_, i) => ( + + ))} +
+ + {/* 현재 탭 항목 (2단 그리드) */} +
+ {multiItems.map((item) => { + if (currentTab >= item.measurementCount) return null; + return renderItemInput(item, currentTab); + })} +
+
+ )} + + {/* 단일 측정 항목 (measurementCount <= 1, 2단 그리드) */} + {singleItems.length > 0 && ( +
+ {singleItems.map((item) => renderItemInput(item, 0))} +
+ )} + + {/* 사진 첨부 */}
- - handleNumberChange('width', e.target.value)} - className="" + setNewPhotos((prev) => [...prev, ...files])} + title="클릭하거나 사진을 드래그하세요" + description="이미지 파일 (최대 10MB)" + /> + {(newPhotos.length > 0 || existingPhotos.length > 0) && ( + ({ file: f }))} + existingFiles={existingPhotos} + onRemove={(index) => + setNewPhotos((prev) => prev.filter((_, i) => i !== index)) + } + onRemoveExisting={(id) => + setExistingPhotos((prev) => prev.filter((p) => p.id !== id)) + } + compact + /> + )} +
+ + {/* 내용 (비고) */} +
+ 내용 +