Files
sam-react-prod/src/components/production/WorkOrders/documents/TemplateInspectionContent.tsx
권혁성 e508014224 fix(WEB): Turbopack use server 파일 간 export type 런타임 에러 수정
- 검사 템플릿 타입(InspectionTemplateData 등)을 WorkerScreen/types.ts로 분리
- use server 파일에서 export type 제거 (Turbopack 모듈 평가 시 값으로 처리되는 문제)
- 모든 타입 import를 types.ts 직접 참조로 변경
2026-02-10 19:27:45 +09:00

483 lines
20 KiB
TypeScript

'use client';
/**
* 템플릿 기반 중간검사 성적서 콘텐츠
*
* DocumentTemplate의 sections/items에서 measurement_type별 입력 셀을 자동 생성:
* - checkbox → 양호/불량 토글
* - numeric → 기준값 표시 + 측정값 입력
* - single_value → 단일값 입력
* - substitute → 성적서 대체 배지
* - text → 자유 텍스트 입력
*/
import { useState, forwardRef, useImperativeHandle, useEffect, Fragment } from 'react';
import type { WorkOrder } from '../types';
import type { WorkItemData } from '@/components/production/WorkerScreen/types';
import type { InspectionDataMap } from './InspectionReportModal';
import type {
InspectionTemplateFormat,
InspectionTemplateSectionItem,
InspectionTolerance,
} from '@/components/production/WorkerScreen/types';
import {
type InspectionContentRef,
InspectionCheckbox,
InspectionLayout,
InspectionFooter,
JudgmentCell,
calculateOverallResult,
getFullDate,
getOrderInfo,
INPUT_CLASS,
} from './inspection-shared';
export type { InspectionContentRef };
// ===== 셀 값 타입 =====
interface CellValue {
status?: 'good' | 'bad' | null;
measurements?: [string, string, string];
value?: string;
text?: string;
}
// ===== Props =====
interface TemplateInspectionContentProps {
data: WorkOrder;
template: InspectionTemplateFormat;
readOnly?: boolean;
workItems?: WorkItemData[];
inspectionDataMap?: InspectionDataMap;
}
// ===== 유틸 =====
function formatTolerance(tol: InspectionTolerance | null): string {
if (!tol) return '-';
if (tol.type === 'symmetric') return `± ${tol.value}`;
if (tol.type === 'asymmetric') return `+${tol.plus} / -${tol.minus}`;
if (tol.type === 'range') return `${tol.min} ~ ${tol.max}`;
return '-';
}
function formatStandard(item: InspectionTemplateSectionItem): string {
const sc = item.standard_criteria;
if (!sc) return item.standard || '-';
if (typeof sc === 'object') {
if ('nominal' in sc) return String(sc.nominal);
if ('min' in sc && 'max' in sc) return `${sc.min} ~ ${sc.max}`;
if ('max' in sc) return `${sc.max}`;
if ('min' in sc) return `${sc.min}`;
}
return String(sc);
}
function getNominalValue(item: InspectionTemplateSectionItem): number | null {
const sc = item.standard_criteria;
if (!sc || typeof sc !== 'object') {
if (typeof sc === 'string') {
const v = parseFloat(sc);
return isNaN(v) ? null : v;
}
return null;
}
if ('nominal' in sc) return sc.nominal;
return null;
}
function formatFrequency(item: InspectionTemplateSectionItem): string {
if (item.frequency_n && item.frequency_c) return `n=${item.frequency_n}, c=${item.frequency_c}`;
if (item.frequency) return item.frequency;
return '-';
}
function getMeasurementLabel(type: string | null): string {
switch (type) {
case 'checkbox': return 'OK/NG';
case 'numeric': return '수치(3회)';
case 'single_value': return '단일값';
case 'substitute': return '대체';
case 'text': return '자유입력';
default: return type || '-';
}
}
/** 측정값이 공차 범위 내인지 판정 */
function isWithinTolerance(measured: number, item: InspectionTemplateSectionItem): boolean {
const nominal = getNominalValue(item);
const tol = item.tolerance;
if (nominal === null || !tol) return true; // 기준 없으면 pass
switch (tol.type) {
case 'symmetric':
return Math.abs(measured - nominal) <= (tol.value ?? 0);
case 'asymmetric':
return measured >= nominal - (tol.minus ?? 0) && measured <= nominal + (tol.plus ?? 0);
case 'range':
return measured >= (tol.min ?? -Infinity) && measured <= (tol.max ?? Infinity);
default:
return true;
}
}
// ===== 컴포넌트 =====
export const TemplateInspectionContent = forwardRef<InspectionContentRef, TemplateInspectionContentProps>(
function TemplateInspectionContent({ data: order, template, readOnly = false, workItems, inspectionDataMap }, ref) {
const fullDate = getFullDate();
const { documentNo, primaryAssignee } = getOrderInfo(order);
// 모든 섹션의 아이템을 평탄화 (DATA 테이블 컬럼용)
const allItems = template.sections.flatMap(s => s.items);
// 셀 값 상태: key = `${rowIdx}-${itemId}`
const [cellValues, setCellValues] = useState<Record<string, CellValue>>({});
const [inadequateContent, setInadequateContent] = useState('');
// inspectionDataMap에서 초기값 복원
useEffect(() => {
if (!inspectionDataMap || !workItems) return;
const initial: Record<string, CellValue> = {};
workItems.forEach((wi, rowIdx) => {
const itemData = inspectionDataMap.get(wi.id);
if (!itemData?.templateValues) return;
allItems.forEach(sectionItem => {
const key = `${rowIdx}-${sectionItem.id}`;
const val = itemData.templateValues?.[`item_${sectionItem.id}`];
if (val && typeof val === 'object') {
initial[key] = val as CellValue;
}
});
});
if (Object.keys(initial).length > 0) setCellValues(initial);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inspectionDataMap, workItems]);
// ref로 데이터 수집 노출
useImperativeHandle(ref, () => ({
getInspectionData: () => {
const items = effectiveWorkItems.map((wi, idx) => ({
id: wi.id,
apiItemId: wi.apiItemId,
judgment: getRowJudgment(idx),
values: allItems.reduce((acc, sItem) => {
const key = `${idx}-${sItem.id}`;
acc[`item_${sItem.id}`] = cellValues[key] || null;
return acc;
}, {} as Record<string, CellValue | null>),
}));
return {
template_id: template.id,
items,
inadequateContent,
overall_result: overallResult,
};
},
}));
const updateCell = (key: string, update: Partial<CellValue>) => {
setCellValues(prev => ({
...prev,
[key]: { ...prev[key], ...update },
}));
};
// 행별 판정 계산
const getRowJudgment = (rowIdx: number): '적' | '부' | null => {
let hasAnyValue = false;
let hasFail = false;
for (const item of allItems) {
const key = `${rowIdx}-${item.id}`;
const cell = cellValues[key];
if (!cell) continue;
if (item.measurement_type === 'checkbox') {
if (cell.status === 'bad') hasFail = true;
if (cell.status) hasAnyValue = true;
} else if (item.measurement_type === 'numeric') {
const measurements = cell.measurements || ['', '', ''];
for (const m of measurements) {
if (m) {
hasAnyValue = true;
const val = parseFloat(m);
if (!isNaN(val) && !isWithinTolerance(val, item)) hasFail = true;
}
}
} else if (item.measurement_type === 'single_value') {
if (cell.value) {
hasAnyValue = true;
const val = parseFloat(cell.value);
if (!isNaN(val) && !isWithinTolerance(val, item)) hasFail = true;
}
} else if (item.measurement_type === 'substitute') {
// 성적서 대체는 항상 적합 취급
hasAnyValue = true;
} else if (cell.value || cell.text) {
hasAnyValue = true;
}
}
if (!hasAnyValue) return null;
return hasFail ? '부' : '적';
};
const effectiveWorkItems = workItems || [];
// 종합판정
const judgments = effectiveWorkItems.map((_, idx) => getRowJudgment(idx));
const overallResult = calculateOverallResult(judgments);
// numeric 아이템의 DATA 열 colspan 계산
const getItemColSpan = (item: InspectionTemplateSectionItem) => {
if (item.measurement_type === 'numeric') return 2; // 기준 + 측정
return 1;
};
const totalDataCols = allItems.reduce((sum, item) => sum + getItemColSpan(item), 0);
return (
<InspectionLayout
title={template.title || template.name || '중간검사 성적서'}
documentNo={documentNo}
fullDate={fullDate}
primaryAssignee={primaryAssignee}
>
{/* 기본 정보 */}
{template.basic_fields?.length > 0 && (
<table className="w-full border-collapse text-xs mb-4">
<tbody>
{template.basic_fields.map(field => (
<tr key={field.id}>
<td className="border border-gray-400 bg-gray-100 px-3 py-1.5 font-medium w-32">{field.label}</td>
<td className="border border-gray-400 px-3 py-1.5">
{field.field_key === 'product_name' ? order.items?.[0]?.productName || '-' :
field.field_key === 'lot_no' ? (order.lotNo || '-') :
field.field_key === 'quantity' ? String(order.items?.reduce((sum, i) => sum + (i.quantity || 0), 0) || 0) :
field.field_key === 'inspection_date' ? fullDate :
field.default_value || '-'}
</td>
</tr>
))}
</tbody>
</table>
)}
{/* 검사 기준서 - 섹션별 */}
{template.sections.map(section => (
<div key={section.id} className="mb-4">
<div className="mb-1 font-bold text-sm"> {section.name}</div>
<table className="w-full border-collapse text-xs">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-400 px-2 py-1.5 w-8">No</th>
<th className="border border-gray-400 px-2 py-1.5"></th>
<th className="border border-gray-400 px-2 py-1.5 w-20"></th>
<th className="border border-gray-400 px-2 py-1.5 w-20"></th>
<th className="border border-gray-400 px-2 py-1.5 w-16"></th>
<th className="border border-gray-400 px-2 py-1.5 w-16"></th>
<th className="border border-gray-400 px-2 py-1.5 w-20"></th>
</tr>
</thead>
<tbody>
{section.items.map((item, idx) => (
<tr key={item.id}>
<td className="border border-gray-400 px-2 py-1.5 text-center">{idx + 1}</td>
<td className="border border-gray-400 px-2 py-1.5">{item.item || item.category || '-'}</td>
<td className="border border-gray-400 px-2 py-1.5 text-center">{formatStandard(item)}</td>
<td className="border border-gray-400 px-2 py-1.5 text-center">{formatTolerance(item.tolerance)}</td>
<td className="border border-gray-400 px-2 py-1.5 text-center">{item.method_name || item.method || '-'}</td>
<td className="border border-gray-400 px-2 py-1.5 text-center">{getMeasurementLabel(item.measurement_type)}</td>
<td className="border border-gray-400 px-2 py-1.5 text-center">{formatFrequency(item)}</td>
</tr>
))}
</tbody>
</table>
</div>
))}
{/* 검사 DATA 테이블 */}
{allItems.length > 0 && effectiveWorkItems.length > 0 && (
<div className="mb-4">
<div className="mb-1 font-bold text-sm"> DATA</div>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-xs">
<thead>
{/* 상위 헤더: 항목 그룹 */}
<tr className="bg-gray-100">
<th className="border border-gray-400 px-2 py-1.5 w-8" rowSpan={2}>No</th>
<th className="border border-gray-400 px-2 py-1.5 min-w-[80px]" rowSpan={2}></th>
{allItems.map(item => (
<th
key={item.id}
className="border border-gray-400 px-2 py-1 text-center"
colSpan={getItemColSpan(item)}
>
{item.item || item.category || '-'}
</th>
))}
<th className="border border-gray-400 px-2 py-1.5 w-10" rowSpan={2}></th>
</tr>
{/* 하위 헤더: numeric 아이템만 기준/측정 분할 */}
<tr className="bg-gray-50">
{allItems.map(item => {
if (item.measurement_type === 'numeric') {
return (
<Fragment key={item.id}>
<th className="border border-gray-400 px-1 py-1 text-center text-[10px]"></th>
<th className="border border-gray-400 px-1 py-1 text-center text-[10px]"></th>
</Fragment>
);
}
// checkbox, single_value, text, substitute: 단일 열
return (
<th key={item.id} className="border border-gray-400 px-1 py-1 text-center text-[10px]">
{item.measurement_type === 'checkbox' ? '양호/불량' :
item.measurement_type === 'substitute' ? '대체' :
'입력'}
</th>
);
})}
</tr>
</thead>
<tbody>
{effectiveWorkItems.map((wi, rowIdx) => (
<tr key={wi.id}>
<td className="border border-gray-400 px-2 py-1.5 text-center">{rowIdx + 1}</td>
<td className="border border-gray-400 px-2 py-1.5 whitespace-nowrap">{wi.itemName || '-'}</td>
{allItems.map(item => {
const key = `${rowIdx}-${item.id}`;
const cell = cellValues[key];
// checkbox → 양호/불량 토글
if (item.measurement_type === 'checkbox') {
return (
<td key={item.id} className="border border-gray-400 p-1">
<div className="flex flex-col items-center gap-0.5">
<label className="flex items-center gap-0.5 cursor-pointer text-xs whitespace-nowrap">
<InspectionCheckbox
checked={cell?.status === 'good'}
onClick={() => updateCell(key, { status: cell?.status === 'good' ? null : 'good' })}
readOnly={readOnly}
/>
</label>
<label className="flex items-center gap-0.5 cursor-pointer text-xs whitespace-nowrap">
<InspectionCheckbox
checked={cell?.status === 'bad'}
onClick={() => updateCell(key, { status: cell?.status === 'bad' ? null : 'bad' })}
readOnly={readOnly}
/>
</label>
</div>
</td>
);
}
// numeric → 기준값 + 측정값 입력
if (item.measurement_type === 'numeric') {
return (
<Fragment key={item.id}>
<td className="border border-gray-400 px-2 py-1.5 text-center text-gray-500">
{formatStandard(item)}
</td>
<td className="border border-gray-400 p-0.5">
<input
type="text"
className={INPUT_CLASS}
value={cell?.measurements?.[0] || ''}
onChange={e => {
const m: [string, string, string] = [
...(cell?.measurements || ['', '', '']),
] as [string, string, string];
m[0] = e.target.value;
updateCell(key, { measurements: m });
}}
readOnly={readOnly}
placeholder="측정값"
/>
</td>
</Fragment>
);
}
// substitute → 성적서 대체 표시
if (item.measurement_type === 'substitute') {
return (
<td key={item.id} className="border border-gray-400 px-2 py-1.5 text-center">
<span className="inline-block px-1.5 py-0.5 bg-gray-100 text-gray-600 rounded text-[10px]">
</span>
</td>
);
}
// single_value, text, default → 입력 필드
return (
<td key={item.id} className="border border-gray-400 p-0.5">
<input
type="text"
className={INPUT_CLASS}
value={cell?.value || cell?.text || ''}
onChange={e => updateCell(key, { value: e.target.value })}
readOnly={readOnly}
placeholder="-"
/>
</td>
);
})}
<JudgmentCell judgment={getRowJudgment(rowIdx)} />
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* 부적합 내용 + 종합판정 */}
<InspectionFooter
readOnly={readOnly}
overallResult={overallResult}
inadequateContent={inadequateContent}
onInadequateContentChange={setInadequateContent}
/>
{/* 결재라인 */}
{template.approval_lines?.length > 0 && (
<div className="mt-4">
<table className="border-collapse text-sm ml-auto">
<tbody>
<tr>
<td className="border border-gray-400 px-4 py-1 text-center align-middle" rowSpan={3}><br/></td>
{template.approval_lines.map(line => (
<td key={`role-${line.id}`} className="border border-gray-400 px-6 py-1 text-center">
{line.role || line.name}
</td>
))}
</tr>
<tr>
{template.approval_lines.map(line => (
<td key={`name-${line.id}`} className="border border-gray-400 px-6 py-3 text-center text-gray-400">
{line.name || '이름'}
</td>
))}
</tr>
<tr>
{template.approval_lines.map(line => (
<td key={`dept-${line.id}`} className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap">
{line.dept || '부서명'}
</td>
))}
</tr>
</tbody>
</table>
</div>
)}
</InspectionLayout>
);
}
);