Files
sam-react-prod/src/app/[locale]/(protected)/quality/qms/components/InspectionModal.tsx
유병철 f0987127eb feat(WEB): QMS 검사 모달 개선, 전자결재/생산대시보드/템플릿 기능 수정
- QMS: InspectionModal/InspectionModalV2 개선, mockData 정리
- 전자결재: DocumentCreate 기능 수정
- 생산대시보드: ProductionDashboard 개선
- 템플릿: IntegratedDetailTemplate/UniversalListPage 기능 수정
- 문서: i18n 가이드 업데이트, 문서뷰어 아키텍처 계획 추가

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

587 lines
24 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";
import React, { useState, useRef, useCallback, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ZoomIn, ZoomOut, Download, Printer, AlertCircle, Maximize2 } from 'lucide-react';
import { Button } from "@/components/ui/button";
import { Document, DocumentItem } from '../types';
import { MOCK_WORK_ORDER, MOCK_SHIPMENT_DETAIL } from '../mockData';
// 기존 문서 컴포넌트 import
import { DeliveryConfirmation } from '@/components/outbound/ShipmentManagement/documents/DeliveryConfirmation';
import { ShippingSlip } from '@/components/outbound/ShipmentManagement/documents/ShippingSlip';
// 품질검사 문서 컴포넌트 import
import {
ImportInspectionDocument,
ProductInspectionDocument,
ScreenInspectionDocument,
BendingInspectionDocument,
SlatInspectionDocument,
JointbarInspectionDocument,
QualityDocumentUploader,
} from './documents';
interface InspectionModalProps {
isOpen: boolean;
onClose: () => void;
document: Document | null;
documentItem: DocumentItem | null;
}
// 문서 타입별 정보
const DOCUMENT_INFO: Record<string, { label: string; hasTemplate: boolean; color: string }> = {
import: { label: '수입검사 성적서', hasTemplate: true, color: 'text-green-600' },
order: { label: '수주서', hasTemplate: true, color: 'text-blue-600' },
log: { label: '작업일지', hasTemplate: true, color: 'text-orange-500' },
report: { label: '중간검사 성적서', hasTemplate: true, color: 'text-blue-500' },
confirmation: { label: '납품확인서', hasTemplate: true, color: 'text-red-500' },
shipping: { label: '출고증', hasTemplate: true, color: 'text-gray-600' },
product: { label: '제품검사 성적서', hasTemplate: true, color: 'text-green-500' },
quality: { label: '품질관리서', hasTemplate: false, color: 'text-purple-600' },
};
// Placeholder 컴포넌트 (양식 대기 문서용)
const PlaceholderDocument = ({ docType, docItem }: { docType: string; docItem: DocumentItem | null }) => {
const info = DOCUMENT_INFO[docType] || { label: '문서', hasTemplate: false, color: 'text-gray-600' };
return (
<div className="bg-white shadow-sm p-16 w-full h-full rounded flex flex-col items-center justify-center text-center">
<div className="w-16 h-16 text-amber-500 mb-4 mx-auto">
<AlertCircle className="w-full h-full" />
</div>
<h2 className="text-xl font-bold text-gray-800 mb-2">{info.label}</h2>
<p className="text-gray-500 text-sm mb-2">{docItem?.title || '문서'}</p>
{docItem?.date && (
<p className="text-gray-400 text-xs mb-2">{docItem.date}</p>
)}
{docItem?.code && (
<p className="text-xs text-green-600 font-mono bg-green-50 px-2 py-1 rounded mb-4">
: {docItem.code}
</p>
)}
<div className="mt-4 p-4 bg-amber-50 rounded-lg border border-amber-200">
<p className="text-amber-700 text-sm font-medium"> </p>
<p className="text-amber-600 text-xs mt-1"> </p>
</div>
</div>
);
};
// 수주서 문서 컴포넌트 (간소화 버전 - deprecated, V2 사용)
const OrderDocument = () => {
const data = { lotNumber: '', orderDate: '', client: '', siteName: '', manager: '', managerContact: '', deliveryRequestDate: '', expectedShipDate: '', deliveryMethod: '', address: '', items: [] as { id: string; name: string; specification: string; unit: string; quantity: number; unitPrice?: number; amount?: number }[], subtotal: 0, discountRate: 0, totalAmount: 0, remarks: '' };
return (
<div className="bg-white p-8 w-full text-sm shadow-sm">
{/* 헤더 */}
<div className="flex justify-between items-start mb-6">
<div className="flex items-center gap-4">
<div className="text-2xl font-bold">KD</div>
<div className="text-xs"></div>
</div>
<div className="text-2xl font-bold tracking-[0.5rem]"> </div>
<table className="text-xs border-collapse">
<tbody>
<tr>
<td className="border px-2 py-1 bg-gray-100" rowSpan={3}>
<div className="flex flex-col items-center">
<span></span><span></span>
</div>
</td>
<td className="border px-2 py-1 bg-gray-100 text-center w-16"></td>
<td className="border px-2 py-1 bg-gray-100 text-center w-16"></td>
<td className="border px-2 py-1 bg-gray-100 text-center w-16"></td>
</tr>
<tr>
<td className="border px-2 py-1 h-10"></td>
<td className="border px-2 py-1 h-10"></td>
<td className="border px-2 py-1 h-10"></td>
</tr>
<tr>
<td className="border px-2 py-1 text-center bg-gray-50"></td>
<td className="border px-2 py-1 text-center bg-gray-50"></td>
<td className="border px-2 py-1 text-center bg-gray-50"></td>
</tr>
</tbody>
</table>
</div>
{/* 기본 정보 */}
<table className="w-full border-collapse mb-6 text-xs">
<tbody>
<tr>
<td className="border px-3 py-2 bg-gray-100 w-24">LOT NO.</td>
<td className="border px-3 py-2">{data.lotNumber}</td>
<td className="border px-3 py-2 bg-gray-100 w-24"></td>
<td className="border px-3 py-2">{data.orderDate}</td>
</tr>
<tr>
<td className="border px-3 py-2 bg-gray-100"></td>
<td className="border px-3 py-2">{data.client}</td>
<td className="border px-3 py-2 bg-gray-100"></td>
<td className="border px-3 py-2">{data.siteName}</td>
</tr>
<tr>
<td className="border px-3 py-2 bg-gray-100"></td>
<td className="border px-3 py-2">{data.manager}</td>
<td className="border px-3 py-2 bg-gray-100"></td>
<td className="border px-3 py-2">{data.managerContact}</td>
</tr>
<tr>
<td className="border px-3 py-2 bg-gray-100"></td>
<td className="border px-3 py-2">{data.deliveryRequestDate}</td>
<td className="border px-3 py-2 bg-gray-100"></td>
<td className="border px-3 py-2">{data.expectedShipDate}</td>
</tr>
<tr>
<td className="border px-3 py-2 bg-gray-100"></td>
<td className="border px-3 py-2">{data.deliveryMethod}</td>
<td className="border px-3 py-2 bg-gray-100"></td>
<td className="border px-3 py-2">{data.address}</td>
</tr>
</tbody>
</table>
{/* 품목 테이블 */}
<table className="w-full border-collapse mb-6 text-xs">
<thead>
<tr className="bg-gray-100">
<th className="border px-2 py-2 w-10">No</th>
<th className="border px-2 py-2"></th>
<th className="border px-2 py-2 w-24"></th>
<th className="border px-2 py-2 w-12"></th>
<th className="border px-2 py-2 w-12"></th>
<th className="border px-2 py-2 w-20"></th>
<th className="border px-2 py-2 w-24"></th>
</tr>
</thead>
<tbody>
{data.items.map((item, index) => (
<tr key={item.id}>
<td className="border px-2 py-2 text-center">{index + 1}</td>
<td className="border px-2 py-2">{item.name}</td>
<td className="border px-2 py-2 text-center">{item.specification}</td>
<td className="border px-2 py-2 text-center">{item.unit}</td>
<td className="border px-2 py-2 text-center">{item.quantity}</td>
<td className="border px-2 py-2 text-right">{item.unitPrice?.toLocaleString()}</td>
<td className="border px-2 py-2 text-right">{item.amount?.toLocaleString()}</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<td colSpan={5} className="border px-2 py-2 text-right bg-gray-50"></td>
<td colSpan={2} className="border px-2 py-2 text-right">{data.subtotal.toLocaleString()}</td>
</tr>
<tr>
<td colSpan={5} className="border px-2 py-2 text-right bg-gray-50"> ({data.discountRate}%)</td>
<td colSpan={2} className="border px-2 py-2 text-right text-red-600">-{(data.subtotal * data.discountRate / 100).toLocaleString()}</td>
</tr>
<tr>
<td colSpan={5} className="border px-2 py-2 text-right bg-gray-100 font-bold"></td>
<td colSpan={2} className="border px-2 py-2 text-right font-bold text-blue-600">{data.totalAmount.toLocaleString()}</td>
</tr>
</tfoot>
</table>
{/* 비고 */}
{data.remarks && (
<div className="border p-4">
<h3 className="font-medium mb-2 text-xs"></h3>
<p className="text-xs text-gray-600">{data.remarks}</p>
</div>
)}
</div>
);
};
// 작업일지 문서 컴포넌트 (간소화 버전)
const WorkLogDocument = () => {
const order = MOCK_WORK_ORDER;
const today = new Date().toLocaleDateString('ko-KR').replace(/\. /g, '-').replace('.', '');
const documentNo = `WL-${order.processCode.toUpperCase().slice(0, 3)}`;
const lotNo = `KD-TS-${new Date().toISOString().slice(2, 10).replace(/-/g, '')}-01`;
const items = [
{ no: 1, name: order.productName, location: '1층/A-01', spec: '3000×2500', qty: 1, status: '완료' },
{ no: 2, name: order.productName, location: '2층/A-02', spec: '3000×2500', qty: 1, status: '작업중' },
{ no: 3, name: order.productName, location: '3층/A-03', spec: '-', qty: 1, status: '대기' },
];
return (
<div className="bg-white p-8 w-full text-sm shadow-sm">
{/* 헤더 */}
<div className="flex justify-between items-start mb-6 border border-gray-300">
<div className="w-24 border-r border-gray-300 flex flex-col items-center justify-center p-3">
<span className="text-2xl font-bold">KD</span>
<span className="text-xs text-gray-500"></span>
</div>
<div className="flex-1 flex flex-col items-center justify-center p-3 border-r border-gray-300">
<h1 className="text-xl font-bold tracking-widest mb-1"> </h1>
<p className="text-xs text-gray-500">{documentNo}</p>
<p className="text-sm font-medium mt-1"> </p>
</div>
<table className="text-xs shrink-0">
<tbody>
<tr>
<td rowSpan={3} className="w-8 text-center font-medium bg-gray-100 border-r border-b border-gray-300">
<div className="flex flex-col items-center"><span></span><span></span></div>
</td>
<td className="w-16 p-2 text-center font-medium bg-gray-100 border-r border-b border-gray-300"></td>
<td className="w-16 p-2 text-center font-medium bg-gray-100 border-r border-b border-gray-300"></td>
<td className="w-16 p-2 text-center font-medium bg-gray-100 border-b border-gray-300"></td>
</tr>
<tr>
<td className="w-16 p-2 text-center border-r border-b border-gray-300">
<div>{order.assignees[0] || '-'}</div>
</td>
<td className="w-16 p-2 text-center border-r border-b border-gray-300"></td>
<td className="w-16 p-2 text-center border-b border-gray-300"></td>
</tr>
<tr>
<td className="w-16 p-2 text-center bg-gray-50 border-r border-gray-300"></td>
<td className="w-16 p-2 text-center bg-gray-50 border-r border-gray-300"></td>
<td className="w-16 p-2 text-center bg-gray-50"></td>
</tr>
</tbody>
</table>
</div>
{/* 기본 정보 */}
<div className="border border-gray-300 mb-6">
<div className="grid grid-cols-2 border-b border-gray-300">
<div className="flex border-r border-gray-300">
<div className="w-24 bg-gray-100 p-3 text-sm font-medium border-r border-gray-300"></div>
<div className="flex-1 p-3 text-sm">{order.client}</div>
</div>
<div className="flex">
<div className="w-24 bg-gray-100 p-3 text-sm font-medium border-r border-gray-300"></div>
<div className="flex-1 p-3 text-sm">{order.projectName}</div>
</div>
</div>
<div className="grid grid-cols-2 border-b border-gray-300">
<div className="flex border-r border-gray-300">
<div className="w-24 bg-gray-100 p-3 text-sm font-medium border-r border-gray-300"></div>
<div className="flex-1 p-3 text-sm">{today}</div>
</div>
<div className="flex">
<div className="w-24 bg-gray-100 p-3 text-sm font-medium border-r border-gray-300">LOT NO.</div>
<div className="flex-1 p-3 text-sm">{lotNo}</div>
</div>
</div>
<div className="grid grid-cols-2">
<div className="flex border-r border-gray-300">
<div className="w-24 bg-gray-100 p-3 text-sm font-medium border-r border-gray-300"></div>
<div className="flex-1 p-3 text-sm">{order.dueDate}</div>
</div>
<div className="flex">
<div className="w-24 bg-gray-100 p-3 text-sm font-medium border-r border-gray-300"></div>
<div className="flex-1 p-3 text-sm">{order.quantity} EA</div>
</div>
</div>
</div>
{/* 품목 테이블 */}
<div className="border border-gray-300 mb-6">
<div className="grid grid-cols-12 border-b border-gray-300 bg-gray-100">
<div className="col-span-1 p-2 text-sm font-medium text-center border-r border-gray-300">No</div>
<div className="col-span-4 p-2 text-sm font-medium text-center border-r border-gray-300"></div>
<div className="col-span-2 p-2 text-sm font-medium text-center border-r border-gray-300">/</div>
<div className="col-span-2 p-2 text-sm font-medium text-center border-r border-gray-300"></div>
<div className="col-span-1 p-2 text-sm font-medium text-center border-r border-gray-300"></div>
<div className="col-span-2 p-2 text-sm font-medium text-center"></div>
</div>
{items.map((item, index) => (
<div key={item.no} className={`grid grid-cols-12 ${index < items.length - 1 ? 'border-b border-gray-300' : ''}`}>
<div className="col-span-1 p-2 text-sm text-center border-r border-gray-300">{item.no}</div>
<div className="col-span-4 p-2 text-sm border-r border-gray-300">{item.name}</div>
<div className="col-span-2 p-2 text-sm text-center border-r border-gray-300">{item.location}</div>
<div className="col-span-2 p-2 text-sm text-center border-r border-gray-300">{item.spec}</div>
<div className="col-span-1 p-2 text-sm text-center border-r border-gray-300">{item.qty}</div>
<div className="col-span-2 p-2 text-sm text-center">
<span className={`px-2 py-0.5 rounded text-xs ${
item.status === '완료' ? 'bg-green-100 text-green-700' :
item.status === '작업중' ? 'bg-blue-100 text-blue-700' :
'bg-gray-100 text-gray-700'
}`}>{item.status}</span>
</div>
</div>
))}
</div>
{/* 특이사항 */}
<div className="border border-gray-300">
<div className="bg-gray-800 text-white p-2.5 text-sm font-medium text-center"></div>
<div className="p-4 min-h-[60px] text-sm">{order.instruction || '-'}</div>
</div>
</div>
);
};
// 줌 레벨 상수
const ZOOM_LEVELS = [50, 75, 100, 125, 150, 200];
const MIN_ZOOM = 50;
const MAX_ZOOM = 200;
export const InspectionModal = ({ isOpen, onClose, document: doc, documentItem }: InspectionModalProps) => {
// 줌 상태
const [zoom, setZoom] = useState(100);
// 드래그 상태
const [isDragging, setIsDragging] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [startPos, setStartPos] = useState({ x: 0, y: 0 });
// refs
const containerRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
// 모달 열릴 때 상태 초기화
useEffect(() => {
if (isOpen) {
setZoom(100);
setPosition({ x: 0, y: 0 });
}
}, [isOpen]);
// 줌 인
const handleZoomIn = useCallback(() => {
setZoom(prev => {
const nextIndex = ZOOM_LEVELS.findIndex(z => z > prev);
return nextIndex !== -1 ? ZOOM_LEVELS[nextIndex] : MAX_ZOOM;
});
}, []);
// 줌 아웃
const handleZoomOut = useCallback(() => {
setZoom(prev => {
const prevIndex = ZOOM_LEVELS.slice().reverse().findIndex(z => z < prev);
const index = prevIndex !== -1 ? ZOOM_LEVELS.length - 1 - prevIndex : 0;
return ZOOM_LEVELS[index] || MIN_ZOOM;
});
}, []);
// 줌 리셋
const handleZoomReset = useCallback(() => {
setZoom(100);
setPosition({ x: 0, y: 0 });
}, []);
// 마우스 드래그 시작
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (zoom > 100) {
setIsDragging(true);
setStartPos({ x: e.clientX - position.x, y: e.clientY - position.y });
}
}, [zoom, position]);
// 마우스 이동
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (!isDragging) return;
setPosition({
x: e.clientX - startPos.x,
y: e.clientY - startPos.y,
});
}, [isDragging, startPos]);
// 마우스 드래그 종료
const handleMouseUp = useCallback(() => {
setIsDragging(false);
}, []);
// 터치 드래그 시작
const handleTouchStart = useCallback((e: React.TouchEvent) => {
if (zoom > 100 && e.touches.length === 1) {
setIsDragging(true);
setStartPos({
x: e.touches[0].clientX - position.x,
y: e.touches[0].clientY - position.y,
});
}
}, [zoom, position]);
// 터치 이동
const handleTouchMove = useCallback((e: React.TouchEvent) => {
if (!isDragging || e.touches.length !== 1) return;
setPosition({
x: e.touches[0].clientX - startPos.x,
y: e.touches[0].clientY - startPos.y,
});
}, [isDragging, startPos]);
// 터치 종료
const handleTouchEnd = useCallback(() => {
setIsDragging(false);
}, []);
if (!doc) return null;
const docInfo = DOCUMENT_INFO[doc.type] || { label: doc.title, hasTemplate: false, color: 'text-gray-600' };
const subtitle = documentItem
? `${docInfo.label} - ${documentItem.date}${documentItem.code ? ` 로트: ${documentItem.code}` : ''}`
: docInfo.label;
const handlePrint = () => {
window.print();
};
// 중간검사 성적서 서브타입에 따른 렌더링
const renderReportDocument = () => {
const subType = documentItem?.subType;
switch (subType) {
case 'screen':
return <ScreenInspectionDocument />;
case 'bending':
return <BendingInspectionDocument />;
case 'slat':
return <SlatInspectionDocument />;
case 'jointbar':
return <JointbarInspectionDocument />;
default:
// 서브타입이 없으면 기본 스크린 문서
return <ScreenInspectionDocument />;
}
};
// 품질관리서 PDF 업로드 핸들러
const handleQualityFileUpload = (file: File) => {
console.log('[InspectionModal] 품질관리서 PDF 업로드:', file.name);
// TODO: 실제 API 연동 시 파일 업로드 로직 구현
};
const handleQualityFileDelete = () => {
console.log('[InspectionModal] 품질관리서 PDF 삭제');
// TODO: 실제 API 연동 시 파일 삭제 로직 구현
};
// 문서 타입에 따른 컨텐츠 렌더링
const renderDocumentContent = () => {
switch (doc.type) {
case 'order':
return <OrderDocument />;
case 'log':
return <WorkLogDocument />;
case 'confirmation':
return <DeliveryConfirmation data={MOCK_SHIPMENT_DETAIL} />;
case 'shipping':
return <ShippingSlip data={MOCK_SHIPMENT_DETAIL} />;
case 'import':
return <ImportInspectionDocument />;
case 'product':
return <ProductInspectionDocument />;
case 'report':
return renderReportDocument();
case 'quality':
// 품질관리서는 PDF 업로드/뷰어 사용
return (
<QualityDocumentUploader
onFileUpload={handleQualityFileUpload}
onFileDelete={handleQualityFileDelete}
/>
);
default:
// 양식 대기 중인 문서
return <PlaceholderDocument docType={doc.type} docItem={documentItem} />;
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="w-[95vw] max-w-[1200px] sm:max-w-[1200px] h-[90vh] p-0 overflow-hidden bg-gray-50 flex flex-col">
<DialogHeader className="p-4 bg-white border-b border-gray-200 flex flex-row items-center justify-between space-y-0 shrink-0">
<div>
<DialogTitle className="text-lg font-bold text-gray-800">{doc.title}</DialogTitle>
<p className="text-xs text-gray-500 mt-1">{subtitle}</p>
</div>
</DialogHeader>
{/* Toolbar */}
<div className="flex items-center justify-between px-4 py-2 bg-white border-b border-gray-100 shrink-0 flex-wrap gap-2">
<div className="flex items-center gap-1 sm:gap-2">
<Button
variant="ghost"
size="sm"
className="h-8 gap-1 text-xs px-2 sm:px-3"
onClick={handleZoomOut}
disabled={zoom <= MIN_ZOOM}
>
<ZoomOut size={14} />
<span className="hidden sm:inline"></span>
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 gap-1 text-xs px-2 sm:px-3"
onClick={handleZoomIn}
disabled={zoom >= MAX_ZOOM}
>
<ZoomIn size={14} />
<span className="hidden sm:inline"></span>
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 gap-1 text-xs px-2 sm:px-3"
onClick={handleZoomReset}
>
<Maximize2 size={14} />
<span className="hidden sm:inline"></span>
</Button>
<span className="text-xs font-mono text-gray-600 bg-gray-100 px-2 py-1 rounded min-w-[48px] text-center">
{zoom}%
</span>
</div>
<div className="flex items-center gap-1 sm:gap-2">
<Button variant="outline" size="sm" className="h-8 gap-1 text-xs px-2 sm:px-3" onClick={handlePrint}>
<Printer size={14} />
<span className="hidden sm:inline"></span>
</Button>
<Button size="sm" className="h-8 bg-green-600 hover:bg-green-700 text-white gap-1 text-xs px-2 sm:px-3">
<Download size={14} />
<span className="hidden sm:inline"></span>
</Button>
</div>
</div>
{/* Content Area - 줌/드래그 가능한 영역 */}
<div
ref={containerRef}
className="flex-1 overflow-auto bg-gray-100 relative"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
style={{ cursor: zoom > 100 ? (isDragging ? 'grabbing' : 'grab') : 'default' }}
>
<div
ref={contentRef}
className="p-4 origin-top-left transition-transform duration-150 ease-out"
style={{
transform: `scale(${zoom / 100}) translate(${position.x / (zoom / 100)}px, ${position.y / (zoom / 100)}px)`,
minWidth: '800px',
}}
>
{renderDocumentContent()}
</div>
</div>
{/* 모바일 줌 힌트 */}
{zoom === 100 && (
<div className="sm:hidden absolute bottom-20 left-1/2 -translate-x-1/2 bg-black/70 text-white text-xs px-3 py-1.5 rounded-full">
</div>
)}
</DialogContent>
</Dialog>
);
};