feat: [production] 절곡 중간검사 입력 모달 — 7개 제품 항목 통합 및 성적서 데이터 연동

- InspectionInputModal: 절곡 전용 7개 제품별 입력 폼 (절곡상태/길이/너비/간격)
- TemplateInspectionContent: products 배열 → bending cellValues 자동 매핑
- 제품 ID 3단계 매칭 (정규화→키워드→인덱스 폴백)
- 절곡 작업지시서 bending 섹션 개선
This commit is contained in:
2026-03-04 21:56:39 +09:00
parent 0b81e9c1dd
commit 4331b84a63
8 changed files with 478 additions and 97 deletions

View File

@@ -73,6 +73,86 @@ interface InspectionInputModalProps {
workItemDimensions?: { width?: number; height?: number };
}
// ===== 절곡 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: '# 슬랫 중간검사',
@@ -463,7 +543,8 @@ export function InspectionInputModal({
workItemDimensions,
}: InspectionInputModalProps) {
// 템플릿 모드 여부
const useTemplateMode = !!(templateData?.has_template && templateData.template);
// 절곡(bending)은 7제품 커스텀 폼 사용 → TemplateInspectionContent의 bending 셀 키와 연동
const useTemplateMode = processType !== 'bending' && !!(templateData?.has_template && templateData.template);
const [formData, setFormData] = useState<InspectionData>({
productName,
@@ -475,11 +556,14 @@ export function InspectionInputModal({
// 동적 폼 값 (템플릿 모드용)
const [dynamicFormValues, setDynamicFormValues] = useState<Record<string, unknown>>({});
// 절곡용 간격 포인트 초기화
// 절곡용 간격 포인트 초기화 (레거시 — bending_wip 등에서 사용)
const [gapPoints, setGapPoints] = useState<{ left: number | null; right: number | null }[]>(
Array(5).fill(null).map(() => ({ left: null, right: null }))
);
// 절곡 7개 제품별 상태 (bending 전용)
const [bendingProducts, setBendingProducts] = useState<BendingProductState[]>(createInitialBendingProducts);
useEffect(() => {
if (open) {
// initialData가 있으면 기존 저장 데이터로 복원
@@ -495,6 +579,29 @@ export function InspectionInputModal({
} 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(BENDING_PRODUCTS.map((def, idx) => {
const saved = savedProducts.find(sp => sp.id === def.id);
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 {
setBendingProducts(createInitialBendingProducts());
}
// 동적 폼 값 복원 (템플릿 기반 검사 데이터)
if (initialData.templateValues) {
setDynamicFormValues(initialData.templateValues);
@@ -554,17 +661,30 @@ export function InspectionInputModal({
}
setGapPoints(Array(5).fill(null).map(() => ({ left: null, right: null })));
setBendingProducts(createInitialBendingProducts());
setDynamicFormValues({});
}
}, [open, productName, specification, processType, initialData]);
// 자동 판정 계산 (템플릿 모드 vs 레거시 모드)
// 자동 판정 계산 (템플릿 모드 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]);
}, [useTemplateMode, templateData, dynamicFormValues, workItemDimensions, processType, formData, bendingProducts]);
// 판정값 자동 동기화
useEffect(() => {
@@ -575,13 +695,32 @@ export function InspectionInputModal({
}, [autoJudgment]);
const handleComplete = () => {
const data: InspectionData = {
const baseData: InspectionData = {
...formData,
gapPoints: processType === 'bending' ? gapPoints : undefined,
// 동적 폼 값을 templateValues로 병합
...(useTemplateMode ? { templateValues: dynamicFormValues } : {}),
};
onComplete(data);
// 절곡: 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: BENDING_PRODUCTS[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);
};
@@ -866,75 +1005,96 @@ export function InspectionInputModal({
</>
)}
{/* ===== 절곡 검사 항목 ===== */}
{/* ===== 절곡 검사 항목 (7개 제품별) ===== */}
{!useTemplateMode && processType === 'bending' && (
<>
<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"> (N/A)</span>
<Input
type="text"
placeholder="N/A"
value={formData.width ?? 'N/A'}
readOnly
className="h-11 bg-gray-100 border-gray-300 rounded-lg"
/>
</div>
</div>
<div className="space-y-3">
<span className="text-sm font-bold"></span>
{gapPoints.map((point, index) => (
<div key={index} className="grid grid-cols-3 gap-2 items-center">
<span className="text-gray-500 text-sm font-medium">{index + 1}</span>
<Input
type="number"
placeholder={String(30 + index * 10)}
value={point.left ?? ''}
onChange={(e) => {
const newPoints = [...gapPoints];
newPoints[index] = {
...newPoints[index],
left: e.target.value === '' ? null : parseFloat(e.target.value),
};
setGapPoints(newPoints);
}}
className="h-11 rounded-lg border-gray-300"
/>
<Input
type="number"
placeholder={String(30 + index * 10)}
value={point.right ?? ''}
onChange={(e) => {
const newPoints = [...gapPoints];
newPoints[index] = {
...newPoints[index],
right: e.target.value === '' ? null : parseFloat(e.target.value),
};
setGapPoints(newPoints);
}}
className="h-11 rounded-lg border-gray-300"
/>
<div className="space-y-4">
{BENDING_PRODUCTS.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>
)}
{/* 부적합 내용 */}