From bb1e4a25a191da981f03744ab21ef3b05d34c944 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 12 Mar 2026 14:01:13 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20[QMS]=20=EB=A1=9C=ED=8A=B8=EC=8B=AC?= =?UTF-8?q?=EC=82=AC=20UI=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 수주루트 → 수주로트 명칭 통일 - 거래처(client) 필드 추가 (types, actions, RouteList) - 문서번호 표시 개선 (로트: 접두어 제거) - ReportList 레이아웃 개선 (분기 표시 위치) - PlaceholderDocument 문서번호 라벨 수정 --- .serena/project.yml | 9 + .../(protected)/quality/qms/actions.ts | 157 ++--------------- .../qms/components/Day1ChecklistPanel.tsx | 7 + .../qms/components/Day1DocumentSection.tsx | 161 ++++++++---------- .../qms/components/Day1DocumentViewer.tsx | 110 +++++++++++- .../quality/qms/components/DocumentList.tsx | 4 +- .../qms/components/InspectionModal.tsx | 4 +- .../quality/qms/components/ReportList.tsx | 17 +- .../quality/qms/components/RouteList.tsx | 9 +- .../quality/qms/hooks/useDay1Audit.ts | 129 ++++++-------- .../quality/qms/hooks/useDay2LotAudit.ts | 109 ++++-------- .../(protected)/quality/qms/mockData.ts | 2 +- .../[locale]/(protected)/quality/qms/page.tsx | 74 +++++++- .../[locale]/(protected)/quality/qms/types.ts | 14 ++ src/middleware.ts | 2 +- 15 files changed, 396 insertions(+), 412 deletions(-) diff --git a/.serena/project.yml b/.serena/project.yml index 3d4296af..47ebb192 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -114,3 +114,12 @@ symbol_info_budget: # Note: the backend is fixed at startup. If a project with a different backend # is activated post-init, an error will be returned. language_backend: + +# line ending convention to use when writing source files. +# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) +# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. +line_ending: + +# list of regex patterns which, when matched, mark a memory entry as read‑only. +# Extends the list from the global configuration, merging the two lists. +read_only_memory_patterns: [] diff --git a/src/app/[locale]/(protected)/quality/qms/actions.ts b/src/app/[locale]/(protected)/quality/qms/actions.ts index ca036034..0a9e85cd 100644 --- a/src/app/[locale]/(protected)/quality/qms/actions.ts +++ b/src/app/[locale]/(protected)/quality/qms/actions.ts @@ -22,6 +22,7 @@ interface RouteItemApi { id: number; code: string; date: string; + client: string; site: string; location_count: number; sub_items: { @@ -44,36 +45,7 @@ interface DocumentApi { date: string; code?: string; sub_type?: string; - }[]; -} - -interface ChecklistDetailApi { - id: number; - year: number; - quarter: number; - type: string; - status: string; - progress: { completed: number; total: number }; - categories: { - id: number; - title: string; - sort_order: number; - sub_items: { - id: number; - name: string; - description?: string; - is_completed: boolean; - completed_at?: string; - sort_order: number; - standard_documents: { - id: number; - title: string; - version: string; - date: string; - file_name?: string; - file_url?: string; - }[]; - }[]; + work_order_id?: number; }[]; } @@ -98,6 +70,7 @@ function transformRouteApi(api: RouteItemApi) { id: String(api.id), code: api.code, date: api.date, + client: api.client, site: api.site, locationCount: api.location_count, subItems: api.sub_items.map((s) => ({ @@ -122,43 +95,11 @@ function transformDocumentApi(api: DocumentApi) { date: i.date, code: i.code, subType: i.sub_type as 'screen' | 'bending' | 'slat' | 'jointbar' | undefined, + workOrderId: i.work_order_id, })), }; } -function transformChecklistDetail(api: ChecklistDetailApi) { - return { - progress: api.progress, - categories: api.categories.map((cat) => ({ - id: String(cat.id), - title: cat.title, - subItems: cat.sub_items.map((item) => ({ - id: String(item.id), - name: item.name, - isCompleted: item.is_completed, - })), - })), - checkItems: api.categories.flatMap((cat) => - cat.sub_items.map((item) => ({ - id: `check-${item.id}`, - categoryId: String(cat.id), - subItemId: String(item.id), - title: item.name, - description: item.description || '', - buttonLabel: '기준/매뉴얼 확인', - standardDocuments: item.standard_documents.map((doc) => ({ - id: String(doc.id), - title: doc.title, - version: doc.version, - date: doc.date, - fileName: doc.file_name, - fileUrl: doc.file_url, - })), - })), - ), - }; -} - // ===== 2일차: 로트 추적 심사 ===== export async function getQualityReports(params: { @@ -216,39 +157,14 @@ export async function confirmUnitInspection(unitId: string, confirmed: boolean) }); } -// ===== 1일차: 기준/매뉴얼 심사 ===== +// ===== 1일차: 점검표 항목 토글 ===== -export async function getChecklistDetail(params: { - year: number; - quarter?: number; -}) { +export async function toggleTemplateItem(templateId: number, subItemId: string) { return executeServerAction({ - url: buildApiUrl('/api/v1/qms/checklists', { - year: params.year, - quarter: params.quarter, - }), - transform: (data: { items: { id: number }[] }) => { - if (data.items.length === 0) return null; - return { checklistId: String(data.items[0].id) }; - }, - errorMessage: '점검표 목록 조회에 실패했습니다.', - }); -} - -export async function getChecklistById(id: string) { - return executeServerAction({ - url: buildApiUrl(`/api/v1/qms/checklists/${id}`), - transform: (data: ChecklistDetailApi) => transformChecklistDetail(data), - errorMessage: '점검표 상세 조회에 실패했습니다.', - }); -} - -export async function toggleChecklistItem(itemId: string) { - return executeServerAction({ - url: buildApiUrl(`/api/v1/qms/checklist-items/${itemId}/toggle`), + url: buildApiUrl(`/api/v1/quality/checklist-templates/${templateId}/items/${subItemId}/toggle`), method: 'PATCH', - transform: (data: { id: number; name: string; is_completed: boolean; completed_at?: string }) => ({ - id: String(data.id), + transform: (data: { id: string; name: string; is_completed: boolean; completed_at?: string }) => ({ + id: data.id, name: data.name, isCompleted: data.is_completed, }), @@ -256,55 +172,6 @@ export async function toggleChecklistItem(itemId: string) { }); } -export async function getCheckItemDocuments(itemId: string) { - return executeServerAction({ - url: buildApiUrl(`/api/v1/qms/checklist-items/${itemId}/documents`), - transform: (data: { id: number; title: string; version: string; date: string; file_name?: string; file_url?: string }[]) => - data.map((d) => ({ - id: String(d.id), - title: d.title, - version: d.version, - date: d.date, - fileName: d.file_name, - fileUrl: d.file_url, - })), - errorMessage: '기준 문서 조회에 실패했습니다.', - }); -} - -export async function attachStandardDocument( - itemId: string, - data: { title: string; version?: string; date?: string; documentId?: number }, -) { - return executeServerAction({ - url: buildApiUrl(`/api/v1/qms/checklist-items/${itemId}/documents`), - method: 'POST', - body: { - title: data.title, - version: data.version, - date: data.date, - document_id: data.documentId, - }, - transform: (d: { id: number; title: string; version: string; date: string; file_name?: string; file_url?: string }) => ({ - id: String(d.id), - title: d.title, - version: d.version, - date: d.date, - fileName: d.file_name, - fileUrl: d.file_url, - }), - errorMessage: '기준 문서 연결에 실패했습니다.', - }); -} - -export async function detachStandardDocument(itemId: string, docId: string) { - return executeServerAction({ - url: buildApiUrl(`/api/v1/qms/checklist-items/${itemId}/documents/${docId}`), - method: 'DELETE', - errorMessage: '기준 문서 연결 해제에 실패했습니다.', - }); -} - // ===== 점검표 템플릿 관리 (설정 모달) ===== interface ChecklistTemplateApi { @@ -314,7 +181,7 @@ interface ChecklistTemplateApi { categories: { id: string; title: string; - subItems: { id: string; name: string }[]; + subItems: { id: string; name: string; is_completed?: boolean }[]; }[]; options: Record | null; file_counts: Record; @@ -345,7 +212,7 @@ export async function getChecklistTemplate(type: string = 'day1_audit') { subItems: cat.subItems.map((item) => ({ id: item.id, name: item.name, - isCompleted: false, + isCompleted: item.is_completed ?? false, })), })), options: data.options, @@ -375,7 +242,7 @@ export async function saveChecklistTemplate( subItems: cat.subItems.map((item) => ({ id: item.id, name: item.name, - isCompleted: false, + isCompleted: item.is_completed ?? false, })), })), options: result.options, diff --git a/src/app/[locale]/(protected)/quality/qms/components/Day1ChecklistPanel.tsx b/src/app/[locale]/(protected)/quality/qms/components/Day1ChecklistPanel.tsx index fe16573f..53281801 100644 --- a/src/app/[locale]/(protected)/quality/qms/components/Day1ChecklistPanel.tsx +++ b/src/app/[locale]/(protected)/quality/qms/components/Day1ChecklistPanel.tsx @@ -50,6 +50,13 @@ export function Day1ChecklistPanel({ }).filter((cat): cat is ChecklistCategory => cat !== null); }, [categories, searchTerm]); + // categories 로드 완료 시 모두 펼치기 + React.useEffect(() => { + if (categories.length > 0) { + setExpandedCategories(new Set(categories.map(c => c.id))); + } + }, [categories]); + // 검색 시 모든 카테고리 펼치기 React.useEffect(() => { if (searchTerm.trim()) { diff --git a/src/app/[locale]/(protected)/quality/qms/components/Day1DocumentSection.tsx b/src/app/[locale]/(protected)/quality/qms/components/Day1DocumentSection.tsx index ee1769dd..56b64d0a 100644 --- a/src/app/[locale]/(protected)/quality/qms/components/Day1DocumentSection.tsx +++ b/src/app/[locale]/(protected)/quality/qms/components/Day1DocumentSection.tsx @@ -1,11 +1,11 @@ 'use client'; import React, { useState, useRef, useCallback } from 'react'; -import { FileText, Download, Eye, CheckCircle2, Upload, X, Loader2 } from 'lucide-react'; +import { FileText, CheckCircle2, Upload, X, Loader2, Trash2 } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { toast } from 'sonner'; -import type { Day1CheckItem, StandardDocument } from '../types'; +import type { Day1CheckItem, TemplateDocument } from '../types'; const ACCEPTED_EXTENSIONS = '.pdf,.xlsx,.xls,.doc,.docx,.hwp'; const ACCEPTED_MIME = [ @@ -20,22 +20,26 @@ const MAX_FILE_SIZE_MB = 20; interface Day1DocumentSectionProps { checkItem: Day1CheckItem | null; - selectedDocumentId: string | null; - onDocumentSelect: (documentId: string) => void; onConfirmComplete: () => void; isCompleted: boolean; isMock?: boolean; - onFileUpload?: (subItemId: string, file: File) => void; + onFileUpload?: (subItemId: string, file: File) => Promise; + uploadedFiles?: TemplateDocument[]; + onFileDelete?: (fileId: number) => void; + onFileSelect?: (file: TemplateDocument) => void; + selectedFileId?: number | null; } export function Day1DocumentSection({ checkItem, - selectedDocumentId, - onDocumentSelect, onConfirmComplete, isCompleted, isMock, onFileUpload, + uploadedFiles = [], + onFileDelete, + onFileSelect, + selectedFileId, }: Day1DocumentSectionProps) { if (!checkItem) { return ( @@ -70,21 +74,20 @@ export function Day1DocumentSection({

