fix: [QMS] 로트심사 UI 개선
- 수주루트 → 수주로트 명칭 통일 - 거래처(client) 필드 추가 (types, actions, RouteList) - 문서번호 표시 개선 (로트: 접두어 제거) - ReportList 레이아웃 개선 (분기 표시 위치) - PlaceholderDocument 문서번호 라벨 수정
This commit is contained in:
@@ -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: []
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 업로드 핸들러
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -98,7 +98,7 @@ export const MOCK_REPORTS: InspectionReport[] = [
|
||||
},
|
||||
];
|
||||
|
||||
// 수주루트 목록 (reportId로 연결)
|
||||
// 수주로트 목록 (reportId로 연결)
|
||||
export const MOCK_ROUTES_INITIAL: Record<string, RouteItem[]> = {
|
||||
'1': [
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 응답)
|
||||
|
||||
@@ -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'",
|
||||
|
||||
Reference in New Issue
Block a user