feat: [제품검사 성적서] 8컬럼 동적 렌더링 + FQC 모드 기본값
- FqcDocumentContent: 8컬럼 시각 레이아웃 (No/검사항목/세부항목/검사기준/검사방법/검사주기/측정값/판정) - rowSpan 병합: category 단독 + method+frequency 복합키 병합 - measurement_type: checkbox→양호/불량, numeric→숫자입력, none→비활성 - InspectionReportModal: FQC 모드 우선 (template 로드 실패 시 legacy fallback) - Lazy Snapshot 준비 (contentWrapperRef 추가)
This commit is contained in:
@@ -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<number, number>();
|
||||
const covered = new Set<number>();
|
||||
|
||||
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<number, number>();
|
||||
const covered = new Set<number>();
|
||||
|
||||
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<number, number> {
|
||||
const numbers = new Map<number, number>();
|
||||
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<FqcDocumentContentRef, FqcDocumentContentProps>(
|
||||
@@ -75,31 +145,52 @@ export const FqcDocumentContent = forwardRef<FqcDocumentContentRef, FqcDocumentC
|
||||
[template.sections]
|
||||
);
|
||||
|
||||
// 판정 컬럼 ID 찾기
|
||||
const sectionItems = useMemo(
|
||||
() => 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<number, JudgmentValue> = {};
|
||||
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<number, string> = {};
|
||||
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<Record<number, JudgmentValue>>(initialJudgments);
|
||||
const [measurements, setMeasurements] = useState<Record<number, string>>(initialMeasurements);
|
||||
|
||||
// 판정 토글
|
||||
const toggleJudgment = useCallback(
|
||||
@@ -113,41 +204,32 @@ export const FqcDocumentContent = forwardRef<FqcDocumentContentRef, FqcDocumentC
|
||||
[readonly]
|
||||
);
|
||||
|
||||
// 종합판정 자동 계산
|
||||
// 측정값 변경
|
||||
const updateMeasurement = useCallback(
|
||||
(rowIndex: number, value: string) => {
|
||||
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<FqcDocumentContentRef, FqcDocumentC
|
||||
field_value: string | null;
|
||||
}> = [];
|
||||
|
||||
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<FqcDocumentContentRef, FqcDocumentC
|
||||
const sorted = [...template.basicFields].sort((a, b) => 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<FqcDocumentContentRef, FqcDocumentC
|
||||
</div>
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
{/* 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<FqcDocumentContentRef, FqcDocumentC
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 검사항목 테이블 */}
|
||||
{/* 검사항목 테이블 (8컬럼 시각 레이아웃) */}
|
||||
{dataSection && (
|
||||
<table className="w-full border-collapse border border-gray-400 mb-4">
|
||||
<thead>
|
||||
<tr className="bg-gray-100">
|
||||
{template.columns
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map(col => (
|
||||
<th
|
||||
key={col.id}
|
||||
className="border border-gray-400 px-2 py-1 text-center"
|
||||
style={col.width ? { width: col.width } : undefined}
|
||||
>
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
<th className="border border-gray-400 px-2 py-1 w-10 text-center">No.</th>
|
||||
<th className="border border-gray-400 px-2 py-1 w-24">검사항목</th>
|
||||
<th className="border border-gray-400 px-2 py-1">세부항목</th>
|
||||
<th className="border border-gray-400 px-2 py-1">검사기준</th>
|
||||
<th className="border border-gray-400 px-2 py-1 w-16 text-center whitespace-pre-line">{'검사\n방법'}</th>
|
||||
<th className="border border-gray-400 px-2 py-1 w-16 text-center whitespace-pre-line">{'검사\n주기'}</th>
|
||||
<th className="border border-gray-400 px-2 py-1 w-16 text-center">측정값</th>
|
||||
<th className="border border-gray-400 px-2 py-1 w-28 text-center">판정</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dataSection.items
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((item, idx) => (
|
||||
<InspectionRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
rowIndex={idx}
|
||||
judgment={judgments[idx] ?? null}
|
||||
onToggleJudgment={toggleJudgment}
|
||||
readonly={readonly}
|
||||
/>
|
||||
))}
|
||||
{sectionItems.map((item, idx) => (
|
||||
<tr key={item.id} className="border-b border-gray-300">
|
||||
{/* 1. No. — category 그룹 병합과 동일 */}
|
||||
{!categoryCoverage.covered.has(idx) && (
|
||||
<td
|
||||
className="border border-gray-400 px-2 py-1 text-center align-middle font-medium"
|
||||
rowSpan={categoryCoverage.spans.get(idx) || 1}
|
||||
>
|
||||
{categoryNumbers.get(idx)}
|
||||
</td>
|
||||
)}
|
||||
{/* 2. 검사항목 — category 그룹 병합 */}
|
||||
{!categoryCoverage.covered.has(idx) && (
|
||||
<td
|
||||
className="border border-gray-400 px-2 py-1 align-middle font-medium whitespace-pre-line"
|
||||
rowSpan={categoryCoverage.spans.get(idx) || 1}
|
||||
>
|
||||
{item.category || '-'}
|
||||
</td>
|
||||
)}
|
||||
{/* 3. 세부항목 */}
|
||||
<td className="border border-gray-400 px-2 py-1 whitespace-pre-line">
|
||||
{item.itemName === '-' ? '' : item.itemName}
|
||||
</td>
|
||||
{/* 4. 검사기준 */}
|
||||
<td className="border border-gray-400 px-2 py-1 whitespace-pre-line">
|
||||
{item.standard || '-'}
|
||||
</td>
|
||||
{/* 5. 검사방법 — method+frequency 복합키 병합 */}
|
||||
{!methodFreqCoverage.covered.has(idx) && (
|
||||
<td
|
||||
className="border border-gray-400 px-2 py-1 text-center align-middle whitespace-pre-line"
|
||||
rowSpan={methodFreqCoverage.spans.get(idx) || 1}
|
||||
>
|
||||
{item.method || ''}
|
||||
</td>
|
||||
)}
|
||||
{/* 6. 검사주기 — method+frequency 복합키 병합 (동일 span) */}
|
||||
{!methodFreqCoverage.covered.has(idx) && (
|
||||
<td
|
||||
className="border border-gray-400 px-2 py-1 text-center align-middle whitespace-pre-line"
|
||||
rowSpan={methodFreqCoverage.spans.get(idx) || 1}
|
||||
>
|
||||
{item.frequency || ''}
|
||||
</td>
|
||||
)}
|
||||
{/* 7. 측정값 */}
|
||||
<td className="border border-gray-400 px-1 py-1 text-center align-middle">
|
||||
<MeasurementCell
|
||||
item={item}
|
||||
rowIndex={idx}
|
||||
value={measurements[idx] ?? ''}
|
||||
judgment={judgments[idx] ?? null}
|
||||
onChange={updateMeasurement}
|
||||
onToggle={toggleJudgment}
|
||||
readonly={readonly}
|
||||
type="measurement"
|
||||
/>
|
||||
</td>
|
||||
{/* 8. 판정 */}
|
||||
<td className="border border-gray-400 px-1 py-1 text-center align-middle">
|
||||
<MeasurementCell
|
||||
item={item}
|
||||
rowIndex={idx}
|
||||
value={measurements[idx] ?? ''}
|
||||
judgment={judgments[idx] ?? null}
|
||||
onChange={updateMeasurement}
|
||||
onToggle={toggleJudgment}
|
||||
readonly={readonly}
|
||||
type="judgment"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{dataSection.items.length === 0 && (
|
||||
{sectionItems.length === 0 && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={template.columns.length}
|
||||
className="border border-gray-400 px-2 py-4 text-center text-gray-400"
|
||||
>
|
||||
<td colSpan={8} className="border border-gray-400 px-2 py-4 text-center text-gray-400">
|
||||
검사항목이 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -354,7 +506,7 @@ export const FqcDocumentContent = forwardRef<FqcDocumentContentRef, FqcDocumentC
|
||||
<tr>
|
||||
<td
|
||||
className="border border-gray-400 bg-gray-100 px-2 py-2 font-bold text-center"
|
||||
colSpan={template.columns.length - 1}
|
||||
colSpan={7}
|
||||
>
|
||||
종합판정
|
||||
</td>
|
||||
@@ -386,69 +538,116 @@ export const FqcDocumentContent = forwardRef<FqcDocumentContentRef, FqcDocumentC
|
||||
}
|
||||
);
|
||||
|
||||
// ===== 검사항목 행 =====
|
||||
// ===== 측정값/판정 셀 =====
|
||||
|
||||
interface InspectionRowProps {
|
||||
interface MeasurementCellProps {
|
||||
item: FqcTemplateItem;
|
||||
rowIndex: number;
|
||||
value: string;
|
||||
judgment: JudgmentValue;
|
||||
onToggleJudgment: (rowIndex: number, value: JudgmentValue) => 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 <span className="text-gray-300">-</span>;
|
||||
}
|
||||
|
||||
if (type === 'measurement') {
|
||||
// checkbox → 양호/불량 텍스트
|
||||
if (item.measurementType === 'checkbox') {
|
||||
if (readonly) {
|
||||
return <span className="text-[10px]">{value || '-'}</span>;
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(rowIndex, value === '양호' ? '' : '양호')}
|
||||
className={`px-1 py-0.5 rounded text-[9px] border transition-colors ${
|
||||
value === '양호'
|
||||
? 'bg-blue-600 text-white border-blue-600'
|
||||
: 'bg-white text-gray-500 border-gray-300 hover:border-blue-400'
|
||||
}`}
|
||||
>
|
||||
양호
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(rowIndex, value === '불량' ? '' : '불량')}
|
||||
className={`px-1 py-0.5 rounded text-[9px] border transition-colors ${
|
||||
value === '불량'
|
||||
? 'bg-red-600 text-white border-red-600'
|
||||
: 'bg-white text-gray-500 border-gray-300 hover:border-red-400'
|
||||
}`}
|
||||
>
|
||||
불량
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// numeric → 숫자 입력
|
||||
if (item.measurementType === 'numeric') {
|
||||
if (readonly) {
|
||||
return <span className="text-[10px]">{value || '-'}</span>;
|
||||
}
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={e => 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 <span className="text-gray-300">-</span>;
|
||||
}
|
||||
|
||||
// type === 'judgment'
|
||||
if (readonly) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-1 text-[10px]">
|
||||
<span className={judgment === '적합' ? 'font-bold text-blue-600' : 'text-gray-400'}>
|
||||
{judgment === '적합' ? '■' : '□'} 적합
|
||||
</span>
|
||||
<span className={judgment === '부적합' ? 'font-bold text-red-600' : 'text-gray-400'}>
|
||||
{judgment === '부적합' ? '■' : '□'} 부적합
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className="border-b border-gray-300">
|
||||
{/* NO */}
|
||||
<td className="border border-gray-400 px-2 py-1 text-center align-middle font-medium w-10">
|
||||
{rowIndex + 1}
|
||||
</td>
|
||||
{/* 검사항목 */}
|
||||
<td className="border border-gray-400 px-2 py-1 align-middle">
|
||||
{item.itemName}
|
||||
</td>
|
||||
{/* 검사기준 */}
|
||||
<td className="border border-gray-400 px-2 py-1 align-middle">
|
||||
{item.standard || item.frequency || '-'}
|
||||
</td>
|
||||
{/* 판정 */}
|
||||
<td className="border border-gray-400 px-1 py-1 text-center align-middle w-28">
|
||||
{readonly ? (
|
||||
<div className="flex items-center justify-center gap-1 text-[10px]">
|
||||
<span className={judgment === '적합' ? 'font-bold text-blue-600' : 'text-gray-400'}>
|
||||
{judgment === '적합' ? '■' : '□'} 적합
|
||||
</span>
|
||||
<span className={judgment === '부적합' ? 'font-bold text-red-600' : 'text-gray-400'}>
|
||||
{judgment === '부적합' ? '■' : '□'} 부적합
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleJudgment(rowIndex, '적합')}
|
||||
className={`px-2 py-0.5 rounded text-[10px] border transition-colors ${
|
||||
judgment === '적합'
|
||||
? 'bg-blue-600 text-white border-blue-600'
|
||||
: 'bg-white text-gray-500 border-gray-300 hover:border-blue-400'
|
||||
}`}
|
||||
>
|
||||
적합
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleJudgment(rowIndex, '부적합')}
|
||||
className={`px-2 py-0.5 rounded text-[10px] border transition-colors ${
|
||||
judgment === '부적합'
|
||||
? 'bg-red-600 text-white border-red-600'
|
||||
: 'bg-white text-gray-500 border-gray-300 hover:border-red-400'
|
||||
}`}
|
||||
>
|
||||
부적합
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(rowIndex, '적합')}
|
||||
className={`px-1.5 py-0.5 rounded text-[9px] border transition-colors ${
|
||||
judgment === '적합'
|
||||
? 'bg-blue-600 text-white border-blue-600'
|
||||
: 'bg-white text-gray-500 border-gray-300 hover:border-blue-400'
|
||||
}`}
|
||||
>
|
||||
적합
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(rowIndex, '부적합')}
|
||||
className={`px-1.5 py-0.5 rounded text-[9px] border transition-colors ${
|
||||
judgment === '부적합'
|
||||
? 'bg-red-600 text-white border-red-600'
|
||||
: 'bg-white text-gray-500 border-gray-300 hover:border-red-400'
|
||||
}`}
|
||||
>
|
||||
부적합
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
|
||||
// FQC 문서/양식 상태
|
||||
const [fqcDocument, setFqcDocument] = useState<FqcDocument | null>(null);
|
||||
const [fqcTemplate, setFqcTemplate] = useState<FqcTemplate | null>(null);
|
||||
const [isLoadingFqc, setIsLoadingFqc] = useState(false);
|
||||
const [fqcError, setFqcError] = useState<string | null>(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({
|
||||
<p>{fqcError}</p>
|
||||
</div>
|
||||
) : fqcDocument && fqcTemplate ? (
|
||||
<FqcDocumentContent
|
||||
template={fqcTemplate}
|
||||
documentData={fqcDocument.data}
|
||||
documentNo={fqcDocument.documentNo}
|
||||
createdDate={formatDate(fqcDocument.createdAt)}
|
||||
readonly={true}
|
||||
/>
|
||||
<div ref={contentWrapperRef}>
|
||||
<FqcDocumentContent
|
||||
template={fqcTemplate}
|
||||
documentData={fqcDocument.data}
|
||||
documentNo={fqcDocument.documentNo}
|
||||
createdDate={formatDate(fqcDocument.createdAt)}
|
||||
readonly={true}
|
||||
/>
|
||||
</div>
|
||||
) : fqcTemplate && !hasFqcDocuments ? (
|
||||
// template은 있지만 문서가 없는 경우 → legacy fallback
|
||||
legacyCurrentData ? <InspectionReportDocument data={legacyCurrentData} /> : (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<AlertCircle className="w-8 h-8 mb-2" />
|
||||
<p>이 개소의 FQC 문서가 아직 생성되지 않았습니다.</p>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
||||
<AlertCircle className="w-8 h-8 mb-2" />
|
||||
|
||||
Reference in New Issue
Block a user