feat: [production] 절곡 중간검사 입력 모달 — 7개 제품 항목 통합 및 성적서 데이터 연동
- InspectionInputModal: 절곡 전용 7개 제품별 입력 폼 (절곡상태/길이/너비/간격) - TemplateInspectionContent: products 배열 → bending cellValues 자동 매핑 - 제품 ID 3단계 매칭 (정규화→키워드→인덱스 폴백) - 절곡 작업지시서 bending 섹션 개선
This commit is contained in:
@@ -624,6 +624,125 @@ export const TemplateInspectionContent = forwardRef<InspectionContentRef, Templa
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [documentRecords, isBending, bendingProducts]);
|
||||
|
||||
// ===== Bending: inspectionDataMap의 products 배열에서 셀 값 복원 =====
|
||||
// InspectionInputModal이 저장한 products 배열 → bending 셀 키로 매핑
|
||||
useEffect(() => {
|
||||
if (!isBending || !inspectionDataMap || !workItems || bendingProducts.length === 0) return;
|
||||
|
||||
// EAV 문서 데이터가 있으면 우선 (이미 위 useEffect에서 복원됨)
|
||||
if (documentRecords && documentRecords.length > 0) return;
|
||||
|
||||
// inspectionDataMap에서 products 배열 찾기
|
||||
type SavedProduct = {
|
||||
id: string;
|
||||
bendingStatus: string | null;
|
||||
lengthMeasured: string;
|
||||
widthMeasured: string;
|
||||
gapPoints: Array<{ point: string; designValue: string; measured: string }>;
|
||||
};
|
||||
let savedProducts: SavedProduct[] | undefined;
|
||||
for (const wi of workItems) {
|
||||
const d = inspectionDataMap.get(wi.id) as Record<string, unknown> | undefined;
|
||||
if (d?.products && Array.isArray(d.products)) {
|
||||
savedProducts = d.products as SavedProduct[];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!savedProducts || savedProducts.length === 0) return;
|
||||
|
||||
const initial: Record<string, CellValue> = {};
|
||||
|
||||
// 컬럼 분류
|
||||
const checkColId = template.columns.find(c => c.column_type === 'check')?.id;
|
||||
const complexCols = template.columns.filter(c =>
|
||||
c.column_type === 'complex' && c.id !== gapColumnId
|
||||
);
|
||||
|
||||
// 각 template bendingProduct → 저장된 product 매핑
|
||||
bendingProducts.forEach((bp, productIdx) => {
|
||||
// 1. ID 정규화 매칭 (guide-rail-wall ↔ guide_rail_wall)
|
||||
const normalizedBpId = bp.id.replace(/[-_]/g, '').toLowerCase();
|
||||
let matched = savedProducts!.find(sp =>
|
||||
sp.id.replace(/[-_]/g, '').toLowerCase() === normalizedBpId
|
||||
);
|
||||
|
||||
// 2. 이름 키워드 매칭
|
||||
if (!matched) {
|
||||
const bpKey = `${bp.productName}${bp.productType}`.replace(/\s/g, '').toLowerCase();
|
||||
matched = savedProducts!.find(sp => {
|
||||
const spId = sp.id.toLowerCase();
|
||||
if (bpKey.includes('가이드레일') && bpKey.includes('벽면') && spId.includes('guide') && spId.includes('wall')) return true;
|
||||
if (bpKey.includes('가이드레일') && bpKey.includes('측면') && spId.includes('guide') && spId.includes('side')) return true;
|
||||
if (bpKey.includes('케이스') && spId.includes('case')) return true;
|
||||
if (bpKey.includes('하단마감') && (spId.includes('bottom-finish') || spId.includes('bottom_bar'))) return true;
|
||||
if (bpKey.includes('연기차단') && bpKey.includes('w50') && spId.includes('w50')) return true;
|
||||
if (bpKey.includes('연기차단') && bpKey.includes('w80') && spId.includes('w80')) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
// 3. 인덱스 폴백
|
||||
if (!matched && productIdx < savedProducts!.length) {
|
||||
matched = savedProducts![productIdx];
|
||||
}
|
||||
if (!matched) return;
|
||||
|
||||
// check 컬럼 (절곡상태)
|
||||
if (checkColId) {
|
||||
const cellKey = `b-${productIdx}-${checkColId}`;
|
||||
if (matched.bendingStatus === '양호') {
|
||||
initial[cellKey] = { status: 'good' };
|
||||
} else if (matched.bendingStatus === '불량') {
|
||||
initial[cellKey] = { status: 'bad' };
|
||||
}
|
||||
}
|
||||
|
||||
// 간격 컬럼
|
||||
if (gapColumnId && matched.gapPoints) {
|
||||
matched.gapPoints.forEach((gp, pointIdx) => {
|
||||
if (gp.measured) {
|
||||
const cellKey = `b-${productIdx}-p${pointIdx}-${gapColumnId}`;
|
||||
initial[cellKey] = { measurements: [gp.measured, '', ''] };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// complex 컬럼 (길이/너비)
|
||||
// bending 렌더링은 measurements[si] (si = sub_label raw index)를 읽으므로
|
||||
// 측정값 sub_label의 실제 si 위치에 값을 넣어야 함
|
||||
for (const col of complexCols) {
|
||||
const label = col.label.trim();
|
||||
const cellKey = `b-${productIdx}-${col.id}`;
|
||||
|
||||
// 측정값 sub_label의 si 인덱스 찾기
|
||||
let measurementSi = 0;
|
||||
if (col.sub_labels) {
|
||||
for (let si = 0; si < col.sub_labels.length; si++) {
|
||||
const sl = col.sub_labels[si].toLowerCase();
|
||||
if (!sl.includes('도면') && !sl.includes('기준')) {
|
||||
measurementSi = si;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const measurements: [string, string, string] = ['', '', ''];
|
||||
if (label.includes('길이') && matched.lengthMeasured) {
|
||||
measurements[measurementSi] = matched.lengthMeasured;
|
||||
initial[cellKey] = { measurements };
|
||||
} else if ((label.includes('너비') || label.includes('폭') || label.includes('높이')) && matched.widthMeasured) {
|
||||
measurements[measurementSi] = matched.widthMeasured;
|
||||
initial[cellKey] = { measurements };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(initial).length > 0) {
|
||||
setCellValues(prev => ({ ...prev, ...initial }));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isBending, inspectionDataMap, workItems, bendingProducts, template.columns, gapColumnId]);
|
||||
|
||||
const updateCell = (key: string, update: Partial<CellValue>) => {
|
||||
setCellValues(prev => ({
|
||||
...prev,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import type { BendingInfoExtended, MaterialMapping } from './types';
|
||||
import { buildBottomBarRows, getBendingImageUrl, fmt, fmtWeight, lengthToCode } from './utils';
|
||||
import { buildBottomBarRows, getBendingImageUrl, fmt, fmtWeight, lookupLotNo } from './utils';
|
||||
|
||||
interface BottomBarSectionProps {
|
||||
bendingInfo: BendingInfoExtended;
|
||||
@@ -17,7 +17,7 @@ interface BottomBarSectionProps {
|
||||
}
|
||||
|
||||
export function BottomBarSection({ bendingInfo, mapping, lotNoMap }: BottomBarSectionProps) {
|
||||
const rows = buildBottomBarRows(bendingInfo.bottomBar, mapping);
|
||||
const rows = buildBottomBarRows(bendingInfo.bottomBar, mapping, bendingInfo.productCode);
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
return (
|
||||
@@ -57,7 +57,7 @@ export function BottomBarSection({ bendingInfo, mapping, lotNoMap }: BottomBarSe
|
||||
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmt(row.length)}</td>
|
||||
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmt(row.quantity)}</td>
|
||||
<td className="border border-gray-400 px-1 py-0.5 text-center">
|
||||
{lotNoMap?.[`BD-${row.lotPrefix}-${lengthToCode(row.length)}`] || '-'}
|
||||
{lookupLotNo(lotNoMap, row.lotPrefix, row.length)}
|
||||
</td>
|
||||
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmtWeight(row.weight)}</td>
|
||||
</tr>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import type { BendingInfoExtended, MaterialMapping, GuideRailPartRow } from './types';
|
||||
import { buildWallGuideRailRows, buildSideGuideRailRows, getBendingImageUrl, fmt, fmtWeight, lengthToCode } from './utils';
|
||||
import { buildWallGuideRailRows, buildSideGuideRailRows, getBendingImageUrl, fmt, fmtWeight, lookupLotNo } from './utils';
|
||||
|
||||
interface GuideRailSectionProps {
|
||||
bendingInfo: BendingInfoExtended;
|
||||
@@ -63,7 +63,7 @@ function PartTable({ title, rows, imageUrl, lotNo, baseSize, lotNoMap }: {
|
||||
</td>
|
||||
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmt(row.quantity)}</td>
|
||||
<td className="border border-gray-400 px-1 py-0.5 text-center">
|
||||
{lotNoMap?.[`BD-${row.lotPrefix}-${lengthToCode(row.length)}`] || '-'}
|
||||
{lookupLotNo(lotNoMap, row.lotPrefix, row.length)}
|
||||
</td>
|
||||
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmtWeight(row.weight)}</td>
|
||||
</tr>
|
||||
@@ -81,11 +81,11 @@ export function GuideRailSection({ bendingInfo, mapping, lotNo, lotNoMap }: Guid
|
||||
const productCode = bendingInfo.productCode;
|
||||
|
||||
const wallRows = wall
|
||||
? buildWallGuideRailRows(wall.lengthData, wall.baseDimension || '135*80', mapping)
|
||||
? buildWallGuideRailRows(wall.lengthData, wall.baseDimension || '135*80', mapping, productCode)
|
||||
: [];
|
||||
|
||||
const sideRows = side
|
||||
? buildSideGuideRailRows(side.lengthData, side.baseDimension || '135*130', mapping)
|
||||
? buildSideGuideRailRows(side.lengthData, side.baseDimension || '135*130', mapping, productCode)
|
||||
: [];
|
||||
|
||||
if (wallRows.length === 0 && sideRows.length === 0) return null;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import type { BendingInfoExtended, ShutterBoxData } from './types';
|
||||
import { buildShutterBoxRows, getBendingImageUrl, fmt, fmtWeight, lengthToCode } from './utils';
|
||||
import { buildShutterBoxRows, getBendingImageUrl, fmt, fmtWeight, lookupLotNo } from './utils';
|
||||
|
||||
interface ShutterBoxSectionProps {
|
||||
bendingInfo: BendingInfoExtended;
|
||||
@@ -75,9 +75,10 @@ function ShutterBoxSubSection({ box, index, lotNoMap }: { box: ShutterBoxData; i
|
||||
{(() => {
|
||||
const dimNum = parseInt(row.dimension);
|
||||
if (!isNaN(dimNum) && !row.dimension.includes('*')) {
|
||||
return lotNoMap?.[`BD-${row.lotPrefix}-${lengthToCode(dimNum)}`] || '-';
|
||||
return lookupLotNo(lotNoMap, row.lotPrefix, dimNum);
|
||||
}
|
||||
return '-';
|
||||
// 치수형(1219*539 등)도 prefix-only fallback
|
||||
return lookupLotNo(lotNoMap, row.lotPrefix);
|
||||
})()}
|
||||
</td>
|
||||
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmtWeight(row.weight)}</td>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import type { BendingInfoExtended } from './types';
|
||||
import { buildSmokeBarrierRows, getBendingImageUrl, fmt, fmtWeight } from './utils';
|
||||
import { buildSmokeBarrierRows, getBendingImageUrl, fmt, fmtWeight, lookupLotNo } from './utils';
|
||||
|
||||
interface SmokeBarrierSectionProps {
|
||||
bendingInfo: BendingInfoExtended;
|
||||
@@ -57,7 +57,14 @@ export function SmokeBarrierSection({ bendingInfo, lotNoMap }: SmokeBarrierSecti
|
||||
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmt(row.length)}</td>
|
||||
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmt(row.quantity)}</td>
|
||||
<td className="border border-gray-400 px-1 py-0.5 text-center">
|
||||
{lotNoMap?.[`BD-${row.lotCode}`] || '-'}
|
||||
{(() => {
|
||||
// 정확 매칭 (GI-83, GI-54 등)
|
||||
const exact = lotNoMap?.[`BD-${row.lotCode}`];
|
||||
if (exact) return exact;
|
||||
// Fallback: GI prefix로 검색
|
||||
const prefix = row.lotCode.split('-')[0];
|
||||
return lookupLotNo(lotNoMap, prefix, row.length);
|
||||
})()}
|
||||
</td>
|
||||
<td className="border border-gray-400 px-1 py-0.5 text-center">{fmtWeight(row.weight)}</td>
|
||||
</tr>
|
||||
|
||||
@@ -181,22 +181,29 @@ export function buildWallGuideRailRows(
|
||||
lengthData: LengthQuantity[],
|
||||
baseDimension: string,
|
||||
mapping: MaterialMapping,
|
||||
productCode?: string,
|
||||
): GuideRailPartRow[] {
|
||||
const rows: GuideRailPartRow[] = [];
|
||||
const codePrefix = productCode?.replace(/\d+$/, '') || 'KSS';
|
||||
const isSteel = codePrefix === 'KTE';
|
||||
const isSUS = ['KSS', 'KQTS', 'KTE'].includes(codePrefix);
|
||||
const finishPrefix = isSUS ? 'RS' : 'RE';
|
||||
const bodyPrefix = isSteel ? 'RT' : 'RM';
|
||||
|
||||
for (const ld of lengthData) {
|
||||
if (ld.quantity <= 0) continue;
|
||||
|
||||
// ①②마감재
|
||||
const finishW = calcWeight(mapping.guideRailFinish, WALL_PART_WIDTH, ld.length);
|
||||
rows.push({
|
||||
partName: '①②마감재', lotPrefix: 'XX', material: mapping.guideRailFinish,
|
||||
partName: '①②마감재', lotPrefix: finishPrefix, material: mapping.guideRailFinish,
|
||||
length: ld.length, quantity: ld.quantity, weight: Math.round(finishW.weight * ld.quantity * 100) / 100,
|
||||
});
|
||||
|
||||
// ③본체
|
||||
const bodyW = calcWeight(mapping.bodyMaterial, WALL_PART_WIDTH, ld.length);
|
||||
rows.push({
|
||||
partName: '③본체', lotPrefix: 'RT', material: mapping.bodyMaterial,
|
||||
partName: '③본체', lotPrefix: bodyPrefix, material: mapping.bodyMaterial,
|
||||
length: ld.length, quantity: ld.quantity, weight: Math.round(bodyW.weight * ld.quantity * 100) / 100,
|
||||
});
|
||||
|
||||
@@ -216,7 +223,7 @@ export function buildWallGuideRailRows(
|
||||
if (mapping.guideRailExtraFinish) {
|
||||
const extraW = calcWeight(mapping.guideRailExtraFinish, WALL_PART_WIDTH, ld.length);
|
||||
rows.push({
|
||||
partName: '⑥별도마감', lotPrefix: 'RS', material: mapping.guideRailExtraFinish,
|
||||
partName: '⑥별도마감', lotPrefix: 'YY', material: mapping.guideRailExtraFinish,
|
||||
length: ld.length, quantity: ld.quantity, weight: Math.round(extraW.weight * ld.quantity * 100) / 100,
|
||||
});
|
||||
}
|
||||
@@ -244,21 +251,27 @@ export function buildSideGuideRailRows(
|
||||
lengthData: LengthQuantity[],
|
||||
baseDimension: string,
|
||||
mapping: MaterialMapping,
|
||||
productCode?: string,
|
||||
): GuideRailPartRow[] {
|
||||
const rows: GuideRailPartRow[] = [];
|
||||
const codePrefix = productCode?.replace(/\d+$/, '') || 'KSS';
|
||||
const isSteel = codePrefix === 'KTE';
|
||||
const isSUS = ['KSS', 'KQTS', 'KTE'].includes(codePrefix);
|
||||
const finishPrefix = isSUS ? 'SS' : 'SE';
|
||||
const bodyPrefix = isSteel ? 'ST' : 'SM';
|
||||
|
||||
for (const ld of lengthData) {
|
||||
if (ld.quantity <= 0) continue;
|
||||
|
||||
const finishW = calcWeight(mapping.guideRailFinish, SIDE_PART_WIDTH, ld.length);
|
||||
rows.push({
|
||||
partName: '①②마감재', lotPrefix: 'SS', material: mapping.guideRailFinish,
|
||||
partName: '①②마감재', lotPrefix: finishPrefix, material: mapping.guideRailFinish,
|
||||
length: ld.length, quantity: ld.quantity, weight: Math.round(finishW.weight * ld.quantity * 100) / 100,
|
||||
});
|
||||
|
||||
const bodyW = calcWeight(mapping.bodyMaterial, SIDE_PART_WIDTH, ld.length);
|
||||
rows.push({
|
||||
partName: '③본체', lotPrefix: 'ST', material: mapping.bodyMaterial,
|
||||
partName: '③본체', lotPrefix: bodyPrefix, material: mapping.bodyMaterial,
|
||||
length: ld.length, quantity: ld.quantity, weight: Math.round(bodyW.weight * ld.quantity * 100) / 100,
|
||||
});
|
||||
|
||||
@@ -295,9 +308,12 @@ export function buildSideGuideRailRows(
|
||||
export function buildBottomBarRows(
|
||||
bottomBar: BendingInfoExtended['bottomBar'],
|
||||
mapping: MaterialMapping,
|
||||
productCode?: string,
|
||||
): BottomBarPartRow[] {
|
||||
const rows: BottomBarPartRow[] = [];
|
||||
const lotPrefix = mapping.bottomBarFinish.includes('SUS') ? 'TS' : 'TE';
|
||||
const codePrefix = productCode?.replace(/\d+$/, '') || 'KSS';
|
||||
const isSteel = codePrefix === 'KTE';
|
||||
const lotPrefix = isSteel ? 'TS' : (mapping.bottomBarFinish.includes('SUS') ? 'BS' : 'BE');
|
||||
|
||||
// ①하단마감재 - 3000mm
|
||||
if (bottomBar.length3000Qty > 0) {
|
||||
@@ -321,7 +337,7 @@ export function buildBottomBarRows(
|
||||
|
||||
// ④별도마감재 (extraFinish !== '없음' 일 때만)
|
||||
if (mapping.bottomBarExtraFinish !== '없음' && mapping.bottomBarExtraFinish) {
|
||||
const extraLotPrefix = mapping.bottomBarExtraFinish.includes('SUS') ? 'TS' : 'TE';
|
||||
const extraLotPrefix = 'YY';
|
||||
|
||||
if (bottomBar.length3000Qty > 0) {
|
||||
const w = calcWeight(mapping.bottomBarExtraFinish, EXTRA_FINISH_WIDTH, 3000);
|
||||
@@ -570,3 +586,33 @@ export function fmtWeight(v: number): string {
|
||||
export function lengthToCode(lengthMm: number): string {
|
||||
return getSLengthCode(lengthMm, '') || String(lengthMm);
|
||||
}
|
||||
|
||||
/**
|
||||
* lotNoMap에서 LOT NO 조회
|
||||
*
|
||||
* bending_info의 길이와 실제 자재투입 길이가 다를 수 있으므로,
|
||||
* 정확한 매칭 실패 시 prefix만으로 fallback 매칭합니다.
|
||||
*
|
||||
* @param lotNoMap - item_code → lot_no 매핑 (e.g. 'BD-SS-35' → 'INIT-260221-BDSS35')
|
||||
* @param prefix - 세부품목 prefix (e.g. 'SS', 'SM', 'BS')
|
||||
* @param length - 길이(mm), optional
|
||||
*/
|
||||
export function lookupLotNo(
|
||||
lotNoMap: Record<string, string> | undefined,
|
||||
prefix: string,
|
||||
length?: number,
|
||||
): string {
|
||||
if (!lotNoMap) return '-';
|
||||
|
||||
// 1. 정확한 매칭 (prefix + lengthCode)
|
||||
if (length) {
|
||||
const code = lengthToCode(length);
|
||||
const exact = lotNoMap[`BD-${prefix}-${code}`];
|
||||
if (exact) return exact;
|
||||
}
|
||||
|
||||
// 2. Fallback: prefix만으로 매칭 (첫 번째 일치 항목)
|
||||
const prefixKey = `BD-${prefix}-`;
|
||||
const fallbackKey = Object.keys(lotNoMap).find(k => k.startsWith(prefixKey));
|
||||
return fallbackKey ? lotNoMap[fallbackKey] : '-';
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
{/* 부적합 내용 */}
|
||||
|
||||
@@ -45,7 +45,7 @@ import { getMyWorkOrders, completeWorkOrder, saveItemInspection, getWorkOrderIns
|
||||
import type { StepProgressItem, DepartmentOption, DepartmentUser } from './actions';
|
||||
import type { InspectionTemplateData } from './types';
|
||||
import { getProcessList } from '@/components/process-management/actions';
|
||||
import type { InspectionSetting, Process } from '@/types/process';
|
||||
import type { InspectionSetting, InspectionScope, Process } from '@/types/process';
|
||||
import type { WorkOrder } from '../ProductionDashboard/types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import type {
|
||||
@@ -416,6 +416,8 @@ export default function WorkerScreen() {
|
||||
const [isInspectionInputModalOpen, setIsInspectionInputModalOpen] = useState(false);
|
||||
// 공정의 중간검사 설정
|
||||
const [currentInspectionSetting, setCurrentInspectionSetting] = useState<InspectionSetting | undefined>();
|
||||
// 공정의 검사 범위 설정
|
||||
const [currentInspectionScope, setCurrentInspectionScope] = useState<InspectionScope | undefined>();
|
||||
// 문서 템플릿 데이터 (document_template 기반 동적 검사용)
|
||||
const [inspectionTemplateData, setInspectionTemplateData] = useState<InspectionTemplateData | undefined>();
|
||||
const [inspectionDimensions, setInspectionDimensions] = useState<{ width?: number; height?: number }>({});
|
||||
@@ -513,8 +515,10 @@ export default function WorkerScreen() {
|
||||
(step) => step.needsInspection && step.inspectionSetting
|
||||
);
|
||||
setCurrentInspectionSetting(inspectionStep?.inspectionSetting);
|
||||
setCurrentInspectionScope(inspectionStep?.inspectionScope);
|
||||
} else {
|
||||
setCurrentInspectionSetting(undefined);
|
||||
setCurrentInspectionScope(undefined);
|
||||
}
|
||||
}, [activeTab, processListCache]);
|
||||
|
||||
@@ -809,6 +813,50 @@ export default function WorkerScreen() {
|
||||
return [...apiItems, ...mockItems];
|
||||
}, [filteredWorkOrders, selectedSidebarOrderId, activeProcessTabKey, stepCompletionMap, bendingSubMode, slatSubMode, activeProcessSteps, inputMaterialsMap, stepProgressMap]);
|
||||
|
||||
// ===== 검사 범위(scope) 기반 검사 단계 활성화/비활성화 =====
|
||||
// 수주 단위로 적용: API 아이템(실제 수주 개소)에만 scope 적용
|
||||
// 목업 아이템은 각각 독립 1개소이므로 항상 검사 버튼 유지
|
||||
const scopedWorkItems: WorkItemData[] = useMemo(() => {
|
||||
if (!currentInspectionScope || currentInspectionScope.type === 'all') {
|
||||
return workItems;
|
||||
}
|
||||
|
||||
// 실제 수주 아이템만 분리 (목업 제외)
|
||||
const apiItems = workItems.filter((item) => !item.id.startsWith('mock-'));
|
||||
const apiCount = apiItems.length;
|
||||
if (apiCount === 0) return workItems;
|
||||
|
||||
// 검사 단계를 아예 제거하는 헬퍼
|
||||
const removeInspectionSteps = (item: WorkItemData): WorkItemData => ({
|
||||
...item,
|
||||
steps: item.steps.filter((step) => !step.isInspection && !step.needsInspection),
|
||||
});
|
||||
|
||||
let realIdx = 0;
|
||||
|
||||
if (currentInspectionScope.type === 'sampling') {
|
||||
const sampleSize = currentInspectionScope.sampleSize || 1;
|
||||
return workItems.map((item) => {
|
||||
// 목업은 독립 1개소 → 검사 유지
|
||||
if (item.id.startsWith('mock-')) return item;
|
||||
const isInSampleRange = realIdx >= apiCount - sampleSize;
|
||||
realIdx++;
|
||||
return isInSampleRange ? item : removeInspectionSteps(item);
|
||||
});
|
||||
}
|
||||
|
||||
if (currentInspectionScope.type === 'group') {
|
||||
return workItems.map((item) => {
|
||||
if (item.id.startsWith('mock-')) return item;
|
||||
const isLast = realIdx === apiCount - 1;
|
||||
realIdx++;
|
||||
return isLast ? item : removeInspectionSteps(item);
|
||||
});
|
||||
}
|
||||
|
||||
return workItems;
|
||||
}, [workItems, currentInspectionScope]);
|
||||
|
||||
// ===== 작업지시 선택 시 기존 검사 데이터 로드 =====
|
||||
// workItems 선언 이후에 위치해야 workItems.length 참조 가능
|
||||
// workItems.length 의존성: selectedSidebarOrderId 변경 시점에 workItems가 아직 비어있을 수 있음
|
||||
@@ -1601,14 +1649,14 @@ export default function WorkerScreen() {
|
||||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
{workItems.map((item, index) => {
|
||||
{scopedWorkItems.map((item, index) => {
|
||||
const isFirstMock = item.id.startsWith('mock-') &&
|
||||
(index === 0 || !workItems[index - 1]?.id.startsWith('mock-'));
|
||||
(index === 0 || !scopedWorkItems[index - 1]?.id.startsWith('mock-'));
|
||||
return (
|
||||
<div key={item.id}>
|
||||
{isFirstMock && (
|
||||
<div className="mb-3 pt-1 space-y-2">
|
||||
{workItems.some((i) => !i.id.startsWith('mock-')) && <div className="border-t border-dashed border-gray-300" />}
|
||||
{scopedWorkItems.some((i) => !i.id.startsWith('mock-')) && <div className="border-t border-dashed border-gray-300" />}
|
||||
<span className="text-[10px] font-semibold text-orange-600 bg-orange-50 px-2 py-0.5 rounded inline-block">
|
||||
목업 데이터
|
||||
</span>
|
||||
|
||||
Reference in New Issue
Block a user