diff --git a/src/app/[locale]/(protected)/material/receiving-management/[id]/page.tsx b/src/app/[locale]/(protected)/material/receiving-management/[id]/page.tsx index 0c65a80b..a7ba8a53 100644 --- a/src/app/[locale]/(protected)/material/receiving-management/[id]/page.tsx +++ b/src/app/[locale]/(protected)/material/receiving-management/[id]/page.tsx @@ -1,6 +1,7 @@ 'use client'; import { use } from 'react'; +import { useSearchParams } from 'next/navigation'; import { ReceivingDetail } from '@/components/material/ReceivingManagement'; interface Props { @@ -9,5 +10,8 @@ interface Props { export default function ReceivingDetailPage({ params }: Props) { const { id } = use(params); - return ; + const searchParams = useSearchParams(); + const mode = (searchParams.get('mode') as 'view' | 'edit' | 'new') || 'view'; + + return ; } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/quality/qms/components/InspectionModalV2.tsx b/src/app/[locale]/(protected)/quality/qms/components/InspectionModalV2.tsx index b4f852bc..b9fcf5f4 100644 --- a/src/app/[locale]/(protected)/quality/qms/components/InspectionModalV2.tsx +++ b/src/app/[locale]/(protected)/quality/qms/components/InspectionModalV2.tsx @@ -1,7 +1,7 @@ "use client"; -import React from 'react'; -import { AlertCircle } from 'lucide-react'; +import React, { useState, useEffect } from 'react'; +import { AlertCircle, Loader2 } from 'lucide-react'; import { DocumentViewer } from '@/components/document-system'; import { Document, DocumentItem } from '../types'; import { MOCK_ORDER_DATA, MOCK_WORK_ORDER, MOCK_SHIPMENT_DETAIL } from '../mockData'; @@ -20,12 +20,20 @@ import { JointbarInspectionDocument, QualityDocumentUploader, } from './documents'; +import type { ImportInspectionTemplate } from './documents/ImportInspectionDocument'; + +// 검사 템플릿 API +import { getInspectionTemplate } from '@/components/material/ReceivingManagement/actions'; interface InspectionModalV2Props { isOpen: boolean; onClose: () => void; document: Document | null; documentItem: DocumentItem | null; + // 수입검사 템플릿 로드용 추가 props + itemName?: string; + specification?: string; + supplier?: string; } // 문서 타입별 정보 @@ -318,17 +326,90 @@ const WorkLogDocument = () => { ); }; +// 로딩 컴포넌트 +const LoadingDocument = () => ( +
+ +

검사 템플릿을 불러오는 중...

+
+); + +// 에러 컴포넌트 +const ErrorDocument = ({ message, onRetry }: { message: string; onRetry?: () => void }) => ( +
+ +

템플릿 로드 실패

+

{message}

+ {onRetry && ( + + )} +
+); + /** * InspectionModal V2 * - DocumentViewer 시스템 사용 - * - 기존 문서 렌더링 로직 유지 + * - 수입검사: 모달 열릴 때 API로 템플릿 로드 (Lazy Loading) */ export const InspectionModalV2 = ({ isOpen, onClose, document: doc, documentItem, + itemName, + specification, + supplier, }: InspectionModalV2Props) => { + // 수입검사 템플릿 상태 + const [importTemplate, setImportTemplate] = useState(null); + const [isLoadingTemplate, setIsLoadingTemplate] = useState(false); + const [templateError, setTemplateError] = useState(null); + + // 수입검사 템플릿 로드 (모달 열릴 때) + useEffect(() => { + if (isOpen && doc?.type === 'import' && itemName && specification) { + loadInspectionTemplate(); + } + // 모달 닫힐 때 상태 초기화 + if (!isOpen) { + setImportTemplate(null); + setTemplateError(null); + } + }, [isOpen, doc?.type, itemName, specification]); + + const loadInspectionTemplate = async () => { + if (!itemName || !specification) return; + + setIsLoadingTemplate(true); + setTemplateError(null); + + try { + const result = await getInspectionTemplate({ + itemName, + specification, + lotNo: documentItem?.code, + supplier, + }); + + if (result.success && result.data) { + // API 응답을 ImportInspectionTemplate 형식으로 변환 + setImportTemplate(result.data as ImportInspectionTemplate); + } else { + setTemplateError(result.error || '템플릿을 불러올 수 없습니다.'); + } + } catch (error) { + console.error('[InspectionModalV2] loadInspectionTemplate error:', error); + setTemplateError('템플릿 로드 중 오류가 발생했습니다.'); + } finally { + setIsLoadingTemplate(false); + } + }; + if (!doc) return null; const docInfo = DOCUMENT_INFO[doc.type] || { label: doc.title, hasTemplate: false, color: 'text-gray-600' }; @@ -362,6 +443,20 @@ export const InspectionModalV2 = ({ } }; + // 수입검사 문서 렌더링 (Lazy Loading) + const renderImportInspectionDocument = () => { + if (isLoadingTemplate) { + return ; + } + + if (templateError) { + return ; + } + + // 템플릿이 로드되면 전달, 아니면 기본 템플릿 사용 + return ; + }; + // 문서 타입에 따른 컨텐츠 렌더링 const renderDocumentContent = () => { switch (doc.type) { @@ -374,7 +469,7 @@ export const InspectionModalV2 = ({ case 'shipping': return ; case 'import': - return ; + return renderImportInspectionDocument(); case 'product': return ; case 'report': diff --git a/src/app/[locale]/(protected)/quality/qms/components/documents/ImportInspectionDocument.tsx b/src/app/[locale]/(protected)/quality/qms/components/documents/ImportInspectionDocument.tsx index 5cca39d2..424f865e 100644 --- a/src/app/[locale]/(protected)/quality/qms/components/documents/ImportInspectionDocument.tsx +++ b/src/app/[locale]/(protected)/quality/qms/components/documents/ImportInspectionDocument.tsx @@ -1,181 +1,647 @@ "use client"; /** - * 수입검사 성적서 문서 컴포넌트 + * 수입검사 성적서 문서 컴포넌트 (동적 버전) * - * 공통 컴포넌트 사용: - * - DocumentHeader: default 레이아웃 + customApproval (QualityApprovalTable) + * - 24종 품목별 검사 템플릿 지원 + * - OK/NG 라디오 버튼 클릭 가능 + * - 종합판정 자동 계산 + * - API 데이터 기반 동적 렌더링 */ -import React from 'react'; +import React, { useState, useCallback, useMemo } from 'react'; import { DocumentHeader, QualityApprovalTable } from '@/components/document-system'; -// 수입검사 성적서 데이터 타입 -export interface ImportInspectionData { - // 헤더 정보 - documentNo: string; - reportDate: string; - productName: string; - specification: string; - materialNo: string; - lotSize: number; - supplier: string; - lotNo: string; - inspectionDate: string; - inspector: string; - approvers: { - writer?: string; - reviewer?: string; - approver?: string; - }; - // 검사 항목 - inspectionItems: { - appearance: { - result: 'OK' | 'NG'; - measurements: ('OK' | 'NG')[]; - }; - dimensions: { - thickness: { - standard: number; - tolerance: string; - measurements: number[]; - }; - width: { - standard: number; - tolerance: string; - measurements: number[]; - }; - length: { - standard: number; - tolerance: string; - measurements: number[]; - }; - }; - tensileStrength: { - standard: string; - measurements: number[]; - }; - elongation: { - thicknessRange: string; - standard: string; - supplier: string; - measurements: number[]; - }; - zincCoating: { - standard: string; - measurements: number[]; - }; - }; - overallResult: '합격' | '불합격'; +// ============================================ +// 타입 정의 +// ============================================ + +/** 검사 항목 타입 */ +export type MeasurementType = 'okng' | 'numeric' | 'both'; +export type JudgmentResult = 'OK' | 'NG' | null; + +/** 검사기준 옵션 (선택형 기준용) */ +export interface StandardOption { + id: string; + label: string; // "0.8 이상 ~ 1.0 미만" + tolerance: string; // "± 0.07" + isSelected: boolean; // 현재 선택된 옵션인지 } -// Mock 데이터 -export const MOCK_IMPORT_INSPECTION: ImportInspectionData = { - documentNo: 'KDQP-01-001', - reportDate: '2025-07-15', - productName: '전기 아연도금 강판 (KS D 3528, SECC) "EGI 평국판"', - specification: '1.55 * 1218 × 480', - materialNo: 'PE02RB', - lotSize: 200, - supplier: '지오TNS (KG스틸)', - lotNo: '250715-02', - inspectionDate: '07/15', - inspector: '노원호', - approvers: { - writer: '노원호', - reviewer: '', - approver: '', - }, - inspectionItems: { - appearance: { - result: 'OK', - measurements: ['OK', 'OK', 'OK'], - }, - dimensions: { - thickness: { - standard: 1.55, - tolerance: '±0.10', - measurements: [1.528, 1.533, 1.521], - }, - width: { - standard: 1219, - tolerance: '±7', - measurements: [1222, 1222, 1222], - }, - length: { - standard: 480, - tolerance: '±15', - measurements: [480, 480, 480], - }, - }, - tensileStrength: { - standard: '270 이상', - measurements: [313.8], - }, - elongation: { - thicknessRange: '두께 1.0 이상 ~ 1.6 미만', - standard: '37 이상', - supplier: '공급업체 밀시트', - measurements: [46.5], - }, - zincCoating: { - standard: '편면 17 이상', - measurements: [17.21, 17.17], +/** 검사 항목 템플릿 */ +export interface InspectionItemTemplate { + id: string; // "appearance", "thickness" 등 + no: number; // 1, 2, 3... + name: string; // "겉모양", "치수" 등 + subName?: string; // "두께", "너비", "길이" (하위 항목) + parentId?: string; // 상위 항목 ID (그룹핑용) + + // 검사 기준 + standard: { + description?: string; // "사용상 해로운 결함이 없을 것" + value?: string | number; // 기준값 + options?: StandardOption[]; // 선택형 기준 (두께 범위 등) + }; + + inspectionMethod: string; // "육안검사", "계측", "n=3 c=0" 등 + inspectionCycle: string; // "체크검사", "입고시" 등 + + // 측정 설정 + measurementType: MeasurementType; + measurementCount: number; // 측정 횟수 (1, 3 등) + + // rowSpan 설정 (복잡한 레이아웃용) + rowSpan?: number; + isSubRow?: boolean; // 하위 행인지 +} + +/** 검사 항목 입력값 */ +export interface InspectionItemValue { + itemId: string; + measurements: (number | 'OK' | 'NG' | null)[]; + result: JudgmentResult; +} + +/** 수입검사 템플릿 데이터 (API 응답) */ +export interface ImportInspectionTemplate { + templateId: string; + templateName: string; + + // 기본 정보 + headerInfo: { + productName: string; + specification: string; + materialNo: string; + lotSize: number; + supplier: string; + lotNo: string; + inspectionDate: string; + inspector: string; + reportDate: string; + approvers: { + writer?: string; + reviewer?: string; + approver?: string; + }; + }; + + // 검사 항목 배열 + inspectionItems: InspectionItemTemplate[]; + + // 주석 + notes?: string[]; +} + +/** 컴포넌트 Props */ +export interface ImportInspectionDocumentProps { + template?: ImportInspectionTemplate; + initialValues?: InspectionItemValue[]; + onValuesChange?: (values: InspectionItemValue[], overallResult: '합격' | '불합격') => void; + readOnly?: boolean; +} + +// ============================================ +// Mock 템플릿 데이터 (EGI 강판) +// ============================================ + +export const MOCK_EGI_TEMPLATE: ImportInspectionTemplate = { + templateId: 'EGI-001', + templateName: '전기 아연도금 강판', + + headerInfo: { + productName: '전기 아연도금 강판 (KS D 3528, SECC) "EGI 평국판"', + specification: '1.55 * 1218 × 480', + materialNo: 'PE02RB', + lotSize: 200, + supplier: '지오TNS (KG스틸)', + lotNo: '250715-02', + inspectionDate: '07/15', + inspector: '노원호', + reportDate: '2025-07-15', + approvers: { + writer: '노원호', + reviewer: '', + approver: '', }, }, - overallResult: '합격', + + inspectionItems: [ + // 1. 겉모양 + { + id: 'appearance', + no: 1, + name: '겉모양', + standard: { + description: '사용상 해로운 결함이 없을 것', + }, + inspectionMethod: '육안검사', + inspectionCycle: '', + measurementType: 'okng', + measurementCount: 3, + }, + // 2. 치수 - 두께 (두께+너비+길이 = 3행) + { + id: 'thickness', + no: 2, + name: '치수', + subName: '두께', + standard: { + value: 1.55, + options: [ + { id: 't1', label: '0.8 이상 ~ 1.0 미만', tolerance: '± 0.07', isSelected: false }, + { id: 't2', label: '1.0 이상 ~ 1.25 미만', tolerance: '± 0.08', isSelected: false }, + { id: 't3', label: '1.25 이상 ~ 1.6 미만', tolerance: '± 0.10', isSelected: true }, + { id: 't4', label: '1.6 이상 ~ 2.0 미만', tolerance: '± 0.12', isSelected: false }, + ], + }, + inspectionMethod: 'n = 3\nc = 0', + inspectionCycle: '체크검사', + measurementType: 'numeric', + measurementCount: 3, + rowSpan: 3, + }, + // 2. 치수 - 너비 + { + id: 'width', + no: 2, + name: '치수', + subName: '너비', + parentId: 'thickness', + standard: { + value: 1219, + options: [ + { id: 'w1', label: '1250 미만', tolerance: '+ 7\n- 0', isSelected: true }, + ], + }, + inspectionMethod: '', + inspectionCycle: '', + measurementType: 'numeric', + measurementCount: 3, + isSubRow: true, + }, + // 2. 치수 - 길이 + { + id: 'length', + no: 2, + name: '치수', + subName: '길이', + parentId: 'thickness', + standard: { + value: 480, + options: [ + { id: 'l1', label: '2000 이상 ~ 4000 미만', tolerance: '+ 15\n- 0', isSelected: true }, + ], + }, + inspectionMethod: '', + inspectionCycle: '', + measurementType: 'numeric', + measurementCount: 3, + isSubRow: true, + }, + // 3. 인장강도 + { + id: 'tensileStrength', + no: 3, + name: '인장강도 (N/㎟)', + standard: { + description: '270 이상', + }, + inspectionMethod: '', + inspectionCycle: '', + measurementType: 'numeric', + measurementCount: 1, + }, + // 4. 연신율 (옵션은 한 셀 안에서 세로로 표시) + { + id: 'elongation', + no: 4, + name: '연신율 %', + standard: { + options: [ + { id: 'e1', label: '두께 0.6 이상 ~ 1.0 미만', tolerance: '36 이상', isSelected: false }, + { id: 'e2', label: '두께 1.0 이상 ~ 1.6 미만', tolerance: '37 이상', isSelected: true }, + { id: 'e3', label: '두께 1.6 이상 ~ 2.3 미만', tolerance: '38 이상', isSelected: false }, + ], + }, + inspectionMethod: '공급업체\n밀시트', + inspectionCycle: '입고시', + measurementType: 'numeric', + measurementCount: 1, + }, + // 5. 아연 부착량 + { + id: 'zincCoating', + no: 5, + name: '아연의 최소 부착량 (g/㎡)', + standard: { + description: '편면 17 이상', + }, + inspectionMethod: '', + inspectionCycle: '', + measurementType: 'numeric', + measurementCount: 2, + }, + ], + + notes: [ + '※ 1.55mm의 경우 KS F 4510에 따른 MIN 1.5의 기준에 따름', + '※ 두께의 경우 너비 1000 이상 ~ 1250 미만 기준에 따름', + ], }; -interface ImportInspectionDocumentProps { - data?: ImportInspectionData; -} +// ============================================ +// 유틸리티 함수 +// ============================================ + +/** + * 허용오차 문자열을 파싱하여 min/max 범위 반환 + * 예: "± 0.07" → { plusTolerance: 0.07, minusTolerance: 0.07 } + * 예: "+ 7\n- 0" → { plusTolerance: 7, minusTolerance: 0 } + * 예: "36 이상" → { minValue: 36 } + * 예: "편면 17 이상" → { minValue: 17 } + */ +const parseTolerance = (tolerance: string): { plusTolerance?: number; minusTolerance?: number; minValue?: number; maxValue?: number } => { + // "± X" 형식 + const plusMinusMatch = tolerance.match(/±\s*([\d.]+)/); + if (plusMinusMatch) { + const val = parseFloat(plusMinusMatch[1]); + return { plusTolerance: val, minusTolerance: val }; + } + + // "+ X\n- Y" 또는 "+X -Y" 형식 + const separateMatch = tolerance.match(/\+\s*([\d.]+)[\s\n]*-\s*([\d.]+)/); + if (separateMatch) { + return { + plusTolerance: parseFloat(separateMatch[1]), + minusTolerance: parseFloat(separateMatch[2]), + }; + } + + // "X 이상" 형식 + const minMatch = tolerance.match(/([\d.]+)\s*이상/); + if (minMatch) { + return { minValue: parseFloat(minMatch[1]) }; + } + + // "X 이하" 형식 + const maxMatch = tolerance.match(/([\d.]+)\s*이하/); + if (maxMatch) { + return { maxValue: parseFloat(maxMatch[1]) }; + } + + return {}; +}; + +/** + * 측정값이 검사기준 범위 내인지 확인 + */ +const isValueInRange = ( + measuredValue: number, + baseValue: number | undefined, + tolerance: { plusTolerance?: number; minusTolerance?: number; minValue?: number; maxValue?: number } +): boolean => { + // "X 이상" 기준 + if (tolerance.minValue !== undefined) { + return measuredValue >= tolerance.minValue; + } + + // "X 이하" 기준 + if (tolerance.maxValue !== undefined) { + return measuredValue <= tolerance.maxValue; + } + + // 기준값 ± 허용오차 기준 + if (baseValue !== undefined && tolerance.plusTolerance !== undefined && tolerance.minusTolerance !== undefined) { + const min = baseValue - tolerance.minusTolerance; + const max = baseValue + tolerance.plusTolerance; + return measuredValue >= min && measuredValue <= max; + } + + // 기준을 파싱할 수 없으면 통과로 처리 + return true; +}; + +// ============================================ +// 컴포넌트 +// ============================================ + +export const ImportInspectionDocument = ({ + template = MOCK_EGI_TEMPLATE, + initialValues, + onValuesChange, + readOnly = false, +}: ImportInspectionDocumentProps) => { + // 검사 항목별 입력값 상태 + const [values, setValues] = useState>(() => { + const initial: Record = {}; + + template.inspectionItems.forEach((item) => { + const existingValue = initialValues?.find((v) => v.itemId === item.id); + initial[item.id] = existingValue || { + itemId: item.id, + measurements: Array(item.measurementCount).fill(null), + result: null, + }; + }); + + return initial; + }); + + // 검사 항목의 기준값과 허용오차를 기반으로 자동 판정 계산 + const calculateAutoResult = useCallback(( + item: InspectionItemTemplate, + measurements: (number | 'OK' | 'NG' | null)[] + ): JudgmentResult => { + // OK/NG 타입은 수동 판정 (자동 계산 안함) + if (item.measurementType === 'okng') { + // 모든 측정값이 OK면 OK, 하나라도 NG면 NG + const validMeasurements = measurements.filter((m) => m === 'OK' || m === 'NG'); + if (validMeasurements.length === 0) return null; + if (validMeasurements.some((m) => m === 'NG')) return 'NG'; + if (validMeasurements.length === item.measurementCount && validMeasurements.every((m) => m === 'OK')) return 'OK'; + return null; + } + + // numeric 타입: 측정값이 기준 범위 내인지 확인 + const numericMeasurements = measurements.filter((m): m is number => typeof m === 'number'); + + // 아직 측정값이 하나도 없으면 null + if (numericMeasurements.length === 0) return null; + + // 기준값 + const baseValue = typeof item.standard.value === 'number' ? item.standard.value : undefined; + + // 선택된 옵션의 허용오차 가져오기 + const selectedOption = item.standard.options?.find((opt) => opt.isSelected); + + // description에서 기준 파싱 (예: "270 이상", "편면 17 이상") + let tolerance: ReturnType = {}; + + if (selectedOption?.tolerance) { + tolerance = parseTolerance(selectedOption.tolerance); + } else if (item.standard.description) { + tolerance = parseTolerance(item.standard.description); + } + + // 모든 측정값이 범위 내인지 확인 + const allInRange = numericMeasurements.every((val) => + isValueInRange(val, baseValue, tolerance) + ); + + // 모든 측정값이 입력되었고 모두 범위 내면 OK + if (numericMeasurements.length === item.measurementCount && allInRange) { + return 'OK'; + } + + // 범위 밖 값이 있으면 NG + if (!allInRange) { + return 'NG'; + } + + // 아직 모든 값이 입력되지 않음 + return null; + }, []); + + // 종합판정 계산 (하위 항목은 제외하고 계산) + const overallResult = useMemo(() => { + // 판정이 필요한 항목만 필터링 (isSubRow가 아니고 parentId가 없는 항목) + const mainItems = template.inspectionItems.filter( + (item) => !item.isSubRow && !item.parentId + ); + const mainResults = mainItems.map((item) => values[item.id]?.result); + + // 하나라도 NG면 불합격, 모두 OK면 합격, 미입력 있으면 null + if (mainResults.some((r) => r === 'NG')) return '불합격'; + if (mainResults.every((r) => r === 'OK')) return '합격'; + return null; + }, [values, template.inspectionItems]); + + // OK/NG 선택 핸들러 + const handleResultChange = useCallback((itemId: string, result: JudgmentResult) => { + if (readOnly) return; + + setValues((prev) => { + const newValues = { + ...prev, + [itemId]: { + ...prev[itemId], + result, + }, + }; + + // 콜백 호출 + if (onValuesChange) { + const valueArray = Object.values(newValues); + const allResults = valueArray.map((v) => v.result); + const calculatedResult = allResults.some((r) => r === 'NG') + ? '불합격' + : allResults.every((r) => r === 'OK') + ? '합격' + : '불합격'; + onValuesChange(valueArray, calculatedResult); + } + + return newValues; + }); + }, [readOnly, onValuesChange]); + + // 측정값 변경 핸들러 (자동 판정 포함) + const handleMeasurementChange = useCallback(( + itemId: string, + index: number, + value: number | 'OK' | 'NG' | null + ) => { + if (readOnly) return; + + // 해당 항목의 템플릿 정보 가져오기 + const item = template.inspectionItems.find((i) => i.id === itemId); + if (!item) return; + + setValues((prev) => { + const newMeasurements = [...prev[itemId].measurements]; + newMeasurements[index] = value; + + // 자동 판정 계산 + const autoResult = calculateAutoResult(item, newMeasurements); + + const newValues = { + ...prev, + [itemId]: { + ...prev[itemId], + measurements: newMeasurements, + result: autoResult, // 자동 판정 결과 적용 + }, + }; + + // 콜백 호출 + if (onValuesChange) { + const valueArray = Object.values(newValues); + const allResults = valueArray.map((v) => v.result); + const calculatedResult = allResults.some((r) => r === 'NG') + ? '불합격' + : allResults.every((r) => r === 'OK') + ? '합격' + : '불합격'; + onValuesChange(valueArray, calculatedResult); + } + + return newValues; + }); + }, [readOnly, template.inspectionItems, calculateAutoResult, onValuesChange]); + + // OK/NG 라디오 버튼 렌더링 (PDF 출력 호환) + const renderOkNgRadio = (itemId: string, measurementIndex?: number) => { + const itemValue = values[itemId]; + + // 측정값용 (n1, n2, n3) + if (measurementIndex !== undefined) { + const currentValue = itemValue.measurements[measurementIndex]; + return ( +
+ + +
+ ); + } + + // 판정용 - 자동 계산 결과 표시 (선택 버튼 없음) + const currentResult = itemValue.result; + return ( +
+ {currentResult === 'OK' && ( + + )} + {currentResult === 'NG' && ( + + )} + {currentResult === null && ( + - + )} +
+ ); + }; + + // 측정값 입력 필드 렌더링 + const renderMeasurementInput = (itemId: string, index: number) => { + const itemValue = values[itemId]; + const currentValue = itemValue.measurements[index]; + + return ( + { + const val = e.target.value ? parseFloat(e.target.value) : null; + handleMeasurementChange(itemId, index, val); + }} + disabled={readOnly} + className="w-full text-center border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-500 rounded text-xs" + placeholder="-" + /> + ); + }; + + // 검사기준 옵션 렌더링 (PDF 출력 호환 - 특수문자 사용 안함) + const renderStandardOptions = (options: StandardOption[]) => { + return options.map((opt) => ( +
+ {/* PDF 호환 체크박스: 테두리 박스 + V 텍스트 */} + + {opt.isSelected ? 'V' : ''} + + {opt.label} + {opt.tolerance} +
+ )); + }; + + const { headerInfo, inspectionItems, notes } = template; -export const ImportInspectionDocument = ({ data = MOCK_IMPORT_INSPECTION }: ImportInspectionDocumentProps) => { return (
- {/* 문서 헤더 (공통 컴포넌트) */} + {/* 문서 헤더 */} } /> - {/* 기본 정보 테이블 */} + {/* 기본 정보 테이블 - 6컬럼 구조 */} - + - + - - - - - + + + - + - + - - - - - + + + + +
품 명{data.productName}{headerInfo.productName} 납품업체
(제조업체)
{data.supplier}{headerInfo.supplier}
규 격
(두께*너비*길이)
{data.specification}로트번호{data.lotNo}{headerInfo.specification}로트번호{headerInfo.lotNo}
자재번호{data.materialNo}{headerInfo.materialNo} 검사일자{data.inspectionDate}{headerInfo.inspectionDate}
로트크기{data.lotSize}검사자 {data.inspector} {headerInfo.lotSize}검사자{headerInfo.inspector} + V +
@@ -195,222 +661,150 @@ export const ImportInspectionDocument = ({ data = MOCK_IMPORT_INSPECTION }: Impo - n1
양호/불량 - n2
양호/불량 - n3
양호/불량 + n1 + n2 + n3 - {/* 1. 겉모양 */} - - 1 - 겉모양 - - 사용상 해로운
결함이 없을 것 - - 육안검사 - - ☑OK - ☐NG - - - ☑OK - ☐NG - - - ☑OK - ☐NG - - - ☑OK - ☐NG - - 적 - + {inspectionItems.map((item, idx) => { + const isFirstInGroup = !item.isSubRow; + const itemValue = values[item.id]; - {/* 2. 치수 */} - - 2 - 치수 - - 두께
{data.inspectionItems.dimensions.thickness.standard} - - -
- - 0.8 이상
~ 1.0 미만
- ± 0.07 -
- - - n = 3
c = 0 - - 체크검사 - - {data.inspectionItems.dimensions.thickness.measurements[0]} - - - {data.inspectionItems.dimensions.thickness.measurements[1]} - - - {data.inspectionItems.dimensions.thickness.measurements[2]} - - 적 - - - -
- - 1.0 이상
~ 1.25 미만
- ± 0.08 -
- - - - -
- - 1.25 이상
~ 1.6 미만
- ± 0.10 -
- - - - -
- - 1.6 이상
~ 2.0 미만
- ± 0.12 -
- - - - - 너비
{data.inspectionItems.dimensions.width.standard} - - -
- - 1250 미만 - + 7
- 0
-
- - - {data.inspectionItems.dimensions.width.measurements[0]} - - - {data.inspectionItems.dimensions.width.measurements[1]} - - - {data.inspectionItems.dimensions.width.measurements[2]} - - - - - 길이
{data.inspectionItems.dimensions.length.standard} - - -
- - 2000 이상
~ 4000 미만
- + 15
- 0
-
- - - {data.inspectionItems.dimensions.length.measurements[0]} - - - {data.inspectionItems.dimensions.length.measurements[1]} - - - {data.inspectionItems.dimensions.length.measurements[2]} - - + return ( + + {/* NO */} + {isFirstInGroup && !item.parentId && ( + + {item.no} + + )} - {/* 3. 인장강도 */} - - 3 - 인장강도 (N/
㎟) - - {data.inspectionItems.tensileStrength.standard} - - - - {data.inspectionItems.tensileStrength.measurements[0]} - - 적 - + {/* 검사항목 */} + {isFirstInGroup && !item.parentId && ( + + {item.name} + + )} - {/* 4. 연신율 */} - - 4 - 연신율
% - -
- - 두께 0.6
이상
~ 1.0 미
- 36 이상 -
- - - 공급업체
밀시트 - - 입고시 - - {data.inspectionItems.elongation.measurements[0]} - - 적 - - - -
- - 두께 1.0
이상
~ 1.6 미
- 37 이상 -
- - - - -
- - 두께 1.6
이상
~ 2.3
미만
- 38 이상 -
- - + {/* 검사기준 - 첫번째 열 */} + + {item.subName && ( + + {item.subName}
{item.standard.value} +
+ )} + {!item.subName && item.standard.description && ( + {item.standard.description} + )} + - {/* 5. 아연의 최소 부착량 */} - - 5 - 아연의 최소
부착량 (g/㎡) - - 편면 17 이상 - - - - {data.inspectionItems.zincCoating.measurements.join(' / ')} - - 적 - + {/* 검사기준 - 두번째 열 (옵션) */} + + {item.standard.options && renderStandardOptions(item.standard.options)} + + + {/* 검사방식 */} + {isFirstInGroup && ( + + {item.inspectionMethod} + + )} + + {/* 검사주기 */} + {isFirstInGroup && ( + + {item.inspectionCycle} + + )} + + {/* 측정값 */} + {item.measurementType === 'okng' ? ( + // OK/NG 선택형 + <> + {Array.from({ length: 3 }).map((_, i) => ( + + {i < item.measurementCount ? renderOkNgRadio(item.id, i) : ''} + + ))} + + ) : item.measurementCount === 1 ? ( + // 단일 측정값 (3칸 합치기) + + {renderMeasurementInput(item.id, 0)} + + ) : item.measurementCount === 2 ? ( + // 2개 측정값 + <> + + {renderMeasurementInput(item.id, 0)} + + + {renderMeasurementInput(item.id, 1)} + + + ) : ( + // 3개 측정값 + <> + {Array.from({ length: 3 }).map((_, i) => ( + + {renderMeasurementInput(item.id, i)} + + ))} + + )} + + {/* 판정 */} + {isFirstInGroup && ( + + {renderOkNgRadio(item.id)} + + )} + + ); + })} {/* 주석 */} -
-

※ 1.55mm의 경우 KS F 4510에 따른 MIN 1.5의 기준에 따름

-

※ 두께의 경우 너비 1000 이상 ~ 1250 미만 기준에 따름

-
+ {notes && notes.length > 0 && ( +
+ {notes.map((note, idx) => ( +

{note}

+ ))} +
+ )} {/* 종합판정 */}
종합판정
-
- {data.overallResult === '합격' ? '☑' : '☐'} +
+ {overallResult === '합격' && '합격 (PASS)'} + {overallResult === '불합격' && '불합격 (FAIL)'} + {overallResult === null && '미완료'}
); -}; \ No newline at end of file +}; diff --git a/src/app/[locale]/(protected)/sales/order-management-sales/[id]/page.tsx b/src/app/[locale]/(protected)/sales/order-management-sales/[id]/page.tsx index 7f828a02..1f3558da 100644 --- a/src/app/[locale]/(protected)/sales/order-management-sales/[id]/page.tsx +++ b/src/app/[locale]/(protected)/sales/order-management-sales/[id]/page.tsx @@ -67,6 +67,7 @@ import { revertProductionOrder, revertOrderConfirmation, deleteOrder, + createProductionOrder, type Order, type OrderStatus, } from "@/components/orders"; @@ -151,6 +152,14 @@ export default function OrderDetailPage() { const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isDeleting, setIsDeleting] = useState(false); + // 생산지시 생성 모달 상태 + const [isProductionDialogOpen, setIsProductionDialogOpen] = useState(false); + const [isCreatingProduction, setIsCreatingProduction] = useState(false); + const [productionPriority, setProductionPriority] = useState<"normal" | "high" | "urgent">("normal"); + const [productionMemo, setProductionMemo] = useState(""); + // 생산지시 완료 알림 모달 상태 + const [isProductionSuccessDialogOpen, setIsProductionSuccessDialogOpen] = useState(false); + // 취소 폼 상태 const [cancelReason, setCancelReason] = useState(""); const [cancelDetail, setCancelDetail] = useState(""); @@ -195,8 +204,42 @@ export default function OrderDetailPage() { }; const handleProductionOrder = () => { - // 생산지시 생성 페이지로 이동 - router.push(`/sales/order-management-sales/${orderId}/production-order`); + // 생산지시 생성 모달 열기 + setProductionPriority("normal"); + setProductionMemo(""); + setIsProductionDialogOpen(true); + }; + + // 생산지시 확정 처리 + const handleProductionOrderSubmit = async () => { + if (order) { + setIsCreatingProduction(true); + try { + const result = await createProductionOrder(order.id, { + priority: productionPriority, + memo: productionMemo || undefined, + }); + if (result.success && result.data) { + // 주문 상태 업데이트 + setOrder({ ...order, status: "production_ordered" as OrderStatus }); + setIsProductionDialogOpen(false); + // 성공 알림 모달 표시 + setIsProductionSuccessDialogOpen(true); + } else { + toast.error(result.error || "생산지시 생성에 실패했습니다."); + } + } catch (error) { + console.error("Error creating production order:", error); + toast.error("생산지시 생성 중 오류가 발생했습니다."); + } finally { + setIsCreatingProduction(false); + } + } + }; + + // 생산지시 완료 알림 확인 + const handleProductionSuccessConfirm = () => { + setIsProductionSuccessDialogOpen(false); }; const handleViewProductionOrder = () => { @@ -419,53 +462,6 @@ export default function OrderDetailPage() { return (
- {/* 수주 정보 헤더 */} - - -
-
- - {order.lotNumber} - - {getOrderStatusBadge(order.status)} -
-
- 수주일: {order.orderDate} -
-
-
- - {/* 문서 버튼들 */} -
- 문서: - - - -
-
-
- {/* 기본 정보 */} @@ -473,10 +469,16 @@ export default function OrderDetailPage() {
- + + + +
+

상태

+
{getOrderStatusBadge(order.status)}
+
@@ -488,14 +490,16 @@ export default function OrderDetailPage() {
- - + - + - +
+

주소

+

{order.address || "-"}

+
@@ -512,11 +516,11 @@ export default function OrderDetailPage() { )} - {/* 제품 내역 (트리 구조) */} + {/* 제품내용 (아코디언) */}
- 제품 내역 + 제품내용 {order.products && order.products.length > 0 && (
-
- {product.floor && ( - - {product.floor} - - )} - {product.code && ( - - {product.code} - - )} - - {productItems.length}개 부품 - -
+ + 부품 {productItems.length}개 + {/* 부품 목록 (확장 시 표시) */} @@ -600,34 +592,20 @@ export default function OrderDetailPage() { 순번 - 품목코드 - 품명 + 품목명 규격 수량 단위 - 단가 - 금액 {productItems.map((item, index) => ( {index + 1} - - - {item.itemCode || "-"} - - {item.itemName} {item.spec || "-"} {formatQuantity(item.quantity, item.unit)} {item.unit || "-"} - - {formatAmount(item.unitPrice || 0)}원 - - - {formatAmount(item.amount || 0)}원 - ))} @@ -642,114 +620,76 @@ export default function OrderDetailPage() {
); })} +
+ ) : null} + + - {/* 매칭되지 않은 부품 (기타 부품) */} - {(() => { - const unmatchedItems = getUnmatchedItems(); - if (unmatchedItems.length === 0) return null; - return ( -
-
- - 기타 부품 - - {unmatchedItems.length}개 부품 - -
-
- - - - 순번 - 품목코드 - 품명 - 규격 - 수량 - 단위 - 단가 - 금액 - - - - {unmatchedItems.map((item, index) => ( - - {index + 1} - - - {item.itemCode || "-"} - - - {item.itemName} - {item.spec || "-"} - {formatQuantity(item.quantity, item.unit)} - {item.unit || "-"} - - {formatAmount(item.unitPrice || 0)}원 - - - {formatAmount(item.amount || 0)}원 - - - ))} - -
-
+ {/* 기타부품 (아코디언) */} + {(() => { + const unmatchedItems = getUnmatchedItems(); + if (unmatchedItems.length === 0) return null; + return ( + + + 기타부품 + + +
+
- ) : ( - /* products가 없는 경우: 기존 부품 테이블 표시 */ -
- - - - 순번 - 품목코드 - 품명 - 규격 - 수량 - 단위 - 단가 - 금액 - - - - {order.items && order.items.length > 0 ? ( - order.items.map((item, index) => ( - - {index + 1} - - - {item.itemCode || "-"} - - - {item.itemName} - {item.spec || "-"} - {formatQuantity(item.quantity, item.unit)} - {item.unit || "-"} - - {formatAmount(item.unitPrice || 0)}원 - - - {formatAmount(item.amount || 0)}원 - - - )) - ) : ( - - - 등록된 부품이 없습니다 - - - )} - -
-
- )} + + {unmatchedItems.length}개 + + + {expandedProducts.has("other-parts") && ( +
+ + + + 순번 + 품목명 + 규격 + 수량 + 단위 + + + + {unmatchedItems.map((item, index) => ( + + {index + 1} + {item.itemName} + {item.spec || "-"} + {formatQuantity(item.quantity, item.unit)} + {item.unit || "-"} + + ))} + +
+
+ )} +
+ + + ); + })()} - {/* 합계 */} -
+ {/* 합계 */} + + +
소계: @@ -760,7 +700,7 @@ export default function OrderDetailPage() { 할인율: {Number.isInteger(order.discountRate || 0) ? (order.discountRate || 0) : Math.round(order.discountRate || 0)}%
-
+
총금액: {formatAmount(order.totalAmount || 0)}원 @@ -771,79 +711,84 @@ export default function OrderDetailPage() {
); - }, [order, expandedProducts, expandAllProducts, collapseAllProducts, toggleProduct, getItemsForProduct, getUnmatchedItems, openDocumentModal]); + }, [order, expandedProducts, expandAllProducts, collapseAllProducts, toggleProduct, getItemsForProduct, getUnmatchedItems]); + + // 견적 수정 핸들러 + const handleEditQuote = () => { + if (order?.quoteId) { + router.push(`/sales/quotes/${order.quoteId}?mode=edit`); + } + }; + + // 수주서 보기 핸들러 + const handleViewOrderDocument = () => { + openDocumentModal("salesOrder"); + }; // 커스텀 헤더 액션 (상태별 버튼) const renderHeaderActions = useCallback(() => { if (!order) return null; // 상태별 버튼 표시 여부 + const showEditQuoteButton = !!order.quoteId; // 연결된 견적이 있을 때 const showEditButton = order.status !== "shipped" && order.status !== "cancelled"; const showConfirmButton = order.status === "order_registered"; const showProductionCreateButton = order.status === "order_confirmed"; - const showProductionViewButton = false; const showRevertButton = order.status === "production_ordered"; const showRevertConfirmButton = order.status === "order_confirmed"; - const showCancelButton = - order.status !== "shipped" && - order.status !== "cancelled" && - order.status !== "production_ordered"; - // 삭제 버튼은 수주등록 또는 취소 상태에서 표시 - const showDeleteButton = order.status === "order_registered" || order.status === "cancelled"; return (
- {showEditButton && ( - )} + {/* 수주서 보기 */} + + {/* 수주 확정 */} {showConfirmButton && ( )} - {showProductionCreateButton && ( - - )} - {showProductionViewButton && ( - - )} - {showRevertButton && ( - - )} + {/* 수주확정 되돌리기 */} {showRevertConfirmButton && ( )} - {showCancelButton && ( - )} - {showDeleteButton && ( - + )} + {/* 수정 */} + {showEditButton && ( + )}
); - }, [order, handleEdit, handleConfirmOrder, handleProductionOrder, handleViewProductionOrder, handleRevertProduction, handleRevertConfirmation, handleCancel, handleDelete]); + }, [order, handleEditQuote, handleViewOrderDocument, handleConfirmOrder, handleRevertConfirmation, handleProductionOrder, handleRevertProduction, handleEdit]); // V2 패턴: ?mode=edit일 때 수정 컴포넌트 렌더링 if (isEditMode) { @@ -886,6 +831,12 @@ export default function OrderDetailPage() { discountRate: order.discountRate, totalAmount: order.totalAmount, remarks: order.remarks, + // 수주서 전용 필드 + documentNumber: order.lotNumber, + certificationNumber: order.lotNumber, + recipientName: order.receiver, + recipientContact: order.receiverContact, + shutterCount: order.products?.length || 0, }} /> )} @@ -1264,6 +1215,102 @@ export default function OrderDetailPage() { + + {/* 생산지시 생성 모달 */} + + + + 생산지시 생성 + + +
+ {/* 우선순위 */} +
+ +
+ + + +
+
+ + {/* 비고 */} +
+ +