{checkItem.description}

- {/* 기준 문서 목록 */} + {/* 관련 기준 문서 */}
관련 기준 문서
-
- {checkItem.standardDocuments.map((doc) => ( - onDocumentSelect(doc.id)} +
+ {uploadedFiles.map((file) => ( + onFileSelect(file) : undefined} + onDelete={onFileDelete ? () => onFileDelete(file.id) : undefined} /> ))}
- - {/* 파일 업로드 */} onFileUpload?.(checkItem.subItemId, file)} /> @@ -119,75 +122,10 @@ export function Day1DocumentSection({ ); } -interface DocumentRowProps { - document: StandardDocument; - isSelected: boolean; - onSelect: () => void; -} - -function DocumentRow({ document, isSelected, onSelect }: DocumentRowProps) { - return ( -
- {/* 아이콘 */} -
- -
- - {/* 문서 정보 */} -
-

{document.title}

-

- {document.version !== '-' && {document.version}} - {document.date} -

-
- - {/* 액션 버튼 */} -
- - -
-
- ); -} - // ===== 파일 업로드 영역 ===== interface DocumentUploadAreaProps { - onUpload: (file: File) => void; + onUpload: (file: File) => Promise; } function DocumentUploadArea({ onUpload }: DocumentUploadAreaProps) { @@ -223,11 +161,11 @@ function DocumentUploadArea({ onUpload }: DocumentUploadAreaProps) { if (!pendingFile) return; setUploading(true); try { - // Mock: 0.5초 딜레이 - await new Promise(resolve => setTimeout(resolve, 500)); - onUpload(pendingFile); - toast.success(`${pendingFile.name} 업로드 완료 (Mock)`); - setPendingFile(null); + const success = await onUpload(pendingFile); + if (success) { + toast.success(`${pendingFile.name} 업로드 완료`); + setPendingFile(null); + } } finally { setUploading(false); } @@ -333,4 +271,51 @@ function DocumentUploadArea({ onUpload }: DocumentUploadAreaProps) {
); +} + +// ===== 업로드된 파일 행 ===== + +interface UploadedFileRowProps { + file: TemplateDocument; + isSelected?: boolean; + onSelect?: () => void; + onDelete?: () => void; +} + +function UploadedFileRow({ file, isSelected, onSelect, onDelete }: UploadedFileRowProps) { + const ext = file.displayName.split('.').pop()?.toLowerCase(); + const sizeMB = (file.fileSize / (1024 * 1024)).toFixed(1); + + return ( +
+
+ +
+
+

