- 검사 템플릿 타입(InspectionTemplateData 등)을 WorkerScreen/types.ts로 분리 - use server 파일에서 export type 제거 (Turbopack 모듈 평가 시 값으로 처리되는 문제) - 모든 타입 import를 types.ts 직접 참조로 변경
483 lines
20 KiB
TypeScript
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>
|
|
);
|
|
}
|
|
);
|