Files
sam-react-prod/src/components/production/WorkOrders/documents/ScreenInspectionContent.tsx
유병철 efcc645e24 feat(WEB): 생산/검사 기능 대폭 확장 및 작업자화면 검사입력 추가
생산관리:
- WipProductionModal 기능 개선
- WorkOrderDetail/Edit 확장 (+265줄)
- 검사성적서 콘텐츠 5종 대폭 확장 (벤딩/벤딩WIP/스크린/슬랫/슬랫조인트바)
- InspectionReportModal 기능 강화

작업자화면:
- WorkerScreen 기능 대폭 확장 (+211줄)
- WorkItemCard 개선
- InspectionInputModal 신규 추가 (작업자 검사입력)

공정관리:
- StepForm 검사항목 설정 기능 추가
- InspectionSettingModal 신규 추가
- InspectionPreviewModal 신규 추가
- process.ts 타입 확장 (+102줄)

자재관리:
- StockStatus 상세/목록/타입/목데이터 개선

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 21:43:28 +09:00

471 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
/**
* 스크린 중간검사 성적서 문서 콘텐츠
*
* 기획서 기준:
* - 헤더: "중간검사성적서 (스크린)" + 결재란
* - 기본정보: 제품명/스크린, 규격/와이어 글라스 코팅직물, 수주처, 현장명 | 제품LOT NO, 로트크기, 검사일자, 검사자
* - ■ 중간검사 기준서: 도해 + 검사항목/검사기준/검사방법/검사주기/관련규정
* 가공상태, 재봉상태, 조립상태, 치수(길이/높이/간격)
* - ■ 중간검사 DATA: No, 가공상태결모양(양호/불량), 재봉상태결모양(양호/불량), 조립상태(양호/불량),
* 길이(도면치수/측정값입력), 나비(도면치수/측정값입력), 간격(기준치/OK·NG선택), 판정(자동)
* - 부적합 내용 / 종합판정(자동)
*/
import { useState, useCallback, useMemo, forwardRef, useImperativeHandle, useEffect } from 'react';
import type { WorkOrder } from '../types';
import type { InspectionData } from '@/components/production/WorkerScreen/InspectionInputModal';
import type { WorkItemData } from '@/components/production/WorkerScreen/types';
import type { InspectionDataMap } from './InspectionReportModal';
export interface InspectionContentRef {
getInspectionData: () => unknown;
}
export interface ScreenInspectionContentProps {
data: WorkOrder;
readOnly?: boolean;
inspectionData?: InspectionData;
/** 작업 아이템 목록 - 행 개수 동적 생성용 */
workItems?: WorkItemData[];
/** 아이템별 검사 데이터 맵 */
inspectionDataMap?: InspectionDataMap;
/** 기준서 도해 이미지 URL */
schematicImage?: string;
/** 검사기준 이미지 URL */
inspectionStandardImage?: string;
}
type CheckStatus = '양호' | '불량' | null;
type GapResult = 'OK' | 'NG' | null;
interface InspectionRow {
id: number;
itemId?: string; // 작업 아이템 ID
itemName?: string; // 작업 아이템 이름
processStatus: CheckStatus; // 가공상태 결모양
sewingStatus: CheckStatus; // 재봉상태 결모양
assemblyStatus: CheckStatus; // 조립상태
lengthDesign: string; // 길이 도면치수 (표시용)
lengthMeasured: string; // 길이 측정값 (입력)
widthDesign: string; // 나비 도면치수 (표시용)
widthMeasured: string; // 나비 측정값 (입력)
gapStandard: string; // 간격 기준치 (표시용)
gapResult: GapResult; // 간격 측정값 (OK/NG 선택)
}
const DEFAULT_ROW_COUNT = 6;
export const ScreenInspectionContent = forwardRef<InspectionContentRef, ScreenInspectionContentProps>(function ScreenInspectionContent({
data: order,
readOnly = false,
workItems,
inspectionDataMap,
schematicImage,
inspectionStandardImage,
}, ref) {
const fullDate = new Date().toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
const today = new Date().toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).replace(/\. /g, '-').replace('.', '');
const documentNo = order.workOrderNo || 'ABC123';
const primaryAssignee = order.assignees?.find(a => a.isPrimary)?.name || order.assignee || '-';
// 행 개수: workItems가 있으면 그 개수, 없으면 기본값
const rowCount = workItems?.length || DEFAULT_ROW_COUNT;
// InspectionData를 InspectionRow로 변환하는 함수
const convertToCheckStatus = (status: 'good' | 'bad' | null | undefined): CheckStatus => {
if (status === 'good') return '양호';
if (status === 'bad') return '불량';
return null;
};
const convertToGapResult = (status: 'ok' | 'ng' | null | undefined): GapResult => {
if (status === 'ok') return 'OK';
if (status === 'ng') return 'NG';
return null;
};
const [rows, setRows] = useState<InspectionRow[]>(() => {
return Array.from({ length: rowCount }, (_, i) => {
const item = workItems?.[i];
const itemData = item && inspectionDataMap?.get(item.id);
return {
id: i + 1,
itemId: item?.id,
itemName: item?.itemName || '',
processStatus: itemData ? convertToCheckStatus(itemData.processingStatus) : null,
sewingStatus: itemData ? convertToCheckStatus(itemData.sewingStatus) : null,
assemblyStatus: itemData ? convertToCheckStatus(itemData.assemblyStatus) : null,
lengthDesign: '7,400',
lengthMeasured: itemData?.length?.toString() || '',
widthDesign: '2,950',
widthMeasured: itemData?.width?.toString() || '',
gapStandard: '400 이하',
gapResult: itemData ? convertToGapResult(itemData.gapStatus) : null,
};
});
});
// workItems나 inspectionDataMap이 변경되면 rows 업데이트
useEffect(() => {
const newRowCount = workItems?.length || DEFAULT_ROW_COUNT;
setRows(Array.from({ length: newRowCount }, (_, i) => {
const item = workItems?.[i];
const itemData = item && inspectionDataMap?.get(item.id);
return {
id: i + 1,
itemId: item?.id,
itemName: item?.itemName || '',
processStatus: itemData ? convertToCheckStatus(itemData.processingStatus) : null,
sewingStatus: itemData ? convertToCheckStatus(itemData.sewingStatus) : null,
assemblyStatus: itemData ? convertToCheckStatus(itemData.assemblyStatus) : null,
lengthDesign: '7,400',
lengthMeasured: itemData?.length?.toString() || '',
widthDesign: '2,950',
widthMeasured: itemData?.width?.toString() || '',
gapStandard: '400 이하',
gapResult: itemData ? convertToGapResult(itemData.gapStatus) : null,
};
}));
}, [workItems, inspectionDataMap]);
const [inadequateContent, setInadequateContent] = useState('');
const handleStatusChange = useCallback((rowId: number, field: 'processStatus' | 'sewingStatus' | 'assemblyStatus', value: CheckStatus) => {
if (readOnly) return;
setRows(prev => prev.map(row =>
row.id === rowId ? { ...row, [field]: value } : row
));
}, [readOnly]);
// 숫자 콤마 포맷
const formatNumberWithComma = (value: string): string => {
const num = value.replace(/[^\d]/g, '');
if (!num) return '';
return Number(num).toLocaleString();
};
const handleInputChange = useCallback((rowId: number, field: 'lengthMeasured' | 'widthMeasured', value: string) => {
if (readOnly) return;
// 숫자만 저장 (콤마 제거)
const numOnly = value.replace(/[^\d]/g, '');
setRows(prev => prev.map(row =>
row.id === rowId ? { ...row, [field]: numOnly } : row
));
}, [readOnly]);
const handleGapChange = useCallback((rowId: number, value: GapResult) => {
if (readOnly) return;
setRows(prev => prev.map(row =>
row.id === rowId ? { ...row, gapResult: value } : row
));
}, [readOnly]);
// 행별 판정 자동 계산
const getRowJudgment = useCallback((row: InspectionRow): '적' | '부' | null => {
const { processStatus, sewingStatus, assemblyStatus, gapResult } = row;
// 하나라도 불량 or NG → 부
if (processStatus === '불량' || sewingStatus === '불량' || assemblyStatus === '불량' || gapResult === 'NG') {
return '부';
}
// 모두 양호 + OK → 적
if (processStatus === '양호' && sewingStatus === '양호' && assemblyStatus === '양호' && gapResult === 'OK') {
return '적';
}
return null;
}, []);
// 종합판정 자동 계산
const overallResult = useMemo(() => {
const judgments = rows.map(getRowJudgment);
if (judgments.some(j => j === '부')) return '불합격';
if (judgments.every(j => j === '적')) return '합격';
return null;
}, [rows, getRowJudgment]);
useImperativeHandle(ref, () => ({
getInspectionData: () => ({
rows: rows.map(row => ({
id: row.id,
processStatus: row.processStatus,
sewingStatus: row.sewingStatus,
assemblyStatus: row.assemblyStatus,
lengthMeasured: row.lengthMeasured,
widthMeasured: row.widthMeasured,
gapResult: row.gapResult,
})),
inadequateContent,
overallResult,
}),
}), [rows, inadequateContent, overallResult]);
// PDF 호환 체크박스 렌더 (양호/불량)
const renderCheckbox = (checked: boolean, onClick: () => void) => (
<span
className={`inline-flex items-center justify-center w-3 h-3 border rounded-sm text-[8px] leading-none cursor-pointer select-none ${
checked ? 'border-gray-600 bg-gray-700 text-white' : 'border-gray-400 bg-white'
}`}
onClick={() => !readOnly && onClick()}
role="checkbox"
aria-checked={checked}
>
{checked ? '✓' : ''}
</span>
);
const renderCheckStatus = (rowId: number, field: 'processStatus' | 'sewingStatus' | 'assemblyStatus', value: CheckStatus) => (
<td className="border border-gray-400 p-1">
<div className="flex flex-col items-center gap-0.5">
<label className="flex items-center gap-0.5 cursor-pointer text-xs whitespace-nowrap">
{renderCheckbox(value === '양호', () => handleStatusChange(rowId, field, value === '양호' ? null : '양호'))}
</label>
<label className="flex items-center gap-0.5 cursor-pointer text-xs whitespace-nowrap">
{renderCheckbox(value === '불량', () => handleStatusChange(rowId, field, value === '불량' ? null : '불량'))}
</label>
</div>
</td>
);
const inputClass = 'w-full text-center border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-500 rounded text-xs';
return (
<div className="p-6 bg-white">
{/* ===== 헤더 영역 ===== */}
<div className="flex justify-between items-start mb-6">
<div>
<h1 className="text-2xl font-bold"> ()</h1>
<p className="text-xs text-gray-500 mt-1 whitespace-nowrap">
: {documentNo} | : {fullDate}
</p>
</div>
{/* 결재란 */}
<table className="border-collapse text-sm flex-shrink-0">
<tbody>
<tr>
<td className="border border-gray-400 px-4 py-1 text-center align-middle" rowSpan={3}><br/></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
<td className="border border-gray-400 px-6 py-1 text-center"></td>
</tr>
<tr>
<td className="border border-gray-400 px-6 py-3 text-center">{primaryAssignee}</td>
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400"></td>
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400"></td>
<td className="border border-gray-400 px-6 py-3 text-center text-gray-400"></td>
</tr>
<tr>
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap"></td>
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap"></td>
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap"></td>
<td className="border border-gray-400 px-6 py-1 text-center whitespace-nowrap"></td>
</tr>
</tbody>
</table>
</div>
{/* ===== 기본 정보 ===== */}
<table className="w-full border-collapse text-xs mb-6">
<tbody>
<tr>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium w-24"></td>
<td className="border border-gray-400 px-3 py-2"></td>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium w-28"> LOT NO</td>
<td className="border border-gray-400 px-3 py-2">{order.lotNo || '-'}</td>
</tr>
<tr>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2"> </td>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">{order.items?.length || 0} </td>
</tr>
<tr>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">{order.client || '-'}</td>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">{today}</td>
</tr>
<tr>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">{order.projectName || '-'}</td>
<td className="border border-gray-400 bg-gray-50 px-3 py-2 font-medium"></td>
<td className="border border-gray-400 px-3 py-2">{primaryAssignee}</td>
</tr>
</tbody>
</table>
{/* ===== 중간검사 기준서 ===== */}
<div className="mb-1 font-bold text-sm"> </div>
<table className="w-full border-collapse text-xs mb-6">
<tbody>
<tr>
{/* 도해 영역 */}
<td className="border border-gray-400 p-2 text-center align-middle w-1/4" rowSpan={8}>
{schematicImage ? (
<img src={schematicImage} alt="기준서 도해" className="max-h-40 mx-auto object-contain" />
) : (
<div className="h-40 flex items-center justify-center text-gray-300"> </div>
)}
</td>
{/* 헤더 행 */}
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center" colSpan={2}></th>
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center"></th>
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center"></th>
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center"></th>
<th className="border border-gray-400 bg-gray-100 px-2 py-1 text-center"></th>
</tr>
<tr>
<td className="border border-gray-400 px-2 py-1 font-medium" colSpan={2}></td>
<td className="border border-gray-400 px-2 py-1"> </td>
<td className="border border-gray-400 px-2 py-1 text-center"></td>
<td className="border border-gray-400 px-2 py-1 text-center"></td>
<td className="border border-gray-400 px-2 py-1">KS F 4510 5.1</td>
</tr>
<tr>
<td className="border border-gray-400 px-2 py-1 font-medium" colSpan={2}></td>
<td className="border border-gray-400 px-2 py-1"> </td>
<td className="border border-gray-400 px-2 py-1 text-center"></td>
<td className="border border-gray-400 px-2 py-1 text-center"></td>
<td className="border border-gray-400 px-2 py-1"></td>
</tr>
<tr>
<td className="border border-gray-400 px-2 py-1 font-medium" colSpan={2}></td>
<td className="border border-gray-400 px-2 py-1"> </td>
<td className="border border-gray-400 px-2 py-1 text-center"></td>
<td className="border border-gray-400 px-2 py-1 text-center"></td>
<td className="border border-gray-400 px-2 py-1">KS F 4510<br/>n = 1, c = 0</td>
</tr>
<tr>
<td className="border border-gray-400 px-2 py-1 font-medium bg-gray-50" rowSpan={3}><br/>(mm)</td>
<td className="border border-gray-400 px-2 py-1 font-medium"></td>
<td className="border border-gray-400 px-2 py-1"> ± 4</td>
<td className="border border-gray-400 px-2 py-1 text-center"></td>
<td className="border border-gray-400 px-2 py-1 text-center"></td>
<td className="border border-gray-400 px-2 py-1"></td>
</tr>
<tr>
<td className="border border-gray-400 px-2 py-1 font-medium"></td>
<td className="border border-gray-400 px-2 py-1"> + 40</td>
<td className="border border-gray-400 px-2 py-1 text-center"></td>
<td className="border border-gray-400 px-2 py-1 text-center"></td>
<td className="border border-gray-400 px-2 py-1"></td>
</tr>
<tr>
<td className="border border-gray-400 px-2 py-1 font-medium"></td>
<td className="border border-gray-400 px-2 py-1">400 </td>
<td className="border border-gray-400 px-2 py-1 text-center">GONO </td>
<td className="border border-gray-400 px-2 py-1 text-center"></td>
<td className="border border-gray-400 px-2 py-1"></td>
</tr>
</tbody>
</table>
{/* ===== 중간검사 DATA ===== */}
<div className="mb-1 font-bold text-sm"> DATA</div>
<table className="w-full border-collapse text-xs mb-4">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-400 p-1 w-8" rowSpan={2}>No.</th>
<th className="border border-gray-400 p-1 w-16" rowSpan={2}><br/></th>
<th className="border border-gray-400 p-1 w-16" rowSpan={2}><br/></th>
<th className="border border-gray-400 p-1 w-16" rowSpan={2}></th>
<th className="border border-gray-400 p-1" colSpan={2}> (mm)</th>
<th className="border border-gray-400 p-1" colSpan={2}> (mm)</th>
<th className="border border-gray-400 p-1" colSpan={2}> (mm)</th>
<th className="border border-gray-400 p-1 w-14" rowSpan={2}><br/>(/)</th>
</tr>
<tr className="bg-gray-100">
<th className="border border-gray-400 p-1 w-16"></th>
<th className="border border-gray-400 p-1 w-16"></th>
<th className="border border-gray-400 p-1 w-16"></th>
<th className="border border-gray-400 p-1 w-16"></th>
<th className="border border-gray-400 p-1 w-16"></th>
<th className="border border-gray-400 p-1 w-16"></th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const judgment = getRowJudgment(row);
return (
<tr key={row.id}>
<td className="border border-gray-400 p-1 text-center">{row.id}</td>
{/* 가공상태 - 양호/불량 체크 */}
{renderCheckStatus(row.id, 'processStatus', row.processStatus)}
{/* 재봉상태 - 양호/불량 체크 */}
{renderCheckStatus(row.id, 'sewingStatus', row.sewingStatus)}
{/* 조립상태 - 양호/불량 체크 */}
{renderCheckStatus(row.id, 'assemblyStatus', row.assemblyStatus)}
{/* 길이 - 도면치수 표시 + 측정값 입력 */}
<td className="border border-gray-400 p-1 text-center">{row.lengthDesign}</td>
<td className="border border-gray-400 p-1">
<input type="text" value={formatNumberWithComma(row.lengthMeasured)} onChange={(e) => handleInputChange(row.id, 'lengthMeasured', e.target.value)} disabled={readOnly} className={inputClass} placeholder="-" />
</td>
{/* 나비 - 도면치수 표시 + 측정값 입력 */}
<td className="border border-gray-400 p-1 text-center">{row.widthDesign}</td>
<td className="border border-gray-400 p-1">
<input type="text" value={formatNumberWithComma(row.widthMeasured)} onChange={(e) => handleInputChange(row.id, 'widthMeasured', e.target.value)} disabled={readOnly} className={inputClass} placeholder="-" />
</td>
{/* 간격 - 기준치 표시 + OK/NG 선택 */}
<td className="border border-gray-400 p-1 text-center">{row.gapStandard}</td>
<td className="border border-gray-400 p-1">
<div className="flex flex-col items-center gap-0.5">
<label className="flex items-center gap-0.5 cursor-pointer text-xs">
{renderCheckbox(row.gapResult === 'OK', () => handleGapChange(row.id, row.gapResult === 'OK' ? null : 'OK'))}
OK
</label>
<label className="flex items-center gap-0.5 cursor-pointer text-xs">
{renderCheckbox(row.gapResult === 'NG', () => handleGapChange(row.id, row.gapResult === 'NG' ? null : 'NG'))}
NG
</label>
</div>
</td>
{/* 판정 - 자동 계산 */}
<td className={`border border-gray-400 p-1 text-center font-bold ${
judgment === '적' ? 'text-blue-600' : judgment === '부' ? 'text-red-600' : 'text-gray-300'
}`}>
{judgment || '-'}
</td>
</tr>
);
})}
</tbody>
</table>
{/* ===== 부적합 내용 + 종합판정 ===== */}
<table className="w-full border-collapse text-xs">
<tbody>
<tr>
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium w-24 align-middle text-center"> </td>
<td className="border border-gray-400 px-3 py-2">
<textarea value={inadequateContent} onChange={(e) => !readOnly && setInadequateContent(e.target.value)} disabled={readOnly}
className="w-full border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-500 rounded text-xs resize-none" rows={2} />
</td>
<td className="border border-gray-400 bg-gray-100 px-3 py-2 font-medium text-center w-24"></td>
<td className={`border border-gray-400 px-3 py-2 text-center font-bold text-sm w-24 ${
overallResult === '합격' ? 'text-blue-600' : overallResult === '불합격' ? 'text-red-600' : 'text-gray-400'
}`}>
{overallResult || '합격'}
</td>
</tr>
</tbody>
</table>
</div>
);
});