fix: [QMS] 로트심사 UI 개선

- 수주루트 → 수주로트 명칭 통일
- 거래처(client) 필드 추가 (types, actions, RouteList)
- 문서번호 표시 개선 (로트: 접두어 제거)
- ReportList 레이아웃 개선 (분기 표시 위치)
- PlaceholderDocument 문서번호 라벨 수정
This commit is contained in:
2026-03-12 14:01:13 +09:00
parent 86383719ec
commit bb1e4a25a1
15 changed files with 396 additions and 412 deletions

View File

@@ -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 readonly.
# Extends the list from the global configuration, merging the two lists.
read_only_memory_patterns: []

View File

@@ -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<string, unknown> | null;
file_counts: Record<string, number>;
@@ -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,

View File

@@ -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()) {

View File

@@ -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<boolean>;
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({
<p className="text-xs sm:text-sm text-blue-700">{checkItem.description}</p>
</div>
{/* 기준 문서 목록 */}
{/* 관련 기준 문서 */}
<div>
<h5 className="text-xs sm:text-sm font-medium text-gray-700 mb-1.5 sm:mb-2"> </h5>
<div className="space-y-1.5 sm:space-y-2">
{checkItem.standardDocuments.map((doc) => (
<DocumentRow
key={doc.id}
document={doc}
isSelected={selectedDocumentId === doc.id}
onSelect={() => onDocumentSelect(doc.id)}
<div className="space-y-1">
{uploadedFiles.map((file) => (
<UploadedFileRow
key={file.id}
file={file}
isSelected={selectedFileId === file.id}
onSelect={onFileSelect ? () => onFileSelect(file) : undefined}
onDelete={onFileDelete ? () => onFileDelete(file.id) : undefined}
/>
))}
</div>
{/* 파일 업로드 */}
<DocumentUploadArea
onUpload={(file) => 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 (
<div
className={cn(
'flex items-center gap-2 sm:gap-3 p-2 sm:p-3 rounded-lg border cursor-pointer transition-colors',
isSelected
? 'bg-blue-50 border-blue-300'
: 'bg-gray-50 border-gray-200 hover:bg-gray-100'
)}
onClick={onSelect}
>
{/* 아이콘 */}
<div className={cn(
'flex-shrink-0 w-8 h-8 sm:w-10 sm:h-10 rounded-lg flex items-center justify-center',
document.fileName?.endsWith('.pdf')
? 'bg-red-100 text-red-600'
: 'bg-green-100 text-green-600'
)}>
<FileText className="h-4 w-4 sm:h-5 sm:w-5" />
</div>
{/* 문서 정보 */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">{document.title}</p>
<p className="text-xs text-gray-500">
{document.version !== '-' && <span className="mr-2">{document.version}</span>}
<span>{document.date}</span>
</p>
</div>
{/* 액션 버튼 */}
<div className="flex items-center gap-1">
<button
type="button"
className="p-1.5 rounded hover:bg-gray-200 text-gray-500 hover:text-blue-600 transition-colors"
title="미리보기"
onClick={(e) => {
e.stopPropagation();
onSelect();
}}
>
<Eye className="h-4 w-4" />
</button>
<button
type="button"
className="p-1.5 rounded hover:bg-gray-200 text-gray-500 hover:text-blue-600 transition-colors"
title="다운로드"
onClick={(e) => {
e.stopPropagation();
// TODO: 다운로드 기능
}}
>
<Download className="h-4 w-4" />
</button>
</div>
</div>
);
}
// ===== 파일 업로드 영역 =====
interface DocumentUploadAreaProps {
onUpload: (file: File) => void;
onUpload: (file: File) => Promise<boolean>;
}
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) {
</div>
</>
);
}
// ===== 업로드된 파일 행 =====
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 (
<div
className={cn(
'flex items-center gap-2 p-2 rounded-lg border cursor-pointer transition-colors',
isSelected
? 'bg-blue-50 border-blue-300'
: 'bg-gray-50 border-gray-200 hover:bg-gray-100'
)}
onClick={onSelect}
>
<div className={cn(
'flex-shrink-0 w-7 h-7 rounded flex items-center justify-center',
ext === 'pdf' ? 'bg-red-100 text-red-600' : 'bg-green-100 text-green-600'
)}>
<FileText className="h-3.5 w-3.5" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-gray-800 truncate">{file.displayName}</p>
<p className="text-[10px] text-gray-400">{sizeMB} MB</p>
</div>
{onDelete && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onDelete(); }}
className="p-1 text-gray-400 hover:text-red-500 transition-colors"
title="삭제"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
);
}

View File

@@ -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 (
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden h-full flex flex-col">
{/* 헤더 */}
<div className="bg-gray-100 px-2 sm:px-4 py-2 sm:py-3 border-b border-gray-200 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={`w-6 h-6 sm:w-8 sm:h-8 rounded flex items-center justify-center ${
isPdf ? 'bg-red-100 text-red-600' : 'bg-green-100 text-green-600'
}`}>
<FileText className="h-3 w-3 sm:h-4 sm:w-4" />
</div>
<div>
<h3 className="font-medium text-gray-900 text-xs sm:text-sm truncate max-w-[200px]">
{uploadedFile.displayName}
</h3>
<p className="text-[10px] sm:text-xs text-gray-500">
{(uploadedFile.fileSize / (1024 * 1024)).toFixed(1)} MB
</p>
</div>
</div>
<div className="flex items-center gap-1">
<a
href={viewUrl}
target="_blank"
rel="noopener noreferrer"
className="p-1.5 sm:p-2 rounded hover:bg-gray-200 text-gray-500 hover:text-gray-700 transition-colors"
title="새 탭에서 보기"
>
<Maximize2 className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
</a>
<a
href={downloadUrl}
className="p-1.5 sm:p-2 rounded hover:bg-gray-200 text-gray-500 hover:text-gray-700 transition-colors"
title="다운로드"
>
<Download className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
</a>
</div>
</div>
{/* 문서 미리보기 */}
<div className="flex-1 bg-gray-200 overflow-auto">
{isPdf ? (
<iframe
src={viewUrl}
className="w-full h-full border-0"
title={uploadedFile.displayName}
/>
) : isOfficeDoc && !isLocalhost ? (
<iframe
src={googleViewerUrl}
className="w-full h-full border-0"
title={uploadedFile.displayName}
/>
) : (
<div className="flex items-center justify-center h-full text-gray-400">
<div className="text-center">
<FileText className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">
{isOfficeDoc && isLocalhost
? '로컬 환경에서는 Office 문서 미리보기가 지원되지 않습니다'
: '미리보기를 지원하지 않는 파일 형식입니다'}
</p>
<a
href={downloadUrl}
className="inline-block mt-2 text-xs text-blue-600 hover:text-blue-800 underline"
>
</a>
</div>
</div>
)}
</div>
{/* 푸터 */}
<div className="bg-gray-100 px-2 sm:px-4 py-1.5 sm:py-2 border-t border-gray-200">
<span className="text-[10px] sm:text-xs text-gray-500 truncate">
{uploadedFile.displayName}
</span>
</div>
</div>
);
}
if (!document) {
return (
<div className="bg-white rounded-lg border border-gray-200 h-full flex items-center justify-center">

View File

@@ -70,7 +70,7 @@ export const DocumentList = ({ documents, routeCode, onViewDocument, isMock }: D
<div className="space-y-2 sm:space-y-3 overflow-y-auto flex-1">
{!routeCode ? (
<div className="flex items-center justify-center h-32 text-gray-400 text-sm">
.
.
</div>
) : (
documents.map((doc) => {
@@ -122,7 +122,7 @@ export const DocumentList = ({ documents, routeCode, onViewDocument, isMock }: D
{item.code && (
<>
<span className="mx-1">|</span>
: {item.code}
{item.code}
</>
)}
</div>

View File

@@ -142,7 +142,7 @@ const PlaceholderDocument = ({ docType, docItem }: { docType: string; docItem: D
)}
{docItem?.code && (
<p className="text-xs text-green-600 font-mono bg-green-50 px-2 py-1 rounded mb-4">
: {docItem.code}
: {docItem.code}
</p>
)}
<div className="mt-4 p-4 bg-amber-50 rounded-lg border border-amber-200">
@@ -487,7 +487,7 @@ export const InspectionModal = ({
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} - ${documentItem.date}${documentItem.code ? ` ${documentItem.code}` : ''}`
: docInfo.label;
// 품질관리서 PDF 업로드 핸들러

View File

@@ -40,19 +40,20 @@ export const ReportList = ({ reports, selectedId, onSelect, isMock }: ReportList
<div
key={report.id}
onClick={() => onSelect(report)}
className={`rounded-lg p-3 sm:p-4 cursor-pointer relative hover:shadow-md transition-all ${
className={`rounded-lg p-3 sm:p-4 cursor-pointer hover:shadow-md transition-all ${
isSelected
? 'border-2 border-blue-500 bg-blue-50'
: 'border border-gray-200 bg-white hover:border-blue-300'
}`}
>
<div className="absolute top-3 sm:top-4 right-3 sm:right-4 text-[10px] sm:text-xs text-gray-400 bg-gray-100 px-1.5 sm:px-2 py-0.5 sm:py-1 rounded">
{report.quarter}
<div className="flex items-center justify-between gap-2 mb-1">
<h3 className={`font-bold text-sm sm:text-lg ${isSelected ? 'text-blue-900' : 'text-gray-800'}`}>
{report.code}
</h3>
<span className="text-[10px] sm:text-xs text-gray-400 bg-gray-100 px-1.5 sm:px-2 py-0.5 sm:py-1 rounded whitespace-nowrap">
{report.quarter}
</span>
</div>
<h3 className={`font-bold text-sm sm:text-lg mb-1 ${isSelected ? 'text-blue-900' : 'text-gray-800'}`}>
{report.code}
</h3>
<p className="text-xs sm:text-base text-gray-700 font-medium mb-1">{report.siteName}</p>
<p className="text-xs sm:text-sm text-gray-500 mb-2 sm:mb-3">: {report.item}</p>
@@ -60,7 +61,7 @@ export const ReportList = ({ reports, selectedId, onSelect, isMock }: ReportList
isSelected ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'
}`}>
<Package size={16} />
<span> {report.routeCount}</span>
<span> {report.routeCount}</span>
<span className="text-gray-400 text-xs ml-1">( {report.totalRoutes})</span>
</div>
</div>

View File

@@ -31,7 +31,7 @@ export const RouteList = ({ routes, selectedId, onSelect, onToggleItem, reportCo
<div className="bg-white rounded-lg p-3 sm:p-4 shadow-sm h-full flex flex-col overflow-hidden">
<div className="flex items-center gap-2 mb-3 sm:mb-4">
<h2 className="font-bold text-gray-800 text-xs sm:text-sm">
{' '}
{' '}
{reportCode && (
<span className="text-gray-400 font-normal ml-1">({reportCode})</span>
)}
@@ -46,7 +46,7 @@ export const RouteList = ({ routes, selectedId, onSelect, onToggleItem, reportCo
<div className="space-y-2 sm:space-y-3 overflow-y-auto flex-1">
{routes.length === 0 ? (
<div className="flex items-center justify-center h-32 text-gray-400 text-sm">
{reportCode ? '수주트가 없습니다.' : '품질관리서를 선택해주세요.'}
{reportCode ? '수주트가 없습니다.' : '품질관리서를 선택해주세요.'}
</div>
) : (
routes.map((route) => {
@@ -80,8 +80,9 @@ export const RouteList = ({ routes, selectedId, onSelect, onToggleItem, reportCo
</span>
)}
</div>
<p className="text-xs text-gray-500 mb-1">: {route.date}</p>
<p className="text-xs text-gray-500 mb-2">: {route.site}</p>
<p className="text-xs text-gray-500 mb-0.5">: {route.date || '-'}</p>
{route.client && <p className="text-xs text-gray-500 mb-0.5">: {route.client}</p>}
<p className="text-xs text-gray-500 mb-2">: {route.site || '-'}</p>
<div className="inline-flex items-center gap-1 bg-gray-100 px-2 py-0.5 rounded text-xs text-gray-600">
<MapPin size={10} />
<span>{route.locationCount}</span>

View File

@@ -1,36 +1,43 @@
'use client';
import { useState, useCallback, useMemo } from 'react';
import { useState, useCallback, useMemo, useEffect } from 'react';
import { toast } from 'sonner';
import type { ChecklistCategory, Day1CheckItem, StandardDocument } from '../types';
import {
getChecklistById,
toggleChecklistItem,
getCheckItemDocuments,
} from '../actions';
import {
MOCK_DAY1_CATEGORIES,
MOCK_DAY1_CHECK_ITEMS,
MOCK_DAY1_STANDARD_DOCUMENTS,
} from '../mockData';
const USE_MOCK = true; // API 연동 완료 시 false로 변경
import type { ChecklistCategory } from '../types';
import { getChecklistTemplate, toggleTemplateItem } from '../actions';
export function useDay1Audit() {
// 데이터 상태
const [categories, setCategories] = useState<ChecklistCategory[]>(USE_MOCK ? MOCK_DAY1_CATEGORIES : []);
const [checkItems] = useState<Day1CheckItem[]>(USE_MOCK ? MOCK_DAY1_CHECK_ITEMS : []);
const [standardDocuments] = useState<Record<string, StandardDocument[]>>(USE_MOCK ? MOCK_DAY1_STANDARD_DOCUMENTS : {});
const [templateId, setTemplateId] = useState<number | null>(null);
const [categories, setCategories] = useState<ChecklistCategory[]>([]);
// 선택 상태
const [selectedSubItemId, setSelectedSubItemId] = useState<string | null>(null);
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);
const [selectedStandardDocId, setSelectedStandardDocId] = useState<string | null>(null);
// 로딩 상태
const [loadingChecklist, setLoadingChecklist] = useState(false);
const [loadingChecklist, setLoadingChecklist] = useState(true);
const [pendingToggleIds, setPendingToggleIds] = useState<Set<string>>(new Set());
// 마운트 시 점검표 로드 (checklist_templates API)
useEffect(() => {
let cancelled = false;
setLoadingChecklist(true);
getChecklistTemplate('day1_audit')
.then((result) => {
if (cancelled) return;
if (result.success && result.data) {
setTemplateId(result.data.id);
setCategories(result.data.categories);
}
})
.finally(() => {
if (!cancelled) setLoadingChecklist(false);
});
return () => { cancelled = true; };
}, []);
// 진행률 계산
const day1Progress = useMemo(() => {
const total = categories.reduce((sum, cat) => sum + cat.subItems.length, 0);
@@ -41,19 +48,6 @@ export function useDay1Audit() {
return { completed, total };
}, [categories]);
// 선택된 점검 항목
const selectedCheckItem = useMemo(() => {
if (!selectedSubItemId) return null;
return checkItems.find((item) => item.subItemId === selectedSubItemId) || null;
}, [selectedSubItemId, checkItems]);
// 선택된 표준 문서
const selectedStandardDoc = useMemo(() => {
if (!selectedStandardDocId || !selectedSubItemId) return null;
const docs = standardDocuments[selectedSubItemId] || [];
return docs.find((doc) => doc.id === selectedStandardDocId) || null;
}, [selectedStandardDocId, selectedSubItemId, standardDocuments]);
// 선택된 항목의 완료 여부
const isSelectedItemCompleted = useMemo(() => {
if (!selectedSubItemId) return false;
@@ -64,38 +58,38 @@ export function useDay1Audit() {
return false;
}, [categories, selectedSubItemId]);
// 선택된 점검 항목 정보 (중앙 패널용)
const selectedCheckItem = useMemo(() => {
if (!selectedSubItemId || !selectedCategoryId) return null;
const category = categories.find((c) => c.id === selectedCategoryId);
if (!category) return null;
const subItem = category.subItems.find((s) => s.id === selectedSubItemId);
if (!subItem) return null;
return {
id: `check-${subItem.id}`,
categoryId: category.id,
subItemId: subItem.id,
title: subItem.name,
description: '',
buttonLabel: '기준/매뉴얼 확인',
standardDocuments: [],
};
}, [selectedSubItemId, selectedCategoryId, categories]);
// === 핸들러 ===
const handleSubItemSelect = useCallback((categoryId: string, subItemId: string) => {
setSelectedCategoryId(categoryId);
setSelectedSubItemId(subItemId);
setSelectedStandardDocId(null);
}, []);
const handleSubItemToggle = useCallback(async (categoryId: string, subItemId: string, isCompleted: boolean) => {
if (USE_MOCK) {
// Mock: 로컬 상태만 업데이트
setCategories((prev) =>
prev.map((cat) => {
if (cat.id !== categoryId) return cat;
return {
...cat,
subItems: cat.subItems.map((item) => {
if (item.id !== subItemId) return item;
return { ...item, isCompleted };
}),
};
}),
);
return;
}
// API: 비관적 업데이트
if (pendingToggleIds.has(subItemId)) return;
const handleSubItemToggle = useCallback(async (categoryId: string, subItemId: string) => {
if (!templateId || pendingToggleIds.has(subItemId)) return;
setPendingToggleIds((prev) => new Set(prev).add(subItemId));
try {
const result = await toggleChecklistItem(subItemId);
const result = await toggleTemplateItem(templateId, subItemId);
if (result.success && result.data) {
setCategories((prev) =>
prev.map((cat) => {
@@ -119,40 +113,24 @@ export function useDay1Audit() {
return next;
});
}
}, [pendingToggleIds]);
}, [templateId, pendingToggleIds]);
const handleConfirmComplete = useCallback(() => {
if (selectedCategoryId && selectedSubItemId) {
handleSubItemToggle(selectedCategoryId, selectedSubItemId, true);
handleSubItemToggle(selectedCategoryId, selectedSubItemId);
}
}, [selectedCategoryId, selectedSubItemId, handleSubItemToggle]);
const fetchChecklist = useCallback(async (checklistId: string) => {
if (USE_MOCK) return;
setLoadingChecklist(true);
try {
const result = await getChecklistById(checklistId);
if (result.success && result.data) {
setCategories(result.data.categories);
}
} finally {
setLoadingChecklist(false);
}
}, []);
return {
// 데이터
templateId,
categories,
day1Progress,
selectedCheckItem,
selectedStandardDoc,
isSelectedItemCompleted,
// 선택
selectedSubItemId,
selectedCategoryId,
selectedStandardDocId,
setSelectedStandardDocId,
handleSubItemSelect,
// 토글
@@ -163,10 +141,7 @@ export function useDay1Audit() {
// 로딩
loadingChecklist,
// API
fetchChecklist,
// Mock 여부
isMock: USE_MOCK,
isMock: false,
};
}

View File

@@ -1,6 +1,6 @@
'use client';
import { useState, useCallback, useMemo } from 'react';
import { useState, useCallback, useMemo, useEffect } from 'react';
import { toast } from 'sonner';
import type { InspectionReport, RouteItem, Document, DocumentItem } from '../types';
import {
@@ -9,24 +9,18 @@ import {
getRouteDocuments,
confirmUnitInspection,
} from '../actions';
import {
MOCK_REPORTS,
MOCK_ROUTES_INITIAL,
MOCK_DOCUMENTS,
DEFAULT_DOCUMENTS,
} from '../mockData';
const USE_MOCK = true; // API 연동 완료 시 false로 변경
const USE_MOCK = false;
export function useDay2LotAudit() {
// 필터 상태
const [selectedYear, setSelectedYear] = useState(2025);
const [selectedYear, setSelectedYear] = useState(2026);
const [selectedQuarter, setSelectedQuarter] = useState<'Q1' | 'Q2' | 'Q3' | 'Q4' | '전체'>('전체');
const [searchTerm, setSearchTerm] = useState('');
// 데이터 상태
const [reports, setReports] = useState<InspectionReport[]>(USE_MOCK ? MOCK_REPORTS : []);
const [routesData, setRoutesData] = useState<Record<string, RouteItem[]>>(USE_MOCK ? MOCK_ROUTES_INITIAL : {});
const [reports, setReports] = useState<InspectionReport[]>([]);
const [routesData, setRoutesData] = useState<Record<string, RouteItem[]>>({});
const [documents, setDocuments] = useState<Document[]>([]);
// 선택 상태
@@ -44,6 +38,32 @@ export function useDay2LotAudit() {
const [loadingDocuments, setLoadingDocuments] = useState(false);
const [pendingConfirmIds, setPendingConfirmIds] = useState<Set<string>>(new Set());
// 마운트 시 + 필터 변경 시 보고서 자동 로드
useEffect(() => {
if (USE_MOCK) return;
const loadReports = async () => {
setLoadingReports(true);
try {
const quarterNum = selectedQuarter !== '전체'
? parseInt(selectedQuarter.replace('Q', ''))
: undefined;
const result = await getQualityReports({
year: selectedYear,
quarter: quarterNum,
q: searchTerm || undefined,
});
if (result.success && result.data) {
setReports(result.data);
}
} finally {
setLoadingReports(false);
}
};
loadReports();
}, [selectedYear, selectedQuarter, searchTerm]);
// 진행률 계산
const day2Progress = useMemo(() => {
let completed = 0;
@@ -59,24 +79,10 @@ export function useDay2LotAudit() {
return { completed, total };
}, [routesData]);
// 필터링된 보고서
// 필터링된 보고서 (API에서 이미 필터링되므로 그대로 반환)
const filteredReports = useMemo(() => {
return reports.filter((report) => {
if (report.year !== selectedYear) return false;
if (selectedQuarter !== '전체') {
const quarterNum = parseInt(selectedQuarter.replace('Q', ''));
if (report.quarterNum !== quarterNum) return false;
}
if (searchTerm) {
const term = searchTerm.toLowerCase();
const matchesCode = report.code.toLowerCase().includes(term);
const matchesSite = report.siteName.toLowerCase().includes(term);
const matchesItem = report.item.toLowerCase().includes(term);
if (!matchesCode && !matchesSite && !matchesItem) return false;
}
return true;
});
}, [reports, selectedYear, selectedQuarter, searchTerm]);
return reports;
}, [reports]);
// 현재 루트/문서
const currentRoutes = useMemo(() => {
@@ -85,35 +91,16 @@ export function useDay2LotAudit() {
}, [selectedReport, routesData]);
const currentDocuments = useMemo(() => {
if (USE_MOCK) {
if (!selectedRoute) return DEFAULT_DOCUMENTS;
return MOCK_DOCUMENTS[selectedRoute.id] || DEFAULT_DOCUMENTS;
}
return documents;
}, [selectedRoute, documents]);
}, [documents]);
// === API 호출 핸들러 ===
const fetchReports = useCallback(async (year: number, quarter?: number, q?: string) => {
if (USE_MOCK) return;
setLoadingReports(true);
try {
const result = await getQualityReports({ year, quarter, q });
if (result.success && result.data) {
setReports(result.data);
}
} finally {
setLoadingReports(false);
}
}, []);
const handleReportSelect = useCallback(async (report: InspectionReport) => {
setSelectedReport(report);
setSelectedRoute(null);
setDocuments([]);
if (USE_MOCK) return;
setLoadingRoutes(true);
try {
const result = await getReportRoutes(report.id);
@@ -128,8 +115,6 @@ export function useDay2LotAudit() {
const handleRouteSelect = useCallback(async (route: RouteItem) => {
setSelectedRoute(route);
if (USE_MOCK) return;
setLoadingDocuments(true);
try {
const result = await getRouteDocuments(route.id);
@@ -148,27 +133,6 @@ export function useDay2LotAudit() {
}, []);
const handleToggleItem = useCallback(async (routeId: string, itemId: string, isCompleted: boolean) => {
if (USE_MOCK) {
// Mock: 로컬 상태만 업데이트
setRoutesData((prev) => {
const newData = { ...prev };
for (const reportId of Object.keys(newData)) {
newData[reportId] = newData[reportId].map((route) => {
if (route.id !== routeId) return route;
return {
...route,
subItems: route.subItems.map((item) => {
if (item.id !== itemId) return item;
return { ...item, isCompleted };
}),
};
});
}
return newData;
});
return;
}
// API: 비관적 업데이트
if (pendingConfirmIds.has(itemId)) return;
setPendingConfirmIds((prev) => new Set(prev).add(itemId));
@@ -259,9 +223,6 @@ export function useDay2LotAudit() {
loadingRoutes,
loadingDocuments,
// API
fetchReports,
// Mock 여부
isMock: USE_MOCK,
};

View File

@@ -98,7 +98,7 @@ export const MOCK_REPORTS: InspectionReport[] = [
},
];
// 수주트 목록 (reportId로 연결)
// 수주트 목록 (reportId로 연결)
export const MOCK_ROUTES_INITIAL: Record<string, RouteItem[]> = {
'1': [
{

View File

@@ -1,6 +1,7 @@
"use client";
import React, { useState, useMemo } from 'react';
import React, { useState, useMemo, useCallback, useEffect } from 'react';
import { toast } from 'sonner';
import { Header } from './components/Header';
import { Filters } from './components/Filters';
import { ReportList } from './components/ReportList';
@@ -15,6 +16,8 @@ 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 type { TemplateDocument } from './types';
// 기본 설정값
const DEFAULT_SETTINGS: AuditDisplaySettings = {
@@ -35,23 +38,73 @@ export default function QualityInspectionPage() {
// 1일차 커스텀 훅
const {
templateId,
categories,
day1Progress,
selectedCheckItem,
selectedStandardDoc,
isSelectedItemCompleted,
selectedSubItemId,
selectedStandardDocId,
setSelectedStandardDocId,
handleSubItemSelect,
handleSubItemToggle,
handleConfirmComplete,
isMock: day1IsMock,
} = useDay1Audit();
// 점검표 템플릿 관리 훅
// 점검표 템플릿 관리 훅 (설정 모달용)
const checklistTemplate = useChecklistTemplate();
// 업로드된 파일 상태 (subItemId별)
const [uploadedFiles, setUploadedFiles] = useState<Record<string, TemplateDocument[]>>({});
const [selectedUploadedFile, setSelectedUploadedFile] = useState<TemplateDocument | null>(null);
// 선택된 항목 변경 시 파일 목록 로드 + 업로드 파일 선택 초기화
useEffect(() => {
setSelectedUploadedFile(null);
if (!selectedSubItemId || !templateId) return;
if (uploadedFiles[selectedSubItemId]) return; // 이미 로드됨
getTemplateDocuments(templateId, selectedSubItemId).then((result) => {
if (result.success && result.data) {
setUploadedFiles((prev) => ({ ...prev, [selectedSubItemId]: result.data! }));
}
});
}, [selectedSubItemId, templateId]);
// 파일 업로드 핸들러
const handleFileUpload = useCallback(async (subItemId: string, file: File): Promise<boolean> => {
if (!templateId) {
toast.error('점검표 템플릿이 로드되지 않았습니다.');
return false;
}
const result = await uploadTemplateDocument(templateId, subItemId, file);
if (!result.success) {
toast.error(result.error || '파일 업로드에 실패했습니다.');
return false;
}
// 업로드 성공 시 파일 목록에 추가
if (result.data) {
setUploadedFiles((prev) => ({
...prev,
[subItemId]: [result.data!, ...(prev[subItemId] || [])],
}));
}
return true;
}, [templateId]);
// 파일 삭제 핸들러
const handleFileDelete = useCallback(async (fileId: number, subItemId: string) => {
const result = await deleteTemplateDocument(fileId);
if (result.success) {
setUploadedFiles((prev) => ({
...prev,
[subItemId]: (prev[subItemId] || []).filter((f) => f.id !== fileId),
}));
toast.success('파일이 삭제되었습니다.');
} else {
toast.error(result.error || '파일 삭제에 실패했습니다.');
}
}, []);
// 2일차 커스텀 훅
const {
selectedYear,
@@ -168,11 +221,16 @@ export default function QualityInspectionPage() {
}`}>
<Day1DocumentSection
checkItem={selectedCheckItem}
selectedDocumentId={selectedStandardDocId}
onDocumentSelect={setSelectedStandardDocId}
onConfirmComplete={handleConfirmComplete}
isCompleted={isSelectedItemCompleted}
isMock={day1IsMock}
onFileUpload={handleFileUpload}
uploadedFiles={selectedSubItemId ? uploadedFiles[selectedSubItemId] || [] : []}
onFileDelete={selectedSubItemId ? (fileId) => handleFileDelete(fileId, selectedSubItemId) : undefined}
onFileSelect={(file) => {
setSelectedUploadedFile(file);
}}
selectedFileId={selectedUploadedFile?.id ?? null}
/>
</div>
)}
@@ -182,7 +240,7 @@ export default function QualityInspectionPage() {
<div className={`col-span-12 min-h-[200px] sm:min-h-[250px] lg:min-h-[500px] lg:h-full overflow-auto ${
displaySettings.showDocumentSection ? 'lg:col-span-5' : 'lg:col-span-8'
}`}>
<Day1DocumentViewer document={selectedStandardDoc} isMock={day1IsMock} />
<Day1DocumentViewer document={null} uploadedFile={selectedUploadedFile} isMock={day1IsMock} />
</div>
)}
</div>

View File

@@ -14,6 +14,7 @@ export interface RouteItem {
id: string;
code: string; // e.g., KD-SS-240924-19
date: string; // 2024-09-24
client: string; // 거래처(발주처)
site: string; // 강남 아파트 A동
locationCount: number;
subItems: UnitInspection[];
@@ -42,6 +43,8 @@ export interface DocumentItem {
code?: string;
// 중간검사 성적서 및 작업일지 서브타입 (report, log 타입에서 사용)
subType?: 'screen' | 'bending' | 'slat' | 'jointbar';
// 작업일지/중간검사에서 실제 WorkOrder 데이터 로딩용
workOrderId?: number;
}
// ===== 기준/매뉴얼 심사 심사 타입 =====
@@ -93,6 +96,17 @@ export interface Day2Progress {
total: number;
}
// 업로드된 템플릿 문서
export interface TemplateDocument {
id: number;
fieldKey: string;
displayName: string;
fileSize: number;
mimeType: string;
uploadedBy?: string | null;
createdAt?: string | null;
}
// ===== 점검표 템플릿 관리 타입 =====
// 점검표 템플릿 (API 응답)

View File

@@ -329,7 +329,7 @@ export async function middleware(request: NextRequest) {
"img-src 'self' data: blob: https:",
"font-src 'self' data: https://fonts.gstatic.com",
"connect-src 'self' https://maps.googleapis.com *.daum.net *.daumcdn.net *.kakao.com *.kakaocdn.net",
"frame-src 'self' *.daum.net *.daumcdn.net *.kakao.com *.kakaocdn.net",
"frame-src 'self' *.daum.net *.daumcdn.net *.kakao.com *.kakaocdn.net https://docs.google.com",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",