{file.displayName}

+

{sizeMB} MB

+
+ {onDelete && ( + + )} +
+ ); } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/quality/qms/components/Day1DocumentViewer.tsx b/src/app/[locale]/(protected)/quality/qms/components/Day1DocumentViewer.tsx index 871b6b72..cdd49f5d 100644 --- a/src/app/[locale]/(protected)/quality/qms/components/Day1DocumentViewer.tsx +++ b/src/app/[locale]/(protected)/quality/qms/components/Day1DocumentViewer.tsx @@ -3,14 +3,120 @@ import React from 'react'; import { FileText, Download, Printer, ZoomIn, ZoomOut, Maximize2 } from 'lucide-react'; import { cn } from '@/lib/utils'; -import type { StandardDocument } from '../types'; +import type { StandardDocument, TemplateDocument } from '../types'; interface Day1DocumentViewerProps { document: StandardDocument | null; + uploadedFile?: TemplateDocument | null; isMock?: boolean; } -export function Day1DocumentViewer({ document, isMock }: Day1DocumentViewerProps) { +export function Day1DocumentViewer({ document, uploadedFile, isMock }: Day1DocumentViewerProps) { + // 업로드된 파일이 선택된 경우 + if (uploadedFile) { + const isPdf = uploadedFile.mimeType === 'application/pdf'; + const viewUrl = `/api/proxy/files/${uploadedFile.id}/view`; + const downloadUrl = `/api/proxy/files/${uploadedFile.id}/download`; + + // Google Docs Viewer용 공개 URL 생성 (개발/운영 서버에서만 동작) + const isLocalhost = typeof window !== 'undefined' && ( + window.location.hostname === 'localhost' || + window.location.hostname.endsWith('.sam.kr') + ); + const publicFileUrl = typeof window !== 'undefined' + ? `${window.location.origin}${viewUrl}` + : ''; + const googleViewerUrl = `https://docs.google.com/gview?url=${encodeURIComponent(publicFileUrl)}&embedded=true`; + + const isOfficeDoc = [ + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ].includes(uploadedFile.mimeType); + + return ( +
+ {/* 헤더 */} +
+
+
+ +
+
+

+ {uploadedFile.displayName} +

+

+ {(uploadedFile.fileSize / (1024 * 1024)).toFixed(1)} MB +

+
+
+ +
+ + {/* 문서 미리보기 */} +
+ {isPdf ? ( +