Files
sam-react-prod/src/components/production/WorkerScreen/InspectionInputModal.tsx

1224 lines
47 KiB
TypeScript
Raw Normal View History

'use client';
/**
*
*
* :
* - screen: 스크린
* - slat: 슬랫
* - slat_jointbar: 조인트바
* - bending: 절곡
* - bending_wip: 재고생산()
*/
import { useState, useEffect, useMemo, useRef } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
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, InspectionTemplateSectionItem } from './types';
import { formatNumber } from '@/lib/utils/amount';
import { getInspectionConfig } from '@/components/production/WorkOrders/actions';
import type { InspectionConfigData } from '@/components/production/WorkOrders/actions';
// 중간검사 공정 타입
export type InspectionProcessType =
| 'screen'
| 'slat'
| 'slat_jointbar'
| 'bending'
| 'bending_wip';
// 검사 결과 데이터 타입
export interface InspectionData {
productName: string;
specification: string;
// 겉모양 상태
bendingStatus?: 'good' | 'bad' | null; // 절곡상태
processingStatus?: 'good' | 'bad' | null; // 가공상태
sewingStatus?: 'good' | 'bad' | null; // 재봉상태
assemblyStatus?: 'good' | 'bad' | null; // 조립상태
// 치수
length?: number | null;
width?: number | null;
height1?: number | null;
height2?: number | null;
length3?: number | null;
gap4?: number | null;
gapStatus?: 'ok' | 'ng' | null;
// 간격 포인트들 (절곡용)
gapPoints?: { left: number | null; right: number | null }[];
// 판정
judgment: 'pass' | 'fail' | null;
nonConformingContent: string;
// 동적 폼 값 (템플릿 기반 검사 시)
templateValues?: Record<string, unknown>;
}
interface InspectionInputModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
processType: InspectionProcessType;
productName?: string;
specification?: string;
initialData?: InspectionData;
onComplete: (data: InspectionData) => void;
/** 문서 템플릿 데이터 (있으면 동적 폼 모드) */
templateData?: InspectionTemplateData;
/** 작업 아이템의 실제 치수 (reference_attribute 연동용) */
workItemDimensions?: { width?: number; height?: number };
/** 작업지시 ID (절곡 gap_points API 조회용) */
workOrderId?: string;
}
// ===== 절곡 7개 제품 검사 항목 (BendingInspectionContent의 INITIAL_PRODUCTS와 동일 구조) =====
interface BendingGapPointDef {
point: string;
design: string;
}
interface BendingProductDef {
id: string;
label: string;
lengthDesign: string;
widthDesign: string;
gapPoints: BendingGapPointDef[];
}
const BENDING_PRODUCTS: BendingProductDef[] = [
{
id: 'guide-rail-wall', label: '가이드레일 (벽면형)', lengthDesign: '3000', widthDesign: 'N/A',
gapPoints: [
{ point: '①', design: '30' }, { point: '②', design: '80' },
{ point: '③', design: '45' }, { point: '④', design: '40' }, { point: '⑤', design: '34' },
],
},
{
id: 'guide-rail-side', label: '가이드레일 (측면형)', lengthDesign: '3000', widthDesign: 'N/A',
gapPoints: [
{ point: '①', design: '28' }, { point: '②', design: '75' },
{ point: '③', design: '42' }, { point: '④', design: '38' }, { point: '⑤', design: '32' },
],
},
{
id: 'case', label: '케이스 (500X380)', lengthDesign: '3000', widthDesign: 'N/A',
gapPoints: [
{ point: '①', design: '380' }, { point: '②', design: '50' },
{ point: '③', design: '240' }, { point: '④', design: '50' },
],
},
{
id: 'bottom-finish', label: '하단마감재 (60X40)', lengthDesign: '3000', widthDesign: 'N/A',
gapPoints: [
{ point: '②', design: '60' }, { point: '②', design: '64' },
],
},
{
id: 'bottom-l-bar', label: '하단L-BAR (17X60)', lengthDesign: '3000', widthDesign: 'N/A',
gapPoints: [
{ point: '①', design: '17' },
],
},
{
id: 'smoke-w50', label: '연기차단재 (W50)', lengthDesign: '3000', widthDesign: '',
gapPoints: [
{ point: '①', design: '50' }, { point: '②', design: '12' },
],
},
{
id: 'smoke-w80', label: '연기차단재 (W80)', lengthDesign: '3000', widthDesign: '',
gapPoints: [
{ point: '①', design: '80' }, { point: '②', design: '12' },
],
},
];
interface BendingProductState {
id: string;
bendingStatus: 'good' | 'bad' | null;
lengthMeasured: string;
widthMeasured: string;
gapMeasured: string[];
}
function createInitialBendingProducts(): BendingProductState[] {
return BENDING_PRODUCTS.map(p => ({
id: p.id,
bendingStatus: null,
lengthMeasured: '',
widthMeasured: '',
gapMeasured: p.gapPoints.map(() => ''),
}));
}
const PROCESS_TITLES: Record<InspectionProcessType, string> = {
screen: '# 스크린 중간검사',
slat: '# 슬랫 중간검사',
slat_jointbar: '# 조인트바 중간검사',
bending: '# 절곡 중간검사',
bending_wip: '# 재고생산 중간검사',
};
// 양호/불량 버튼 컴포넌트
function StatusToggle({
value,
onChange,
}: {
value: 'good' | 'bad' | null;
onChange: (v: 'good' | 'bad') => void;
}) {
return (
<div className="flex gap-2 flex-1">
<button
type="button"
onClick={() => onChange('good')}
className={cn(
'flex-1 py-2.5 rounded-lg text-sm font-bold transition-colors',
value === 'good'
? 'bg-black text-white'
: 'bg-gray-100 text-gray-700 border border-gray-300 hover:bg-gray-200'
)}
>
</button>
<button
type="button"
onClick={() => onChange('bad')}
className={cn(
'flex-1 py-2.5 rounded-lg text-sm font-bold transition-colors',
value === 'bad'
? 'bg-black text-white'
: 'bg-gray-100 text-gray-700 border border-gray-300 hover:bg-gray-200'
)}
>
</button>
</div>
);
}
// OK/NG 버튼 컴포넌트
function OkNgToggle({
value,
onChange,
}: {
value: 'ok' | 'ng' | null;
onChange: (v: 'ok' | 'ng') => void;
}) {
return (
<div className="flex gap-2 flex-1">
<button
type="button"
onClick={() => onChange('ok')}
className={cn(
'flex-1 py-2.5 rounded-lg text-sm font-bold transition-colors',
value === 'ok'
? 'bg-black text-white'
: 'bg-gray-100 text-gray-700 border border-gray-300 hover:bg-gray-200'
)}
>
OK
</button>
<button
type="button"
onClick={() => onChange('ng')}
className={cn(
'flex-1 py-2.5 rounded-lg text-sm font-bold transition-colors',
value === 'ng'
? 'bg-black text-white'
: 'bg-gray-100 text-gray-700 border border-gray-300 hover:bg-gray-200'
)}
>
NG
</button>
</div>
);
}
// 자동 판정 표시 컴포넌트
function JudgmentDisplay({ value }: { value: 'pass' | 'fail' | null }) {
return (
<div className="flex gap-2 flex-1">
<div
className={cn(
'flex-1 py-2.5 rounded-lg text-sm font-bold text-center transition-colors',
value === 'pass'
? 'bg-orange-600 text-white'
: 'bg-gray-100 text-gray-400 border border-gray-300'
)}
>
</div>
<div
className={cn(
'flex-1 py-2.5 rounded-lg text-sm font-bold text-center transition-colors',
value === 'fail'
? 'bg-black text-white'
: 'bg-gray-100 text-gray-400 border border-gray-300'
)}
>
</div>
</div>
);
}
// 공정별 자동 판정 계산
function hasMeasurement(v: number | null | undefined): boolean {
return v != null;
}
function computeJudgment(processType: InspectionProcessType, data: InspectionData): 'pass' | 'fail' | null {
switch (processType) {
case 'screen': {
const { processingStatus, sewingStatus, assemblyStatus, gapStatus, length, width } = data;
// 불량이 하나라도 있으면 즉시 부적합
if (processingStatus === 'bad' || sewingStatus === 'bad' || assemblyStatus === 'bad' || gapStatus === 'ng') return 'fail';
// 모든 상태 양호 + 측정값 입력 완료 시 적합
if (processingStatus === 'good' && sewingStatus === 'good' && assemblyStatus === 'good' && gapStatus === 'ok'
&& hasMeasurement(length) && hasMeasurement(width)) return 'pass';
return null;
}
case 'slat': {
const { processingStatus, assemblyStatus, height1, height2, length } = data;
if (processingStatus === 'bad' || assemblyStatus === 'bad') return 'fail';
if (processingStatus === 'good' && assemblyStatus === 'good'
&& hasMeasurement(height1) && hasMeasurement(height2) && hasMeasurement(length)) return 'pass';
return null;
}
case 'slat_jointbar': {
const { processingStatus, assemblyStatus, height1, height2, length3, gap4 } = data;
if (processingStatus === 'bad' || assemblyStatus === 'bad') return 'fail';
if (processingStatus === 'good' && assemblyStatus === 'good'
&& hasMeasurement(height1) && hasMeasurement(height2) && hasMeasurement(length3) && hasMeasurement(gap4)) return 'pass';
return null;
}
case 'bending': {
const { bendingStatus, length } = data;
if (bendingStatus === 'bad') return 'fail';
if (bendingStatus === 'good' && hasMeasurement(length)) return 'pass';
return null;
}
case 'bending_wip': {
const { bendingStatus, length, width } = data;
if (bendingStatus === 'bad') return 'fail';
if (bendingStatus === 'good' && hasMeasurement(length) && hasMeasurement(width)) return 'pass';
return null;
}
default:
return null;
}
}
// ===== Tolerance 기반 판정 유틸 =====
type ToleranceConfig = NonNullable<NonNullable<InspectionTemplateData['template']>['sections'][number]['items'][number]['tolerance']>;
function evaluateTolerance(measured: number, design: number, tolerance: ToleranceConfig): 'pass' | 'fail' {
switch (tolerance.type) {
case 'symmetric':
return Math.abs(measured - design) <= (tolerance.value ?? 0) ? 'pass' : 'fail';
case 'asymmetric':
return (measured >= design - (tolerance.minus ?? 0) && measured <= design + (tolerance.plus ?? 0)) ? 'pass' : 'fail';
case 'range':
return (measured >= (tolerance.min ?? -Infinity) && measured <= (tolerance.max ?? Infinity)) ? 'pass' : 'fail';
default:
return 'pass';
}
}
function formatToleranceLabel(tolerance: ToleranceConfig): string {
switch (tolerance.type) {
case 'symmetric':
return `± ${tolerance.value}`;
case 'asymmetric':
return `+${tolerance.plus} / -${tolerance.minus}`;
case 'range':
return `${tolerance.min} ~ ${tolerance.max}`;
default:
return '';
}
}
/** reference_attribute에서 치수 resolve */
function resolveRefValue(
fieldValues: Record<string, unknown> | null,
dimensions?: { width?: number; height?: number }
): number | null {
if (!fieldValues || !dimensions) return null;
const refAttr = fieldValues.reference_attribute;
if (typeof refAttr !== 'string') return null;
const mapping: Record<string, number | undefined> = {
width: dimensions.width,
height: dimensions.height,
length: dimensions.width, // 스크린 '길이' = 폭(width)
};
return mapping[refAttr] ?? null;
}
function formatDimension(val: number | undefined): string {
if (val === undefined || val === null) return '-';
return formatNumber(val);
}
// ===== 항목별 입력 유형 판별 =====
// measurement_type이 'numeric' 또는 'measurement'이면 측정치수 입력, 나머지는 OK/NG 체크
function isNumericItem(item: InspectionTemplateSectionItem): boolean {
const mt = item.measurement_type?.toLowerCase();
return mt === 'numeric' || mt === 'measurement';
}
// 기준치수 resolve (standard_criteria → reference_attribute fallback)
function resolveDesignValue(
item: InspectionTemplateSectionItem,
workItemDimensions?: { width?: number; height?: number }
): number | undefined {
if (item.standard_criteria) {
const designStr = typeof item.standard_criteria === 'object'
? String((item.standard_criteria as Record<string, number>).nominal ?? '')
: String(item.standard_criteria);
const parsed = parseFloat(designStr);
if (!isNaN(parsed)) return parsed;
}
const refVal = resolveRefValue(item.field_values, workItemDimensions);
return refVal !== null ? refVal : undefined;
}
// ===== 동적 폼 (템플릿 기반) =====
function DynamicInspectionForm({
template,
formValues,
onValueChange,
workItemDimensions,
}: {
template: NonNullable<InspectionTemplateData['template']>;
formValues: Record<string, unknown>;
onValueChange: (key: string, value: unknown) => void;
workItemDimensions?: { width?: number; height?: number };
}) {
// 모든 섹션의 아이템을 플랫하게 렌더링 (섹션 구분은 가벼운 구분선으로)
return (
<div className="space-y-4">
{template.sections.map((section, sectionIdx) => (
<div key={section.id} className="space-y-3">
{/* 섹션 구분: 2번째 섹션부터 구분선 표시 */}
{sectionIdx > 0 && (
<div className="border-t border-gray-200 pt-3">
<span className="text-xs text-gray-400 font-medium">{section.name}</span>
</div>
)}
{section.items.map((item) => {
const fieldKey = `section_${section.id}_item_${item.id}`;
const itemLabel = item.item || item.name || '';
const designValue = resolveDesignValue(item, workItemDimensions);
// ── 측정치수 입력 (길이, 높이 등) ──
if (isNumericItem(item)) {
const numValue = formValues[fieldKey] as number | null | undefined;
const designLabel = designValue !== undefined ? formatNumber(designValue) : '';
const toleranceLabel = item.tolerance
? ` (${designLabel ? designLabel + ' ' : ''}${formatToleranceLabel(item.tolerance)})`
: designLabel ? ` (${designLabel})` : '';
let itemJudgment: 'pass' | 'fail' | null = null;
if (item.tolerance && numValue != null && designValue !== undefined) {
itemJudgment = evaluateTolerance(numValue, designValue, item.tolerance);
}
return (
<div key={item.id} className="space-y-1.5">
<div className="flex items-center gap-2">
<span className="text-sm font-bold">
{itemLabel}{toleranceLabel}
</span>
{itemJudgment && (
<span className={cn(
'text-xs font-bold px-2 py-0.5 rounded',
itemJudgment === 'pass' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
)}>
{itemJudgment === 'pass' ? '적합' : '부적합'}
</span>
)}
</div>
<Input
type="number"
placeholder={designValue !== undefined ? String(designValue) : '입력'}
value={numValue ?? ''}
onChange={(e) => {
const v = e.target.value === '' ? null : parseFloat(e.target.value);
onValueChange(fieldKey, v);
}}
className="h-11 rounded-lg border-gray-300"
/>
</div>
);
}
// ── OK/NG 체크 (기준치수 표시 있으면 함께 표시) ──
const value = formValues[fieldKey] as 'ok' | 'ng' | null | undefined;
const hasStandard = designValue !== undefined || item.standard;
const standardDisplay = designValue !== undefined
? (item.tolerance
? `${formatNumber(designValue)} ${formatToleranceLabel(item.tolerance)}`
: String(formatNumber(designValue)))
: item.standard;
return (
<div key={item.id} className="space-y-1.5">
<div className="space-y-0.5">
<span className="text-sm font-bold">{itemLabel}</span>
{hasStandard && standardDisplay && (
<p className="text-xs text-gray-500">: {standardDisplay}</p>
)}
</div>
<OkNgToggle
value={value ?? null}
onChange={(v) => onValueChange(fieldKey, v)}
/>
</div>
);
})}
</div>
))}
</div>
);
}
// 동적 폼의 자동 판정 계산
function computeDynamicJudgment(
template: NonNullable<InspectionTemplateData['template']>,
formValues: Record<string, unknown>,
workItemDimensions?: { width?: number; height?: number }
): 'pass' | 'fail' | null {
let totalItems = 0;
let filledItems = 0;
let hasFail = false;
for (const section of template.sections) {
for (const item of section.items) {
totalItems++;
const fieldKey = `section_${section.id}_item_${item.id}`;
const value = formValues[fieldKey];
if (isNumericItem(item)) {
const numValue = value as number | null | undefined;
if (numValue != null) {
filledItems++;
if (item.tolerance) {
const design = resolveDesignValue(item, workItemDimensions);
if (design !== undefined) {
const result = evaluateTolerance(numValue, design, item.tolerance);
if (result === 'fail') hasFail = true;
}
}
}
} else {
if (value != null) {
filledItems++;
if (value === 'ng') hasFail = true;
}
}
}
}
if (filledItems === 0) return null;
if (hasFail) return 'fail';
// 모든 항목이 입력되어야 적합 판정
if (filledItems < totalItems) return null;
return 'pass';
}
export function InspectionInputModal({
open,
onOpenChange,
processType,
productName = '',
specification = '',
initialData,
onComplete,
templateData,
workItemDimensions,
workOrderId,
}: InspectionInputModalProps) {
// 템플릿 모드 여부
// 절곡(bending)은 7제품 커스텀 폼 사용 → TemplateInspectionContent의 bending 셀 키와 연동
const useTemplateMode = processType !== 'bending' && !!(templateData?.has_template && templateData.template);
const [formData, setFormData] = useState<InspectionData>({
productName,
specification,
judgment: null,
nonConformingContent: '',
});
// 동적 폼 값 (템플릿 모드용)
const [dynamicFormValues, setDynamicFormValues] = useState<Record<string, unknown>>({});
// 이전 형식 데이터 로드 시 auto-judgment가 judgment를 덮어쓰지 않도록 보호
const skipAutoJudgmentRef = useRef(false);
// 절곡용 간격 포인트 초기화 (레거시 — bending_wip 등에서 사용)
const [gapPoints, setGapPoints] = useState<{ left: number | null; right: number | null }[]>(
Array(5).fill(null).map(() => ({ left: null, right: null }))
);
// 절곡 API 제품 정의 (gap_points 동적 로딩)
const [apiProductDefs, setApiProductDefs] = useState<BendingProductDef[] | null>(null);
const effectiveProductDefs = apiProductDefs || BENDING_PRODUCTS;
// 절곡 7개 제품별 상태 (bending 전용)
const [bendingProducts, setBendingProducts] = useState<BendingProductState[]>(createInitialBendingProducts);
// API에서 절곡 제품 gap_points 동적 로딩
useEffect(() => {
if (!open || processType !== 'bending' || !workOrderId) return;
let cancelled = false;
getInspectionConfig(workOrderId).then(result => {
if (cancelled) return;
if (result.success && result.data?.items?.length) {
const displayMap: Record<string, { label: string; len: string; wid: string }> = {
guide_rail_wall: { label: '가이드레일 (벽면형)', len: '3000', wid: 'N/A' },
guide_rail_side: { label: '가이드레일 (측면형)', len: '3000', wid: 'N/A' },
case_box: { label: '케이스 (500X380)', len: '3000', wid: 'N/A' },
bottom_bar: { label: '하단마감재 (60X40)', len: '3000', wid: 'N/A' },
bottom_l_bar: { label: '하단L-BAR (17X60)', len: '3000', wid: 'N/A' },
smoke_w50: { label: '연기차단재 (W50)', len: '3000', wid: '' },
smoke_w80: { label: '연기차단재 (W80)', len: '3000', wid: '' },
};
const defs: BendingProductDef[] = result.data.items.map(item => {
const d = displayMap[item.id] || { label: item.name, len: '-', wid: 'N/A' };
return {
id: item.id,
label: d.label,
lengthDesign: d.len,
widthDesign: d.wid,
gapPoints: item.gap_points.map(gp => ({ point: gp.point, design: gp.design_value })),
};
});
setApiProductDefs(defs);
}
});
return () => { cancelled = true; };
}, [open, processType, workOrderId]);
// API 제품 정의 로딩 시 bendingProducts 갱신 (gap 개수 동기화)
useEffect(() => {
if (!apiProductDefs || processType !== 'bending') return;
setBendingProducts(prev => {
return apiProductDefs.map((def, idx) => {
// 기존 입력값 보존 (ID 매칭 또는 인덱스 폴백)
const existing = prev.find(p => p.id === def.id || p.id.replace(/[-_]/g, '') === def.id.replace(/[-_]/g, ''))
|| (idx < prev.length ? prev[idx] : undefined);
return {
id: def.id,
bendingStatus: existing?.bendingStatus ?? null,
lengthMeasured: existing?.lengthMeasured ?? '',
widthMeasured: existing?.widthMeasured ?? '',
gapMeasured: def.gapPoints.map((_, gi) => existing?.gapMeasured?.[gi] ?? ''),
};
});
});
}, [apiProductDefs, processType]);
useEffect(() => {
if (open) {
// initialData가 있으면 기존 저장 데이터로 복원
if (initialData) {
setFormData({
...initialData,
productName: initialData.productName || productName,
specification: initialData.specification || specification,
nonConformingContent: initialData.nonConformingContent ?? '',
});
if (initialData.gapPoints) {
setGapPoints(initialData.gapPoints);
} else {
setGapPoints(Array(5).fill(null).map(() => ({ left: null, right: null })));
}
// 절곡 제품별 데이터 복원
const savedProducts = (initialData as unknown as Record<string, unknown>).products as Array<{
id: string;
bendingStatus: string;
lengthMeasured: string;
widthMeasured: string;
gapPoints: Array<{ point: string; designValue: string; measured: string }>;
}> | undefined;
if (savedProducts && Array.isArray(savedProducts)) {
setBendingProducts(effectiveProductDefs.map((def, idx) => {
const saved = savedProducts.find(sp =>
sp.id === def.id || sp.id.replace(/[-_]/g, '') === def.id.replace(/[-_]/g, '')
) || (idx < savedProducts.length ? savedProducts[idx] : undefined);
if (!saved) return { id: def.id, bendingStatus: null, lengthMeasured: '', widthMeasured: '', gapMeasured: def.gapPoints.map(() => '') };
return {
id: def.id,
bendingStatus: saved.bendingStatus === '양호' ? 'good' : saved.bendingStatus === '불량' ? 'bad' : (saved.bendingStatus as 'good' | 'bad' | null),
lengthMeasured: saved.lengthMeasured || '',
widthMeasured: saved.widthMeasured || '',
gapMeasured: def.gapPoints.map((_, gi) => saved.gapPoints?.[gi]?.measured || ''),
};
}));
} else if (processType === 'bending' && initialData.judgment) {
// 이전 형식 데이터 호환: products 배열 없이 저장된 경우
// judgment 값으로 제품별 상태 추론 (pass → 전체 양호)
const restoredStatus: 'good' | 'bad' | null =
initialData.judgment === 'pass' ? 'good' : initialData.judgment === 'fail' ? 'bad' : null;
setBendingProducts(effectiveProductDefs.map(def => ({
id: def.id,
bendingStatus: restoredStatus,
lengthMeasured: '',
widthMeasured: '',
gapMeasured: def.gapPoints.map(() => ''),
})));
// 이전 형식은 lengthMeasured가 없어 autoJudgment가 null이 되므로
// 로드된 judgment를 덮어쓰지 않도록 보호
skipAutoJudgmentRef.current = true;
} else {
setBendingProducts(createInitialBendingProducts());
}
// 동적 폼 값 복원 (템플릿 기반 검사 데이터)
if (initialData.templateValues) {
setDynamicFormValues(initialData.templateValues);
} else {
setDynamicFormValues({});
}
return;
}
// 공정별 기본값 설정 - 모두 미선택(null) 상태로 초기화
const baseData: InspectionData = {
productName,
specification,
judgment: null,
nonConformingContent: '',
};
// 공정별 추가 기본값 설정 (모두 null)
switch (processType) {
case 'screen':
setFormData({
...baseData,
processingStatus: null,
sewingStatus: null,
assemblyStatus: null,
gapStatus: null,
});
break;
case 'slat':
setFormData({
...baseData,
processingStatus: null,
assemblyStatus: null,
});
break;
case 'slat_jointbar':
setFormData({
...baseData,
processingStatus: null,
assemblyStatus: null,
});
break;
case 'bending':
setFormData({
...baseData,
bendingStatus: null,
});
break;
case 'bending_wip':
setFormData({
...baseData,
bendingStatus: null,
});
break;
default:
setFormData(baseData);
}
setGapPoints(Array(5).fill(null).map(() => ({ left: null, right: null })));
setBendingProducts(createInitialBendingProducts());
setDynamicFormValues({});
}
}, [open, productName, specification, processType, initialData]);
// 자동 판정 계산 (템플릿 모드 vs 절곡 7제품 모드 vs 레거시 모드)
const autoJudgment = useMemo(() => {
if (useTemplateMode && templateData?.template) {
return computeDynamicJudgment(templateData.template, dynamicFormValues, workItemDimensions);
}
// 절곡 7개 제품 전용 판정
if (processType === 'bending') {
let allGood = true;
let allFilled = true;
for (const p of bendingProducts) {
if (p.bendingStatus === 'bad') return 'fail';
if (p.bendingStatus !== 'good') { allGood = false; allFilled = false; }
if (!p.lengthMeasured) allFilled = false;
}
if (allGood && allFilled) return 'pass';
return null;
}
return computeJudgment(processType, formData);
}, [useTemplateMode, templateData, dynamicFormValues, workItemDimensions, processType, formData, bendingProducts]);
// 판정값 자동 동기화 (이전 형식 데이터 로드 시 첫 번째 동기화 건너뜀)
useEffect(() => {
if (skipAutoJudgmentRef.current) {
skipAutoJudgmentRef.current = false;
return;
}
setFormData((prev) => {
if (prev.judgment === autoJudgment) return prev;
return { ...prev, judgment: autoJudgment };
});
}, [autoJudgment]);
const handleComplete = () => {
const baseData: InspectionData = {
...formData,
// 동적 폼 값을 templateValues로 병합
...(useTemplateMode ? { templateValues: dynamicFormValues } : {}),
};
// 절곡: products 배열을 성적서와 동일 포맷으로 저장
if (processType === 'bending') {
const products = bendingProducts.map((p, idx) => ({
id: p.id,
bendingStatus: p.bendingStatus === 'good' ? '양호' : p.bendingStatus === 'bad' ? '불량' : null,
lengthMeasured: p.lengthMeasured,
widthMeasured: p.widthMeasured,
gapPoints: (effectiveProductDefs[idx]?.gapPoints || []).map((gp, gi) => ({
point: gp.point,
designValue: gp.design,
measured: p.gapMeasured[gi] || '',
})),
}));
const data = { ...baseData, products } as unknown as InspectionData;
onComplete(data);
onOpenChange(false);
return;
}
onComplete(baseData);
onOpenChange(false);
};
const handleCancel = () => {
onOpenChange(false);
};
// 숫자 입력 핸들러
const handleNumberChange = (
key: keyof InspectionData,
value: string
) => {
const num = value === '' ? null : parseFloat(value);
setFormData((prev) => ({ ...prev, [key]: num }));
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[95vw] max-w-[500px] sm:max-w-[500px] max-h-[90vh] flex flex-col p-0">
<DialogHeader className="px-6 pt-6 pb-0 shrink-0">
<DialogTitle className="text-lg font-bold">
{PROCESS_TITLES[processType]}
</DialogTitle>
</DialogHeader>
<div className="flex-1 min-h-0 overflow-y-auto px-5 py-4 space-y-5">
{/* 기본 정보 (읽기전용) */}
<div className="space-y-3">
<div className="space-y-1.5">
<span className="text-sm font-bold"></span>
<Input
value={formData.productName}
readOnly
className="h-11 bg-gray-100 border-gray-300 rounded-lg"
/>
</div>
<div className="space-y-1.5">
<span className="text-sm font-bold"></span>
<Input
value={formData.specification}
readOnly
className="h-11 bg-gray-100 border-gray-300 rounded-lg"
/>
</div>
</div>
{/* ===== 동적 폼 (템플릿 기반) ===== */}
{useTemplateMode && templateData?.template && (
<DynamicInspectionForm
template={templateData.template}
formValues={dynamicFormValues}
onValueChange={(key, value) =>
setDynamicFormValues((prev) => ({ ...prev, [key]: value }))
}
workItemDimensions={workItemDimensions}
/>
)}
{/* ===== 레거시: 공정별 하드코딩 검사 항목 (템플릿 없을 때만 표시) ===== */}
{/* ===== 재고생산 (bending_wip) 검사 항목 ===== */}
{!useTemplateMode && processType === 'bending_wip' && (
<>
<div className="space-y-1.5">
<span className="text-sm font-bold"> </span>
<StatusToggle
value={formData.bendingStatus || null}
onChange={(v) => setFormData((prev) => ({ ...prev, bendingStatus: v }))}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<span className="text-sm font-bold"> ({formatDimension(workItemDimensions?.width)})</span>
<Input
type="number"
placeholder={formatDimension(workItemDimensions?.width)}
value={formData.length ?? ''}
onChange={(e) => handleNumberChange('length', e.target.value)}
className="h-11 rounded-lg border-gray-300"
/>
</div>
<div className="space-y-1.5">
<span className="text-sm font-bold"> ({formatDimension(workItemDimensions?.height)})</span>
<Input
type="number"
placeholder={formatDimension(workItemDimensions?.height)}
value={formData.width ?? ''}
onChange={(e) => handleNumberChange('width', e.target.value)}
className="h-11 rounded-lg border-gray-300"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<span className="text-sm font-bold"> (1,000)</span>
<div className="flex gap-2">
<Input
type="number"
placeholder="①"
className="h-11 rounded-lg border-gray-300"
/>
<Input
type="number"
placeholder="1,000"
className="h-11 rounded-lg border-gray-300"
/>
</div>
</div>
</div>
</>
)}
{/* ===== 스크린 검사 항목 ===== */}
{!useTemplateMode && processType === 'screen' && (
<>
<div className="space-y-1.5">
<span className="text-sm font-bold"> </span>
<StatusToggle
value={formData.processingStatus || null}
onChange={(v) => setFormData((prev) => ({ ...prev, processingStatus: v }))}
/>
</div>
<div className="space-y-1.5">
<span className="text-sm font-bold"> </span>
<StatusToggle
value={formData.sewingStatus || null}
onChange={(v) => setFormData((prev) => ({ ...prev, sewingStatus: v }))}
/>
</div>
<div className="space-y-1.5">
<span className="text-sm font-bold"> </span>
<StatusToggle
value={formData.assemblyStatus || null}
onChange={(v) => setFormData((prev) => ({ ...prev, assemblyStatus: v }))}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<span className="text-sm font-bold"> ({formatDimension(workItemDimensions?.width)})</span>
<Input
type="number"
placeholder={formatDimension(workItemDimensions?.width)}
value={formData.length ?? ''}
onChange={(e) => handleNumberChange('length', e.target.value)}
className="h-11 rounded-lg border-gray-300"
/>
</div>
<div className="space-y-1.5">
<span className="text-sm font-bold"> ({formatDimension(workItemDimensions?.height)})</span>
<Input
type="number"
placeholder={formatDimension(workItemDimensions?.height)}
value={formData.width ?? ''}
onChange={(e) => handleNumberChange('width', e.target.value)}
className="h-11 rounded-lg border-gray-300"
/>
</div>
</div>
<div className="space-y-1.5">
<span className="text-sm font-bold"> (400 )</span>
<OkNgToggle
value={formData.gapStatus || null}
onChange={(v) => setFormData((prev) => ({ ...prev, gapStatus: v }))}
/>
</div>
</>
)}
{/* ===== 슬랫 검사 항목 ===== */}
{!useTemplateMode && processType === 'slat' && (
<>
<div className="space-y-1.5">
<span className="text-sm font-bold"> </span>
<StatusToggle
value={formData.processingStatus || null}
onChange={(v) => setFormData((prev) => ({ ...prev, processingStatus: v }))}
/>
</div>
<div className="space-y-1.5">
<span className="text-sm font-bold"> </span>
<StatusToggle
value={formData.assemblyStatus || null}
onChange={(v) => setFormData((prev) => ({ ...prev, assemblyStatus: v }))}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<span className="text-sm font-bold"> (16.5 ± 1)</span>
<Input
type="number"
placeholder="16.5"
value={formData.height1 ?? ''}
onChange={(e) => handleNumberChange('height1', e.target.value)}
className="h-11 rounded-lg border-gray-300"
/>
</div>
<div className="space-y-1.5">
<span className="text-sm font-bold"> (14.5 ± 1)</span>
<Input
type="number"
placeholder="14.5"
value={formData.height2 ?? ''}
onChange={(e) => handleNumberChange('height2', e.target.value)}
className="h-11 rounded-lg border-gray-300"
/>
</div>
</div>
<div className="space-y-1.5">
<span className="text-sm font-bold"> (0)</span>
<Input
type="number"
placeholder="0"
value={formData.length ?? ''}
onChange={(e) => handleNumberChange('length', e.target.value)}
className="h-11 rounded-lg border-gray-300"
/>
</div>
</>
)}
{/* ===== 조인트바 검사 항목 ===== */}
{!useTemplateMode && processType === 'slat_jointbar' && (
<>
<div className="space-y-1.5">
<span className="text-sm font-bold"> </span>
<StatusToggle
value={formData.processingStatus || null}
onChange={(v) => setFormData((prev) => ({ ...prev, processingStatus: v }))}
/>
</div>
<div className="space-y-1.5">
<span className="text-sm font-bold"> </span>
<StatusToggle
value={formData.assemblyStatus || null}
onChange={(v) => setFormData((prev) => ({ ...prev, assemblyStatus: v }))}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<span className="text-sm font-bold"> (16.5 ± 1)</span>
<Input
type="number"
placeholder="16.5"
value={formData.height1 ?? ''}
onChange={(e) => handleNumberChange('height1', e.target.value)}
className="h-11 rounded-lg border-gray-300"
/>
</div>
<div className="space-y-1.5">
<span className="text-sm font-bold"> (14.5 ± 1)</span>
<Input
type="number"
placeholder="14.5"
value={formData.height2 ?? ''}
onChange={(e) => handleNumberChange('height2', e.target.value)}
className="h-11 rounded-lg border-gray-300"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<span className="text-sm font-bold"> (300 ± 1)</span>
<Input
type="number"
placeholder="300"
value={formData.length3 ?? ''}
onChange={(e) => handleNumberChange('length3', e.target.value)}
className="h-11 rounded-lg border-gray-300"
/>
</div>
<div className="space-y-1.5">
<span className="text-sm font-bold"> (150 ± 1)</span>
<Input
type="number"
placeholder="150"
value={formData.gap4 ?? ''}
onChange={(e) => handleNumberChange('gap4', e.target.value)}
className="h-11 rounded-lg border-gray-300"
/>
</div>
</div>
</>
)}
{/* ===== 절곡 검사 항목 (7개 제품별) ===== */}
{!useTemplateMode && processType === 'bending' && (
<div className="space-y-4">
{effectiveProductDefs.map((productDef, pIdx) => {
const pState = bendingProducts[pIdx];
if (!pState) return null;
const updateProduct = (updates: Partial<BendingProductState>) => {
setBendingProducts(prev => prev.map((p, i) => i === pIdx ? { ...p, ...updates } : p));
};
return (
<div key={productDef.id} className={cn(pIdx > 0 && 'border-t border-gray-200 pt-4')}>
{/* 제품명 헤더 */}
<div className="mb-3">
<span className="text-sm font-bold text-gray-900">
{pIdx + 1}. {productDef.label}
</span>
</div>
{/* 절곡상태 */}
<div className="space-y-1.5 mb-3">
<span className="text-xs text-gray-500 font-medium"></span>
<StatusToggle
value={pState.bendingStatus}
onChange={(v) => updateProduct({ bendingStatus: v })}
/>
</div>
{/* 길이 / 너비 */}
<div className="grid grid-cols-2 gap-3 mb-3">
<div className="space-y-1.5">
<span className="text-xs text-gray-500 font-medium"> ({productDef.lengthDesign})</span>
<Input
type="number"
placeholder={productDef.lengthDesign}
value={pState.lengthMeasured}
onChange={(e) => updateProduct({ lengthMeasured: e.target.value })}
className="h-10 rounded-lg border-gray-300 text-sm"
/>
</div>
<div className="space-y-1.5">
<span className="text-xs text-gray-500 font-medium"> ({productDef.widthDesign || '-'})</span>
{productDef.widthDesign === 'N/A' ? (
<Input
type="text"
value="N/A"
readOnly
className="h-10 bg-gray-100 border-gray-300 rounded-lg text-sm"
/>
) : (
<Input
type="number"
placeholder={productDef.widthDesign || '-'}
value={pState.widthMeasured}
onChange={(e) => updateProduct({ widthMeasured: e.target.value })}
className="h-10 rounded-lg border-gray-300 text-sm"
/>
)}
</div>
</div>
{/* 간격 포인트 */}
{productDef.gapPoints.length > 0 && (
<div className="space-y-1.5">
<span className="text-xs text-gray-500 font-medium"></span>
<div className="grid grid-cols-2 gap-2">
{productDef.gapPoints.map((gp, gi) => (
<div key={gi} className="flex items-center gap-1.5">
<span className="text-xs text-gray-400 w-14 shrink-0">{gp.point} ({gp.design})</span>
<Input
type="number"
placeholder={gp.design}
value={pState.gapMeasured[gi] || ''}
onChange={(e) => {
const newGaps = [...pState.gapMeasured];
newGaps[gi] = e.target.value;
updateProduct({ gapMeasured: newGaps });
}}
className="h-9 rounded-lg border-gray-300 text-sm"
/>
</div>
))}
</div>
</div>
)}
</div>
);
})}
</div>
)}
{/* 부적합 내용 */}
<div className="space-y-1.5">
<span className="text-sm font-bold"> </span>
<Textarea
value={formData.nonConformingContent}
onChange={(e) =>
setFormData((prev) => ({ ...prev, nonConformingContent: e.target.value }))
}
placeholder="입력 시 '일련번호: 내용' 형태로 취합되어 표시"
className="min-h-[80px] rounded-lg border-gray-300"
/>
</div>
</div>
{/* 하단 고정: 판정 + 버튼 */}
<div className="shrink-0 border-t bg-white px-5 pb-5 pt-3 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.05)]">
<div className="space-y-2 mb-4">
<span className="text-sm font-bold"> ()</span>
<JudgmentDisplay value={autoJudgment} />
</div>
<div className="flex gap-3">
<Button
variant="outline"
onClick={handleCancel}
className="flex-1 h-11 rounded-lg border-gray-300 font-bold"
>
</Button>
<Button
variant="ghost"
onClick={handleComplete}
className="flex-1 h-11 rounded-lg bg-black hover:bg-gray-800 text-white font-bold"
>
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}