diff options
| author | joonhoekim <26rote@gmail.com> | 2025-11-07 17:39:36 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-11-07 17:39:36 +0900 |
| commit | 1363913352722a03e051b15297f72bf16d80106f (patch) | |
| tree | 1f4b1228ff171bda515deb95dcdde1f4484ced8e | |
| parent | ba8cd44a0ed2c613a5f2cee06bfc9bd0f61f21c7 (diff) | |
(김준회) 돌체 업로드 MIME 타입 검증 문제 확장자로 처리
| -rw-r--r-- | components/ship-vendor-document/add-attachment-dialog.tsx | 55 | ||||
| -rw-r--r-- | components/ship-vendor-document/new-revision-dialog.tsx | 51 | ||||
| -rw-r--r-- | lib/file-stroage.ts | 12 | ||||
| -rw-r--r-- | lib/vendor-document-list/enhanced-document-service.ts | 33 | ||||
| -rw-r--r-- | lib/vendor-document-list/service.ts | 36 | ||||
| -rw-r--r-- | lib/vendor-document-list/ship/bulk-b4-upload-dialog.tsx | 51 | ||||
| -rw-r--r-- | lib/vendor-document-list/ship/import-from-dolce-button.tsx | 126 |
7 files changed, 297 insertions, 67 deletions
diff --git a/components/ship-vendor-document/add-attachment-dialog.tsx b/components/ship-vendor-document/add-attachment-dialog.tsx index 6765bcf5..4a51c3b5 100644 --- a/components/ship-vendor-document/add-attachment-dialog.tsx +++ b/components/ship-vendor-document/add-attachment-dialog.tsx @@ -38,7 +38,7 @@ import { useSession } from "next-auth/react" * -----------------------------------------------------------------------------------------------*/ // 파일 검증 스키마 -const MAX_FILE_SIZE = 1024 * 1024 * 1024 // 50MB +const MAX_FILE_SIZE = 1024 * 1024 * 1024 // 1GB const ACCEPTED_FILE_TYPES = [ 'application/pdf', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', @@ -73,7 +73,7 @@ const attachmentUploadSchema = z.object({ // .max(10, "Maximum 10 files can be uploaded") .refine( (files) => files.every((file) => file.size <= MAX_FILE_SIZE), - "File size must be 50MB or less" + "File size must be 1GB or less" ) // .refine( // (files) => files.every((file) => ACCEPTED_FILE_TYPES.includes(file.type)), @@ -101,10 +101,46 @@ function FileUploadArea({ }) { const fileInputRef = React.useRef<HTMLInputElement>(null) + // 파일 검증 함수 + const validateFiles = (filesToValidate: File[]): { valid: File[], invalid: string[] } => { + const MAX_FILE_SIZE = 1024 * 1024 * 1024 // 1GB + const FORBIDDEN_EXTENSIONS = ['exe', 'com', 'dll', 'vbs', 'js', 'asp', 'aspx', 'bat', 'cmd'] + + const valid: File[] = [] + const invalid: string[] = [] + + filesToValidate.forEach(file => { + // 파일 크기 검증 + if (file.size > MAX_FILE_SIZE) { + invalid.push(`${file.name}: 파일 크기가 1GB를 초과합니다 (${formatFileSize(file.size)})`) + return + } + + // 파일 확장자 검증 + const extension = file.name.split('.').pop()?.toLowerCase() + if (extension && FORBIDDEN_EXTENSIONS.includes(extension)) { + invalid.push(`${file.name}: 금지된 파일 형식입니다 (.${extension})`) + return + } + + valid.push(file) + }) + + return { valid, invalid } + } + const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => { const selectedFiles = Array.from(event.target.files || []) if (selectedFiles.length > 0) { - onFilesChange([...files, ...selectedFiles]) + const { valid, invalid } = validateFiles(selectedFiles) + + if (invalid.length > 0) { + invalid.forEach(msg => toast.error(msg)) + } + + if (valid.length > 0) { + onFilesChange([...files, ...valid]) + } } } @@ -112,7 +148,15 @@ function FileUploadArea({ event.preventDefault() const droppedFiles = Array.from(event.dataTransfer.files) if (droppedFiles.length > 0) { - onFilesChange([...files, ...droppedFiles]) + const { valid, invalid } = validateFiles(droppedFiles) + + if (invalid.length > 0) { + invalid.forEach(msg => toast.error(msg)) + } + + if (valid.length > 0) { + onFilesChange([...files, ...valid]) + } } } @@ -147,6 +191,9 @@ function FileUploadArea({ <p className="text-xs text-muted-foreground"> Supports PDF, Word, Excel, Image, Text, ZIP, CAD files (DWG, DXF, STEP, STL, IGES) (max 1GB) </p> + <p className="text-xs text-red-600 mt-1 font-medium"> + Forbidden file types: .exe, .com, .dll, .vbs, .js, .asp, .aspx, .bat, .cmd + </p> <p className="text-xs text-orange-600 mt-1"> Note: File names cannot contain these characters: < > : " ' | ? * </p> diff --git a/components/ship-vendor-document/new-revision-dialog.tsx b/components/ship-vendor-document/new-revision-dialog.tsx index 91694827..bdbb1bc6 100644 --- a/components/ship-vendor-document/new-revision-dialog.tsx +++ b/components/ship-vendor-document/new-revision-dialog.tsx @@ -83,10 +83,46 @@ function FileUploadArea({ }) { const fileInputRef = React.useRef<HTMLInputElement>(null) + // 파일 검증 함수 + const validateFiles = (filesToValidate: File[]): { valid: File[], invalid: string[] } => { + const MAX_FILE_SIZE = 1024 * 1024 * 1024 // 1GB + const FORBIDDEN_EXTENSIONS = ['exe', 'com', 'dll', 'vbs', 'js', 'asp', 'aspx', 'bat', 'cmd'] + + const valid: File[] = [] + const invalid: string[] = [] + + filesToValidate.forEach(file => { + // 파일 크기 검증 + if (file.size > MAX_FILE_SIZE) { + invalid.push(`${file.name}: 파일 크기가 1GB를 초과합니다 (${formatFileSize(file.size)})`) + return + } + + // 파일 확장자 검증 + const extension = file.name.split('.').pop()?.toLowerCase() + if (extension && FORBIDDEN_EXTENSIONS.includes(extension)) { + invalid.push(`${file.name}: 금지된 파일 형식입니다 (.${extension})`) + return + } + + valid.push(file) + }) + + return { valid, invalid } + } + const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => { const selectedFiles = Array.from(event.target.files || []) if (selectedFiles.length > 0) { - onFilesChange([...files, ...selectedFiles]) + const { valid, invalid } = validateFiles(selectedFiles) + + if (invalid.length > 0) { + invalid.forEach(msg => toast.error(msg)) + } + + if (valid.length > 0) { + onFilesChange([...files, ...valid]) + } } } @@ -94,7 +130,15 @@ function FileUploadArea({ event.preventDefault() const droppedFiles = Array.from(event.dataTransfer.files) if (droppedFiles.length > 0) { - onFilesChange([...files, ...droppedFiles]) + const { valid, invalid } = validateFiles(droppedFiles) + + if (invalid.length > 0) { + invalid.forEach(msg => toast.error(msg)) + } + + if (valid.length > 0) { + onFilesChange([...files, ...valid]) + } } } @@ -132,6 +176,9 @@ function FileUploadArea({ <p className="text-xs text-orange-600 mt-1"> Note: File names cannot contain these characters: < > : " ' | ? * </p> + <p className="text-xs text-red-600 mt-1 font-medium"> + Forbidden file types: .exe, .com, .dll, .vbs, .js, .asp, .aspx, .bat, .cmd + </p> <input ref={fileInputRef} type="file" diff --git a/lib/file-stroage.ts b/lib/file-stroage.ts index 34b9983a..cb6fdfbd 100644 --- a/lib/file-stroage.ts +++ b/lib/file-stroage.ts @@ -28,7 +28,9 @@ const SECURITY_CONFIG = { 'exe', 'bat', 'cmd', 'scr', 'vbs', 'js', 'jar', 'com', 'pif', 'msi', 'reg', 'ps1', 'sh', 'php', 'asp', 'jsp', 'py', 'pl', // XSS 방지를 위한 추가 확장자 - 'html', 'htm', 'xhtml', 'xml', 'xsl', 'xslt','svg' + 'html', 'htm', 'xhtml', 'xml', 'xsl', 'xslt','svg', + // 돌체 블랙리스트 추가 + 'dll', 'vbs', 'js', 'aspx', 'cmd' ]), // 허용된 MIME 타입 @@ -45,7 +47,7 @@ const SECURITY_CONFIG = { 'application/zip', 'application/x-rar-compressed', 'application/x-7z-compressed' ]), - // 최대 파일 크기 (100MB) + // 최대 파일 크기 (1GB) MAX_FILE_SIZE: 1024 * 1024 * 1024, // 파일명 최대 길이 @@ -129,6 +131,12 @@ class FileSecurityValidator { // MIME 타입 검증 static validateMimeType(mimeType: string, fileName: string): { valid: boolean; error?: string } { if (!mimeType) { + // xlsx 파일의 경우 MIME 타입이 누락될 수 있으므로 경고만 표시 + const extension = path.extname(fileName).toLowerCase().substring(1); + if (['xlsx', 'xls', 'docx', 'doc', 'pptx', 'ppt', 'pdf', 'dwg', 'dxf', 'zip', 'rar', '7z'].includes(extension)) { + console.warn(`⚠️ MIME 타입 누락 (Office 파일 및 주요 확장자): ${fileName}, 확장자 기반으로 허용`); + return { valid: true }; // 확장자 기반으로 허용 + } return { valid: false, error: "MIME 타입을 확인할 수 없습니다" }; } diff --git a/lib/vendor-document-list/enhanced-document-service.ts b/lib/vendor-document-list/enhanced-document-service.ts index 2a81ddec..bb497f3b 100644 --- a/lib/vendor-document-list/enhanced-document-service.ts +++ b/lib/vendor-document-list/enhanced-document-service.ts @@ -1609,7 +1609,21 @@ export async function getDocumentDetails(documentId: number) { const docNumber = formData.get(`docNumber_${i}`) as string const revision = formData.get(`revision_${i}`) as string - if (!file || !docNumber) continue + // 디버깅 로그 추가 + console.log(`📋 파일 ${i} 처리:`, { + fileName: file?.name, + fileType: file?.type, + fileSize: file?.size, + docNumber, + revision, + hasFile: !!file, + hasDocNumber: !!docNumber + }) + + if (!file || !docNumber) { + console.warn(`⚠️ 파일 ${i} 스킵: file=${!!file}, docNumber=${!!docNumber}`) + continue + } if (!fileGroups.has(docNumber)) { fileGroups.set(docNumber, []) @@ -1746,6 +1760,14 @@ export async function getDocumentDetails(documentId: number) { } // 파일 저장 + console.log(`💾 파일 저장 시작:`, { + fileName: fileInfo.file.name, + fileType: fileInfo.file.type, + fileSize: fileInfo.file.size, + docNumber, + revision: fileInfo.revision + }) + const saveResult = await saveFile({ file: fileInfo.file, directory: `documents/${existingDoc.id}/revisions/${revisionId}`, @@ -1754,8 +1776,17 @@ export async function getDocumentDetails(documentId: number) { }) if (!saveResult.success) { + console.error(`❌ 파일 저장 실패:`, { + fileName: fileInfo.file.name, + error: saveResult.error + }) throw new Error(saveResult.error || "파일 저장 실패") } + + console.log(`✅ 파일 저장 성공:`, { + fileName: fileInfo.file.name, + publicPath: saveResult.publicPath + }) // 첨부파일 정보 저장 const [newAttachment] = await db.insert(documentAttachments).values({ diff --git a/lib/vendor-document-list/service.ts b/lib/vendor-document-list/service.ts index 76bdac49..502d9352 100644 --- a/lib/vendor-document-list/service.ts +++ b/lib/vendor-document-list/service.ts @@ -4,6 +4,7 @@ import { eq, SQL } from "drizzle-orm" import db from "@/db/db" import { documents, documentStagesView, issueStages } from "@/db/schema/vendorDocu" import { contracts } from "@/db/schema" +import { projects } from "@/db/schema/projects" import { GetVendorDcoumentsSchema } from "./validations" import { unstable_cache } from "@/lib/unstable-cache"; import { filterColumns } from "@/lib/filter-columns"; @@ -323,4 +324,39 @@ export async function getProjectIdsByVendor(vendorId: number): Promise<number[]> console.error('Error fetching contract IDs by vendor:', error) return [] } +} + +/** + * 프로젝트 ID 배열로 프로젝트 정보를 조회하는 서버 액션 + * @param projectIds - 프로젝트 ID 배열 + * @returns 프로젝트 정보 배열 [{ id, code, name }] + */ +export async function getProjectsByIds(projectIds: number[]): Promise<Array<{ id: number; code: string; name: string }>> { + try { + if (projectIds.length === 0) { + return [] + } + + // null 값 제거 + const validProjectIds = projectIds.filter((id): id is number => id !== null && !isNaN(id)) + + if (validProjectIds.length === 0) { + return [] + } + + const projectsData = await db + .select({ + id: projects.id, + code: projects.code, + name: projects.name, + }) + .from(projects) + .where(inArray(projects.id, validProjectIds)) + .orderBy(projects.code) + + return projectsData + } catch (error) { + console.error('프로젝트 정보 조회 중 오류:', error) + return [] + } }
\ No newline at end of file diff --git a/lib/vendor-document-list/ship/bulk-b4-upload-dialog.tsx b/lib/vendor-document-list/ship/bulk-b4-upload-dialog.tsx index 3ff2f467..be656a48 100644 --- a/lib/vendor-document-list/ship/bulk-b4-upload-dialog.tsx +++ b/lib/vendor-document-list/ship/bulk-b4-upload-dialog.tsx @@ -163,9 +163,53 @@ export function BulkB4UploadDialog({ setPendingProjectId("") } + // 파일 검증 함수 + const validateFile = (file: File): { valid: boolean; error?: string } => { + const MAX_FILE_SIZE = 1024 * 1024 * 1024 // 1GB + const FORBIDDEN_EXTENSIONS = ['exe', 'com', 'dll', 'vbs', 'js', 'asp', 'aspx', 'bat', 'cmd'] + + // 파일 크기 검증 + if (file.size > MAX_FILE_SIZE) { + return { + valid: false, + error: `파일 크기가 1GB를 초과합니다 (${(file.size / (1024 * 1024 * 1024)).toFixed(2)}GB)` + } + } + + // 파일 확장자 검증 + const extension = file.name.split('.').pop()?.toLowerCase() + if (extension && FORBIDDEN_EXTENSIONS.includes(extension)) { + return { + valid: false, + error: `금지된 파일 형식입니다 (.${extension})` + } + } + + return { valid: true } + } + // 파일 선택 시 파싱 const handleFilesChange = (files: File[]) => { - const parsed = files.map(file => { + const validFiles: File[] = [] + const invalidFiles: string[] = [] + + // 파일 검증 + files.forEach(file => { + const validation = validateFile(file) + if (validation.valid) { + validFiles.push(file) + } else { + invalidFiles.push(`${file.name}: ${validation.error}`) + } + }) + + // 유효하지 않은 파일이 있으면 토스트 표시 + if (invalidFiles.length > 0) { + invalidFiles.forEach(msg => toast.error(msg)) + } + + // 유효한 파일만 파싱 + const parsed = validFiles.map(file => { const { docNumber, revision } = parseFileName(file.name) return { file, @@ -429,7 +473,10 @@ export function BulkB4UploadDialog({ } </p> <p className="text-xs text-muted-foreground mt-1"> - PDF, DOC, DOCX, XLS, XLSX, DWG, DXF + PDF, DOC, DOCX, XLS, XLSX, DWG, DXF (max 1GB per file) + </p> + <p className="text-xs text-red-600 mt-1 font-medium"> + Forbidden: .exe, .com, .dll, .vbs, .js, .asp, .aspx, .bat, .cmd </p> </label> </div> diff --git a/lib/vendor-document-list/ship/import-from-dolce-button.tsx b/lib/vendor-document-list/ship/import-from-dolce-button.tsx index 76d66960..dfbd0600 100644 --- a/lib/vendor-document-list/ship/import-from-dolce-button.tsx +++ b/lib/vendor-document-list/ship/import-from-dolce-button.tsx @@ -24,21 +24,28 @@ import { Separator } from "@/components/ui/separator" import { SimplifiedDocumentsView } from "@/db/schema" import { ImportStatus } from "../import-service" import { useSession } from "next-auth/react" -import { getProjectIdsByVendor } from "../service" +import { getProjectIdsByVendor, getProjectsByIds } from "../service" import { useParams } from "next/navigation" import { useTranslation } from "@/i18n/client" -// 🔥 API 응답 캐시 (컴포넌트 외부에 선언하여 인스턴스 간 공유) +// API 응답 캐시 (컴포넌트 외부에 선언하여 인스턴스 간 공유) const statusCache = new Map<string, { data: ImportStatus; timestamp: number }>() const CACHE_TTL = 2 * 60 * 1000 // 2분 캐시 interface ImportFromDOLCEButtonProps { allDocuments: SimplifiedDocumentsView[] - projectIds?: number[] // 🔥 미리 계산된 projectIds를 props로 받음 + projectIds?: number[] // 미리 계산된 projectIds를 props로 받음 onImportComplete?: () => void } -// 🔥 디바운스 훅 +// 프로젝트 정보 타입 +interface ProjectInfo { + id: number + code: string + name: string +} + +// 디바운스 훅 function useDebounce<T>(value: T, delay: number): T { const [debouncedValue, setDebouncedValue] = React.useState(value) @@ -67,6 +74,7 @@ export function ImportFromDOLCEButton({ const [statusLoading, setStatusLoading] = React.useState(false) const [vendorProjectIds, setVendorProjectIds] = React.useState<number[]>([]) const [loadingVendorProjects, setLoadingVendorProjects] = React.useState(false) + const [projectsMap, setProjectsMap] = React.useState<Map<number, ProjectInfo>>(new Map()) const { data: session } = useSession() const vendorId = session?.user.companyId @@ -75,15 +83,15 @@ export function ImportFromDOLCEButton({ const lng = (params?.lng as string) || "ko" const { t } = useTranslation(lng, "engineering") - // 🔥 allDocuments에서 projectIds 추출 (props로 전달받은 경우 사용) + // allDocuments에서 projectIds 추출 (props로 전달받은 경우 사용) const documentsProjectIds = React.useMemo(() => { if (propProjectIds) return propProjectIds // props로 받은 경우 그대로 사용 - const uniqueIds = [...new Set(allDocuments.map(doc => doc.projectId).filter(Boolean))] + const uniqueIds = [...new Set(allDocuments.map(doc => doc.projectId).filter((id): id is number => id !== null))] return uniqueIds.sort() }, [allDocuments, propProjectIds]) - // 🔥 최종 projectIds (변경 빈도 최소화) + // 최종 projectIds (변경 빈도 최소화) const projectIds = React.useMemo(() => { if (documentsProjectIds.length > 0) { return documentsProjectIds @@ -91,28 +99,10 @@ export function ImportFromDOLCEButton({ return vendorProjectIds }, [documentsProjectIds, vendorProjectIds]) - // 🔥 projectIds 디바운싱 (API 호출 과다 방지) + // projectIds 디바운싱 (API 호출 과다 방지) const debouncedProjectIds = useDebounce(projectIds, 300) - // 🔥 주요 projectId 메모이제이션 - const primaryProjectId = React.useMemo(() => { - if (projectIds.length === 1) return projectIds[0] - - if (allDocuments.length > 0) { - const counts = allDocuments.reduce((acc, doc) => { - const id = doc.projectId || 0 - acc[id] = (acc[id] || 0) + 1 - return acc - }, {} as Record<number, number>) - - return Number(Object.entries(counts) - .sort(([,a], [,b]) => b - a)[0]?.[0] || projectIds[0] || 0) - } - - return projectIds[0] || 0 - }, [projectIds, allDocuments]) - - // 🔥 캐시된 API 호출 함수 + // 캐시된 API 호출 함수 const fetchImportStatusCached = React.useCallback(async (projectId: number): Promise<ImportStatus | null> => { const cacheKey = `import-status-${projectId}` const cached = statusCache.get(cacheKey) @@ -148,7 +138,7 @@ export function ImportFromDOLCEButton({ } }, []) - // 🔥 모든 projectId에 대한 상태 조회 (최적화된 버전) + // 모든 projectId에 대한 상태 조회 (최적화된 버전) const fetchAllImportStatus = React.useCallback(async () => { if (debouncedProjectIds.length === 0) return @@ -156,9 +146,9 @@ export function ImportFromDOLCEButton({ const statusMap = new Map<number, ImportStatus>() try { - // 🔥 병렬 처리하되 동시 연결 수 제한 (3개씩) + // 병렬 처리하되 동시 연결 수 제한 (3개씩) const batchSize = 3 - const batches = [] + const batches: number[][] = [] for (let i = 0; i < debouncedProjectIds.length; i += batchSize) { batches.push(debouncedProjectIds.slice(i, i + batchSize)) @@ -194,7 +184,7 @@ export function ImportFromDOLCEButton({ } }, [debouncedProjectIds, fetchImportStatusCached, t]) - // 🔥 vendorId로 projects 가져오기 (최적화) + // vendorId로 projects 가져오기 (최적화) React.useEffect(() => { let isCancelled = false; @@ -206,7 +196,7 @@ export function ImportFromDOLCEButton({ .then((projectIds) => { if (!isCancelled) setVendorProjectIds(projectIds); }) - .catch((error) => { + .catch(() => { if (!isCancelled) toast.error(t('dolceImport.messages.projectFetchError')); }) .finally(() => { @@ -215,15 +205,36 @@ export function ImportFromDOLCEButton({ return () => { isCancelled = true; }; }, [allDocuments, vendorId, t]); + + // projectIds로 프로젝트 정보 가져오기 (서버 액션 사용) + React.useEffect(() => { + if (projectIds.length === 0) return; + + const fetchProjectsInfo = async () => { + try { + const projectsData = await getProjectsByIds(projectIds); + + const newProjectsMap = new Map<number, ProjectInfo>(); + projectsData.forEach((project) => { + newProjectsMap.set(project.id, project); + }); + setProjectsMap(newProjectsMap); + } catch (error) { + console.error('프로젝트 정보 조회 실패:', error); + } + }; + + fetchProjectsInfo(); + }, [projectIds]); - // 🔥 컴포넌트 마운트 시 상태 조회 (디바운싱 적용) + // 컴포넌트 마운트 시 상태 조회 (디바운싱 적용) React.useEffect(() => { if (debouncedProjectIds.length > 0) { fetchAllImportStatus() } }, [debouncedProjectIds, fetchAllImportStatus]) - // 🔥 전체 통계 메모이제이션 - 리비전과 첨부파일 추가 + // 전체 통계 메모이제이션 - 리비전과 첨부파일 추가 const totalStats = React.useMemo(() => { const statuses = Array.from(importStatusMap.values()) return statuses.reduce((acc, status) => ({ @@ -251,12 +262,7 @@ export function ImportFromDOLCEButton({ }) }, [importStatusMap]) - // 🔥 주요 상태 메모이제이션 - const primaryImportStatus = React.useMemo(() => { - return importStatusMap.get(primaryProjectId) - }, [importStatusMap, primaryProjectId]) - - // 🔥 가져오기 실행 함수 최적화 + // 가져오기 실행 함수 최적화 const handleImport = React.useCallback(async () => { if (projectIds.length === 0) return @@ -268,8 +274,14 @@ export function ImportFromDOLCEButton({ setImportProgress(prev => Math.min(prev + 10, 85)) }, 500) - // 🔥 순차 처리로 서버 부하 방지 - const results = [] + // 순차 처리로 서버 부하 방지 + const results: Array<{ + success: boolean + newCount?: number + updatedCount?: number + skippedCount?: number + error?: string + }> = [] for (const projectId of projectIds) { try { const response = await fetch('/api/sync/import', { @@ -304,14 +316,14 @@ export function ImportFromDOLCEButton({ // 결과 집계 const totalResult = results.reduce((acc, result) => ({ - newCount: acc.newCount + (result.newCount || 0), - updatedCount: acc.updatedCount + (result.updatedCount || 0), - skippedCount: acc.skippedCount + (result.skippedCount || 0), + newCount: (acc.newCount || 0) + (result.newCount || 0), + updatedCount: (acc.updatedCount || 0) + (result.updatedCount || 0), + skippedCount: (acc.skippedCount || 0) + (result.skippedCount || 0), success: acc.success && result.success }), { - newCount: 0, - updatedCount: 0, - skippedCount: 0, + newCount: 0 as number, + updatedCount: 0 as number, + skippedCount: 0 as number, success: true }) @@ -341,7 +353,7 @@ export function ImportFromDOLCEButton({ ) } - // 🔥 캐시 무효화 + // 캐시 무효화 statusCache.clear() fetchAllImportStatus() onImportComplete?.() @@ -357,14 +369,14 @@ export function ImportFromDOLCEButton({ } }, [projectIds, fetchAllImportStatus, onImportComplete, t]) - // 🔥 전체 변경 사항 계산 + // 전체 변경 사항 계산 const totalChanges = React.useMemo(() => { return totalStats.newDocuments + totalStats.updatedDocuments + totalStats.newRevisions + totalStats.updatedRevisions + totalStats.newAttachments + totalStats.updatedAttachments }, [totalStats]) - // 🔥 상태 뱃지 메모이제이션 - 리비전과 첨부파일 포함 + // 상태 뱃지 메모이제이션 - 리비전과 첨부파일 포함 const statusBadge = React.useMemo(() => { if (loadingVendorProjects) { return <Badge variant="secondary">{t('dolceImport.status.loadingProjectInfo')}</Badge> @@ -399,16 +411,16 @@ export function ImportFromDOLCEButton({ ) }, [loadingVendorProjects, statusLoading, importStatusMap.size, totalStats.importEnabled, totalChanges, projectIds.length, t]) - // 🔥 가져오기 가능 여부 - 리비전과 첨부파일도 체크 + // 가져오기 가능 여부 - 리비전과 첨부파일도 체크 const canImport = totalStats.importEnabled && totalChanges > 0 - // 🔥 새로고침 핸들러 최적화 + // 새로고침 핸들러 최적화 const handleRefresh = React.useCallback(() => { statusCache.clear() // 캐시 무효화 fetchAllImportStatus() }, [fetchAllImportStatus]) - // 🔥 자동 동기화 실행 (기존 useEffect들 다음에 추가) + // 자동 동기화 실행 (기존 useEffect들 다음에 추가) React.useEffect(() => { // 조건: 가져오기 가능하고, 동기화할 항목이 있고, 현재 진행중이 아닐 때 if (canImport && totalChanges > 0 && !isImporting && !isDialogOpen) { @@ -496,7 +508,7 @@ export function ImportFromDOLCEButton({ <div className="text-muted-foreground">{t('dolceImport.labels.targetProjects')}</div> <div className="font-medium">{t('dolceImport.labels.projectCount', { count: projectIds.length })}</div> <div className="text-xs text-muted-foreground"> - {t('dolceImport.labels.projectIds')}: {projectIds.join(', ')} + {t('dolceImport.labels.projectIds')}: {projectIds.map(id => projectsMap.get(id)?.code || id).join(', ')} </div> </div> )} @@ -584,9 +596,11 @@ export function ImportFromDOLCEButton({ <div className="mt-2 space-y-2 pl-2 border-l-2 border-muted"> {projectIds.map(projectId => { const status = importStatusMap.get(projectId) + const projectInfo = projectsMap.get(projectId) + const projectLabel = projectInfo?.code || projectId return ( <div key={projectId} className="text-xs"> - <div className="font-medium">{t('dolceImport.labels.projectLabel', { projectId })}</div> + <div className="font-medium">{t('dolceImport.labels.projectLabel', { projectId: projectLabel })}</div> {status ? ( <div className="text-muted-foreground space-y-1"> <div> |
