diff --git a/src/components/quality/InspectionManagement/InspectionDetail.tsx b/src/components/quality/InspectionManagement/InspectionDetail.tsx index 61b37ccf..c91450a6 100644 --- a/src/components/quality/InspectionManagement/InspectionDetail.tsx +++ b/src/components/quality/InspectionManagement/InspectionDetail.tsx @@ -62,6 +62,7 @@ import { updateInspection, completeInspection, } from './actions'; +import { getFqcStatus } from './fqcActions'; import { statusColorMap, isOrderSpecSame, @@ -138,6 +139,9 @@ export function InspectionDetail({ id }: InspectionDetailProps) { const [inspectionInputOpen, setInspectionInputOpen] = useState(false); const [selectedOrderItem, setSelectedOrderItem] = useState(null); + // FQC 문서 매핑 (orderItemId → documentId) + const [fqcDocumentMap, setFqcDocumentMap] = useState>({}); + // ===== API 데이터 로드 ===== const loadInspection = useCallback(async () => { setIsLoading(true); @@ -175,6 +179,39 @@ export function InspectionDetail({ id }: InspectionDetailProps) { loadInspection(); }, [loadInspection]); + // ===== FQC 문서 매핑 로드 ===== + useEffect(() => { + if (!inspection) return; + + // orderItems에서 고유 orderId 추출 (API에서 제공되는 경우만) + const orderIds = new Set(); + inspection.orderItems.forEach((item) => { + if (item.orderId) orderIds.add(item.orderId); + }); + + if (orderIds.size === 0) return; // orderId 없으면 legacy 모드 유지 + + // 각 orderId별 FQC 상태 조회 후 매핑 구축 + const loadFqcMap = async () => { + const map: Record = {}; + for (const orderId of orderIds) { + const result = await getFqcStatus(orderId); + if (result.success && result.data) { + result.data.items.forEach((item) => { + if (item.documentId) { + map[String(item.orderItemId)] = item.documentId; + } + }); + } + } + if (Object.keys(map).length > 0) { + setFqcDocumentMap(map); + } + }; + + loadFqcMap(); + }, [inspection]); + // ===== 네비게이션 ===== const handleEdit = useCallback(() => { router.push(`/quality/inspections/${id}?mode=edit`); @@ -1108,6 +1145,7 @@ export function InspectionDetail({ id }: InspectionDetailProps) { data={inspection ? buildReportDocumentData(inspection, isEditMode ? formData.orderItems : undefined) : null} inspection={inspection} orderItems={isEditMode ? formData.orderItems : inspection?.orderItems} + fqcDocumentMap={Object.keys(fqcDocumentMap).length > 0 ? fqcDocumentMap : undefined} /> {/* 제품검사 입력 모달 */} @@ -1119,6 +1157,7 @@ export function InspectionDetail({ id }: InspectionDetailProps) { specification={selectedOrderItem ? `${selectedOrderItem.orderWidth}x${selectedOrderItem.orderHeight}` : ''} initialData={selectedOrderItem?.inspectionData} onComplete={handleInspectionComplete} + fqcDocumentId={selectedOrderItem ? fqcDocumentMap[selectedOrderItem.id] ?? null : null} /> ); diff --git a/src/components/quality/InspectionManagement/ProductInspectionInputModal.tsx b/src/components/quality/InspectionManagement/ProductInspectionInputModal.tsx index 0b4c8f2c..65eae9d2 100644 --- a/src/components/quality/InspectionManagement/ProductInspectionInputModal.tsx +++ b/src/components/quality/InspectionManagement/ProductInspectionInputModal.tsx @@ -3,18 +3,15 @@ /** * 제품검사 입력 모달 * - * 수주 설정 정보 아코디언의 "검사하기" 버튼에서 열림 - * 검사 결과를 입력하면 해당 수주 항목의 inspectionData에 저장 - * - * 검사 항목: - * - 제품 사진 (2장) - * - 겉모양 검사: 가공상태, 재봉상태, 조립상태, 연기차단재, 하단마감재, 모터 - * - 재질/치수 검사: 재질, 길이, 높이, 가이드레일 출간격, 하단마감재 간격 - * - 시험 검사: 내화시험, 차연시험, 개폐시험, 내충격시험 - * - 특이사항 + * 양식 기반 전환 (5.2.4): + * - FQC 모드: documents API template 기반으로 검사항목 렌더링 + * → 11개 설치 후 최종검사 항목 (template_id: 65) + * → 모두 visual/checkbox (적합/부적합) + * → saveFqcDocument로 서버에 직접 저장 + * - Legacy 모드: 기존 하드코딩 필드 (fallback) */ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { Dialog, DialogContent, @@ -22,13 +19,15 @@ import { DialogTitle, } from '@/components/ui/dialog'; 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 { Loader2 } from 'lucide-react'; import { cn } from '@/lib/utils'; -import { Camera, X, Plus } from 'lucide-react'; +import { toast } from 'sonner'; +import { getFqcTemplate, getFqcDocument, saveFqcDocument } from './fqcActions'; +import type { FqcTemplate, FqcTemplateItem, FqcDocumentData } from './fqcActions'; import type { ProductInspectionData } from './types'; +type JudgmentValue = '적합' | '부적합' | null; + interface ProductInspectionInputModalProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -37,228 +36,10 @@ interface ProductInspectionInputModalProps { specification?: string; initialData?: ProductInspectionData; onComplete: (data: ProductInspectionData) => void; + /** FQC 문서 ID (있으면 양식 기반 모드) */ + fqcDocumentId?: number | null; } -// 적합/부적합 버튼 컴포넌트 -function PassFailToggle({ - value, - onChange, - disabled = false, -}: { - value: 'pass' | 'fail' | null; - onChange: (v: 'pass' | 'fail') => void; - disabled?: boolean; -}) { - return ( -
- - -
- ); -} - -// 사진 업로드 컴포넌트 -function ImageUploader({ - images, - onImagesChange, - maxImages = 2, -}: { - images: string[]; - onImagesChange: (images: string[]) => void; - maxImages?: number; -}) { - const fileInputRef = useRef(null); - - const handleFileSelect = (e: React.ChangeEvent) => { - const files = e.target.files; - if (!files) return; - - Array.from(files).forEach((file) => { - if (images.length >= maxImages) return; - - const reader = new FileReader(); - reader.onloadend = () => { - const base64 = reader.result as string; - onImagesChange([...images, base64]); - }; - reader.readAsDataURL(file); - }); - - // Reset input - if (fileInputRef.current) { - fileInputRef.current.value = ''; - } - }; - - const handleRemove = (index: number) => { - onImagesChange(images.filter((_, i) => i !== index)); - }; - - return ( -
- -
- {images.map((img, index) => ( -
- {`제품 - -
- ))} - {images.length < maxImages && ( - - )} -
- -
- ); -} - -// 검사 항목 그룹 컴포넌트 -function InspectionGroup({ - title, - children, -}: { - title: string; - children: React.ReactNode; -}) { - return ( -
-
- {title} -
-
{children}
-
- ); -} - -// 검사 항목 행 컴포넌트 -function InspectionRow({ - label, - children, -}: { - label: string; - children: React.ReactNode; -}) { - return ( -
- - {children} -
- ); -} - -// 측정값 입력 + 판정 컴포넌트 -function MeasurementInput({ - value, - judgment, - onValueChange, - onJudgmentChange, - placeholder = '측정값', - unit = 'mm', -}: { - value: number | null; - judgment: 'pass' | 'fail' | null; - onValueChange: (v: number | null) => void; - onJudgmentChange: (v: 'pass' | 'fail') => void; - placeholder?: string; - unit?: string; -}) { - return ( -
-
- onValueChange(e.target.value === '' ? null : parseFloat(e.target.value))} - placeholder={placeholder} - className="w-24 text-sm" - /> - {unit} -
- -
- ); -} - -const INITIAL_DATA: ProductInspectionData = { - productName: '', - specification: '', - productImages: [], - // 겉모양 검사 (기본값: 적합) - appearanceProcessing: 'pass', - appearanceSewing: 'pass', - appearanceAssembly: 'pass', - appearanceSmokeBarrier: 'pass', - appearanceBottomFinish: 'pass', - motor: 'pass', - // 재질/치수 검사 (기본값: 적합) - material: 'pass', - lengthValue: null, - lengthJudgment: 'pass', - heightValue: null, - heightJudgment: 'pass', - guideRailGapValue: null, - guideRailGap: 'pass', - bottomFinishGapValue: null, - bottomFinishGap: 'pass', - // 시험 검사 (기본값: 적합) - fireResistanceTest: 'pass', - smokeLeakageTest: 'pass', - openCloseTest: 'pass', - impactTest: 'pass', - // 특이사항 - hasSpecialNotes: false, - specialNotes: '', -}; - export function ProductInspectionInputModal({ open, onOpenChange, @@ -267,42 +48,217 @@ export function ProductInspectionInputModal({ specification = '', initialData, onComplete, + fqcDocumentId, }: ProductInspectionInputModalProps) { - const [formData, setFormData] = useState({ - ...INITIAL_DATA, - productName, - specification, - }); + // FQC 모드 상태 + const [fqcTemplate, setFqcTemplate] = useState(null); + const [fqcDocData, setFqcDocData] = useState([]); + const [isLoadingFqc, setIsLoadingFqc] = useState(false); + const [isSaving, setIsSaving] = useState(false); + // 판정 상태 (FQC 모드) + const [judgments, setJudgments] = useState>({}); + + const useFqcMode = !!fqcDocumentId; + + // FQC 데이터 로드 useEffect(() => { - if (open) { - if (initialData) { - setFormData(initialData); - } else { - setFormData({ - ...INITIAL_DATA, + if (!open || !useFqcMode) return; + + setIsLoadingFqc(true); + + Promise.all([ + getFqcTemplate(), + getFqcDocument(fqcDocumentId!), + ]) + .then(([templateResult, docResult]) => { + if (templateResult.success && templateResult.data) { + setFqcTemplate(templateResult.data); + } + if (docResult.success && docResult.data) { + setFqcDocData(docResult.data.data); + // 기존 판정 데이터 복원 + const dataSection = docResult.data.template.sections.find(s => s.items.length > 0); + const judgmentCol = docResult.data.template.columns.find(c => c.label === '판정'); + if (dataSection && judgmentCol) { + const map: Record = {}; + for (const d of docResult.data.data) { + if (d.sectionId === dataSection.id && d.columnId === judgmentCol.id && d.fieldKey === 'result') { + map[d.rowIndex] = (d.fieldValue as JudgmentValue) ?? null; + } + } + setJudgments(map); + } + } + }) + .finally(() => setIsLoadingFqc(false)); + }, [open, useFqcMode, fqcDocumentId]); + + // 모달 닫힐 때 상태 초기화 + useEffect(() => { + if (!open) { + setJudgments({}); + setFqcDocData([]); + } + }, [open]); + + // 판정 토글 + const toggleJudgment = useCallback((rowIndex: number, value: JudgmentValue) => { + setJudgments(prev => ({ + ...prev, + [rowIndex]: prev[rowIndex] === value ? null : value, + })); + }, []); + + // FQC 종합판정 계산 + const overallJudgment = useCallback((): '합격' | '불합격' | null => { + if (!fqcTemplate) return null; + const dataSection = fqcTemplate.sections.find(s => s.items.length > 0); + if (!dataSection || dataSection.items.length === 0) return null; + + const values = dataSection.items.map((_, idx) => judgments[idx]); + const hasValue = values.some(v => v !== undefined && v !== null); + if (!hasValue) return null; + if (values.some(v => v === '부적합')) return '불합격'; + if (values.every(v => v === '적합')) return '합격'; + return null; + }, [fqcTemplate, judgments]); + + // FQC 검사 완료 (서버 저장) + const handleFqcComplete = useCallback(async () => { + if (!fqcTemplate || !fqcDocumentId) return; + + const dataSection = fqcTemplate.sections.find(s => s.items.length > 0); + const judgmentCol = fqcTemplate.columns.find(c => c.label === '판정'); + if (!dataSection || !judgmentCol) return; + + setIsSaving(true); + try { + // document_data 형식으로 변환 + const records: Array<{ + section_id: number | null; + column_id: number | null; + row_index: number; + field_key: string; + field_value: string | null; + }> = []; + + dataSection.items.forEach((_, idx) => { + const value = judgments[idx]; + if (value) { + records.push({ + section_id: dataSection.id, + column_id: judgmentCol.id, + row_index: idx, + field_key: 'result', + field_value: value, + }); + } + }); + + // 종합판정 + records.push({ + section_id: null, + column_id: null, + row_index: 0, + field_key: 'footer_judgement', + field_value: overallJudgment(), + }); + + const result = await saveFqcDocument({ + documentId: fqcDocumentId, + data: records, + }); + + if (result.success) { + toast.success('검사 데이터가 저장되었습니다.'); + + // onComplete callback으로 로컬 상태도 업데이트 + // Legacy 타입 호환: FQC 판정 데이터를 ProductInspectionData 형태로 변환 + const legacyData: ProductInspectionData = { productName, specification, - }); + productImages: [], + // FQC 모드에서는 모든 항목을 적합/부적합으로만 판정 + // 11개 항목을 legacy 필드에 매핑 (가능한 만큼) + appearanceProcessing: judgments[0] === '적합' ? 'pass' : judgments[0] === '부적합' ? 'fail' : null, + appearanceSewing: judgments[1] === '적합' ? 'pass' : judgments[1] === '부적합' ? 'fail' : null, + appearanceAssembly: judgments[2] === '적합' ? 'pass' : judgments[2] === '부적합' ? 'fail' : null, + appearanceSmokeBarrier: judgments[3] === '적합' ? 'pass' : judgments[3] === '부적합' ? 'fail' : null, + appearanceBottomFinish: judgments[4] === '적합' ? 'pass' : judgments[4] === '부적합' ? 'fail' : null, + motor: judgments[5] === '적합' ? 'pass' : judgments[5] === '부적합' ? 'fail' : null, + material: judgments[6] === '적합' ? 'pass' : judgments[6] === '부적합' ? 'fail' : null, + lengthValue: null, + lengthJudgment: judgments[7] === '적합' ? 'pass' : judgments[7] === '부적합' ? 'fail' : null, + heightValue: null, + heightJudgment: judgments[8] === '적합' ? 'pass' : judgments[8] === '부적합' ? 'fail' : null, + guideRailGapValue: null, + guideRailGap: judgments[9] === '적합' ? 'pass' : judgments[9] === '부적합' ? 'fail' : null, + bottomFinishGapValue: null, + bottomFinishGap: judgments[10] === '적합' ? 'pass' : judgments[10] === '부적합' ? 'fail' : null, + fireResistanceTest: null, + smokeLeakageTest: null, + openCloseTest: null, + impactTest: null, + hasSpecialNotes: false, + specialNotes: '', + }; + + onComplete(legacyData); + onOpenChange(false); + } else { + toast.error(result.error || '검사 데이터 저장에 실패했습니다.'); } + } finally { + setIsSaving(false); } - }, [open, productName, specification, initialData]); + }, [fqcTemplate, fqcDocumentId, judgments, overallJudgment, productName, specification, onComplete, onOpenChange]); - const updateField = ( - key: K, - value: ProductInspectionData[K] - ) => { - setFormData((prev) => ({ ...prev, [key]: value })); - }; - - const handleComplete = () => { - onComplete(formData); + // Legacy 완료 핸들러 + const handleLegacyComplete = useCallback(() => { + if (!legacyFormData) return; + onComplete(legacyFormData); onOpenChange(false); - }; + }, [onComplete, onOpenChange]); - const handleCancel = () => { - onOpenChange(false); - }; + // ===== Legacy 모드 상태 ===== + const [legacyFormData, setLegacyFormData] = useState(null); + + useEffect(() => { + if (open && !useFqcMode) { + setLegacyFormData(initialData || { + productName, + specification, + productImages: [], + appearanceProcessing: 'pass', + appearanceSewing: 'pass', + appearanceAssembly: 'pass', + appearanceSmokeBarrier: 'pass', + appearanceBottomFinish: 'pass', + motor: 'pass', + material: 'pass', + lengthValue: null, + lengthJudgment: 'pass', + heightValue: null, + heightJudgment: 'pass', + guideRailGapValue: null, + guideRailGap: 'pass', + bottomFinishGapValue: null, + bottomFinishGap: 'pass', + fireResistanceTest: 'pass', + smokeLeakageTest: 'pass', + openCloseTest: 'pass', + impactTest: 'pass', + hasSpecialNotes: false, + specialNotes: '', + }); + } + }, [open, useFqcMode, initialData, productName, specification]); + + // FQC 데이터 섹션 + const dataSection = fqcTemplate?.sections.find(s => s.items.length > 0); + const sortedItems = dataSection?.items.sort((a, b) => a.sortOrder - b.sortOrder) || []; + const currentOverallJudgment = overallJudgment(); return ( @@ -310,176 +266,247 @@ export function ProductInspectionInputModal({ # 제품검사 + {useFqcMode && ( + + 양식 기반 + + )}
{/* 제품명 / 규격 */}
-
- - +
+ 제품명 +
{productName || '-'}
-
- - +
+ 규격 +
{specification || '-'}
- {/* 제품 사진 */} - updateField('productImages', images)} - maxImages={2} - /> + {useFqcMode ? ( + // ===== FQC 양식 기반 모드 ===== + isLoadingFqc ? ( +
+ + 양식 로딩 중... +
+ ) : ( + <> + {/* 검사항목 목록 (template 기반) */} +
+
+ {dataSection?.title || dataSection?.name || '검사항목'} + + ({sortedItems.length}항목) + +
- {/* 겉모양 검사 */} - - - updateField('appearanceProcessing', v)} - /> - - - updateField('appearanceSewing', v)} - /> - - - updateField('appearanceAssembly', v)} - /> - - - updateField('appearanceSmokeBarrier', v)} - /> - - - updateField('appearanceBottomFinish', v)} - /> - - - updateField('motor', v)} - /> - - +
+ {sortedItems.map((item, idx) => ( + + ))} +
+
- {/* 재질/치수 검사 */} - - - updateField('material', v)} + {/* 종합판정 */} +
+ 종합판정 + + {currentOverallJudgment || '미판정'} + +
+ + ) + ) : ( + // ===== Legacy 하드코딩 모드 (fallback) ===== + legacyFormData && ( + -
- - updateField('lengthValue', v)} - onJudgmentChange={(v) => updateField('lengthJudgment', v)} - /> - - - updateField('heightValue', v)} - onJudgmentChange={(v) => updateField('heightJudgment', v)} - /> - - - updateField('guideRailGapValue', v)} - onJudgmentChange={(v) => updateField('guideRailGap', v)} - /> - - - updateField('bottomFinishGapValue', v)} - onJudgmentChange={(v) => updateField('bottomFinishGap', v)} - /> - -
- - {/* 시험 검사 */} - - - updateField('fireResistanceTest', v)} - /> - - - updateField('smokeLeakageTest', v)} - /> - - - updateField('openCloseTest', v)} - /> - - - updateField('impactTest', v)} - /> - - - - {/* 특이사항 */} -
- -