fix(WEB): Turbopack use server 파일 간 export type 런타임 에러 수정

- 검사 템플릿 타입(InspectionTemplateData 등)을 WorkerScreen/types.ts로 분리
- use server 파일에서 export type 제거 (Turbopack 모듈 평가 시 값으로 처리되는 문제)
- 모든 타입 import를 types.ts 직접 참조로 변경
This commit is contained in:
2026-02-10 19:27:45 +09:00
parent 6e5ccca038
commit e508014224
7 changed files with 785 additions and 209 deletions

View File

@@ -734,6 +734,108 @@ export async function saveInspectionData(
}
}
// ===== 검사 문서 템플릿 조회 (document_template 기반) =====
import type { InspectionTemplateData } from '@/components/production/WorkerScreen/types';
export async function getInspectionTemplate(
workOrderId: string
): Promise<{ success: boolean; data?: InspectionTemplateData; error?: string }> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders/${workOrderId}/inspection-template`,
{ method: 'GET' }
);
if (error || !response) {
return { success: false, error: error?.message || 'API 요청 실패' };
}
const result = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '검사 템플릿을 불러올 수 없습니다.' };
}
return { success: true, data: result.data };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[WorkOrderActions] getInspectionTemplate error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 검사 문서 저장 (Document + DocumentData) =====
export async function saveInspectionDocument(
workOrderId: string,
data: {
step_id?: number;
title?: string;
data: Record<string, unknown>[];
approvers?: { role_name: string; user_id?: number }[];
}
): Promise<{
success: boolean;
data?: { document_id: number; document_no: string; status: string };
error?: string;
}> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders/${workOrderId}/inspection-document`,
{
method: 'POST',
body: JSON.stringify(data),
}
);
if (error || !response) {
return { success: false, error: error?.message || 'API 요청 실패' };
}
const result = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '검사 문서 저장에 실패했습니다.' };
}
return { success: true, data: result.data };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[WorkOrderActions] saveInspectionDocument error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 검사 문서 resolve (기존 문서/템플릿 조회) =====
export async function resolveInspectionDocument(
workOrderId: string,
params?: { step_id?: number }
): Promise<{
success: boolean;
data?: { mode: 'existing' | 'new'; document?: Record<string, unknown>; template?: Record<string, unknown> };
error?: string;
}> {
try {
const query = params?.step_id ? `?step_id=${params.step_id}` : '';
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders/${workOrderId}/inspection-resolve${query}`,
{ method: 'GET' }
);
if (error || !response) {
return { success: false, error: error?.message || 'API 요청 실패' };
}
const result = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '검사 문서 조회에 실패했습니다.' };
}
return { success: true, data: result.data };
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[WorkOrderActions] resolveInspectionDocument error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 수주 목록 조회 (작업지시 생성용) =====
export interface SalesOrderForWorkOrder {
id: number;

View File

@@ -16,7 +16,13 @@ import { Loader2, Save } from 'lucide-react';
import { DocumentViewer } from '@/components/document-system';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import { getWorkOrderById, saveInspectionData, getInspectionReport } from '../actions';
import {
getWorkOrderById,
saveInspectionData,
getInspectionReport,
getInspectionTemplate,
saveInspectionDocument,
} from '../actions';
import type { WorkOrder, ProcessType } from '../types';
import type { InspectionReportData } from '../actions';
import { ScreenInspectionContent } from './ScreenInspectionContent';
@@ -24,11 +30,12 @@ import { SlatInspectionContent } from './SlatInspectionContent';
import { BendingInspectionContent } from './BendingInspectionContent';
import { BendingWipInspectionContent } from './BendingWipInspectionContent';
import { SlatJointBarInspectionContent } from './SlatJointBarInspectionContent';
import { TemplateInspectionContent } from './TemplateInspectionContent';
import type { InspectionContentRef } from './ScreenInspectionContent';
import type { InspectionData } from '@/components/production/WorkerScreen/InspectionInputModal';
import type { WorkItemData } from '@/components/production/WorkerScreen/types';
import type { InspectionSetting } from '@/types/process';
import type { InspectionTemplateData } from '@/components/production/WorkerScreen/actions';
import type { InspectionTemplateData } from '@/components/production/WorkerScreen/types';
const PROCESS_LABELS: Record<ProcessType, string> = {
screen: '스크린',
@@ -124,6 +131,8 @@ export function InspectionReportModal({
const [apiWorkItems, setApiWorkItems] = useState<WorkItemData[] | null>(null);
const [apiInspectionDataMap, setApiInspectionDataMap] = useState<InspectionDataMap | null>(null);
const [reportSummary, setReportSummary] = useState<InspectionReportData['summary'] | null>(null);
// 자체 로딩한 템플릿 데이터 (prop으로 안 넘어올 때)
const [selfTemplateData, setSelfTemplateData] = useState<InspectionTemplateData | null>(null);
// props에서 목업 제외한 실제 개소만 사용 (WorkerScreen에서 apiItems + mockItems가 합쳐져 전달됨)
// React에서는 개소 미등록 시 성적서 버튼 자체가 노출되지 않으므로 API fallback 불필요
@@ -180,12 +189,14 @@ export function InspectionReportModal({
setIsLoading(true);
setError(null);
// 작업지시 기본정보 + 검사 성적서 데이터 동시 로딩
// 작업지시 기본정보 + 검사 성적서 + 템플릿 동시 로딩
Promise.all([
getWorkOrderById(workOrderId),
getInspectionReport(workOrderId),
// prop으로 템플릿이 안 넘어왔으면 자체 로딩
!templateData ? getInspectionTemplate(workOrderId) : Promise.resolve(null),
])
.then(([orderResult, reportResult]) => {
.then(([orderResult, reportResult, templateResult]) => {
// 1) WorkOrder 기본정보
if (orderResult.success && orderResult.data) {
const orderData = orderResult.data;
@@ -217,6 +228,11 @@ export function InspectionReportModal({
setApiInspectionDataMap(null);
setReportSummary(null);
}
// 3) 템플릿 데이터 (자체 로딩한 경우)
if (templateResult && templateResult.success && templateResult.data) {
setSelfTemplateData(templateResult.data);
}
})
.catch(() => {
setError('서버 오류가 발생했습니다.');
@@ -229,9 +245,15 @@ export function InspectionReportModal({
setApiWorkItems(null);
setApiInspectionDataMap(null);
setReportSummary(null);
setSelfTemplateData(null);
setError(null);
}
}, [open, workOrderId, processType]);
}, [open, workOrderId, processType, templateData]);
// 템플릿 결정: prop 우선, 없으면 자체 로딩 결과 사용
const resolvedTemplateData = templateData || selfTemplateData;
const activeTemplate = resolvedTemplateData?.has_template ? resolvedTemplateData.template : null;
const activeStepId = resolvedTemplateData?.templates?.[0]?.step_id ?? null;
const handleSave = useCallback(async () => {
if (!workOrderId || !contentRef.current) return;
@@ -239,18 +261,43 @@ export function InspectionReportModal({
const data = contentRef.current.getInspectionData();
setIsSaving(true);
try {
const result = await saveInspectionData(workOrderId, processType, data);
if (result.success) {
toast.success('검사 데이터가 저장되었습니다.');
// 템플릿 모드: Document 기반 저장
if (activeTemplate) {
const inspData = data as {
template_id: number;
items: { id: string; apiItemId?: number; judgment: string | null; values: Record<string, unknown> }[];
inadequateContent: string;
overall_result: string | null;
};
const result = await saveInspectionDocument(workOrderId, {
step_id: activeStepId ?? undefined,
title: activeTemplate.title || activeTemplate.name,
data: inspData.items.map(item => ({
item_id: item.apiItemId || item.id,
judgment: item.judgment,
values: item.values,
})),
});
if (result.success) {
toast.success('검사 문서가 저장되었습니다.');
} else {
toast.error(result.error || '저장에 실패했습니다.');
}
} else {
toast.error(result.error || '저장에 실패했습니다.');
// 레거시 모드: 기존 저장
const result = await saveInspectionData(workOrderId, processType, data);
if (result.success) {
toast.success('검사 데이터가 저장되었습니다.');
} else {
toast.error(result.error || '저장에 실패했습니다.');
}
}
} catch {
toast.error('저장 중 오류가 발생했습니다.');
} finally {
setIsSaving(false);
}
}, [workOrderId, processType]);
}, [workOrderId, processType, activeTemplate, activeStepId]);
if (!workOrderId) return null;
@@ -262,139 +309,20 @@ export function InspectionReportModal({
? '중간검사성적서 (조인트바)'
: '중간검사 성적서';
// 템플릿 기반 동적 렌더링 여부
const useTemplateMode = !!(templateData?.has_template && templateData.template);
const renderContent = () => {
if (!order) return null;
// 템플릿 모드: 동적 렌더링
if (useTemplateMode && templateData?.template) {
const tpl = templateData.template;
// 템플릿 모드: TemplateInspectionContent 사용
if (activeTemplate) {
return (
<div className="p-6 bg-white space-y-6">
{/* 기본 정보 */}
<div>
<h3 className="text-sm font-bold mb-2"> </h3>
<table className="w-full border-collapse border text-xs">
<tbody>
{tpl.basic_fields?.map((field) => (
<tr key={field.id}>
<td className="border px-3 py-1.5 bg-gray-50 font-medium w-32">{field.name}</td>
<td className="border 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) :
'-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 검사 기준서 (sections) */}
{tpl.sections?.map((section) => (
<div key={section.id}>
<h3 className="text-sm font-bold mb-2">{section.name}</h3>
<table className="w-full border-collapse border text-xs">
<thead>
<tr className="bg-gray-50">
<th className="border px-3 py-1.5 text-left w-8">No</th>
<th className="border px-3 py-1.5 text-left"></th>
<th className="border px-3 py-1.5 text-left w-24"></th>
<th className="border px-3 py-1.5 text-left w-24"></th>
<th className="border px-3 py-1.5 text-left w-20"></th>
</tr>
</thead>
<tbody>
{section.items?.map((item, idx) => (
<tr key={item.id}>
<td className="border px-3 py-1.5">{idx + 1}</td>
<td className="border px-3 py-1.5">{item.name}</td>
<td className="border px-3 py-1.5">{item.standard_criteria || '-'}</td>
<td className="border px-3 py-1.5">
{item.tolerance ? (
item.tolerance.type === 'symmetric' ? `± ${item.tolerance.value}` :
item.tolerance.type === 'asymmetric' ? `+${item.tolerance.plus} / -${item.tolerance.minus}` :
item.tolerance.type === 'range' ? `${item.tolerance.min} ~ ${item.tolerance.max}` : '-'
) : '-'}
</td>
<td className="border px-3 py-1.5">{item.measurement_type || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
))}
{/* 검사 DATA (columns) */}
{tpl.columns && tpl.columns.length > 0 && (
<div>
<h3 className="text-sm font-bold mb-2"> DATA</h3>
<table className="w-full border-collapse border text-xs">
<thead>
<tr className="bg-gray-50">
<th className="border px-3 py-1.5 text-left w-8">No</th>
{tpl.columns.map((col) => (
<th key={col.id} className="border px-3 py-1.5 text-left">{col.name}</th>
))}
<th className="border px-3 py-1.5 text-left w-16"></th>
</tr>
</thead>
<tbody>
{(effectiveWorkItems || []).map((item, idx) => {
const itemData = effectiveInspectionDataMap?.get(item.id);
return (
<tr key={item.id}>
<td className="border px-3 py-1.5">{idx + 1}</td>
{tpl.columns.map((col) => (
<td key={col.id} className="border px-3 py-1.5">
{itemData?.templateValues
? String(itemData.templateValues[`col_${col.id}`] ?? '-')
: '-'}
</td>
))}
<td className="border px-3 py-1.5 font-bold">
{itemData?.judgment === 'pass' ? (
<span className="text-green-600"></span>
) : itemData?.judgment === 'fail' ? (
<span className="text-red-600"></span>
) : '-'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{/* 결재라인 */}
{tpl.approval_lines && tpl.approval_lines.length > 0 && (
<div>
<h3 className="text-sm font-bold mb-2"></h3>
<table className="w-full border-collapse border text-xs">
<thead>
<tr className="bg-gray-50">
{tpl.approval_lines.map((line) => (
<th key={line.id} className="border px-3 py-2 text-center w-24">{line.role_name}</th>
))}
</tr>
</thead>
<tbody>
<tr>
{tpl.approval_lines.map((line) => (
<td key={line.id} className="border px-3 py-4 text-center text-gray-400">
()
</td>
))}
</tr>
</tbody>
</table>
</div>
)}
</div>
<TemplateInspectionContent
ref={contentRef}
data={order}
template={activeTemplate}
readOnly={readOnly}
workItems={effectiveWorkItems}
inspectionDataMap={effectiveInspectionDataMap}
/>
);
}

View File

@@ -0,0 +1,482 @@
'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>
);
}
);

View File

@@ -22,7 +22,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import type { InspectionTemplateData } from './actions';
import type { InspectionTemplateData } from './types';
// 중간검사 공정 타입
export type InspectionProcessType =
@@ -281,7 +281,7 @@ function DynamicInspectionForm({
// 양호/불량 토글
return (
<div key={item.id} className="space-y-1.5">
<span className="text-sm font-bold">{item.name}</span>
<span className="text-sm font-bold">{item.item || item.name}</span>
<div className="flex gap-2 flex-1">
<button
type="button"
@@ -318,17 +318,24 @@ function DynamicInspectionForm({
// 판정 표시
let itemJudgment: 'pass' | 'fail' | null = null;
if (item.tolerance && numValue != null && item.standard_criteria) {
const design = parseFloat(item.standard_criteria);
const designStr = typeof item.standard_criteria === 'object'
? String((item.standard_criteria as Record<string, number>).nominal ?? '')
: String(item.standard_criteria);
const design = parseFloat(designStr);
if (!isNaN(design)) {
itemJudgment = evaluateTolerance(numValue, design, item.tolerance);
}
}
const placeholderStr = typeof item.standard_criteria === 'object'
? String((item.standard_criteria as Record<string, number>).nominal ?? '입력')
: (item.standard_criteria || '입력');
return (
<div key={item.id} className="space-y-1.5">
<div className="flex items-center gap-2">
<span className="text-sm font-bold">
{item.name}{toleranceLabel}
{item.item || item.name}{toleranceLabel}
</span>
{itemJudgment && (
<span className={cn(
@@ -341,7 +348,7 @@ function DynamicInspectionForm({
</div>
<Input
type="number"
placeholder={item.standard_criteria || '입력'}
placeholder={placeholderStr}
value={numValue ?? ''}
onChange={(e) => {
const v = e.target.value === '' ? null : parseFloat(e.target.value);
@@ -378,7 +385,10 @@ function computeDynamicJudgment(
const numValue = value as number | null | undefined;
if (numValue != null) {
hasAnyValue = true;
const design = parseFloat(item.standard_criteria);
const designStr = typeof item.standard_criteria === 'object'
? String((item.standard_criteria as Record<string, number>).nominal ?? '')
: String(item.standard_criteria);
const design = parseFloat(designStr);
if (!isNaN(design)) {
const result = evaluateTolerance(numValue, design, item.tolerance);
if (result === 'fail') hasFail = true;

View File

@@ -515,69 +515,8 @@ export async function getWorkOrderInspectionData(
return { success: result.success, data: result.data, error: result.error };
}
// ===== 검사 문서 템플릿 조회 (document_template 기반) =====
export interface InspectionTemplateData {
work_order_id: number;
has_template: boolean;
template?: {
id: number;
name: string;
category: string;
description: string | null;
sections: {
id: number;
name: string;
sort_order: number;
items: {
id: number;
name: string;
type: string;
is_required: boolean;
sort_order: number;
options: Record<string, unknown> | null;
tolerance: {
type: 'symmetric' | 'asymmetric' | 'range';
value?: number;
plus?: number;
minus?: number;
min?: number;
max?: number;
} | null;
standard_criteria: string | null;
measurement_type: string | null;
}[];
}[];
columns: {
id: number;
name: string;
type: string;
sort_order: number;
is_required: boolean;
options: Record<string, unknown> | null;
}[];
approval_lines: {
id: number;
role_name: string;
sort_order: number;
is_required: boolean;
}[];
basic_fields: {
id: number;
name: string;
field_key: string;
field_type: string;
is_required: boolean;
sort_order: number;
options: Record<string, unknown> | null;
}[];
};
work_order_info?: {
work_order_no: string;
project_name: string | null;
process_name: string | null;
scheduled_date: string | null;
};
}
// ===== 검사 문서 템플릿 타입 (types.ts에서 import) =====
import type { InspectionTemplateData } from './types';
export async function getInspectionTemplate(
workOrderId: string

View File

@@ -34,7 +34,7 @@ import { PageLayout } from '@/components/organisms/PageLayout';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
import { getMyWorkOrders, completeWorkOrder, saveItemInspection, getWorkOrderInspectionData, saveInspectionDocument, getInspectionTemplate } from './actions';
import type { InspectionTemplateData } from './actions';
import type { InspectionTemplateData } from './types';
import { getProcessList } from '@/components/process-management/actions';
import type { InspectionSetting, Process } from '@/types/process';
import type { WorkOrder } from '../ProductionDashboard/types';

View File

@@ -193,3 +193,118 @@ export interface CompletionToastInfo {
quantity: number;
lotNo: string;
}
// ===== 검사 템플릿 타입 =====
export interface InspectionTolerance {
type: 'symmetric' | 'asymmetric' | 'range';
value?: number;
plus?: number;
minus?: number;
min?: number;
max?: number;
}
/** 섹션 아이템 (검사항목) - API formatTemplateForReact 응답 기준 */
export interface InspectionTemplateSectionItem {
id: number;
field_values: Record<string, unknown> | null;
category: string | null;
item: string | null;
standard: string | null;
standard_criteria: Record<string, number> | string | null;
tolerance: InspectionTolerance | null;
method: string | null;
method_name: string | null;
measurement_type: string | null;
frequency: string | null;
frequency_n: number | null;
frequency_c: number | null;
regulation: string | null;
sort_order: number;
// backward compat (InspectionInputModal uses these)
name?: string;
type?: string;
is_required?: boolean;
options?: Record<string, unknown> | null;
}
/** 템플릿 전체 구조 (formatTemplateForReact 응답) */
export interface InspectionTemplateFormat {
id: number;
name: string;
category: string;
title?: string;
company_name?: string;
company_address?: string;
company_contact?: string;
footer_remark_label?: string;
footer_judgement_label?: string;
footer_judgement_options?: string[];
sections: {
id: number;
name: string;
sort_order: number;
items: InspectionTemplateSectionItem[];
}[];
section_fields: {
id: number;
field_key: string;
label: string;
field_type: string;
options: Record<string, unknown> | null;
width: string | null;
is_required: boolean;
sort_order: number;
}[];
columns: {
id: number;
label: string;
input_type: string;
options: Record<string, unknown> | null;
width: string | null;
is_required: boolean;
sort_order: number;
}[];
approval_lines: {
id: number;
name: string;
dept: string | null;
role: string | null;
user_id: number | null;
sort_order: number;
}[];
basic_fields: {
id: number;
field_key: string;
label: string;
input_type: string;
options: Record<string, unknown> | null;
default_value: string | null;
is_required: boolean;
sort_order: number;
}[];
}
/** 검사 단계별 템플릿 */
export interface InspectionTemplateStep {
step_id: number;
step_name: string;
step_code: string;
sort_order: number;
template: InspectionTemplateFormat;
}
/** 검사 문서 템플릿 조회 응답 */
export interface InspectionTemplateData {
work_order_id: number;
has_template: boolean;
templates?: InspectionTemplateStep[];
template?: InspectionTemplateFormat; // backward compat (첫 번째 템플릿)
work_order_info?: {
work_order_no: string;
project_name: string | null;
process_name: string | null;
scheduled_date: string | null;
};
}