From 13249384e2f3c20d03bc71be3d80736a7a44d0c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Fri, 13 Mar 2026 00:30:09 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[=EB=B6=80=EC=84=9C=EA=B4=80=EB=A6=AC]?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EB=B3=B4=EC=99=84=20-=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=ED=99=95=EC=9E=A5,=20=EA=B2=80=EC=83=89/=ED=95=84?= =?UTF-8?q?=ED=84=B0,=20UI=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Department 타입에 code, description, isActive, sortOrder 필드 추가 - DepartmentDialog: Zod + react-hook-form 폼 검증 (5개 필드) - DepartmentToolbar: 상태 필터(전체/활성/비활성) + 검색 기능 - DepartmentTree: 트리 필터링 (검색어 + 상태) - DepartmentTreeItem: 코드 Badge, 부서명 볼드, 설명 표시, 체크박스 크기 조정 - convertApiToLocal에서 누락 필드 매핑 복원 --- next.config.ts | 2 +- .../(protected)/quality/qms/actions.ts | 33 + .../quality/qms/components/DocumentList.tsx | 159 +++- .../qms/components/InspectionModal.tsx | 574 +++++------- .../quality/qms/components/ReportList.tsx | 4 +- .../quality/qms/components/RouteList.tsx | 3 +- .../documents/ImportInspectionDocument.tsx | 4 +- .../quality/qms/hooks/useDay2LotAudit.ts | 18 + .../[locale]/(protected)/quality/qms/page.tsx | 80 +- .../[locale]/(protected)/quality/qms/types.ts | 3 + .../order-management-sales/[id]/page.tsx | 1 + .../sales/pricing-management/[id]/page.tsx | 11 +- .../sales/pricing-management/page.tsx | 25 - .../DepartmentManagement/DepartmentDialog.tsx | 155 +++- .../DepartmentToolbar.tsx | 51 +- .../DepartmentManagement/DepartmentTree.tsx | 2 +- .../DepartmentTreeItem.tsx | 29 +- .../hr/DepartmentManagement/index.tsx | 65 +- .../hr/DepartmentManagement/types.ts | 66 +- .../ReceivingManagement/ReceivingDetail.tsx | 5 +- src/components/orders/actions.ts | 11 + .../orders/documents/OrderDocumentModal.tsx | 26 +- .../orders/documents/SalesOrderDocument.tsx | 823 +++++++++--------- src/components/pricing/PricingListClient.tsx | 13 +- src/components/pricing/actions.ts | 35 +- .../production/WorkOrders/documents/index.ts | 1 + 26 files changed, 1284 insertions(+), 915 deletions(-) diff --git a/next.config.ts b/next.config.ts index d86b9b1c..0626ec4c 100644 --- a/next.config.ts +++ b/next.config.ts @@ -6,7 +6,7 @@ const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts'); const nextConfig: NextConfig = { reactStrictMode: false, // 🧪 TEST: Strict Mode 비활성화로 중복 요청 테스트 turbopack: {}, // ✅ CRITICAL: Next.js 15 + next-intl compatibility - allowedDevOrigins: ['192.168.0.*'], // 로컬 네트워크 기기 접속 허용 + allowedDevOrigins: ['dev.sam.kr', '192.168.0.*'], // 로컬 도메인 + 네트워크 기기 접속 허용 serverExternalPackages: ['puppeteer'], // PDF 생성용 - Webpack 번들 제외 images: { remotePatterns: [ diff --git a/src/app/[locale]/(protected)/quality/qms/actions.ts b/src/app/[locale]/(protected)/quality/qms/actions.ts index 0a9e85cd..42e5827c 100644 --- a/src/app/[locale]/(protected)/quality/qms/actions.ts +++ b/src/app/[locale]/(protected)/quality/qms/actions.ts @@ -39,6 +39,9 @@ interface DocumentApi { title: string; date?: string; count: number; + file_id?: number; + file_name?: string; + file_size?: number; items?: { id: number; title: string; @@ -89,6 +92,9 @@ function transformDocumentApi(api: DocumentApi) { title: api.title, date: api.date, count: api.count, + fileId: api.file_id, + fileName: api.file_name, + fileSize: api.file_size, items: api.items?.map((i) => ({ id: String(i.id), title: i.title, @@ -305,3 +311,30 @@ export async function deleteTemplateDocument(fileId: number, replace: boolean = errorMessage: '파일 삭제에 실패했습니다.', }); } + +// ===== 품질관리서 파일 업로드/삭제 ===== + +export async function uploadQualityDocumentFile(qualityDocumentId: string, file: File) { + const formData = new FormData(); + formData.append('file', file); + + return executeServerAction({ + url: buildApiUrl(`/api/v1/quality/documents/${qualityDocumentId}/upload-file`), + method: 'POST', + body: formData, + transform: (data: { id: number; display_name: string; file_size: number }) => ({ + fileId: data.id, + fileName: data.display_name, + fileSize: data.file_size, + }), + errorMessage: '품질관리서 파일 업로드에 실패했습니다.', + }); +} + +export async function deleteQualityDocumentFile(qualityDocumentId: string) { + return executeServerAction({ + url: buildApiUrl(`/api/v1/quality/documents/${qualityDocumentId}/file`), + method: 'DELETE', + errorMessage: '품질관리서 파일 삭제에 실패했습니다.', + }); +} diff --git a/src/app/[locale]/(protected)/quality/qms/components/DocumentList.tsx b/src/app/[locale]/(protected)/quality/qms/components/DocumentList.tsx index 58534c27..28ad25fa 100644 --- a/src/app/[locale]/(protected)/quality/qms/components/DocumentList.tsx +++ b/src/app/[locale]/(protected)/quality/qms/components/DocumentList.tsx @@ -1,16 +1,20 @@ "use client"; -import React, { useState } from 'react'; +import React, { useState, useRef } from 'react'; import { FileText, CheckCircle, ChevronDown, ChevronUp, - Eye, Truck, Calendar, ClipboardCheck, Box, FileCheck + Eye, Truck, Calendar, ClipboardCheck, Box, FileCheck, + Upload, Download, Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; import { Document, DocumentItem } from '../types'; +import { downloadFileById } from '@/lib/utils/fileDownload'; interface DocumentListProps { documents: Document[]; routeCode: string | null; onViewDocument: (doc: Document, item?: DocumentItem) => void; + onQualityFileUpload?: (qualityDocumentId: string, file: File) => Promise; isMock?: boolean; } @@ -28,11 +32,76 @@ const getIcon = (type: string) => { } }; -export const DocumentList = ({ documents, routeCode, onViewDocument, isMock }: DocumentListProps) => { +/** 파일 크기를 읽기 쉬운 형식으로 변환 */ +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes}B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; +} + +export const DocumentList = ({ documents, routeCode, onViewDocument, onQualityFileUpload, isMock }: DocumentListProps) => { const [expandedId, setExpandedId] = useState(null); + const [uploadingDocId, setUploadingDocId] = useState(null); + const [downloadingDocId, setDownloadingDocId] = useState(null); + const fileInputRef = useRef(null); + const uploadTargetDocId = useRef(null); // doc.id (상태 추적용) + const uploadApiDocId = useRef(null); // 실제 DB ID (API 호출용) + + // 품질관리서 파일 다운로드 + const handleQualityDownload = async (doc: Document) => { + if (!doc.fileId) return; + setDownloadingDocId(doc.id); + try { + await downloadFileById(doc.fileId, doc.fileName); + } catch { + toast.error('파일 다운로드에 실패했습니다.'); + } finally { + setDownloadingDocId(null); + } + }; + + // 업로드 버튼 클릭 → hidden input 트리거 + const handleUploadClick = (e: React.MouseEvent, docId: string, apiDocId?: string) => { + e.stopPropagation(); + uploadTargetDocId.current = docId; // 상태 추적용 + uploadApiDocId.current = apiDocId || docId; // API 호출용 (품질관리서는 items[0].id) + fileInputRef.current?.click(); + }; + + // 파일 선택 후 업로드 실행 + const handleFileChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + const trackingId = uploadTargetDocId.current; + const apiId = uploadApiDocId.current; + if (!file || !trackingId || !apiId || !onQualityFileUpload) return; + + setUploadingDocId(trackingId); + try { + const success = await onQualityFileUpload(apiId, file); + if (success) { + toast.success('파일이 업로드되었습니다.'); + } + } finally { + setUploadingDocId(null); + uploadTargetDocId.current = null; + uploadApiDocId.current = null; + // input 초기화 (같은 파일 재선택 가능하도록) + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } + }; // 문서 카테고리 클릭 핸들러 const handleDocClick = (doc: Document) => { + // 품질관리서: 파일이 있으면 다운로드, 없으면 무시 + if (doc.type === 'quality') { + if (doc.fileId) { + handleQualityDownload(doc); + } + return; + } + const hasItems = doc.items && doc.items.length > 0; if (!hasItems) return; @@ -51,8 +120,34 @@ export const DocumentList = ({ documents, routeCode, onViewDocument, isMock }: D onViewDocument(doc, item); }; + // 품질관리서 서브텍스트 렌더링 + const renderQualitySubText = (doc: Document) => { + if (doc.fileId && doc.fileName) { + return ( + + + + {doc.fileName} + + {doc.fileSize && ( + ({formatFileSize(doc.fileSize)}) + )} + + ); + } + return 파일 없음; + }; + return (
+ {/* hidden file input for quality document upload */} + +

