summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-11-07 17:39:36 +0900
committerjoonhoekim <26rote@gmail.com>2025-11-07 17:39:36 +0900
commit1363913352722a03e051b15297f72bf16d80106f (patch)
tree1f4b1228ff171bda515deb95dcdde1f4484ced8e
parentba8cd44a0ed2c613a5f2cee06bfc9bd0f61f21c7 (diff)
(김준회) 돌체 업로드 MIME 타입 검증 문제 확장자로 처리
-rw-r--r--components/ship-vendor-document/add-attachment-dialog.tsx55
-rw-r--r--components/ship-vendor-document/new-revision-dialog.tsx51
-rw-r--r--lib/file-stroage.ts12
-rw-r--r--lib/vendor-document-list/enhanced-document-service.ts33
-rw-r--r--lib/vendor-document-list/service.ts36
-rw-r--r--lib/vendor-document-list/ship/bulk-b4-upload-dialog.tsx51
-rw-r--r--lib/vendor-document-list/ship/import-from-dolce-button.tsx126
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: &lt; &gt; : &quot; &apos; | ? *
</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: &lt; &gt; : &quot; &apos; | ? *
</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>