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

@@ -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,

View File

@@ -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>

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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] : '-';
}

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>
)}
{/* 부적합 내용 */}

View File

@@ -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>