관련 서류{' '} @@ -75,35 +170,65 @@ export const DocumentList = ({ documents, routeCode, onViewDocument, isMock }: D ) : ( documents.map((doc) => { const isExpanded = expandedId === doc.id; + const isQuality = doc.type === 'quality'; const hasItems = doc.items && doc.items.length > 0; const hasMultipleItems = doc.items && doc.items.length > 1; + const isUploading = uploadingDocId === doc.id; + const isDownloading = downloadingDocId === doc.id; + + // 품질관리서: 파일 유무로 클릭 가능 여부 결정 + // 나머지: 아이템 유무로 결정 + const isClickable = isQuality ? !!doc.fileId : hasItems; return (
handleDocClick(doc)} className={`p-3 sm:p-4 flex justify-between items-center transition-colors ${ - hasItems ? 'cursor-pointer hover:bg-gray-50' : 'cursor-default opacity-60' + isClickable ? 'cursor-pointer hover:bg-gray-50' : 'cursor-default opacity-60' } ${isExpanded ? 'bg-green-50' : 'bg-white'}`} > -
-
- {getIcon(doc.type)} +
+
+ {(isUploading || isDownloading) ? ( + + ) : ( + getIcon(doc.type) + )}
-
+

{doc.title}

- {doc.count > 0 ? `${doc.count}건의 서류` : '서류 없음'} + {isQuality + ? renderQualitySubText(doc) + : doc.count > 0 ? `${doc.count}건의 서류` : '서류 없음' + }

- {hasMultipleItems && ( - isExpanded ? ( - - ) : ( - - ) - )} + +
+ {/* 품질관리서 업로드 버튼 */} + {isQuality && onQualityFileUpload && doc.items?.[0]?.id && ( + + )} + {/* 기존: 여러 아이템일 때 펼치기/접기 아이콘 */} + {!isQuality && hasMultipleItems && ( + isExpanded ? ( + + ) : ( + + ) + )} +
{isExpanded && hasMultipleItems && ( @@ -139,4 +264,4 @@ export const DocumentList = ({ documents, routeCode, onViewDocument, isMock }: D
); -}; \ No newline at end of file +}; diff --git a/src/app/[locale]/(protected)/quality/qms/components/InspectionModal.tsx b/src/app/[locale]/(protected)/quality/qms/components/InspectionModal.tsx index 23a38c02..bea4d737 100644 --- a/src/app/[locale]/(protected)/quality/qms/components/InspectionModal.tsx +++ b/src/app/[locale]/(protected)/quality/qms/components/InspectionModal.tsx @@ -1,5 +1,15 @@ "use client"; +/** + * InspectionModal (QMS 전용) + * + * 수입검사, 수주서, 납품확인서, 출고증, 품질관리서 등 + * 아직 독립 모달이 없는 문서 타입만 처리. + * + * 작업일지(log), 중간검사(report), 제품검사(product)는 + * 각각 WorkLogModal, InspectionReportModal, ProductInspectionViewModal로 분리됨. + */ + import React, { useState, useEffect, useRef, useCallback } from 'react'; import { AlertCircle, Loader2, Save } from 'lucide-react'; import { DocumentViewer } from '@/components/document-system'; @@ -7,78 +17,114 @@ import { Button } from '@/components/ui/button'; import { toast } from 'sonner'; import { Document, DocumentItem } from '../types'; import { getDocumentDetail } from '../actions'; -import { MOCK_SHIPMENT_DETAIL } from '../mockData'; // 기존 문서 컴포넌트 import import { DeliveryConfirmation } from '@/components/outbound/ShipmentManagement/documents/DeliveryConfirmation'; import { ShippingSlip } from '@/components/outbound/ShipmentManagement/documents/ShippingSlip'; +import type { ShipmentDetail } from '@/components/outbound/ShipmentManagement/types'; // 수주서 문서 컴포넌트 import import { SalesOrderDocument } from '@/components/orders/documents/SalesOrderDocument'; -import type { ProductInfo } from '@/components/orders/documents/OrderDocumentModal'; -import type { OrderItem } from '@/components/orders/actions'; // 품질검사 문서 컴포넌트 import import { ImportInspectionDocument, - JointbarInspectionDocument, QualityDocumentUploader, } from './documents'; -// 제품검사 성적서 (FQC 양식) import -import { FqcDocumentContent } from '@/components/quality/InspectionManagement/documents/FqcDocumentContent'; -import { InspectionReportDocument } from '@/components/quality/InspectionManagement/documents/InspectionReportDocument'; -import type { FqcTemplate, FqcDocumentData } from '@/components/quality/InspectionManagement/fqcActions'; -import type { InspectionReportDocument as InspectionReportDocumentType, ProductInspectionData } from '@/components/quality/InspectionManagement/types'; -import { mockReportInspectionItems, mapInspectionDataToItems } from '@/components/quality/InspectionManagement/mockData'; import type { ImportInspectionTemplate, ImportInspectionRef, InspectionItemValue } from './documents/ImportInspectionDocument'; -// 작업일지 + 중간검사 성적서 문서 컴포넌트 import (공정별 신규 버전) -import { - ScreenWorkLogContent, - SlatWorkLogContent, - BendingWorkLogContent, - ScreenInspectionContent, - SlatInspectionContent, - BendingInspectionContent, -} from '@/components/production/WorkOrders/documents'; -import type { WorkOrder } from '@/components/production/WorkOrders/types'; - // 검사 템플릿 API import { getInspectionTemplate } from '@/components/material/ReceivingManagement/actions'; -// 작업지시 상세 API (QMS 작업일지/중간검사용) -import { getWorkOrderById } from '@/components/production/WorkOrders/actions'; - /** * 저장된 document.data (field_key 기반)를 ImportInspectionDocument의 initialValues로 변환 * - * field_key 패턴: - * - {itemId}_n{1,2,3} → numeric 측정값 - * - {itemId}_okng_n{1,2,3} → OK/NG 값 - * - {itemId}_result → 항목별 판정 + * 두 가지 저장 형식을 모두 지원: + * - 정규화 형식: section_id + row_index로 항목 식별, field_key는 "n1", "n1_ok" 등 + * - 레거시 형식: field_key에 item.id 포함, 예: "${itemId}_n1", "${itemId}_okng_n1" */ function parseSavedDataToInitialValues( tmpl: ImportInspectionTemplate, - docData: Array<{ field_key: string; field_value: string | null }> + docData: Array<{ field_key: string; field_value: string | null; section_id?: number | null; row_index?: number }>, + sections?: Array<{ id: number; items: Array<{ id: number }> }> ): InspectionItemValue[] { - // field_key → value 맵 생성 - const dataMap = new Map(); + // (sectionId, rowIndex) → inspectionItem.id 역매핑 구축 + const reverseMap = new Map(); + if (sections) { + for (const section of sections) { + section.items.forEach((sItem, idx) => { + reverseMap.set(`${section.id}_${idx}`, String(sItem.id)); + }); + } + } + + // 정규화 형식: itemId → { field_key → value } + const normalizedMap = new Map>(); + // 레거시 형식: "${itemId}_n1" → value + const legacyMap = new Map(); + for (const d of docData) { - if (d.field_value) dataMap.set(d.field_key, d.field_value); + if (!d.field_value) continue; + const key = d.field_key; + const val = d.field_value; + + // 전역 필드는 스킵 + if (key === 'overall_result' || key === 'footer_judgement') continue; + if (key === 'remark' || key === 'footer_remark') continue; + + // 정규화 형식: section_id가 있으면 역매핑으로 item 찾기 + if (d.section_id != null && reverseMap.size > 0) { + const itemId = reverseMap.get(`${d.section_id}_${d.row_index ?? 0}`); + if (itemId) { + if (!normalizedMap.has(itemId)) normalizedMap.set(itemId, new Map()); + normalizedMap.get(itemId)!.set(key, val); + } + continue; + } + + // 레거시 형식 fallback + legacyMap.set(key, val); } return tmpl.inspectionItems.map((item) => { const isOkng = item.measurementType === 'okng'; const measurements: (number | 'OK' | 'NG' | null)[] = Array(item.measurementCount).fill(null); + // 정규화 형식 우선 시도 + const nData = normalizedMap.get(item.id); + if (nData && nData.size > 0) { + for (let n = 0; n < item.measurementCount; n++) { + if (isOkng) { + const okVal = nData.get(`n${n + 1}_ok`); + const ngVal = nData.get(`n${n + 1}_ng`); + if (okVal === 'OK') measurements[n] = 'OK'; + else if (ngVal === 'NG') measurements[n] = 'NG'; + } else { + const val = nData.get(`n${n + 1}`); + if (val) { + const num = parseFloat(val); + measurements[n] = isNaN(num) ? null : num; + } + } + } + + const resultVal = nData.get('value'); + let result: 'OK' | 'NG' | null = null; + if (resultVal === '적합' || resultVal === 'ok') result = 'OK'; + else if (resultVal === '부적합' || resultVal === 'ng') result = 'NG'; + + return { itemId: item.id, measurements, result }; + } + + // 레거시 형식 fallback for (let n = 0; n < item.measurementCount; n++) { if (isOkng) { - const val = dataMap.get(`${item.id}_okng_n${n + 1}`); + const val = legacyMap.get(`${item.id}_okng_n${n + 1}`); if (val === 'ok') measurements[n] = 'OK'; else if (val === 'ng') measurements[n] = 'NG'; } else { - const val = dataMap.get(`${item.id}_n${n + 1}`); + const val = legacyMap.get(`${item.id}_n${n + 1}`); if (val) { const num = parseFloat(val); measurements[n] = isNaN(num) ? null : num; @@ -86,8 +132,7 @@ function parseSavedDataToInitialValues( } } - // 항목별 판정 - const resultVal = dataMap.get(`${item.id}_result`); + const resultVal = legacyMap.get(`${item.id}_result`); let result: 'OK' | 'NG' | null = null; if (resultVal === 'ok') result = 'OK'; else if (resultVal === 'ng') result = 'NG'; @@ -102,15 +147,14 @@ interface InspectionModalProps { document: Document | null; documentItem: DocumentItem | null; // 수입검사 템플릿 로드용 추가 props - itemId?: number; // 품목 ID (실제 API로 템플릿 조회 시 사용) + itemId?: number; itemName?: string; specification?: string; supplier?: string; - inspector?: string; // 검사자 (현재 로그인 사용자) - inspectorDept?: string; // 검사자 부서 - lotSize?: number; // 로트크기 (입고수량) - materialNo?: string; // 자재번호 - // 읽기 전용 모드 (QMS 심사 확인용) + inspector?: string; + inspectorDept?: string; + lotSize?: number; + materialNo?: string; readOnly?: boolean; } @@ -118,11 +162,8 @@ interface InspectionModalProps { const DOCUMENT_INFO: Record = { 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' }, }; @@ -153,84 +194,54 @@ const PlaceholderDocument = ({ docType, docItem }: { docType: string; docItem: D ); }; -// QMS용 수주서 Mock 데이터 -const QMS_MOCK_PRODUCTS: ProductInfo[] = [ - { productName: '방화 스크린 셔터 (표준형)', productCategory: '스크린', openWidth: '3000', openHeight: '2500', quantity: 5, floor: '1F', code: 'FSS-01' }, - { productName: '방화 스크린 셔터 (방화형)', productCategory: '스크린', openWidth: '3000', openHeight: '2500', quantity: 3, floor: '2F', code: 'FSS-02' }, -]; -const QMS_MOCK_ORDER_ITEMS: OrderItem[] = [ - { id: 'mt-1', itemCode: 'MT-001', itemName: '모터(380V 단상)', specification: '150K', type: '모터', quantity: 8, unit: 'EA', unitPrice: 120000, supplyAmount: 960000, taxAmount: 96000, totalAmount: 1056000, sortOrder: 1 }, - { id: 'br-1', itemCode: 'BR-001', itemName: '브라켓트', specification: '380X180 [2-4"]', type: '브라켓', quantity: 16, unit: 'EA', unitPrice: 15000, supplyAmount: 240000, taxAmount: 24000, totalAmount: 264000, sortOrder: 2 }, - { id: 'gr-1', itemCode: 'GR-001', itemName: '가이드레일 백면형 (120X70)', specification: 'EGI 1.5ST', type: '가이드레일', quantity: 16, unit: 'EA', unitPrice: 25000, supplyAmount: 400000, taxAmount: 40000, totalAmount: 440000, width: 120, height: 2500, sortOrder: 3 }, - { id: 'cs-1', itemCode: 'CS-001', itemName: '케이스(셔터박스)', specification: 'EGI 1.5ST 380X180', type: '케이스', quantity: 8, unit: 'EA', unitPrice: 35000, supplyAmount: 280000, taxAmount: 28000, totalAmount: 308000, width: 380, height: 180, sortOrder: 4 }, - { id: 'bf-1', itemCode: 'BF-001', itemName: '하단마감재', specification: 'EGI 1.5ST', type: '하단마감재', quantity: 8, unit: 'EA', unitPrice: 18000, supplyAmount: 144000, taxAmount: 14400, totalAmount: 158400, sortOrder: 5 }, -]; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type DocumentDetailData = Record; -// FQC 문서 API 응답 → FqcTemplate 변환 -function transformFqcApiToTemplate(apiTemplate: Record): FqcTemplate { - const t = apiTemplate as { - id: number; name: string; category: string; title: string | null; - approval_lines: { id: number; name: string; department: string; sort_order: number }[]; - basic_fields: { id: number; label: string; field_key: string; field_type: string; default_value: string | null; is_required: boolean; sort_order: number }[]; - sections: { id: number; name: string; title: string | null; description: string | null; image_path: string | null; sort_order: number; - items: { id: number; section_id: number; item_name: string; standard: string | null; tolerance: string | null; measurement_type: string; frequency: string; sort_order: number; category: string; method: string }[]; - }[]; - columns: { id: number; label: string; column_type: string; width: string | null; group_name: string | null; sort_order: number }[]; - }; +/** + * API 출고 상세 응답 → ShipmentDetail 타입 매핑 + * ShipmentOrderDocument 내부에서 아직 MOCK_ 데이터를 사용하므로 + * 여기서는 헤더 정보 매핑만 수행 (Phase 2에서 완전 전환) + */ +function mapShipmentApiToDetail(api: DocumentDetailData): ShipmentDetail { return { - id: t.id, name: t.name, category: t.category, title: t.title, - approvalLines: (t.approval_lines || []).map(a => ({ id: a.id, name: a.name, department: a.department, sortOrder: a.sort_order })), - basicFields: (t.basic_fields || []).map(f => ({ id: f.id, label: f.label, fieldKey: f.field_key, fieldType: f.field_type, defaultValue: f.default_value, isRequired: f.is_required, sortOrder: f.sort_order })), - sections: (t.sections || []).map(s => ({ - id: s.id, name: s.name, title: s.title, description: s.description, imagePath: s.image_path, sortOrder: s.sort_order, - items: (s.items || []).map(i => ({ id: i.id, sectionId: i.section_id, itemName: i.item_name, standard: i.standard, tolerance: i.tolerance, measurementType: i.measurement_type, frequency: i.frequency, sortOrder: i.sort_order, category: i.category || '', method: i.method || '' })), + id: String(api.id || ''), + shipmentNo: api.shipment_no || '-', + lotNo: api.lot_no || '-', + siteName: api.site_name || '-', + customerName: api.customer_name || '-', + customerGrade: api.customer_grade || '-', + status: api.status || 'scheduled', + scheduledDate: api.scheduled_date || '-', + deliveryMethod: api.delivery_method || 'loading', + freightCost: api.shipping_cost, + receiver: api.receiver, + receiverContact: api.receiver_contact, + deliveryAddress: api.delivery_address || '-', + vehicleNo: api.vehicle_no, + driverName: api.driver_name, + driverContact: api.driver_contact, + remarks: api.remarks, + vehicleDispatches: (api.vehicle_dispatches || []).map((d: DocumentDetailData, i: number) => ({ + id: String(i), + logisticsCompany: d.logistics_company || '-', + arrivalDateTime: d.arrival_datetime || '-', + tonnage: d.tonnage || '-', + vehicleNo: d.vehicle_no || '-', + driverContact: d.driver_contact || '-', + remarks: d.remarks || '', })), - columns: (t.columns || []).map(c => ({ id: c.id, label: c.label, columnType: c.column_type, width: c.width, groupName: c.group_name ?? null, sortOrder: c.sort_order })), - }; + // Phase 2: product_groups/other_parts 실 데이터 매핑 예정 + productGroups: [], + otherParts: [], + // 하위 호환 필드 (최소값) + products: [], + priority: 'normal', + depositConfirmed: false, + invoiceIssued: false, + canShip: false, + } as ShipmentDetail; } -function transformFqcApiToData(apiData: { section_id: number | null; column_id: number | null; row_index: number; field_key: string; field_value: string | null }[]): FqcDocumentData[] { - return (apiData || []).map(d => ({ sectionId: d.section_id, columnId: d.column_id, rowIndex: d.row_index, fieldKey: d.field_key, fieldValue: d.field_value })); -} - -// QMS용 작업일지 Mock WorkOrder 생성 -const createQmsMockWorkOrder = (subType?: string): WorkOrder => ({ - id: 'qms-wo-1', - workOrderNo: 'KD-WO-240924-01', - lotNo: 'KD-SS-240924-19', - processId: 1, - processName: subType === 'slat' ? '슬랫' : subType === 'bending' ? '절곡' : '스크린', - processCode: subType || 'screen', - processType: (subType || 'screen') as 'screen' | 'slat' | 'bending', - status: 'in_progress', - client: '삼성물산(주)', - projectName: '강남 아파트 단지', - dueDate: '2024-10-05', - assignee: '김작업', - assignees: [ - { id: '1', name: '김작업', isPrimary: true }, - { id: '2', name: '이생산', isPrimary: false }, - ], - orderDate: '2024-09-20', - scheduledDate: '2024-09-24', - shipmentDate: '2024-10-04', - salesOrderDate: '2024-09-18', - isAssigned: true, - isStarted: true, - priority: 3, - priorityLabel: '긴급', - shutterCount: 5, - department: '생산부', - items: [ - { id: '1', no: 1, status: 'completed', productName: '와이어 스크린', floorCode: '1층/FSS-01', specification: '3000×2500', quantity: 2, unit: 'EA', orderNodeId: null, orderNodeName: '' }, - { id: '2', no: 2, status: 'in_progress', productName: '메쉬 스크린', floorCode: '2층/FSS-03', specification: '3000×2500', quantity: 3, unit: 'EA', orderNodeId: null, orderNodeName: '' }, - { id: '3', no: 3, status: 'waiting', productName: '광폭 와이어', floorCode: '3층/FSS-05', specification: '12000×4500', quantity: 1, unit: 'EA', orderNodeId: null, orderNodeName: '' }, - ], - currentStep: 2, - issues: [], - note: '품질 검수 철저히 진행', -}); - // 로딩 컴포넌트 const LoadingDocument = () => (
@@ -257,9 +268,9 @@ const ErrorDocument = ({ message, onRetry }: { message: string; onRetry?: () => ); /** - * InspectionModal V2 - * - DocumentViewer 시스템 사용 - * - 수입검사: 모달 열릴 때 API로 템플릿 로드 (Lazy Loading) + * InspectionModal + * - 수입검사, 수주서, 납품확인서, 출고증, 품질관리서만 처리 + * - 작업일지/중간검사/제품검사는 각각 독립 모달로 분리됨 */ export const InspectionModal = ({ isOpen, @@ -282,84 +293,50 @@ export const InspectionModal = ({ const [isLoadingTemplate, setIsLoadingTemplate] = useState(false); const [templateError, setTemplateError] = useState(null); - // 작업일지/중간검사용 WorkOrder 상태 - const [workOrderData, setWorkOrderData] = useState(null); - const [isLoadingWorkOrder, setIsLoadingWorkOrder] = useState(false); - const [workOrderError, setWorkOrderError] = useState(null); - // 수입검사 저장용 ref/상태 const importDocRef = useRef(null); const [isSaving, setIsSaving] = useState(false); - // 제품검사 성적서 FQC 상태 - const [fqcTemplate, setFqcTemplate] = useState(null); - const [fqcData, setFqcData] = useState([]); - const [fqcDocumentNo, setFqcDocumentNo] = useState(''); - const [isLoadingFqc, setIsLoadingFqc] = useState(false); - const [fqcError, setFqcError] = useState(null); - // 레거시 inspection_data 기반 제품검사 성적서 - const [legacyReportData, setLegacyReportData] = useState(null); + // 수주서/출고증/납품확인서 실 데이터 상태 + const [docDetailData, setDocDetailData] = useState(null); + const [isLoadingDocDetail, setIsLoadingDocDetail] = useState(false); + + // 수주서/출고증/납품확인서 데이터 로드 + useEffect(() => { + if (!isOpen || !doc) return; + if (!['order', 'confirmation', 'shipping'].includes(doc.type)) return; + + const docItemId = documentItem?.id || doc.id; + if (!docItemId) return; + + setIsLoadingDocDetail(true); + getDocumentDetail(doc.type, docItemId) + .then((result) => { + if (result.success && result.data) { + const raw = result.data as DocumentDetailData; + setDocDetailData(raw?.data ?? raw); + } + }) + .finally(() => setIsLoadingDocDetail(false)); + + return () => { + setDocDetailData(null); + }; + }, [isOpen, doc?.type, doc?.id, documentItem?.id]); // 수입검사 템플릿 로드 (모달 열릴 때) useEffect(() => { - // itemId가 있으면 실제 API로 조회, 없으면 itemName/specification으로 mock 조회 if (isOpen && doc?.type === 'import' && (itemId || (itemName && specification))) { loadInspectionTemplate(); } - // 모달 닫힐 때 상태 초기화 if (!isOpen) { setImportTemplate(null); setImportInitialValues(undefined); setTemplateError(null); - setFqcTemplate(null); - setFqcData([]); - setFqcDocumentNo(''); - setFqcError(null); - setLegacyReportData(null); - setWorkOrderData(null); - setWorkOrderError(null); } }, [isOpen, doc?.type, itemId, itemName, specification]); - // 작업일지/중간검사 WorkOrder 로드 (모달 열릴 때) - // log: documentItem.id === work_order_id, report: documentItem.workOrderId로 전달 - useEffect(() => { - if (isOpen && (doc?.type === 'log' || doc?.type === 'report')) { - const woId = documentItem?.workOrderId || (doc?.type === 'log' ? Number(documentItem?.id) : null); - if (woId) { - loadWorkOrderData(woId); - } - } - }, [isOpen, doc?.type, documentItem?.workOrderId, documentItem?.id]); - - // 제품검사 성적서 FQC 로드 (모달 열릴 때) - useEffect(() => { - if (isOpen && doc?.type === 'product' && documentItem?.id) { - loadFqcDocument(documentItem.id); - } - }, [isOpen, doc?.type, documentItem?.id]); - - const loadWorkOrderData = async (workOrderId: number) => { - setIsLoadingWorkOrder(true); - setWorkOrderError(null); - - try { - const result = await getWorkOrderById(String(workOrderId)); - if (result.success && result.data) { - setWorkOrderData(result.data); - } else { - setWorkOrderError(result.error || '작업지시 데이터를 불러올 수 없습니다.'); - } - } catch (error) { - console.error('[InspectionModal] loadWorkOrderData error:', error); - setWorkOrderError('작업지시 데이터 로드 중 오류가 발생했습니다.'); - } finally { - setIsLoadingWorkOrder(false); - } - }; - const loadInspectionTemplate = async () => { - // itemId가 있으면 실제 API 호출, 없으면 itemName/specification 필요 if (!itemId && (!itemName || !specification)) return; setIsLoadingTemplate(true); @@ -381,10 +358,19 @@ export const InspectionModal = ({ const tmpl = result.data as ImportInspectionTemplate; setImportTemplate(tmpl); - // 저장된 측정값을 initialValues로 변환 const docData = result.resolveData?.document?.data; if (docData && docData.length > 0) { - const values = parseSavedDataToInitialValues(tmpl, docData.map((d: { field_key: string; field_value?: string | null }) => ({ field_key: d.field_key, field_value: d.field_value ?? null }))); + const sections = result.resolveData?.template?.sections; + const values = parseSavedDataToInitialValues( + tmpl, + docData.map((d: { field_key: string; field_value?: string | null; section_id?: number | null; row_index?: number }) => ({ + field_key: d.field_key, + field_value: d.field_value ?? null, + section_id: d.section_id, + row_index: d.row_index, + })), + sections + ); setImportInitialValues(values); } else { setImportInitialValues(undefined); @@ -400,74 +386,7 @@ export const InspectionModal = ({ } }; - // 제품검사 성적서 문서 로드 (FQC 우선, inspection_data fallback) - const loadFqcDocument = async (locationId: string) => { - setIsLoadingFqc(true); - setFqcError(null); - setLegacyReportData(null); - - try { - const result = await getDocumentDetail('product', locationId); - if (result.success && result.data) { - const data = result.data as { - document_id: number | null; - inspection_status: string | null; - inspection_data: ProductInspectionData | null; - floor_code: string | null; - symbol_code: string | null; - fqc_document?: { - document_no: string; - template: Record; - data: { section_id: number | null; column_id: number | null; row_index: number; field_key: string; field_value: string | null }[]; - }; - }; - - if (data.fqc_document) { - // FQC 문서가 있는 경우 - setFqcTemplate(transformFqcApiToTemplate(data.fqc_document.template)); - setFqcData(transformFqcApiToData(data.fqc_document.data)); - setFqcDocumentNo(data.fqc_document.document_no || ''); - } else if (data.inspection_data && data.inspection_status === 'completed') { - // FQC 없지만 inspection_data가 있는 경우 → 레거시 리포트 생성 - const inspData = data.inspection_data; - const mappedItems = mapInspectionDataToItems(mockReportInspectionItems, inspData); - const locationLabel = [data.floor_code, data.symbol_code].filter(Boolean).join(' '); - - setLegacyReportData({ - documentNumber: '', - createdDate: '', - approvalLine: [ - { role: '작성', name: '', department: '' }, - { role: '승인', name: '', department: '' }, - ], - productName: inspData.productName || '', - productLotNo: '', - productCode: '', - lotSize: '1', - client: '', - inspectionDate: '', - siteName: locationLabel, - inspector: '', - productImages: inspData.productImages || [], - inspectionItems: mappedItems, - specialNotes: inspData.specialNotes || '', - finalJudgment: '합격', - }); - } else { - setFqcError('제품검사 성적서 문서가 아직 생성되지 않았습니다.'); - } - } else { - setFqcError(result.error || '제품검사 성적서 조회에 실패했습니다.'); - } - } catch (error) { - console.error('[InspectionModal] loadFqcDocument error:', error); - setFqcError('제품검사 성적서 로드 중 오류가 발생했습니다.'); - } finally { - setIsLoadingFqc(false); - } - }; - - // 수입검사 저장 핸들러 (hooks는 early return 전에 호출해야 함) + // 수입검사 저장 핸들러 const handleImportSave = useCallback(async () => { if (!importDocRef.current) return; @@ -497,66 +416,6 @@ export const InspectionModal = ({ const handleQualityFileDelete = () => { }; - // 작업일지/중간검사 공통: WorkOrder 데이터 로딩 상태 처리 - const renderWorkOrderLoading = () => { - if (isLoadingWorkOrder) { - return ( -
- -

작업지시 데이터를 불러오는 중...

-
- ); - } - if (workOrderError) { - return loadWorkOrderData(documentItem.workOrderId!) : undefined} />; - } - return null; - }; - - // 작업일지 공정별 렌더링 - const renderWorkLogDocument = () => { - const loadingEl = renderWorkOrderLoading(); - if (loadingEl) return loadingEl; - - const subType = documentItem?.subType; - // 실제 WorkOrder 데이터 사용, 없으면 fallback mock - const orderData = workOrderData || createQmsMockWorkOrder(subType); - - switch (subType) { - case 'screen': - return ; - case 'slat': - return ; - case 'bending': - return ; - default: - return ; - } - }; - - // 중간검사 성적서 서브타입에 따른 렌더링 (신규 버전 통일) - const renderReportDocument = () => { - const loadingEl = renderWorkOrderLoading(); - if (loadingEl) return loadingEl; - - const subType = documentItem?.subType; - // 실제 WorkOrder 데이터 사용, 없으면 fallback mock - const orderData = workOrderData || createQmsMockWorkOrder(subType || 'screen'); - - switch (subType) { - case 'screen': - return ; - case 'bending': - return ; - case 'slat': - return ; - case 'jointbar': - return ; - default: - return ; - } - }; - // 수입검사 문서 렌더링 (Lazy Loading) const renderImportInspectionDocument = () => { if (isLoadingTemplate) { @@ -567,7 +426,6 @@ export const InspectionModal = ({ return ; } - // 템플릿이 로드되면 전달, 아니면 기본 템플릿 사용 return ( { - if (isLoadingFqc) { - return ; - } - - if (fqcError) { - return loadFqcDocument(documentItem.id) : undefined} />; - } - - // FQC 문서 기반 렌더링 - if (fqcTemplate) { - return ( - - ); - } - - // 레거시 inspection_data 기반 렌더링 - if (legacyReportData) { - return ; - } - - return ; - }; - // 문서 타입에 따른 컨텐츠 렌더링 const renderDocumentContent = () => { switch (doc.type) { - case 'order': + case 'order': { + if (isLoadingDocDetail) return ; + if (!docDetailData) return ; + const d = docDetailData; return ( ); - case 'log': - return renderWorkLogDocument(); + } case 'confirmation': - return ; case 'shipping': - return ; + if (isLoadingDocDetail) return ; + if (!docDetailData) return ; + // TODO Phase 2: ShipmentOrderDocument도 실 데이터로 전환 시 여기서 매핑 + // 현재는 ShipmentOrderDocument 내부 mock data를 사용하되 헤더 정보만 전달 + return doc.type === 'confirmation' + ? + : ; case 'import': return renderImportInspectionDocument(); - case 'product': - return renderProductDocument(); - case 'report': - return renderReportDocument(); case 'quality': return ( ); -}; +}; \ No newline at end of file diff --git a/src/app/[locale]/(protected)/quality/qms/components/ReportList.tsx b/src/app/[locale]/(protected)/quality/qms/components/ReportList.tsx index 85587d80..39687c06 100644 --- a/src/app/[locale]/(protected)/quality/qms/components/ReportList.tsx +++ b/src/app/[locale]/(protected)/quality/qms/components/ReportList.tsx @@ -61,8 +61,8 @@ export const ReportList = ({ reports, selectedId, onSelect, isMock }: ReportList isSelected ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600' }`}> - 수주로트 {report.routeCount}건 - (총 {report.totalRoutes}개소) + 수주로트 {report.totalRoutes}건 + (확인 {report.routeCount}/{report.totalRoutes})
); diff --git a/src/app/[locale]/(protected)/quality/qms/components/RouteList.tsx b/src/app/[locale]/(protected)/quality/qms/components/RouteList.tsx index 806a35bc..cb36342b 100644 --- a/src/app/[locale]/(protected)/quality/qms/components/RouteList.tsx +++ b/src/app/[locale]/(protected)/quality/qms/components/RouteList.tsx @@ -81,8 +81,7 @@ export const RouteList = ({ routes, selectedId, onSelect, onToggleItem, reportCo )}

수주일: {route.date || '-'}

- {route.client &&

거래처: {route.client}

} -

현장: {route.site || '-'}

+

현장: {route.site || '-'}{route.client ? ` (${route.client})` : ''}

{route.locationCount}개소 diff --git a/src/app/[locale]/(protected)/quality/qms/components/documents/ImportInspectionDocument.tsx b/src/app/[locale]/(protected)/quality/qms/components/documents/ImportInspectionDocument.tsx index d685cce2..d98009f9 100644 --- a/src/app/[locale]/(protected)/quality/qms/components/documents/ImportInspectionDocument.tsx +++ b/src/app/[locale]/(protected)/quality/qms/components/documents/ImportInspectionDocument.tsx @@ -903,13 +903,13 @@ export const ImportInspectionDocument = forwardRef ) : item.measurementType === 'single_value' ? ( - // 단일 입력 (colspan으로 합침) + // 단일 입력 (colspan으로 합침) - 저장된 값이 있으면 표시 - ( 입력 ) + {renderMeasurementInput(item.id, 0)} ) : item.measurementType === 'okng' ? ( // OK/NG 선택형 - n 값에 따라 열 개수 결정 diff --git a/src/app/[locale]/(protected)/quality/qms/hooks/useDay2LotAudit.ts b/src/app/[locale]/(protected)/quality/qms/hooks/useDay2LotAudit.ts index a2f4e088..77bd8b8c 100644 --- a/src/app/[locale]/(protected)/quality/qms/hooks/useDay2LotAudit.ts +++ b/src/app/[locale]/(protected)/quality/qms/hooks/useDay2LotAudit.ts @@ -127,6 +127,9 @@ export function useDay2LotAudit() { }, []); const handleViewDocument = useCallback((doc: Document, item?: DocumentItem) => { + // 품질관리서는 파일 다운로드로 처리 (DocumentList에서 직접 처리하므로 모달 열지 않음) + if (doc.type === 'quality') return; + setSelectedDoc(doc); setSelectedDocItem(item || null); setModalOpen(true); @@ -168,6 +171,18 @@ export function useDay2LotAudit() { } }, [pendingConfirmIds]); + // 품질관리서 파일 정보 업데이트 (업로드 성공 후 documents 상태 반영) + // 품질관리서는 doc.id가 'quality' 문자열이므로 type으로 매칭 + const updateQualityDocumentFile = useCallback((_docId: string, fileInfo: { fileId: number; fileName: string; fileSize: number }) => { + setDocuments((prev) => + prev.map((doc) => + doc.type === 'quality' + ? { ...doc, fileId: fileInfo.fileId, fileName: fileInfo.fileName, fileSize: fileInfo.fileSize } + : doc + ) + ); + }, []); + const handleYearChange = useCallback((year: number) => { setSelectedYear(year); setSelectedReport(null); @@ -218,6 +233,9 @@ export function useDay2LotAudit() { handleToggleItem, pendingConfirmIds, + // 품질관리서 파일 + updateQualityDocumentFile, + // 로딩 loadingReports, loadingRoutes, diff --git a/src/app/[locale]/(protected)/quality/qms/page.tsx b/src/app/[locale]/(protected)/quality/qms/page.tsx index 422472f3..dece05ae 100644 --- a/src/app/[locale]/(protected)/quality/qms/page.tsx +++ b/src/app/[locale]/(protected)/quality/qms/page.tsx @@ -8,6 +8,10 @@ import { ReportList } from './components/ReportList'; import { RouteList } from './components/RouteList'; import { DocumentList } from './components/DocumentList'; import { InspectionModal } from './components/InspectionModal'; +import { InspectionReportModal } from '@/components/production/WorkOrders/documents'; +import { WorkLogModal } from '@/components/production/WorkOrders/documents'; +import { ProductInspectionViewModal } from '@/components/quality/InspectionManagement/ProductInspectionViewModal'; +import { getDocumentDetail } from './actions'; import { DayTabs } from './components/DayTabs'; import { Day1ChecklistPanel } from './components/Day1ChecklistPanel'; import { Day1DocumentSection } from './components/Day1DocumentSection'; @@ -16,7 +20,7 @@ import { AuditSettingsPanel, SettingsButton, type AuditDisplaySettings } from '. import { useDay1Audit } from './hooks/useDay1Audit'; import { useDay2LotAudit } from './hooks/useDay2LotAudit'; import { useChecklistTemplate } from './hooks/useChecklistTemplate'; -import { uploadTemplateDocument, getTemplateDocuments, deleteTemplateDocument } from './actions'; +import { uploadTemplateDocument, getTemplateDocuments, deleteTemplateDocument, uploadQualityDocumentFile } from './actions'; import type { TemplateDocument } from './types'; // 기본 설정값 @@ -127,9 +131,24 @@ export default function QualityInspectionPage() { handleViewDocument, setModalOpen, handleToggleItem, + updateQualityDocumentFile, isMock: day2IsMock, } = useDay2LotAudit(); + // 품질관리서 파일 업로드 핸들러 (2일차 DocumentList용) + const handleQualityFileUpload = useCallback(async (qualityDocumentId: string, file: File): Promise => { + const result = await uploadQualityDocumentFile(qualityDocumentId, file); + if (!result.success) { + toast.error(result.error || '품질관리서 파일 업로드에 실패했습니다.'); + return false; + } + // 업로드 성공 시 documents 상태에서 해당 문서의 파일 정보 업데이트 + if (result.data) { + updateQualityDocumentFile(qualityDocumentId, result.data); + } + return true; + }, [updateQualityDocumentFile]); + // 1일차 필터링된 카테고리 (완료 항목 숨기기 옵션) const filteredDay1Categories = useMemo(() => { if (displaySettings.showCompletedItems) return categories; @@ -245,9 +264,9 @@ export default function QualityInspectionPage() { )}
) : ( - // ===== 로트 추적 심사 심사 ===== + // ===== 로트 추적 심사 =====
-
+
-
+
@@ -305,13 +325,51 @@ export default function QualityInspectionPage() { }} /> - setModalOpen(false)} - document={selectedDoc} - documentItem={selectedDocItem} - readOnly - /> + {/* 중간검사 성적서 → 기존 독립 모달 재사용 */} + {selectedDoc?.type === 'report' && ( + !open && setModalOpen(false)} + workOrderId={selectedDocItem?.workOrderId ? String(selectedDocItem.workOrderId) : selectedDocItem?.id || null} + processType={ + selectedDocItem?.subType === 'jointbar' ? 'slat' + : (selectedDocItem?.subType as 'screen' | 'slat' | 'bending') || 'screen' + } + readOnly + isJointBar={selectedDocItem?.subType === 'jointbar'} + /> + )} + + {/* 작업일지 → 독립 WorkLogModal */} + {selectedDoc?.type === 'log' && ( + !open && setModalOpen(false)} + workOrderId={selectedDocItem?.workOrderId ? String(selectedDocItem.workOrderId) : selectedDocItem?.id || null} + processType={(selectedDocItem?.subType as 'screen' | 'slat' | 'bending') || 'screen'} + /> + )} + + {/* 제품검사 성적서 → 독립 ProductInspectionViewModal */} + {selectedDoc?.type === 'product' && ( + !open && setModalOpen(false)} + locationId={selectedDocItem?.id || null} + fetchDetail={getDocumentDetail} + /> + )} + + {/* 나머지 문서 타입 (수입검사, 수주서, 납품확인서, 출고증) → 기존 InspectionModal */} + {selectedDoc && !['report', 'log', 'product', 'quality'].includes(selectedDoc.type) && ( + setModalOpen(false)} + document={selectedDoc} + documentItem={selectedDocItem} + readOnly + /> + )}
); } diff --git a/src/app/[locale]/(protected)/quality/qms/types.ts b/src/app/[locale]/(protected)/quality/qms/types.ts index c0dd6a53..109684f5 100644 --- a/src/app/[locale]/(protected)/quality/qms/types.ts +++ b/src/app/[locale]/(protected)/quality/qms/types.ts @@ -34,6 +34,9 @@ export interface Document { date?: string; count: number; // e.g., 3건의 서류 items?: DocumentItem[]; + fileId?: number; // files.id (품질관리서 파일) + fileName?: string; // 파일명 + fileSize?: number; // 파일 크기 (bytes) } export interface DocumentItem { diff --git a/src/app/[locale]/(protected)/sales/order-management-sales/[id]/page.tsx b/src/app/[locale]/(protected)/sales/order-management-sales/[id]/page.tsx index 51f28df9..9e16e013 100644 --- a/src/app/[locale]/(protected)/sales/order-management-sales/[id]/page.tsx +++ b/src/app/[locale]/(protected)/sales/order-management-sales/[id]/page.tsx @@ -1030,6 +1030,7 @@ export default function OrderDetailPage() { recipientName: order.receiver, recipientContact: order.receiverContact, shutterCount: order.products?.length || 0, + orderId: Number(order.id), }} /> )} diff --git a/src/app/[locale]/(protected)/sales/pricing-management/[id]/page.tsx b/src/app/[locale]/(protected)/sales/pricing-management/[id]/page.tsx index ce36c30b..59c31c62 100644 --- a/src/app/[locale]/(protected)/sales/pricing-management/[id]/page.tsx +++ b/src/app/[locale]/(protected)/sales/pricing-management/[id]/page.tsx @@ -2,7 +2,7 @@ /** * 단가 상세/수정 페이지 (Client Component) - * V2 패턴: ?mode=edit로 수정 모드 전환 + * V2 패턴: 기본 조회(view), ?mode=edit로 수정 모드 전환 * * 경로: /sales/pricing-management/[id] * 수정 모드: /sales/pricing-management/[id]?mode=edit @@ -24,9 +24,10 @@ interface PricingDetailPageProps { export default function PricingDetailPage({ params }: PricingDetailPageProps) { const { id } = use(params); - const _router = useRouter(); - const _searchParams = useSearchParams(); - const mode: 'create' | 'edit' = 'edit'; + const router = useRouter(); + const searchParams = useSearchParams(); + const mode = searchParams.get('mode') || 'view'; + const isEditMode = mode === 'edit'; const [data, setData] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -77,7 +78,7 @@ export default function PricingDetailPage({ params }: PricingDetailPageProps) { return ( ([]); const [isLoading, setIsLoading] = useState(true); @@ -33,27 +29,6 @@ export default function PricingManagementPage() { .finally(() => setIsLoading(false)); }, []); - // mode=new: 단가 등록은 품목 선택이 필요하므로 안내 표시 - if (mode === 'new') { - return ( -
-
-

품목을 선택해주세요

-

- 단가 등록은 품목 선택이 필요합니다.
- 단가 목록에서 미등록 품목을 선택한 후 등록해주세요. -

- -
-
- ); - } - if (isLoading) return ; return ; diff --git a/src/components/hr/DepartmentManagement/DepartmentDialog.tsx b/src/components/hr/DepartmentManagement/DepartmentDialog.tsx index 1d52664e..4b5535e4 100644 --- a/src/components/hr/DepartmentManagement/DepartmentDialog.tsx +++ b/src/components/hr/DepartmentManagement/DepartmentDialog.tsx @@ -1,6 +1,9 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useEffect } from 'react'; +import { z } from 'zod'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; import { Dialog, DialogContent, @@ -11,7 +14,19 @@ import { import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; -import type { DepartmentDialogProps } from './types'; +import { Switch } from '@/components/ui/switch'; +import { Textarea } from '@/components/ui/textarea'; +import type { DepartmentDialogProps, DepartmentFormData } from './types'; + +const departmentFormSchema = z.object({ + code: z.string().min(1, '부서 코드를 입력하세요').max(50, '50자 이내로 입력하세요'), + name: z.string().min(1, '부서명을 입력하세요').max(100, '100자 이내로 입력하세요'), + description: z.string().max(500, '500자 이내로 입력하세요').default(''), + sortOrder: z.coerce.number().min(0, '0 이상 입력하세요').default(0), + isActive: z.boolean().default(true), +}); + +type FormData = z.infer; /** * 부서 추가/수정 다이얼로그 @@ -22,27 +37,59 @@ export function DepartmentDialog({ mode, parentDepartment, department, - onSubmit + onSubmit, }: DepartmentDialogProps) { - const [name, setName] = useState(''); + const { + register, + handleSubmit, + reset, + setValue, + watch, + formState: { errors }, + } = useForm({ + resolver: zodResolver(departmentFormSchema), + defaultValues: { + code: '', + name: '', + description: '', + sortOrder: 0, + isActive: true, + }, + }); + + const isActive = watch('isActive'); // 다이얼로그 열릴 때 초기값 설정 useEffect(() => { if (isOpen) { if (mode === 'edit' && department) { - setName(department.name); + reset({ + code: department.code || '', + name: department.name, + description: department.description || '', + sortOrder: department.sortOrder, + isActive: department.isActive, + }); } else { - setName(''); + reset({ + code: '', + name: '', + description: '', + sortOrder: 0, + isActive: true, + }); } } - }, [isOpen, mode, department]); + }, [isOpen, mode, department, reset]); - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (name.trim()) { - onSubmit(name.trim()); - setName(''); - } + const onFormSubmit = (data: FormData) => { + onSubmit({ + code: data.code, + name: data.name, + description: data.description || '', + sortOrder: data.sortOrder, + isActive: data.isActive, + } as DepartmentFormData); }; const title = mode === 'add' ? '부서 추가' : '부서 수정'; @@ -50,12 +97,12 @@ export function DepartmentDialog({ return ( - + {title} -
+
{/* 부모 부서 표시 (추가 모드일 때) */} {mode === 'add' && parentDepartment && ( @@ -64,16 +111,78 @@ export function DepartmentDialog({
)} - {/* 부서명 입력 */} + {/* 부서 코드 */}
- + setName(e.target.value)} - placeholder="부서명을 입력하세요" + id="department-code" + {...register('code')} + placeholder="예: DEV, SALES, HR" autoFocus /> + {errors.code && ( +

{errors.code.message}

+ )} +
+ + {/* 부서명 */} +
+ + + {errors.name && ( +

{errors.name.message}

+ )} +
+ + {/* 설명 */} +
+ +