fix: [inspection] 절곡 검사성적서 재공품 대응 통합 수정

- 검사부위 공백 수정 (템플릿 컬럼 "부위" 라벨 매칭)
- hasWipItems 판정 보완 (sidebar order fallback)
- bending_wip 7제품 폼 통합 (products 배열 저장)
- 도면치수 실제 품목 길이 반영 (3000 하드코딩 제거)
- 테스트입력 버튼 7제품 데이터 채우기
- 하단 버튼 분리 유지 (작업일지/검사성적서)
- STOCK 단일부품 해당 부품만 검사항목 표시
- bendingInfo 기반 동적 검사 제품 생성
- 작업일지 LOT NO 원자재 투입 로트번호 표시
This commit is contained in:
김보곤
2026-03-21 21:21:06 +09:00
parent d91057aeb1
commit f483cff206
10 changed files with 441 additions and 238 deletions

View File

@@ -547,8 +547,9 @@ export function InspectionInputModal({
workOrderId,
}: InspectionInputModalProps) {
// 템플릿 모드 여부
// 절곡(bending)은 7제품 커스텀 폼 사용 → TemplateInspectionContent의 bending 셀 키와 연동
const useTemplateMode = processType !== 'bending' && !!(templateData?.has_template && templateData.template);
// 절곡(bending/bending_wip)은 7제품 커스텀 폼 사용 → TemplateInspectionContent의 bending 셀 키와 연동
const isBendingProcess = processType === 'bending' || processType === 'bending_wip';
const useTemplateMode = !isBendingProcess && !!(templateData?.has_template && templateData.template);
const [formData, setFormData] = useState<InspectionData>({
productName,
@@ -577,19 +578,21 @@ export function InspectionInputModal({
// API에서 절곡 제품 gap_points 동적 로딩
useEffect(() => {
if (!open || processType !== 'bending' || !workOrderId) return;
if (!open || !isBendingProcess || !workOrderId) return;
let cancelled = false;
getInspectionConfig(workOrderId).then(result => {
if (cancelled) return;
if (result.success && result.data?.items?.length) {
// 실제 품목 길이: workItemDimensions.height (예: 2438mm) 우선, 없으면 3000 폴백
const actualLen = workItemDimensions?.height ? String(workItemDimensions.height) : '3000';
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: '' },
guide_rail_wall: { label: '가이드레일 (벽면형)', len: actualLen, wid: 'N/A' },
guide_rail_side: { label: '가이드레일 (측면형)', len: actualLen, wid: 'N/A' },
case_box: { label: '케이스 (500X380)', len: actualLen, wid: 'N/A' },
bottom_bar: { label: '하단마감재 (60X40)', len: actualLen, wid: 'N/A' },
bottom_l_bar: { label: '하단L-BAR (17X60)', len: actualLen, wid: 'N/A' },
smoke_w50: { label: '연기차단재 (W50)', len: actualLen, wid: '' },
smoke_w80: { label: '연기차단재 (W80)', len: actualLen, wid: '' },
};
const defs: BendingProductDef[] = result.data.items.map(item => {
const d = displayMap[item.id] || { label: item.name, len: '-', wid: 'N/A' };
@@ -605,11 +608,11 @@ export function InspectionInputModal({
}
});
return () => { cancelled = true; };
}, [open, processType, workOrderId]);
}, [open, processType, workOrderId, workItemDimensions?.height]);
// API 제품 정의 로딩 시 bendingProducts 갱신 (gap 개수 동기화)
useEffect(() => {
if (!apiProductDefs || processType !== 'bending') return;
if (!apiProductDefs || !isBendingProcess) return;
setBendingProducts(prev => {
return apiProductDefs.map((def, idx) => {
// 기존 입력값 보존 (ID 매칭 또는 인덱스 폴백)
@@ -663,7 +666,7 @@ export function InspectionInputModal({
gapMeasured: def.gapPoints.map((_, gi) => saved.gapPoints?.[gi]?.measured || ''),
};
}));
} else if (processType === 'bending' && initialData.judgment) {
} else if (isBendingProcess && initialData.judgment) {
// 이전 형식 데이터 호환: products 배열 없이 저장된 경우
// judgment 값으로 제품별 상태 추론 (pass → 전체 양호)
const restoredStatus: 'good' | 'bad' | null =
@@ -751,7 +754,7 @@ export function InspectionInputModal({
return computeDynamicJudgment(templateData.template, dynamicFormValues, workItemDimensions);
}
// 절곡 7개 제품 전용 판정
if (processType === 'bending') {
if (isBendingProcess) {
let allGood = true;
let allFilled = true;
for (const p of bendingProducts) {
@@ -785,7 +788,7 @@ export function InspectionInputModal({
};
// 절곡: products 배열을 성적서와 동일 포맷으로 저장
if (processType === 'bending') {
if (isBendingProcess) {
const products = bendingProducts.map((p, idx) => ({
id: p.id,
bendingStatus: p.bendingStatus === 'good' ? '양호' : p.bendingStatus === 'bad' ? '불량' : null,
@@ -841,22 +844,55 @@ export function InspectionInputModal({
)}
onClick={() => {
if (!formData.judgment) {
const w = workItemDimensions?.width || 1000;
const h = workItemDimensions?.height || 500;
setFormData((prev) => ({
...prev,
bendingStatus: 'good',
processingStatus: 'good',
sewingStatus: 'good',
assemblyStatus: 'good',
length: w,
width: h,
height1: h,
height2: h,
judgment: 'pass',
nonConformingContent: '',
}));
// 동적 템플릿 모드: 각 항목의 기준값을 사용하여 적합한 값 입력
if (useTemplateMode && templateData?.template) {
const testValues: Record<string, unknown> = {};
for (const section of templateData.template.sections) {
for (const item of section.items) {
const fieldKey = `section_${section.id}_item_${item.id}`;
if (isNumericItem(item)) {
const design = resolveDesignValue(item, workItemDimensions);
testValues[fieldKey] = design ?? 100;
} else {
testValues[fieldKey] = 'ok';
}
}
}
setDynamicFormValues(testValues);
}
if (!useTemplateMode) {
// 레거시 모드: 기존 로직
const w = workItemDimensions?.width || 1000;
const h = workItemDimensions?.height || 500;
setFormData((prev) => ({
...prev,
bendingStatus: 'good',
processingStatus: 'good',
sewingStatus: 'good',
assemblyStatus: 'good',
length: w,
width: h,
height1: h,
height2: h,
judgment: 'pass',
nonConformingContent: '',
}));
// 절곡 7제품: 모든 제품 양호 + 도면치수와 동일한 측정값 입력
if (isBendingProcess) {
setBendingProducts(effectiveProductDefs.map(def => ({
id: def.id,
bendingStatus: 'good' as const,
lengthMeasured: def.lengthDesign || '',
widthMeasured: def.widthDesign || '',
gapMeasured: def.gapPoints.map(gp => gp.design || ''),
})));
}
}
} else {
// 초기화
if (useTemplateMode) {
setDynamicFormValues({});
}
setFormData((prev) => ({
...prev,
bendingStatus: null,
@@ -870,6 +906,10 @@ export function InspectionInputModal({
judgment: null,
nonConformingContent: '',
}));
// 절곡 7제품 초기화
if (isBendingProcess) {
setBendingProducts(createInitialBendingProducts());
}
}
}}
>
@@ -914,8 +954,8 @@ export function InspectionInputModal({
{/* ===== 레거시: 공정별 하드코딩 검사 항목 (템플릿 없을 때만 표시) ===== */}
{/* ===== 재고생산 (bending_wip) 검사 항목 ===== */}
{!useTemplateMode && processType === 'bending_wip' && (
{/* ===== 재고생산 (bending_wip) 검사 항목 — 7제품 폼으로 통합됨 (위 절곡 검사 항목 참조) ===== */}
{false && processType === 'bending_wip' && (
<>
<div className="space-y-1.5">
<span className="text-sm font-bold"> </span>
@@ -1139,7 +1179,7 @@ export function InspectionInputModal({
)}
{/* ===== 절곡 검사 항목 (7개 제품별) ===== */}
{!useTemplateMode && processType === 'bending' && (
{!useTemplateMode && isBendingProcess && (
<div className="space-y-4">
{effectiveProductDefs.map((productDef, pIdx) => {
const pState = bendingProducts[pIdx];

View File

@@ -22,6 +22,7 @@ function extractLengthFromName(name?: string | null): number {
import { useState, useMemo, useCallback, useEffect } from 'react';
import dynamic from 'next/dynamic';
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
import { useSidebarCollapsed } from '@/stores/menuStore';
import { ClipboardList, PlayCircle, CheckCircle2, AlertTriangle, ChevronDown, ChevronUp, List } from 'lucide-react';
import {
@@ -145,9 +146,20 @@ const PROCESS_STEPS: Record<string, { name: string; isMaterialInput: boolean; is
export default function WorkerScreen() {
// ===== 상태 관리 =====
const sidebarCollapsed = useSidebarCollapsed();
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [activeTab, setActiveTab] = useState<string>('');
const [activeTab, setActiveTabState] = useState<string>(searchParams.get('tab') || '');
// 탭 변경 시 URL query parameter 동기화 (새로고침 시 탭 유지)
const setActiveTab = useCallback((tab: string) => {
setActiveTabState(tab);
const params = new URLSearchParams(searchParams.toString());
params.set('tab', tab);
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
}, [searchParams, router, pathname]);
const [bendingSubMode, setBendingSubMode] = useState<'normal' | 'wip'>('normal');
const [slatSubMode, setSlatSubMode] = useState<'normal' | 'jointbar'>('normal');
@@ -328,14 +340,21 @@ export default function WorkerScreen() {
return groupedTabs.find((g) => g.group === groupName) || null;
}, [activeTab, processListCache, groupedTabs]);
// 공정 목록 로드 후 첫 번째 그룹을 기본 선택
// 공정 목록 로드 후 탭 선택 (URL 파라미터 우선, 없으면 첫 번째 그룹)
useEffect(() => {
if (activeTab) return;
if (groupedTabs.length > 0) {
setActiveTab(groupedTabs[0].defaultProcessId);
} else if (!isLoading) {
setActiveTab('screen');
if (groupedTabs.length === 0 && !isLoading) {
if (!activeTab) setActiveTabState('screen');
return;
}
if (groupedTabs.length === 0) return;
// URL에 tab이 있고 유효한 탭이면 유지
if (activeTab) {
const isValid = groupedTabs.some((g) => g.defaultProcessId === activeTab);
if (isValid) return;
}
// 없으면 첫 번째 그룹 선택
setActiveTabState(groupedTabs[0].defaultProcessId);
}, [groupedTabs, activeTab, isLoading]);
// 선택된 공정의 ProcessTab 키 (mock 데이터 및 기존 로직 호환용)
@@ -350,15 +369,19 @@ export default function WorkerScreen() {
}, [activeTab, processListCache]);
// 선택된 공정의 작업일지/검사성적서 설정
// subProcessId가 선택되어 있으면 자식 공정의 설정 사용
const activeProcessSettings = useMemo(() => {
const process = processListCache.find((p) => p.id === activeTab);
const effectiveId = subProcessId !== 'all' ? subProcessId : activeTab;
const process = processListCache.find((p) => p.id === effectiveId);
// 자식 공정에 설정이 없으면 부모 공정 폴백
const parentProcess = processListCache.find((p) => p.id === activeTab);
return {
needsWorkLog: process?.needsWorkLog ?? false,
hasDocumentTemplate: !!process?.documentTemplateId,
workLogTemplateId: process?.workLogTemplateId,
workLogTemplateName: process?.workLogTemplateName,
needsWorkLog: process?.needsWorkLog ?? parentProcess?.needsWorkLog ?? false,
hasDocumentTemplate: !!(process?.documentTemplateId ?? parentProcess?.documentTemplateId),
workLogTemplateId: process?.workLogTemplateId ?? parentProcess?.workLogTemplateId,
workLogTemplateName: process?.workLogTemplateName ?? parentProcess?.workLogTemplateName,
};
}, [activeTab, processListCache]);
}, [activeTab, subProcessId, processListCache]);
// activeTab 변경 시 해당 공정의 중간검사 설정 조회
useEffect(() => {
@@ -1329,8 +1352,13 @@ export default function WorkerScreen() {
// ===== 재공품 감지 =====
const hasWipItems = useMemo(() => {
return activeProcessTabKey === 'bending' && workItems.some(item => item.isWip);
}, [activeProcessTabKey, workItems]);
if (activeProcessTabKey !== 'bending') return false;
// 1. workItems에서 isWip 체크
if (workItems.some(item => item.isWip)) return true;
// 2. Fallback: 선택된 작업지시의 프로젝트명/수주번호로 WIP 판별
const selectedWo = filteredWorkOrders.find(wo => wo.id === selectedSidebarOrderId);
return !!(selectedWo && (selectedWo.projectName === '재고생산' || selectedWo.salesOrderNo?.startsWith('STK')));
}, [activeProcessTabKey, workItems, filteredWorkOrders, selectedSidebarOrderId]);
// ===== 조인트바 감지 =====
const hasJointBarItems = useMemo(() => {
@@ -1620,33 +1648,22 @@ export default function WorkerScreen() {
{(hasWipItems || activeProcessSettings.needsWorkLog || activeProcessSettings.hasDocumentTemplate) && (
<div className={`fixed bottom-4 left-4 right-4 px-4 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 md:bottom-6 md:px-6 md:right-[24px] ${sidebarCollapsed ? 'md:left-[120px]' : 'md:left-[280px]'}`}>
<div className="flex gap-2 md:gap-3">
{hasWipItems ? (
{(hasWipItems || activeProcessSettings.needsWorkLog) && (
<Button
variant="outline"
onClick={handleWorkLog}
className="flex-1 py-5 md:py-6 text-sm md:text-base font-medium"
>
</Button>
)}
{(hasWipItems || activeProcessSettings.hasDocumentTemplate) && (
<Button
onClick={handleInspection}
className="flex-1 py-5 md:py-6 text-sm md:text-base font-medium bg-gray-900 hover:bg-gray-800"
>
</Button>
) : (
<>
{activeProcessSettings.needsWorkLog && (
<Button
variant="outline"
onClick={handleWorkLog}
className="flex-1 py-5 md:py-6 text-sm md:text-base font-medium"
>
</Button>
)}
{activeProcessSettings.hasDocumentTemplate && (
<Button
onClick={handleInspection}
className="flex-1 py-5 md:py-6 text-sm md:text-base font-medium bg-gray-900 hover:bg-gray-800"
>
</Button>
)}
</>
)}
</div>
</div>