From 4ea03922a3abc453d954237d4bc7cba79ce75df6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Fri, 6 Mar 2026 21:47:33 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[=EC=A0=9C=ED=92=88=EA=B2=80=EC=82=AC?= =?UTF-8?q?=20=EC=84=B1=EC=A0=81=EC=84=9C]=208=EC=BB=AC=EB=9F=BC=20?= =?UTF-8?q?=EB=8F=99=EC=A0=81=20=EB=A0=8C=EB=8D=94=EB=A7=81=20+=20FQC=20?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=20=EA=B8=B0=EB=B3=B8=EA=B0=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FqcDocumentContent: 8컬럼 시각 레이아웃 (No/검사항목/세부항목/검사기준/검사방법/검사주기/측정값/판정) - rowSpan 병합: category 단독 + method+frequency 복합키 병합 - measurement_type: checkbox→양호/불량, numeric→숫자입력, none→비활성 - InspectionReportModal: FQC 모드 우선 (template 로드 실패 시 legacy fallback) - Lazy Snapshot 준비 (contentWrapperRef 추가) --- .../documents/FqcDocumentContent.tsx | 481 +++++++++++++----- .../documents/InspectionReportModal.tsx | 46 +- 2 files changed, 372 insertions(+), 155 deletions(-) diff --git a/src/components/quality/InspectionManagement/documents/FqcDocumentContent.tsx b/src/components/quality/InspectionManagement/documents/FqcDocumentContent.tsx index 5cdfbf70..58461fd8 100644 --- a/src/components/quality/InspectionManagement/documents/FqcDocumentContent.tsx +++ b/src/components/quality/InspectionManagement/documents/FqcDocumentContent.tsx @@ -1,17 +1,20 @@ 'use client'; /** - * FQC 제품검사 성적서 - 양식 기반 렌더링 + * FQC 제품검사 성적서 - 양식 기반 렌더링 (8컬럼) * * documents 시스템의 template 구조를 기반으로 렌더링: * - 결재라인 (3인: 작성/검토/승인) * - 기본정보 (7필드: 납품명, 제품명, 발주처, LOT NO, 로트크기, 검사일자, 검사자) - * - 검사항목 테이블 (4컬럼: NO, 검사항목, 검사기준, 판정) - * - 11개 설치 후 최종검사 항목 (모두 visual/checkbox → 적합/부적합) - * - 종합판정 (자동 계산) + * - 검사항목 테이블 (8컬럼 시각 레이아웃) + * - 1~6: section_item 읽기전용 (No, 검사항목, 세부항목, 검사기준, 검사방법, 검사주기) + * - 7~8: template column 편집 (측정값, 판정) + * - rowSpan: category 단독 + method+frequency 복합키 병합 + * - measurement_type: checkbox→양호/불량, numeric→숫자입력, none→비활성 + * - 종합판정 (자동 계산, measurement_type='none' 제외) * - * readonly=true → 조회 모드 (InspectionReportModal에서 사용) - * readonly=false → 편집 모드 (ProductInspectionInputModal 대체) + * readonly=true → 조회 모드 + * readonly=false → 편집 모드 */ import { useState, useMemo, useCallback, useImperativeHandle, forwardRef } from 'react'; @@ -55,6 +58,73 @@ interface FqcDocumentContentProps { type JudgmentValue = '적합' | '부적합' | null; +// ===== rowSpan 병합 유틸 ===== + +/** 단일 필드 기준 연속 rowSpan 계산 (category용) */ +function buildFieldRowSpan(items: FqcTemplateItem[], field: 'category') { + const spans = new Map(); + const covered = new Set(); + + let i = 0; + while (i < items.length) { + const value = items[i][field]; + if (!value || value === '-') { i++; continue; } + + let span = 1; + while (i + span < items.length && items[i + span][field] === value) { + covered.add(i + span); + span++; + } + if (span > 1) spans.set(i, span); + i += span; + } + return { spans, covered }; +} + +/** 복합 키 기준 연속 rowSpan 계산 (method+frequency용) */ +function buildCompositeRowSpan(items: FqcTemplateItem[]) { + const spans = new Map(); + const covered = new Set(); + + let i = 0; + while (i < items.length) { + const method = items[i].method || ''; + const freq = items[i].frequency || ''; + if (!method && !freq) { i++; continue; } + + const key = `${method}|${freq}`; + let span = 1; + while (i + span < items.length) { + const nextMethod = items[i + span].method || ''; + const nextFreq = items[i + span].frequency || ''; + if (!nextMethod && !nextFreq) break; + if (`${nextMethod}|${nextFreq}` !== key) break; + covered.add(i + span); + span++; + } + if (span > 1) spans.set(i, span); + i += span; + } + return { spans, covered }; +} + +/** category별 그룹 번호 생성 */ +function buildCategoryNumbers(items: FqcTemplateItem[]): Map { + const numbers = new Map(); + let num = 0; + let lastCategory = ''; + + for (let i = 0; i < items.length; i++) { + const cat = items[i].category; + if (cat && cat !== '-' && cat !== lastCategory) { + num++; + lastCategory = cat; + } + numbers.set(i, num); + } + return numbers; +} + // ===== Component ===== export const FqcDocumentContent = forwardRef( @@ -75,31 +145,52 @@ export const FqcDocumentContent = forwardRef dataSection?.items.sort((a, b) => a.sortOrder - b.sortOrder) ?? [], + [dataSection] + ); + + // 컬럼 ID 찾기 const judgmentColumnId = useMemo( () => template.columns.find(c => c.label === '판정')?.id ?? null, [template.columns] ); + const measurementColumnId = useMemo( + () => template.columns.find(c => c.label === '측정값')?.id ?? null, + [template.columns] + ); - // 기존 문서 데이터에서 판정값 추출 + // rowSpan 계산 + const categoryCoverage = useMemo(() => buildFieldRowSpan(sectionItems, 'category'), [sectionItems]); + const methodFreqCoverage = useMemo(() => buildCompositeRowSpan(sectionItems), [sectionItems]); + const categoryNumbers = useMemo(() => buildCategoryNumbers(sectionItems), [sectionItems]); + + // 기존 문서 데이터에서 판정값 + 측정값 추출 const initialJudgments = useMemo(() => { const map: Record = {}; if (!dataSection || !judgmentColumnId) return map; - for (const d of documentData) { - if ( - d.sectionId === dataSection.id && - d.columnId === judgmentColumnId && - d.fieldKey === 'result' - ) { + if (d.sectionId === dataSection.id && d.columnId === judgmentColumnId && d.fieldKey === 'result') { map[d.rowIndex] = (d.fieldValue as JudgmentValue) ?? null; } } return map; }, [documentData, dataSection, judgmentColumnId]); - // 판정 상태 (편집 모드용) + const initialMeasurements = useMemo(() => { + const map: Record = {}; + if (!dataSection || !measurementColumnId) return map; + for (const d of documentData) { + if (d.sectionId === dataSection.id && d.columnId === measurementColumnId && d.fieldKey === 'measured_value') { + map[d.rowIndex] = d.fieldValue ?? ''; + } + } + return map; + }, [documentData, dataSection, measurementColumnId]); + + // 상태 (편집 모드용) const [judgments, setJudgments] = useState>(initialJudgments); + const [measurements, setMeasurements] = useState>(initialMeasurements); // 판정 토글 const toggleJudgment = useCallback( @@ -113,41 +204,32 @@ export const FqcDocumentContent = forwardRef { + if (readonly) return; + setMeasurements(prev => ({ ...prev, [rowIndex]: value })); + }, + [readonly] + ); + + // 종합판정 자동 계산 (measurement_type='none' 제외) const overallJudgment = useMemo(() => { if (!dataSection) return null; - const items = dataSection.items; - if (items.length === 0) return null; + const activeItems = sectionItems.filter(item => item.measurementType !== 'none'); + if (activeItems.length === 0) return null; - const values = items.map((_, idx) => judgments[idx]); + const activeIndices = sectionItems + .map((item, idx) => item.measurementType !== 'none' ? idx : -1) + .filter(idx => idx >= 0); + + const values = activeIndices.map(idx => judgments[idx]); const hasValue = values.some(v => v !== undefined && v !== null); if (!hasValue) return null; if (values.some(v => v === '부적합')) return '불합격' as const; if (values.every(v => v === '적합')) return '합격' as const; return null; - }, [dataSection, judgments]); - - // 기본필드 값 조회 - const getBasicFieldValue = useCallback( - (fieldKey: string): string => { - const field = template.basicFields.find(f => f.fieldKey === fieldKey); - if (!field) return ''; - - // bf_{id} 형식 (mng show.blade.php 호환) - const bfKey = `bf_${field.id}`; - if (basicFieldValues[bfKey]) return basicFieldValues[bfKey]; - - const found = documentData.find(d => d.fieldKey === bfKey && !d.sectionId); - if (found?.fieldValue) return found.fieldValue; - - // 레거시 호환: bf_{label} 형식 - const legacyKey = `bf_${field.label}`; - if (basicFieldValues[legacyKey]) return basicFieldValues[legacyKey]; - const legacyFound = documentData.find(d => d.fieldKey === legacyKey && !d.sectionId); - return legacyFound?.fieldValue ?? ''; - }, - [basicFieldValues, documentData, template.basicFields] - ); + }, [dataSection, sectionItems, judgments]); // ref를 통해 데이터 추출 (편집 모드에서 저장 시 사용) useImperativeHandle(ref, () => ({ @@ -160,17 +242,33 @@ export const FqcDocumentContent = forwardRef = []; - if (dataSection && judgmentColumnId) { - dataSection.items.forEach((_, idx) => { - const value = judgments[idx]; - if (value) { - records.push({ - section_id: dataSection.id, - column_id: judgmentColumnId, - row_index: idx, - field_key: 'result', - field_value: value, - }); + if (dataSection) { + sectionItems.forEach((item, idx) => { + // 판정 + if (judgmentColumnId && item.measurementType !== 'none') { + const value = judgments[idx]; + if (value) { + records.push({ + section_id: dataSection.id, + column_id: judgmentColumnId, + row_index: idx, + field_key: 'result', + field_value: value, + }); + } + } + // 측정값 + if (measurementColumnId && item.measurementType !== 'none') { + const value = measurements[idx]; + if (value !== undefined && value !== '') { + records.push({ + section_id: dataSection.id, + column_id: measurementColumnId, + row_index: idx, + field_key: 'measured_value', + field_value: value, + }); + } } }); } @@ -203,7 +301,6 @@ export const FqcDocumentContent = forwardRef a.sortOrder - b.sortOrder); const pairs: Array<{ label: string; value: string }> = []; for (const field of sorted) { - // bf_{id} 형식 우선, 레거시 bf_{label} fallback const bfKey = `bf_${field.id}`; const legacyKey = `bf_${field.label}`; const value = basicFieldValues[bfKey] @@ -249,7 +346,6 @@ export const FqcDocumentContent = forwardRef - {/* 2열씩 표시 */} {Array.from({ length: Math.ceil(basicFieldPairs.length / 2) }, (_, rowIdx) => { const left = basicFieldPairs[rowIdx * 2]; const right = basicFieldPairs[rowIdx * 2 + 1]; @@ -307,44 +403,100 @@ export const FqcDocumentContent = forwardRef ))} - {/* 검사항목 테이블 */} + {/* 검사항목 테이블 (8컬럼 시각 레이아웃) */} {dataSection && (
- {template.columns - .sort((a, b) => a.sortOrder - b.sortOrder) - .map(col => ( - - ))} + + + + + + + + - {dataSection.items - .sort((a, b) => a.sortOrder - b.sortOrder) - .map((item, idx) => ( - - ))} + {sectionItems.map((item, idx) => ( + + {/* 1. No. — category 그룹 병합과 동일 */} + {!categoryCoverage.covered.has(idx) && ( + + )} + {/* 2. 검사항목 — category 그룹 병합 */} + {!categoryCoverage.covered.has(idx) && ( + + )} + {/* 3. 세부항목 */} + + {/* 4. 검사기준 */} + + {/* 5. 검사방법 — method+frequency 복합키 병합 */} + {!methodFreqCoverage.covered.has(idx) && ( + + )} + {/* 6. 검사주기 — method+frequency 복합키 병합 (동일 span) */} + {!methodFreqCoverage.covered.has(idx) && ( + + )} + {/* 7. 측정값 */} + + {/* 8. 판정 */} + + + ))} - {dataSection.items.length === 0 && ( + {sectionItems.length === 0 && ( - @@ -354,7 +506,7 @@ export const FqcDocumentContent = forwardRef @@ -386,69 +538,116 @@ export const FqcDocumentContent = forwardRef void; + onChange: (rowIndex: number, value: string) => void; + onToggle: (rowIndex: number, value: JudgmentValue) => void; readonly: boolean; + type: 'measurement' | 'judgment'; } -function InspectionRow({ item, rowIndex, judgment, onToggleJudgment, readonly }: InspectionRowProps) { +function MeasurementCell({ item, rowIndex, value, judgment, onChange, onToggle, readonly, type }: MeasurementCellProps) { + // none → 비활성 + if (item.measurementType === 'none') { + return -; + } + + if (type === 'measurement') { + // checkbox → 양호/불량 텍스트 + if (item.measurementType === 'checkbox') { + if (readonly) { + return {value || '-'}; + } + return ( +
+ + +
+ ); + } + + // numeric → 숫자 입력 + if (item.measurementType === 'numeric') { + if (readonly) { + return {value || '-'}; + } + return ( + onChange(rowIndex, e.target.value)} + className="w-full text-center text-[10px] border border-gray-300 rounded px-1 py-0.5 focus:outline-none focus:border-blue-400" + placeholder="-" + /> + ); + } + + return -; + } + + // type === 'judgment' + if (readonly) { + return ( +
+ + {judgment === '적합' ? '■' : '□'} 적합 + + + {judgment === '부적합' ? '■' : '□'} 부적합 + +
+ ); + } + return ( -
- {/* NO */} - - {/* 검사항목 */} - - {/* 검사기준 */} - - {/* 판정 */} - - +
+ + +
); } \ No newline at end of file diff --git a/src/components/quality/InspectionManagement/documents/InspectionReportModal.tsx b/src/components/quality/InspectionManagement/documents/InspectionReportModal.tsx index e7e90cb3..dcd83d6b 100644 --- a/src/components/quality/InspectionManagement/documents/InspectionReportModal.tsx +++ b/src/components/quality/InspectionManagement/documents/InspectionReportModal.tsx @@ -11,7 +11,7 @@ * Fallback: 문서가 없는 경우 기존 하드코딩 InspectionReportDocument 사용 */ -import { useState, useCallback, useMemo, useEffect } from 'react'; +import { useState, useCallback, useMemo, useEffect, useRef } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { ChevronLeft, ChevronRight, Loader2, AlertCircle } from 'lucide-react'; @@ -51,14 +51,19 @@ export function InspectionReportModal({ const [currentPage, setCurrentPage] = useState(1); const [inputPage, setInputPage] = useState('1'); + // rendered_html 캡처용 ref (Phase 1.3 준비) + const contentWrapperRef = useRef(null); + // FQC 문서/양식 상태 const [fqcDocument, setFqcDocument] = useState(null); const [fqcTemplate, setFqcTemplate] = useState(null); const [isLoadingFqc, setIsLoadingFqc] = useState(false); const [fqcError, setFqcError] = useState(null); + const [templateLoadFailed, setTemplateLoadFailed] = useState(false); - // 양식 기반 모드 사용 여부 - const useFqcMode = !!fqcDocumentMap && Object.keys(fqcDocumentMap).length > 0; + // FQC 모드 우선 (fqcDocumentMap 없어도 시도, template 로드 실패 시 fallback) + const hasFqcDocuments = !!fqcDocumentMap && Object.keys(fqcDocumentMap).length > 0; + const useFqcMode = !templateLoadFailed && (hasFqcDocuments || !!fqcTemplate); // 총 페이지 수 const totalPages = useMemo(() => { @@ -73,22 +78,25 @@ export function InspectionReportModal({ setInputPage('1'); setFqcDocument(null); setFqcError(null); + setTemplateLoadFailed(false); } }, [open]); - // FQC 양식 로드 (한 번만) + // FQC 양식 로드 (항상 시도, template 로드 실패 시 legacy fallback) useEffect(() => { - if (!open || !useFqcMode || fqcTemplate) return; + if (!open || fqcTemplate || templateLoadFailed) return; getFqcTemplate().then(result => { if (result.success && result.data) { setFqcTemplate(result.data); + } else { + setTemplateLoadFailed(true); } }); - }, [open, useFqcMode, fqcTemplate]); + }, [open, fqcTemplate, templateLoadFailed]); // 페이지 변경 시 FQC 문서 로드 useEffect(() => { - if (!open || !useFqcMode || !orderItems || !fqcDocumentMap) return; + if (!open || !hasFqcDocuments || !orderItems || !fqcDocumentMap) return; const currentItem = orderItems[currentPage - 1]; if (!currentItem) return; @@ -243,13 +251,23 @@ export function InspectionReportModal({

{fqcError}

) : fqcDocument && fqcTemplate ? ( - +
+ +
+ ) : fqcTemplate && !hasFqcDocuments ? ( + // template은 있지만 문서가 없는 경우 → legacy fallback + legacyCurrentData ? : ( +
+ +

이 개소의 FQC 문서가 아직 생성되지 않았습니다.

+
+ ) ) : (
- {col.label} - No.검사항목세부항목검사기준{'검사\n방법'}{'검사\n주기'}측정값판정
+ {categoryNumbers.get(idx)} + + {item.category || '-'} + + {item.itemName === '-' ? '' : item.itemName} + + {item.standard || '-'} + + {item.method || ''} + + {item.frequency || ''} + + + + +
+ 검사항목이 없습니다.
종합판정
- {rowIndex + 1} - - {item.itemName} - - {item.standard || item.frequency || '-'} - - {readonly ? ( -
- - {judgment === '적합' ? '■' : '□'} 적합 - - - {judgment === '부적합' ? '■' : '□'} 부적합 - -
- ) : ( -
- - -
- )} -