diff options
25 files changed, 1575 insertions, 716 deletions
diff --git a/app/[lng]/partners/(partners)/dolce-upload/dolce-upload-page.tsx b/app/[lng]/partners/(partners)/dolce-upload/dolce-upload-page.tsx index db8d528b..43800838 100644 --- a/app/[lng]/partners/(partners)/dolce-upload/dolce-upload-page.tsx +++ b/app/[lng]/partners/(partners)/dolce-upload/dolce-upload-page.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect, useCallback, useMemo } from "react"; +import { useParams } from "next/navigation"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Skeleton } from "@/components/ui/skeleton"; @@ -16,6 +17,7 @@ import { } from "@/components/ui/select"; import { InfoIcon, RefreshCw, Search, Upload } from "lucide-react"; import { toast } from "sonner"; +import { useTranslation } from "@/i18n/client"; import { UnifiedDwgReceiptItem, fetchDwgReceiptList, @@ -33,6 +35,10 @@ interface DolceUploadPageProps { } export default function DolceUploadPage({ searchParams }: DolceUploadPageProps) { + const params = useParams(); + const lng = params?.lng as string; + const { t } = useTranslation(lng, "dolce"); + // URL에서 초기 프로젝트 코드 const initialProjNo = (searchParams.projNo as string) || ""; @@ -78,7 +84,7 @@ export default function DolceUploadPage({ searchParams }: DolceUploadPageProps) fetchVendorProjects(), ]); - setVendorInfo(vendorInfoData); + setVendorInfo(vendorInfoData as typeof vendorInfo); setProjects(projectsData); // 초기 프로젝트가 있으면 도면 로드 @@ -92,12 +98,12 @@ export default function DolceUploadPage({ searchParams }: DolceUploadPageProps) } } catch (err) { console.error("초기 데이터 로드 실패:", err); - setError(err instanceof Error ? err.message : "데이터 로드 실패"); - toast.error("데이터 로드 실패"); + setError(err instanceof Error ? err.message : t("page.initialLoadError")); + toast.error(t("page.initialLoadError")); } finally { setIsLoading(false); } - }, [initialProjNo]); + }, [initialProjNo, t]); // 도면 목록 조회 const loadDrawings = useCallback(async () => { @@ -114,21 +120,28 @@ export default function DolceUploadPage({ searchParams }: DolceUploadPageProps) }); setDrawings(drawingsData); - toast.success("도면 목록을 갱신했습니다"); + toast.success(t("page.drawingLoadSuccess")); } catch (err) { console.error("도면 로드 실패:", err); - setError(err instanceof Error ? err.message : "도면 로드 실패"); - toast.error("도면 로드 실패"); + setError(err instanceof Error ? err.message : t("page.drawingLoadError")); + toast.error(t("page.drawingLoadError")); } finally { setIsRefreshing(false); } - }, [projNo, vendorInfo]); + }, [projNo, vendorInfo, t]); // 초기 데이터 로드 useEffect(() => { loadInitialData(); }, [loadInitialData]); + // 프로젝트 변경 시 자동 검색 + useEffect(() => { + if (projNo && vendorInfo) { + loadDrawings(); + } + }, [projNo, vendorInfo, loadDrawings]); + // 도면 클릭 핸들러 const handleDrawingClick = (drawing: UnifiedDwgReceiptItem) => { setSelectedDrawing(drawing); @@ -227,7 +240,7 @@ export default function DolceUploadPage({ searchParams }: DolceUploadPageProps) <Alert> <InfoIcon className="h-4 w-4" /> <AlertDescription> - 프로젝트를 선택하여 도면 목록을 조회하세요. + {t("page.selectProject")} </AlertDescription> </Alert> )} @@ -235,16 +248,16 @@ export default function DolceUploadPage({ searchParams }: DolceUploadPageProps) {/* 필터 카드 */} <Card> <CardHeader> - <CardTitle>검색 필터</CardTitle> + <CardTitle>{t("filter.title")}</CardTitle> </CardHeader> <CardContent> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> {/* 프로젝트 선택 */} <div className="space-y-2"> - <Label>프로젝트</Label> + <Label>{t("filter.project")}</Label> <Select value={projNo} onValueChange={setProjNo}> <SelectTrigger> - <SelectValue placeholder="프로젝트를 선택하세요" /> + <SelectValue placeholder={t("filter.projectPlaceholder")} /> </SelectTrigger> <SelectContent> {projects.map((project) => ( @@ -258,77 +271,69 @@ export default function DolceUploadPage({ searchParams }: DolceUploadPageProps) {/* 도면번호 검색 */} <div className="space-y-2"> - <Label>도면번호 (Drawing No)</Label> + <Label>{t("filter.drawingNo")}</Label> <Input value={drawingNo} onChange={(e) => setDrawingNo(e.target.value)} - placeholder="도면번호 입력" + placeholder={t("filter.drawingNoPlaceholder")} /> </div> {/* 도면명 검색 */} <div className="space-y-2"> - <Label>도면명 (Drawing Name)</Label> + <Label>{t("filter.drawingName")}</Label> <Input value={drawingName} onChange={(e) => setDrawingName(e.target.value)} - placeholder="도면명 입력" + placeholder={t("filter.drawingNamePlaceholder")} /> </div> {/* 설계공종 검색 */} <div className="space-y-2"> - <Label>설계공종 (Discipline)</Label> + <Label>{t("filter.discipline")}</Label> <Input value={discipline} onChange={(e) => setDiscipline(e.target.value)} - placeholder="설계공종 입력" + placeholder={t("filter.disciplinePlaceholder")} /> </div> {/* 담당자명 검색 (클라이언트 필터) */} <div className="space-y-2"> - <Label>담당자명 (Manager)</Label> + <Label>{t("filter.manager")}</Label> <Input value={manager} onChange={(e) => setManager(e.target.value)} - placeholder="담당자명 입력" + placeholder={t("filter.managerPlaceholder")} /> </div> {/* B4(GTT) 전용: Document Type 필터 */} {vendorInfo?.drawingKind === "B4" && ( <div className="space-y-2"> - <Label>Document Type</Label> + <Label>{t("filter.documentType")}</Label> <Select value={documentType} onValueChange={(value) => setDocumentType(value as DocumentType)}> <SelectTrigger> <SelectValue /> </SelectTrigger> <SelectContent> - <SelectItem value="ALL">ALL (전체)</SelectItem> - <SelectItem value="GTT_DELIVERABLES">GTT Deliverables (도면입수)</SelectItem> - <SelectItem value="SHI_INPUT">SHI Input Document (도면제출)</SelectItem> + <SelectItem value="ALL">{t("filter.documentTypeAll")}</SelectItem> + <SelectItem value="GTT_DELIVERABLES">{t("filter.documentTypeGttDeliverables")}</SelectItem> + <SelectItem value="SHI_INPUT">{t("filter.documentTypeSHIInput")}</SelectItem> </SelectContent> </Select> </div> )} </div> - <div className="flex gap-2 mt-4"> + <div className="flex gap-2 mt-4 justify-end"> <Button onClick={handleSearch} disabled={!projNo || isRefreshing} - className="flex-1" > <Search className="h-4 w-4 mr-2" /> - 검색 - </Button> - <Button - variant="outline" - onClick={handleRefresh} - disabled={!projNo || isRefreshing} - > - <RefreshCw className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} /> + {t("filter.searchButton")} </Button> {/* B4 벤더인 경우에만 일괄 업로드 버튼 표시 */} {vendorInfo?.drawingKind === "B4" && ( @@ -338,9 +343,16 @@ export default function DolceUploadPage({ searchParams }: DolceUploadPageProps) disabled={!projNo || isRefreshing} > <Upload className="h-4 w-4 mr-2" /> - 일괄 업로드 + {t("filter.bulkUploadButton")} </Button> )} + <Button + variant="outline" + onClick={handleRefresh} + disabled={!projNo || isRefreshing} + > + <RefreshCw className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} /> + </Button> </div> </CardContent> </Card> @@ -350,24 +362,20 @@ export default function DolceUploadPage({ searchParams }: DolceUploadPageProps) <Card> <CardHeader> <CardTitle> - 도면 리스트 - {filteredDrawings.length > 0 && ` (${filteredDrawings.length}건)`} + {t("drawingList.title")} + {filteredDrawings.length > 0 && ` ${t("drawingList.count", { count: filteredDrawings.length })}`} </CardTitle> </CardHeader> <CardContent className="overflow-x-auto"> - {vendorInfo.drawingKind === "B4" ? ( - <DrawingListTable - columns={createGttDrawingListColumns({ documentType })} - data={filteredDrawings} - onRowClick={handleDrawingClick} - /> - ) : ( - <DrawingListTable - columns={drawingListColumns} - data={filteredDrawings} - onRowClick={handleDrawingClick} - /> - )} + <DrawingListTable + columns={ + vendorInfo.drawingKind === "B4" + ? (createGttDrawingListColumns({ documentType, lng, t }) as unknown as typeof drawingListColumns) + : (drawingListColumns(lng, t) as unknown as typeof drawingListColumns) + } + data={filteredDrawings} + onRowClick={handleDrawingClick} + /> </CardContent> </Card> )} @@ -383,6 +391,7 @@ export default function DolceUploadPage({ searchParams }: DolceUploadPageProps) userName={vendorInfo.userName} userEmail={vendorInfo.email} drawingKind={vendorInfo.drawingKind} + lng={lng} /> )} @@ -397,6 +406,7 @@ export default function DolceUploadPage({ searchParams }: DolceUploadPageProps) userEmail={vendorInfo.email} vendorCode={vendorInfo.vendorCode} onUploadComplete={handleBulkUploadComplete} + lng={lng} /> )} </div> diff --git a/app/[lng]/partners/(partners)/dolce-upload/page.tsx b/app/[lng]/partners/(partners)/dolce-upload/page.tsx index d44e71b6..4d7b1a74 100644 --- a/app/[lng]/partners/(partners)/dolce-upload/page.tsx +++ b/app/[lng]/partners/(partners)/dolce-upload/page.tsx @@ -5,8 +5,8 @@ import DolceUploadPage from "./dolce-upload-page"; import { Shell } from "@/components/shell"; export const metadata = { - title: "DOLCE 업로드", - description: "설계문서 업로드 및 관리", + title: "조선 벤더문서 업로드(DOLCE)", + description: "조선 설계문서 업로드 및 관리", }; // ============================================================================ @@ -31,11 +31,14 @@ function DolceUploadSkeleton() { } export default async function DolceUploadPageWrapper({ + params, searchParams, }: { + params: Promise<{ lng: string }>; searchParams: Promise<{ [key: string]: string | string[] | undefined }>; }) { - const params = await searchParams; + const { lng } = await params; + const resolvedParams = await searchParams; return ( <Shell> @@ -43,17 +46,21 @@ export default async function DolceUploadPageWrapper({ <div className="flex items-center justify-between"> <div> <h2 className="text-2xl font-bold tracking-tight"> - DOLCE 문서 업로드 + {lng === "ko" + ? "DOLCE 도면 업로드" + : "DOLCE Drawing Upload"} </h2> <p className="text-muted-foreground"> - 설계문서를 조회하고 업로드할 수 있습니다 + {lng === "ko" + ? "설계문서를 조회하고 업로드할 수 있습니다" + : "View and upload design documents"} </p> </div> </div> {/* 메인 컨텐츠 */} <Suspense fallback={<DolceUploadSkeleton />}> - <DolceUploadPage searchParams={params} /> + <DolceUploadPage searchParams={resolvedParams} /> </Suspense> </Shell> ); diff --git a/app/api/dolce/upload-files/route.ts b/app/api/dolce/upload-files/route.ts index 1d302cb2..898f9b2a 100644 --- a/app/api/dolce/upload-files/route.ts +++ b/app/api/dolce/upload-files/route.ts @@ -1,114 +1,204 @@ import { NextRequest, NextResponse } from "next/server"; -import fs from "fs/promises"; -import { createReadStream } from "fs"; -import path from "path"; -import os from "os"; const DOLCE_API_URL = process.env.DOLCE_API_URL || "http://60.100.99.217:1111"; +// Next.js API Route 설정 +export const maxDuration = 3600; // 1시간 (대용량 파일 업로드 지원) +export const dynamic = "force-dynamic"; + /** - * 임시 파일 저장 및 정리 헬퍼 + * 파일을 DOLCE API로 업로드 */ -async function saveToTempFile(file: File): Promise<{ filepath: string; cleanup: () => Promise<void> }> { - const tempDir = os.tmpdir(); - const tempFilePath = path.join(tempDir, `upload-${Date.now()}-${file.name}`); - - const arrayBuffer = await file.arrayBuffer(); - await fs.writeFile(tempFilePath, Buffer.from(arrayBuffer)); - - return { - filepath: tempFilePath, - cleanup: async () => { - try { - await fs.unlink(tempFilePath); - } catch (error) { - console.error(`임시 파일 삭제 실패: ${tempFilePath}`, error); +async function uploadFileToDolce( + file: File, + uploadId: string, + fileId: string +): Promise<string> { + const uploadUrl = `${DOLCE_API_URL}/PWPUploadService.ashx?UploadId=${uploadId}&FileId=${fileId}`; + const startTime = Date.now(); + + console.log(`[Proxy] 파일 업로드 시작: ${file.name} (${(file.size / 1024 / 1024).toFixed(2)}MB)`); + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + controller.abort(); + }, 3600000); // 1시간 타임아웃 + + const uploadResponse = await fetch(uploadUrl, { + method: "POST", + headers: { + "Content-Type": "application/octet-stream", + "Content-Length": file.size.toString(), + }, + body: file, // File 객체를 바로 전송 (자동으로 스트리밍됨) + signal: controller.signal, + // @ts-expect-error - duplex is required for streaming uploads + duplex: "half", + }); + + clearTimeout(timeoutId); + + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + console.log(`[Proxy] DOLCE API 응답: ${file.name} (${elapsed}초, HTTP ${uploadResponse.status})`); + + if (!uploadResponse.ok) { + const errorText = await uploadResponse.text(); + console.error(`[Proxy] DOLCE API 실패 (${file.name}): HTTP ${uploadResponse.status}`, errorText); + throw new Error( + `파일 업로드 실패 (${file.name}): ${uploadResponse.status} ${uploadResponse.statusText}` + ); + } + + const fileRelativePath = await uploadResponse.text(); + + if (!fileRelativePath || fileRelativePath.trim() === "") { + console.error(`[Proxy] DOLCE API 빈 경로 반환 (${file.name})`); + throw new Error(`파일 업로드 실패: 빈 경로 반환 (${file.name})`); + } + + const totalElapsed = ((Date.now() - startTime) / 1000).toFixed(1); + const speed = (file.size / 1024 / 1024 / (Date.now() - startTime) * 1000).toFixed(2); + console.log(`[Proxy] 업로드 완료: ${file.name} (${totalElapsed}초, ${speed}MB/s) → ${fileRelativePath}`); + + return fileRelativePath; + } catch (error) { + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + + if (error instanceof Error && error.name === "AbortError") { + console.error(`[Proxy] 타임아웃 (${elapsed}초): ${file.name}`); + throw new Error(`업로드 타임아웃 (1시간 초과): ${file.name}`); + } + + console.error(`[Proxy] 업로드 에러 (${elapsed}초): ${file.name}`, error); + throw error; + } +} + +/** + * 기존 파일 목록 조회 + */ +async function getExistingFileSeq(uploadId: string): Promise<number> { + try { + const response = await fetch( + `${DOLCE_API_URL}/PorjectWebProxyService.ashx?service=FileInfoList`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ uploadId }), } - }, - }; + ); + + if (response.ok) { + const data = await response.json(); + const existingCount = data.FileInfoListResult?.length || 0; + console.log(`[Proxy] 기존 파일=${existingCount}개, 새 파일 시작 Seq=${existingCount + 1}`); + return existingCount + 1; + } else { + console.warn(`[Proxy] FileInfoList 조회 실패, startSeq=1로 시작`); + return 1; + } + } catch (error) { + console.warn(`[Proxy] FileInfoList 조회 에러:`, error); + return 1; + } } /** - * 스트리밍 방식으로 파일 업로드 - * Node.js ReadableStream을 Web ReadableStream으로 변환하여 fetch 사용 + * 업로드 완료 통지 (DB 저장) */ -async function uploadFileStream( - filepath: string, - uploadId: string, - fileId: string, - fileSize: number -): Promise<string> { - const uploadUrl = `${DOLCE_API_URL}/PWPUploadService.ashx?UploadId=${uploadId}&FileId=${fileId}`; - - // Node.js ReadableStream 생성 - const nodeStream = createReadStream(filepath, { - highWaterMark: 64 * 1024, // 64KB 청크로 읽기 - }); +async function notifyUploadComplete(uploadResults: Array<{ + FileId: string; + UploadId: string; + FileSeq: number; + FileName: string; + FileRelativePath: string; + FileSize: number; + FileCreateDT: string; + FileWriteDT: string; + OwnerUserId: string; +}>): Promise<void> { + console.log(`\n[Proxy] ========================================`); + console.log(`[Proxy] 업로드 완료 통지 시작 (DB 저장)`); + console.log(`[Proxy] PWPUploadResultService 호출: ${uploadResults.length}개 파일 메타데이터 전송`); + console.log(`[Proxy] 전송 데이터:`, JSON.stringify(uploadResults, null, 2)); + + const resultServiceUrl = `${DOLCE_API_URL}/PWPUploadResultService.ashx?`; - // Node.js Stream을 Web ReadableStream으로 변환 - const webStream = new ReadableStream({ - start(controller) { - nodeStream.on("data", (chunk: Buffer) => { - controller.enqueue(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength)); - }); - - nodeStream.on("end", () => { - controller.close(); - }); - - nodeStream.on("error", (error) => { - controller.error(error); - }); - }, - cancel() { - nodeStream.destroy(); - }, - }); + console.log(`[Proxy] 요청 URL: ${resultServiceUrl}`); - // 스트리밍 업로드 - const uploadResponse = await fetch(uploadUrl, { + const resultResponse = await fetch(resultServiceUrl, { method: "POST", - headers: { - "Content-Type": "application/octet-stream", - "Content-Length": fileSize.toString(), - }, - body: webStream as unknown as BodyInit, - // @ts-expect-error - duplex is required for streaming uploads with ReadableStream - duplex: "half", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(uploadResults), }); - - if (!uploadResponse.ok) { - throw new Error( - `파일 업로드 실패: ${uploadResponse.status} ${uploadResponse.statusText}` - ); + + console.log(`[Proxy] PWPUploadResultService HTTP 상태: ${resultResponse.status}`); + + if (!resultResponse.ok) { + const errorText = await resultResponse.text(); + console.error(`[Proxy] PWPUploadResultService 실패: HTTP ${resultResponse.status}`, errorText); + throw new Error(`업로드 완료 통지 실패: ${resultResponse.status}`); } - - const fileRelativePath = await uploadResponse.text(); - return fileRelativePath; + + const resultText = await resultResponse.text(); + console.log(`[Proxy] PWPUploadResultService 응답: "${resultText}"`); + + if (resultText !== "Success") { + console.error(`[Proxy] PWPUploadResultService 예상치 못한 응답: "${resultText}"`); + throw new Error(`업로드 완료 통지 실패: ${resultText}`); + } + + console.log(`[Proxy] ✅ DB 저장 완료!`); + console.log(`[Proxy] ========================================\n`); } /** * 상세도면 파일 업로드 API - * 스트리밍 처리로 메모리 효율적 업로드 + * + * 간단하고 효율적인 구현: + * - Next.js의 네이티브 formData API 사용 + * - File 객체를 바로 DOLCE API로 전송 (자동 스트리밍) + * - 복잡한 이벤트 핸들링 없음 */ export async function POST(request: NextRequest) { - const tempFiles: Array<{ filepath: string; cleanup: () => Promise<void> }> = []; - try { - // FormData 파싱 + console.log("[Proxy] 업로드 요청 수신"); + + // FormData 파싱 (Next.js 네이티브) const formData = await request.formData(); const uploadId = formData.get("uploadId") as string; const userId = formData.get("userId") as string; - const fileCount = parseInt(formData.get("fileCount") as string); - if (!uploadId || !userId || !fileCount) { + if (!uploadId || !userId) { return NextResponse.json( { success: false, error: "필수 파라미터가 누락되었습니다" }, { status: 400 } ); } + // 파일 수집 + const files: File[] = []; + for (const [, value] of formData.entries()) { + if (value instanceof File) { + files.push(value); + } + } + + if (files.length === 0) { + return NextResponse.json( + { success: false, error: "업로드된 파일이 없습니다" }, + { status: 400 } + ); + } + + console.log(`[Proxy] 총 ${files.length}개 파일 업로드 시작`); + + // 기존 파일 Seq 조회 + const startSeq = await getExistingFileSeq(uploadId); + + // 파일 업로드 결과 const uploadResults: Array<{ FileId: string; UploadId: string; @@ -121,52 +211,14 @@ export async function POST(request: NextRequest) { OwnerUserId: string; }> = []; - // 기존 파일 개수 조회 - const existingFilesResponse = await fetch( - `${DOLCE_API_URL}/PorjectWebProxyService.ashx?service=FileInfoList`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - uploadId: uploadId, - }), - } - ); - - if (!existingFilesResponse.ok) { - throw new Error("기존 파일 조회 실패"); - } - - const existingFilesData = await existingFilesResponse.json(); - const startSeq = (existingFilesData.FileInfoListResult?.length || 0) + 1; - - // 파일 수집 - const files: File[] = []; - for (let i = 0; i < fileCount; i++) { - const file = formData.get(`file_${i}`) as File; - if (file) { - files.push(file); - } - } - - // 각 파일을 임시 디렉터리에 저장 후 스트리밍 업로드 + // 순차 업로드 for (let i = 0; i < files.length; i++) { const file = files[i]; const fileId = crypto.randomUUID(); - // 임시 파일로 저장 (메모리 압박 감소) - const tempFile = await saveToTempFile(file); - tempFiles.push(tempFile); + console.log(`[Proxy] 파일 ${i + 1}/${files.length}: ${file.name} (${(file.size / 1024 / 1024).toFixed(2)}MB)`); - // 스트리밍 방식으로 DOLCE API에 업로드 - const fileRelativePath = await uploadFileStream( - tempFile.filepath, - uploadId, - fileId, - file.size - ); + const fileRelativePath = await uploadFileToDolce(file, uploadId, fileId); uploadResults.push({ FileId: fileId, @@ -179,45 +231,26 @@ export async function POST(request: NextRequest) { FileWriteDT: new Date().toISOString(), OwnerUserId: userId, }); - - // 처리 완료된 임시 파일 즉시 삭제 - await tempFile.cleanup(); } - // 업로드 완료 통지 - const resultServiceUrl = `${DOLCE_API_URL}/PWPUploadResultService.ashx?`; - - const resultResponse = await fetch(resultServiceUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(uploadResults), - }); - - if (!resultResponse.ok) { - throw new Error( - `업로드 완료 통지 실패: ${resultResponse.status} ${resultResponse.statusText}` - ); - } + console.log(`\n[Proxy] 모든 파일 업로드 완료, DB 저장 시작...`); + + // 업로드 완료 통지 (DB 저장) + await notifyUploadComplete(uploadResults); - const resultText = await resultResponse.text(); - if (resultText !== "Success") { - throw new Error(`업로드 완료 통지 실패: ${resultText}`); - } + console.log(`[Proxy] ✅ 전체 프로세스 완료: ${uploadResults.length}개 파일 업로드 및 DB 저장 성공`); return NextResponse.json({ success: true, uploadedCount: uploadResults.length, }); } catch (error) { - console.error("파일 업로드 실패:", error); + console.error("[Proxy] ❌ 업로드 실패:", error); - // 에러 발생 시 남아있는 임시 파일 모두 정리 - for (const tempFile of tempFiles) { - await tempFile.cleanup(); + if (error instanceof Error) { + console.error("[Proxy] 에러 스택:", error.stack); } - + return NextResponse.json( { success: false, diff --git a/app/api/revisions/max-serial-no/route.ts b/app/api/revisions/max-serial-no/route.ts index 0681b66d..6cdf18b6 100644 --- a/app/api/revisions/max-serial-no/route.ts +++ b/app/api/revisions/max-serial-no/route.ts @@ -18,8 +18,8 @@ export async function GET(request: NextRequest) { debugLog('1. Input documentId:', documentId) - if (!documentId) { - debugLog('2. documentId is missing, returning 400') + if (!documentId || documentId === 'undefined' || documentId === 'null') { + debugLog('2. documentId is missing or invalid, returning 400') return NextResponse.json( { error: 'documentId is required' }, { status: 400 } @@ -27,6 +27,15 @@ export async function GET(request: NextRequest) { } const parsedDocumentId = parseInt(documentId) + + if (isNaN(parsedDocumentId)) { + debugError('3. Invalid documentId - cannot parse to integer:', documentId) + return NextResponse.json( + { error: 'Invalid documentId format' }, + { status: 400 } + ) + } + debugLog('3. Parsed documentId:', parsedDocumentId) // 1. 내부 DB에서 최대 serialNo 조회 diff --git a/components/layout/Header.tsx b/components/layout/Header.tsx index b12c673c..b70749c7 100644 --- a/components/layout/Header.tsx +++ b/components/layout/Header.tsx @@ -53,7 +53,8 @@ const HIDDEN_MENU_SECTION_KEYS = [ "menu.vendor.procurement.title", ]; -const HIDDEN_ADDITIONAL_MENU_KEYS = [ +// partners 도메인에서만 숨길 추가 메뉴 키 목록 +const HIDDEN_ADDITIONAL_MENU_KEYS_PARTNERS = [ "menu.additional.system_settings", ]; @@ -72,15 +73,22 @@ const filterMenusByEnvironment = (sections: MenuSection[]): MenuSection[] => { /** * 환경변수에 따라 추가 메뉴 항목을 필터링하는 함수 + * @param items 필터링할 메뉴 항목 배열 + * @param isPartners partners 도메인 여부 */ -const filterAdditionalMenusByEnvironment = (items: MenuItem[]): MenuItem[] => { +const filterAdditionalMenusByEnvironment = (items: MenuItem[], isPartners: boolean): MenuItem[] => { const shouldHideMenus = process.env.NEXT_PUBLIC_HIDE_PARTNERS_MENU_BEFORE_OPEN === 'true'; if (!shouldHideMenus) { return items; } - return items.filter(item => !HIDDEN_ADDITIONAL_MENU_KEYS.includes(item.titleKey)); + // partners 도메인일 때만 system_settings 필터링 + if (isPartners) { + return items.filter(item => !HIDDEN_ADDITIONAL_MENU_KEYS_PARTNERS.includes(item.titleKey)); + } + + return items; }; export function Header() { @@ -168,9 +176,12 @@ export function Header() { const { main: originalMain, additional: originalAdditional, logoHref, brandNameKey, basePath } = getDomainConfig(pathname); + // partners 도메인 여부 확인 + const isPartners = pathname?.includes("/partners") ?? false; + // 1단계: 환경변수에 따른 메뉴 필터링 const envFilteredMain = filterMenusByEnvironment(originalMain); - const envFilteredAdditional = filterAdditionalMenusByEnvironment(originalAdditional); + const envFilteredAdditional = filterAdditionalMenusByEnvironment(originalAdditional, isPartners); // 2단계: 활성 메뉴만 필터링 (로딩 중이거나 에러 시에는 환경변수 필터링만 적용) const main = isLoading ? envFilteredMain : filterActiveMenus(envFilteredMain, activeMenus); diff --git a/components/ship-vendor-document/new-revision-dialog.tsx b/components/ship-vendor-document/new-revision-dialog.tsx index bdbb1bc6..39025fa4 100644 --- a/components/ship-vendor-document/new-revision-dialog.tsx +++ b/components/ship-vendor-document/new-revision-dialog.tsx @@ -248,6 +248,14 @@ export function NewRevisionDialog({ // Serial No 조회 const fetchNextSerialNo = React.useCallback(async () => { console.log('🔍 fetchNextSerialNo called with documentId:', documentId) + + // documentId 유효성 검사 + if (!documentId || documentId === undefined || documentId === null) { + console.warn('⚠️ Invalid documentId, using default serialNo: 1') + setNextSerialNo("1") + return + } + setIsLoadingSerialNo(true) try { const apiUrl = `/api/revisions/max-serial-no?documentId=${documentId}` @@ -267,11 +275,16 @@ export function NewRevisionDialog({ setNextSerialNo(serialNoString) console.log('🔍 nextSerialNo state updated') } else { - console.error('🔍 API call failed with status:', response.status) + const errorData = await response.json().catch(() => ({})) + console.error('🔍 API call failed with status:', response.status, errorData) + // API 실패 시 기본값 1 사용 + console.warn('⚠️ Using default serialNo: 1') + setNextSerialNo("1") } } catch (error) { console.error('❌ Failed to fetch serial no:', error) // 에러 시 기본값 1 사용 + console.warn('⚠️ Using default serialNo: 1 due to error') setNextSerialNo("1") } finally { setIsLoadingSerialNo(false) @@ -280,12 +293,15 @@ export function NewRevisionDialog({ // Dialog 열릴 때 Serial No 조회 React.useEffect(() => { - console.log('🎯 useEffect triggered - open:', open, 'documentId:', documentId) - if (open && documentId) { - console.log('🎯 Calling fetchNextSerialNo') - fetchNextSerialNo() - } else { - console.log('🎯 Conditions not met for fetchNextSerialNo') + console.log('🎯 useEffect triggered - open:', open, 'documentId:', documentId, 'type:', typeof documentId) + if (open) { + if (documentId && typeof documentId === 'number' && documentId > 0) { + console.log('🎯 Calling fetchNextSerialNo') + fetchNextSerialNo() + } else { + console.warn('🎯 Invalid documentId, using default serialNo: 1') + setNextSerialNo("1") + } } }, [open, documentId, fetchNextSerialNo]) @@ -475,17 +491,12 @@ export function NewRevisionDialog({ <DialogDescription className="text-sm space-y-1"> <div>Document: {documentTitle}</div> <div className="text-xs text-muted-foreground"> - Drawing Type: {drawingKind} | Serial No: {nextSerialNo} - {isLoadingSerialNo && ( + Drawing Type: {drawingKind} | Serial No: {isLoadingSerialNo ? ( <> - <Loader2 className="inline-block ml-2 h-3 w-3 animate-spin" /> + <Loader2 className="inline-block ml-1 h-3 w-3 animate-spin" /> <span className="ml-1">Loading...</span> </> - )} - {/* 디버그용 임시 표시 */} - <div className="mt-1 text-xs text-orange-600"> - Debug: nextSerialNo={nextSerialNo}, isLoading={isLoadingSerialNo} - </div> + ) : nextSerialNo} </div> </DialogDescription> )} diff --git a/components/ship-vendor-document/user-vendor-document-table-container.tsx b/components/ship-vendor-document/user-vendor-document-table-container.tsx index 0c3390d1..1670732a 100644 --- a/components/ship-vendor-document/user-vendor-document-table-container.tsx +++ b/components/ship-vendor-document/user-vendor-document-table-container.tsx @@ -833,7 +833,7 @@ function SubTables() { const handleDownloadFile = React.useCallback(async (attachment: AttachmentInfo) => { try { // 파일 경로 처리 - let downloadPath = attachment.filePath + const downloadPath = attachment.filePath // 공용 다운로드 함수 사용 (보안 검증, 파일 체크 모두 포함) const result = await downloadFile(downloadPath, attachment.fileName, { @@ -1240,14 +1240,16 @@ function SubTables() { </DialogContent> </Dialog> - <NewRevisionDialog - open={newRevisionDialogOpen} - onOpenChange={setNewRevisionDialogOpen} - documentId={selectedDocument.documentId} - documentTitle={selectedDocument.title} - drawingKind={selectedDocument.drawingKind || 'B4'} - onSuccess={handleRevisionUploadSuccess} - /> + {selectedDocument && ( + <NewRevisionDialog + open={newRevisionDialogOpen} + onOpenChange={setNewRevisionDialogOpen} + documentId={selectedDocument.documentId} + documentTitle={selectedDocument.title} + drawingKind={selectedDocument.drawingKind || 'B4'} + onSuccess={handleRevisionUploadSuccess} + /> + )} {/* ✅ 리비전 수정 다이얼로그 */} <EditRevisionDialog diff --git a/lib/compliance/approval-actions.ts b/lib/compliance/approval-actions.ts new file mode 100644 index 00000000..3cded178 --- /dev/null +++ b/lib/compliance/approval-actions.ts @@ -0,0 +1,179 @@ +/** + * RED FLAG 해소요청 결재 서버 액션 + * + * ✅ 베스트 프랙티스: + * - 'use server' 지시어 포함 (서버 액션) + * - UI에서 호출하는 진입점 함수들 + * - ApprovalSubmissionSaga를 사용하여 결재 프로세스 시작 + * - 템플릿 변수 준비 및 입력 검증 + * - 핸들러(Internal)에는 최소 데이터만 전달 + */ + +'use server'; + +import { ApprovalSubmissionSaga } from '@/lib/approval'; +import type { ApprovalResult } from '@/lib/approval/types'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { mapRedFlagResolutionToTemplateVariables, getPurchasingManagerEpId } from './approval-handlers'; +import { fetchContractsWithFlags, validateRedFlagResolutionRequest } from './red-flag-resolution'; +import db from '@/db/db'; +import { inArray } from 'drizzle-orm'; +import { complianceResponses } from '@/db/schema/compliance'; +import { revalidatePath } from 'next/cache'; +import { debugLog, debugError, debugSuccess } from '@/lib/debug-utils'; + +/** + * 결재를 거쳐 RED FLAG 해소요청을 상신하는 서버 액션 + * + * ✅ 사용법 (클라이언트 컴포넌트에서): + * ```typescript + * const result = await requestRedFlagResolutionWithApproval({ + * contractIds: [1, 2, 3], + * }); + * + * if (result.status === 'pending_approval') { + * toast.success(`결재가 상신되었습니다. (ID: ${result.approvalId})`); + * } + * ``` + */ +export async function requestRedFlagResolutionWithApproval(data: { + contractIds: number[]; +}): Promise<ApprovalResult> { + debugLog('[RedFlagResolutionApproval] RED FLAG 해소요청 결재 서버 액션 시작', { + contractCount: data.contractIds.length, + }); + + // 1. 입력 검증 + if (!data.contractIds || data.contractIds.length === 0) { + debugError('[RedFlagResolutionApproval] 계약서 ID 없음'); + throw new Error('RED FLAG 해소요청을 위한 계약서를 선택해주세요.'); + } + + const uniqueContractIds = Array.from(new Set(data.contractIds)); + + // 2. 세션 및 사용자 정보 확인 + const session = await getServerSession(authOptions); + if (!session?.user) { + debugError('[RedFlagResolutionApproval] 인증되지 않은 사용자'); + throw new Error('인증이 필요합니다.'); + } + + const currentUser = session.user; + if (!currentUser.epId) { + debugError('[RedFlagResolutionApproval] Knox EP ID 없음'); + throw new Error('Knox EP ID가 필요합니다.'); + } + + const currentUserId = Number(currentUser.id); + if (Number.isNaN(currentUserId)) { + debugError('[RedFlagResolutionApproval] 유효하지 않은 사용자 ID'); + throw new Error('유효한 사용자 정보가 필요합니다.'); + } + + // 3. 구매기획 담당자 EP ID 조회 + const purchasingManagerEpId = await getPurchasingManagerEpId(); + if (!purchasingManagerEpId || purchasingManagerEpId.trim() === '') { + debugError('[RedFlagResolutionApproval] 구매기획 담당자 EP ID 없음'); + throw new Error('구매기획 담당자의 EP ID가 설정되지 않았습니다. 준법서약 관리 페이지에서 레드플래그 담당자를 설정해주세요.'); + } + + const trimmedEpId = purchasingManagerEpId.trim(); + debugLog('[RedFlagResolutionApproval] 구매기획 담당자 EP ID', { epId: trimmedEpId }); + + // 4. 계약서 및 RED FLAG 확인 + const contractSummaries = await fetchContractsWithFlags(uniqueContractIds); + if (contractSummaries.length === 0) { + debugError('[RedFlagResolutionApproval] RED FLAG가 있는 계약서 없음'); + throw new Error('선택한 계약서에 RED FLAG가 존재하지 않습니다.'); + } + + const validContractIds = contractSummaries.map((contract) => contract.contractId); + debugLog('[RedFlagResolutionApproval] 처리할 계약서', { count: validContractIds.length, ids: validContractIds }); + + // 5. 중복 해소요청 방지 검증 + await validateRedFlagResolutionRequest(validContractIds, contractSummaries); + + // 6. 템플릿 변수 매핑 + debugLog('[RedFlagResolutionApproval] 템플릿 변수 매핑 시작'); + const requestedAt = new Date(); + const variables = await mapRedFlagResolutionToTemplateVariables(contractSummaries, { + requesterName: currentUser.name || currentUser.email || '요청자', + requestedAt, + }); + debugLog('[RedFlagResolutionApproval] 템플릿 변수 매핑 완료', { + variableKeys: Object.keys(variables), + }); + + // 7. 결재 제목 생성 + const title = buildApprovalTitle(contractSummaries); + + // 8. 결재 워크플로우 시작 (Saga 패턴) + debugLog('[RedFlagResolutionApproval] ApprovalSubmissionSaga 생성'); + const saga = new ApprovalSubmissionSaga( + // actionType: 핸들러를 찾을 때 사용할 키 + 'compliance_red_flag_resolution', + + // actionPayload: 결재 승인 후 핸들러에 전달될 데이터 (최소 데이터만) + { + contractIds: validContractIds, + requestedBy: currentUserId, + requestedAt: requestedAt.toISOString(), + }, + + // approvalConfig: 결재 상신 정보 (템플릿 포함) + { + title, + description: '컴플라이언스 Red Flag 해소요청', + templateName: '컴플라이언스 Red Flag 해소요청', + variables, + approvers: [trimmedEpId], + currentUser: { + id: currentUserId, + epId: currentUser.epId, + email: currentUser.email ?? undefined, + }, + } + ); + + debugLog('[RedFlagResolutionApproval] Saga 실행 시작'); + const result = await saga.execute(); + + debugSuccess('[RedFlagResolutionApproval] 결재 워크플로우 완료', { + approvalId: result.approvalId, + pendingActionId: result.pendingActionId, + status: result.status, + }); + + // 9. 결재 상신 성공 시 compliance_responses 업데이트 + if (result.status === 'pending_approval') { + await db + .update(complianceResponses) + .set({ + redFlagResolutionApprovalId: result.approvalId, + redFlagResolvedAt: null, + updatedAt: new Date(), + }) + .where(inArray(complianceResponses.basicContractId, validContractIds)); + + await revalidatePath('/evcp/basic-contract'); + await revalidatePath('/evcp/compliance'); + } + + return result; +} + +/** + * 결재 제목 생성 + */ +function buildApprovalTitle(contracts: Array<{ contractId: number; vendorName: string | null }>): string { + if (contracts.length === 0) return '컴플라이언스 Red Flag 해소요청'; + const firstVendor = contracts[0].vendorName ?? `계약 ${contracts[0].contractId}`; + + if (contracts.length === 1) { + return `Red Flag 해소요청 - ${firstVendor}`; + } + + return `Red Flag 해소요청 - ${firstVendor} 외 ${contracts.length - 1}건`; +} + diff --git a/lib/compliance/approval-handlers.ts b/lib/compliance/approval-handlers.ts index 05f92a28..11f95a3c 100644 --- a/lib/compliance/approval-handlers.ts +++ b/lib/compliance/approval-handlers.ts @@ -1,64 +1,169 @@ "use server" -import db from "@/db/db" -import { and, inArray, isNull } from "drizzle-orm" -import { complianceResponses } from "@/db/schema/compliance" -import { resolveRedFlag } from "./red-flag-resolution" +import { resolveRedFlag, type ContractSummary } from "./red-flag-resolution" import { revalidatePath } from "next/cache" - -interface RedFlagResolutionPayload { - contractIds: number[] -} +import { debugLog, debugError, debugSuccess } from "@/lib/debug-utils" +import { htmlListConverter, htmlTableConverter } from "@/lib/approval/template-utils" +import db from "@/db/db" +import { eq } from "drizzle-orm" +import { redFlagManagers } from "@/db/schema/compliance" +import { users } from "@/db/schema" /** - * 결재 승인 후 RED FLAG 해제를 처리하는 핸들러 + * RED FLAG 해소 결재 승인 핸들러 * - * approval-workflow에서 자동으로 호출됩니다. + * 결재 승인 후 자동으로 호출되어 RED FLAG를 해제합니다. + * + * @param payload - 결재 상신 시 저장한 actionPayload */ -export async function resolveRedFlagAfterApproval(payload: RedFlagResolutionPayload) { - if (!payload?.contractIds || payload.contractIds.length === 0) { - return { - success: false, - message: "처리할 계약서가 없습니다.", +export async function resolveRedFlagAfterApproval(payload: { + contractIds: number[] + requestedBy: number + requestedAt: string +}) { + debugLog("[RedFlagResolutionHandler] RED FLAG 해소 결재 승인 핸들러 시작", payload) + + try { + if (!payload?.contractIds || payload.contractIds.length === 0) { + debugError("[RedFlagResolutionHandler] 계약서 ID가 없습니다", payload) + return { + success: false, + message: "처리할 계약서가 없습니다.", + } } - } - const uniqueContractIds = Array.from(new Set(payload.contractIds)) + const uniqueContractIds = Array.from(new Set(payload.contractIds)) + debugLog("[RedFlagResolutionHandler] 처리할 계약서 수", { count: uniqueContractIds.length }) - // 이미 해제된 계약을 제외한 대상을 조회 - const targets = await db - .select({ - basicContractId: complianceResponses.basicContractId, - approvalId: complianceResponses.redFlagResolutionApprovalId, - }) - .from(complianceResponses) - .where( - and( - inArray(complianceResponses.basicContractId, uniqueContractIds), - isNull(complianceResponses.redFlagResolvedAt) - ) + // 각 계약서에 대해 RED FLAG 해소 처리 + // approvalId는 resolveRedFlag 내부에서 조회하므로 여기서는 전달하지 않음 + const results = await Promise.allSettled( + uniqueContractIds.map(async (contractId) => { + const result = await resolveRedFlag(contractId, { + revalidate: false, + }) + return { contractId, result } + }) ) - if (targets.length === 0) { + const successful = results.filter((r) => r.status === "fulfilled").length + const failed = results.filter((r) => r.status === "rejected").length + + debugLog("[RedFlagResolutionHandler] 처리 결과", { + total: uniqueContractIds.length, + successful, + failed, + }) + + if (failed > 0) { + const errors = results + .filter((r) => r.status === "rejected") + .map((r) => (r as PromiseRejectedResult).reason) + debugError("[RedFlagResolutionHandler] 일부 계약서 처리 실패", errors) + } + + await revalidatePath("/evcp/basic-contract") + await revalidatePath("/evcp/compliance") + + debugSuccess("[RedFlagResolutionHandler] RED FLAG 해소 완료", { + successful, + failed, + }) + return { success: true, - message: "해제 대상이 없습니다.", + message: `${successful}개 계약서의 RED FLAG가 해제되었습니다.`, + updated: successful, } + } catch (error) { + debugError("[RedFlagResolutionHandler] RED FLAG 해소 처리 중 오류 발생", error) + throw error } +} - for (const target of targets) { - await resolveRedFlag(target.basicContractId, { - approvalId: target.approvalId ?? undefined, - revalidate: false, +/** + * 구매기획 담당자 EP ID 조회 + */ +export async function getPurchasingManagerEpId(): Promise<string | null> { + const [manager] = await db + .select({ + purchasingManagerId: redFlagManagers.purchasingManagerId, }) + .from(redFlagManagers) + .orderBy(redFlagManagers.createdAt) + .limit(1) + + if (!manager?.purchasingManagerId) { + return null } - await revalidatePath("/evcp/basic-contract") - await revalidatePath("/evcp/compliance") + const [user] = await db + .select({ + epId: users.epId, + }) + .from(users) + .where(eq(users.id, manager.purchasingManagerId)) + .limit(1) + + return user?.epId ?? null +} + +/** + * RED FLAG 해소요청 데이터를 결재 템플릿 변수로 매핑 + * + * @param contracts - RED FLAG가 있는 계약서 목록 + * @param meta - 요청자 정보 + * @returns 템플릿 변수 객체 (Record<string, string>) + */ +export async function mapRedFlagResolutionToTemplateVariables( + contracts: ContractSummary[], + meta: { requesterName: string; requestedAt: Date } +): Promise<Record<string, string>> { + const summaryRows = contracts.map((contract) => ({ + contractId: contract.contractId, + vendorName: contract.vendorName ?? "-", + templateName: contract.templateName ?? "-", + redFlagCount: contract.triggeredFlags.length, + })) + + const summaryTable = await htmlTableConverter(summaryRows, [ + { key: "contractId", label: "계약 ID" }, + { key: "vendorName", label: "업체명" }, + { key: "templateName", label: "템플릿" }, + { key: "redFlagCount", label: "RED FLAG 수" }, + ]) + + const detailSections = await Promise.all( + contracts.map(async (contract) => { + const questionList = contract.triggeredFlags.map((flag, index) => { + const prefix = flag.questionNumber || `${index + 1}` + return `${prefix}. ${flag.questionText}` + }) + + const listHtml = await htmlListConverter(questionList) + return ` + <div style="margin-bottom: 24px;"> + <div style="font-weight:600;margin-bottom:8px;"> + 계약 ID: ${contract.contractId} / ${contract.vendorName ?? "-"} + </div> + <div>${listHtml}</div> + </div> + ` + }) + ) + + const detailHtml = detailSections.join("") + const formattedDate = new Intl.DateTimeFormat("ko-KR", { + dateStyle: "medium", + timeStyle: "short", + }).format(meta.requestedAt) return { - success: true, - updated: targets.length, + 요청자이름: meta.requesterName, + 요청일시: formattedDate, + 요청사유: "컴플라이언스 Red Flag 해소를 위해 구매기획 합의를 요청드립니다.", + RedFlag요약테이블: summaryTable, + RedFlag상세내역: detailHtml, } } diff --git a/lib/compliance/red-flag-resolution.ts b/lib/compliance/red-flag-resolution.ts index af69dbf4..47a805bb 100644 --- a/lib/compliance/red-flag-resolution.ts +++ b/lib/compliance/red-flag-resolution.ts @@ -2,19 +2,15 @@ import db from "@/db/db" import { and, eq, inArray } from "drizzle-orm" -import { complianceResponses, redFlagManagers } from "@/db/schema/compliance" +import { complianceResponses } from "@/db/schema/compliance" import { basicContract, basicContractTemplates } from "@/db/schema/basicContractDocumnet" import { vendors } from "@/db/schema/vendors" -import { users } from "@/db/schema" import { getTriggeredRedFlagQuestions, type TriggeredRedFlagInfo } from "./red-flag-notifier" -import { getServerSession } from "next-auth" -import { authOptions } from "@/app/api/auth/[...nextauth]/route" -import { ApprovalSubmissionSaga } from "@/lib/approval" -import { htmlListConverter, htmlTableConverter } from "@/lib/approval/template-utils" -import type { ApprovalResult } from "@/lib/approval/types" import { revalidatePath } from "next/cache" +import { requestRedFlagResolutionWithApproval } from "./approval-actions" +import type { ApprovalResult } from "@/lib/approval/types" -type ContractSummary = { +export type ContractSummary = { contractId: number vendorName: string | null vendorCode: string | null @@ -25,6 +21,9 @@ type ContractSummary = { /** * RED FLAG 해소요청 - Approval Saga를 통해 상신 + * + * @deprecated 이 함수는 호환성을 위해 유지됩니다. + * 새로운 코드는 `requestRedFlagResolutionWithApproval`을 사용하세요. */ export async function requestRedFlagResolution(contractIds: number[]): Promise<ApprovalResult> { if (!contractIds || contractIds.length === 0) { @@ -180,31 +179,10 @@ export async function resolveRedFlag( } } -async function getPurchasingManagerEpId(): Promise<string | null> { - const [manager] = await db - .select({ - purchasingManagerId: redFlagManagers.purchasingManagerId, - }) - .from(redFlagManagers) - .orderBy(redFlagManagers.createdAt) - .limit(1) - - if (!manager?.purchasingManagerId) { - return null - } - - const [user] = await db - .select({ - epId: users.epId, - }) - .from(users) - .where(eq(users.id, manager.purchasingManagerId)) - .limit(1) - - return user?.epId ?? null -} - -async function fetchContractsWithFlags(contractIds: number[]): Promise<ContractSummary[]> { +/** + * 계약서와 RED FLAG 정보를 함께 조회 + */ +export async function fetchContractsWithFlags(contractIds: number[]): Promise<ContractSummary[]> { const contracts = await db .select({ contractId: basicContract.id, diff --git a/lib/dolce/actions.ts b/lib/dolce/actions.ts index a9cda76a..77de430f 100644 --- a/lib/dolce/actions.ts +++ b/lib/dolce/actions.ts @@ -275,6 +275,10 @@ export async function fetchFileInfoList(uploadId: string): Promise<FileInfoItem[ /** * 4. 상세도면 추가/수정 + * + * 참고: DetailDwgReceiptMmgtEditResult는 실제 성공 건수를 정확히 반영하지 않음 + * (1개 추가되어도 0을 반환하는 경우 있음) + * API 호출이 성공하면 요청한 건수가 처리된 것으로 간주 */ export async function editDetailDwgReceipt(params: { dwgList: DetailDwgEditRequest[]; @@ -294,7 +298,15 @@ export async function editDetailDwgReceipt(params: { EMAIL: params.email, }); - return response.DetailDwgReceiptMmgtEditResult; + // 응답값이 신뢰할 수 없으므로 로그만 남김 + if (response.DetailDwgReceiptMmgtEditResult !== params.dwgList.length) { + console.warn( + `[DOLCE API] DetailDwgReceiptMmgtEditResult 불일치: 요청=${params.dwgList.length}, 응답=${response.DetailDwgReceiptMmgtEditResult}` + ); + } + + // API 호출 성공 시 요청한 건수 반환 (응답값 무시) + return params.dwgList.length; } catch (error) { console.error("상세도면 수정 실패:", error); throw error; diff --git a/lib/dolce/components/file-upload-progress-list.tsx b/lib/dolce/components/file-upload-progress-list.tsx index e016402d..7354c85b 100644 --- a/lib/dolce/components/file-upload-progress-list.tsx +++ b/lib/dolce/components/file-upload-progress-list.tsx @@ -18,7 +18,7 @@ export function FileUploadProgressList({ fileProgresses }: FileUploadProgressLis <h4 className="text-sm font-medium"> 파일 업로드 진행 상황 ({fileProgresses.length}개) </h4> - <div className="max-h-64 overflow-auto space-y-2"> + <div className="max-h-64 overflow-y-auto space-y-2"> {fileProgresses.map((fileProgress, index) => ( <FileUploadProgressItem key={index} fileProgress={fileProgress} /> ))} @@ -85,7 +85,16 @@ function FileUploadProgressItem({ fileProgress }: FileUploadProgressItemProps) { {/* Progress Bar */} {status === "uploading" && ( - <Progress value={progress} className="h-1.5" /> + <> + <Progress value={progress} className="h-1.5" /> + {/* 90% 이상일 때 추가 안내 메시지 */} + {progress >= 90 && progress < 100 && ( + <p className="text-xs text-muted-foreground flex items-center gap-1"> + <Loader2 className="h-3 w-3 animate-spin" /> + 서버에서 DOLCE API로 전송 중... + </p> + )} + </> )} {/* 에러 메시지 */} diff --git a/lib/dolce/dialogs/add-detail-drawing-dialog.tsx b/lib/dolce/dialogs/add-detail-drawing-dialog.tsx index 34d06368..48614ecf 100644 --- a/lib/dolce/dialogs/add-detail-drawing-dialog.tsx +++ b/lib/dolce/dialogs/add-detail-drawing-dialog.tsx @@ -26,6 +26,12 @@ import { v4 as uuidv4 } from "uuid"; import { useFileUploadWithProgress } from "../hooks/use-file-upload-with-progress"; import { uploadFilesWithProgress } from "../utils/upload-with-progress"; import { FileUploadProgressList } from "../components/file-upload-progress-list"; +import { + getB3DrawingUsageOptions, + getB3RegisterKindOptions, + getB4DrawingUsageOptions, + getB4RegisterKindOptions +} from "../utils/code-translator"; interface AddDetailDrawingDialogProps { open: boolean; @@ -36,38 +42,10 @@ interface AddDetailDrawingDialogProps { userName: string; userEmail: string; onComplete: () => void; - drawingKind: "B3" | "B4"; // 추가 + drawingKind: "B3" | "B4"; + lng?: string; // i18n support } -// B3 벤더의 선택 옵션 -const B3_DRAWING_USAGE_OPTIONS = [ - { value: "APP", label: "APPROVAL (승인용)" }, - { value: "WOR", label: "WORKING (작업용)" }, -]; - -const B3_REGISTER_KIND_OPTIONS: Record<string, Array<{ value: string; label: string; revisionRule: string }>> = { - APP: [ - { value: "APPR", label: "승인용 도면 (Full)", revisionRule: "예: A, B, C 또는 R00, R01, R02" }, - { value: "APPR-P", label: "승인용 도면 (Partial)", revisionRule: "예: A, B, C 또는 R00, R01, R02" }, - ], - WOR: [ - { value: "WORK", label: "작업용 입수도면 (Full)", revisionRule: "예: A, B, C 또는 R00, R01, R02" }, - { value: "WORK-P", label: "작업용 입수도면 (Partial)", revisionRule: "예: A, B, C 또는 R00, R01, R02" }, - ], -}; - -// B4 벤더(GTT)의 선택 옵션 -const B4_DRAWING_USAGE_OPTIONS = [ - { value: "REC", label: "RECEIVE (입수용)" }, -]; - -const B4_REGISTER_KIND_OPTIONS: Record<string, Array<{ value: string; label: string; revisionRule: string }>> = { - REC: [ - { value: "RECP", label: "Pre. 도면입수", revisionRule: "예: R00, R01, R02, R03" }, - { value: "RECW", label: "Working 도면입수", revisionRule: "예: R00, R01, R02, R03" }, - ], -}; - export function AddDetailDrawingDialog({ open, onOpenChange, @@ -78,12 +56,29 @@ export function AddDetailDrawingDialog({ userEmail, onComplete, drawingKind, + lng = "ko", }: AddDetailDrawingDialogProps) { const [drawingUsage, setDrawingUsage] = useState<string>(""); const [registerKind, setRegisterKind] = useState<string>(""); const [revision, setRevision] = useState<string>(""); + const [revisionError, setRevisionError] = useState<string>(""); const [isSubmitting, setIsSubmitting] = useState(false); + // 옵션 생성 (다국어 지원) + const drawingUsageOptions = drawingKind === "B3" + ? getB3DrawingUsageOptions(lng) + : getB4DrawingUsageOptions(lng); + + const registerKindOptions = drawingKind === "B3" + ? getB3RegisterKindOptions(drawingUsage, lng).map(opt => ({ + ...opt, + revisionRule: lng === "ko" ? "예: A, B, C 또는 R00, R01, R02" : "e.g. A, B, C or R00, R01, R02" + })) + : getB4RegisterKindOptions(drawingUsage, lng).map(opt => ({ + ...opt, + revisionRule: lng === "ko" ? "예: R00, R01, R02, R03" : "e.g. R00, R01, R02, R03" + })); + // 파일 업로드 훅 사용 (진행도 추적) const { fileProgresses, @@ -96,11 +91,47 @@ export function AddDetailDrawingDialog({ isDragActive, } = useFileUploadWithProgress(); + // Revision 유효성 검증 함수 + const validateRevision = (value: string): string => { + if (!value.trim()) { + return "Revision을 입력하세요"; + } + + const upperValue = value.toUpperCase().trim(); + + // A-Z 패턴 (단일 알파벳) + if (/^[A-Z]$/.test(upperValue)) { + return ""; + } + + // R00-R99 패턴 + if (/^R\d{2}$/.test(upperValue)) { + return ""; + } + + return "올바른 형식이 아닙니다 (A-Z 또는 R00-R99)"; + }; + + // Revision 입력 핸들러 + const handleRevisionChange = (value: string) => { + const processedValue = value.toUpperCase(); + setRevision(processedValue); + + // 값이 있을 때만 validation + if (processedValue.trim()) { + const error = validateRevision(processedValue); + setRevisionError(error); + } else { + setRevisionError(""); + } + }; + // 폼 초기화 const resetForm = () => { setDrawingUsage(""); setRegisterKind(""); setRevision(""); + setRevisionError(""); clearFiles(); }; @@ -119,8 +150,18 @@ export function AddDetailDrawingDialog({ } if (!revision.trim()) { toast.error("Revision을 입력하세요"); + setRevisionError("Revision을 입력하세요"); return; } + + // Revision 형식 검증 + const revisionValidationError = validateRevision(revision); + if (revisionValidationError) { + toast.error(revisionValidationError); + setRevisionError(revisionValidationError); + return; + } + if (files.length === 0) { toast.error("최소 1개 이상의 파일을 첨부해야 합니다"); return; @@ -222,19 +263,21 @@ export function AddDetailDrawingDialog({ const handleDrawingUsageChange = (value: string) => { setDrawingUsage(value); setRegisterKind(""); + setRevision(""); + setRevisionError(""); }; - // 현재 선택 가능한 DrawingUsage 및 RegisterKind 옵션 - const drawingUsageOptions = drawingKind === "B4" ? B4_DRAWING_USAGE_OPTIONS : B3_DRAWING_USAGE_OPTIONS; - const registerKindOptionsMap = drawingKind === "B4" ? B4_REGISTER_KIND_OPTIONS : B3_REGISTER_KIND_OPTIONS; - - const registerKindOptions = drawingUsage - ? registerKindOptionsMap[drawingUsage] || [] - : []; - // 선택된 RegisterKind의 Revision Rule const revisionRule = registerKindOptions.find((opt) => opt.value === registerKind)?.revisionRule || ""; + // 추가 버튼 활성화 조건 + const isFormValid = + drawingUsage.trim() !== "" && + registerKind.trim() !== "" && + revision.trim() !== "" && + !revisionError && + files.length > 0; + return ( <Dialog open={open} onOpenChange={onOpenChange}> <DialogContent className="max-w-2xl"> @@ -302,10 +345,21 @@ export function AddDetailDrawingDialog({ <Label>Revision</Label> <Input value={revision} - onChange={(e) => setRevision(e.target.value)} + onChange={(e) => handleRevisionChange(e.target.value)} placeholder="예: A, B, R00, R01" disabled={!registerKind} + className={revisionError ? "border-red-500 focus-visible:ring-red-500" : ""} /> + {revisionError && ( + <p className="text-sm text-red-500 flex items-center gap-1"> + {revisionError} + </p> + )} + {!revisionError && revision && ( + <p className="text-sm text-green-600 flex items-center gap-1"> + ✓ 올바른 형식입니다 + </p> + )} </div> {/* 파일 업로드 */} @@ -366,7 +420,7 @@ export function AddDetailDrawingDialog({ 전체 제거 </Button> </div> - <div className="max-h-48 overflow-auto space-y-2"> + <div className="max-h-60 overflow-y-auto space-y-2"> {files.map((file, index) => ( <div key={index} @@ -400,7 +454,7 @@ export function AddDetailDrawingDialog({ <Button variant="outline" onClick={handleCancel} disabled={isSubmitting}> 취소 </Button> - <Button onClick={handleSubmit} disabled={isSubmitting}> + <Button onClick={handleSubmit} disabled={isSubmitting || !isFormValid}> {isSubmitting ? "처리 중..." : "추가"} </Button> </DialogFooter> diff --git a/lib/dolce/dialogs/b4-bulk-upload-dialog.tsx b/lib/dolce/dialogs/b4-bulk-upload-dialog.tsx index f4816328..1be7f226 100644 --- a/lib/dolce/dialogs/b4-bulk-upload-dialog.tsx +++ b/lib/dolce/dialogs/b4-bulk-upload-dialog.tsx @@ -22,6 +22,7 @@ import { import { FolderOpen, Loader2, ChevronRight, ChevronLeft, CheckCircle2 } from "lucide-react"; import { toast } from "sonner"; import { Progress } from "@/components/ui/progress"; +import { useTranslation } from "@/i18n/client"; import { validateB4FileName, B4UploadValidationDialog, @@ -29,10 +30,11 @@ import { } from "./b4-upload-validation-dialog"; import { checkB4MappingStatus, - bulkUploadB4Files, + editDetailDwgReceipt, type MappingCheckItem, type B4BulkUploadResult, } from "../actions"; +import { v4 as uuidv4 } from "uuid"; interface B4BulkUploadDialogProps { open: boolean; @@ -43,20 +45,9 @@ interface B4BulkUploadDialogProps { userEmail: string; vendorCode: string; onUploadComplete?: () => void; + lng: string; } -// B4 GTT 옵션 -const B4_DRAWING_USAGE_OPTIONS = [ - { value: "REC", label: "RECEIVE (입수용)" }, -]; - -const B4_REGISTER_KIND_OPTIONS: Record<string, Array<{ value: string; label: string }>> = { - REC: [ - { value: "RECP", label: "Pre. 도면입수" }, - { value: "RECW", label: "Working 도면입수" }, - ], -}; - type UploadStep = "settings" | "files" | "validation" | "uploading" | "complete"; export function B4BulkUploadDialog({ @@ -68,7 +59,9 @@ export function B4BulkUploadDialog({ userEmail, vendorCode, onUploadComplete, + lng, }: B4BulkUploadDialogProps) { + const { t } = useTranslation(lng, "dolce"); const [currentStep, setCurrentStep] = useState<UploadStep>("settings"); const [drawingUsage, setDrawingUsage] = useState<string>("REC"); const [registerKind, setRegisterKind] = useState<string>(""); @@ -80,6 +73,18 @@ export function B4BulkUploadDialog({ const [uploadProgress, setUploadProgress] = useState(0); const [uploadResult, setUploadResult] = useState<B4BulkUploadResult | null>(null); + // B4 GTT 옵션 (코드 번역 유틸리티 사용) + const drawingUsageOptions = [ + { value: "REC", label: t("bulkUpload.drawingUsageReceive") }, + ]; + + const registerKindOptionsMap: Record<string, Array<{ value: string; label: string }>> = { + REC: [ + { value: "RECP", label: t("bulkUpload.registerKindRecP") }, + { value: "RECW", label: t("bulkUpload.registerKindRecW") }, + ], + }; + // 다이얼로그 닫을 때 초기화 React.useEffect(() => { if (!open) { @@ -104,12 +109,12 @@ export function B4BulkUploadDialog({ const newFiles = files.filter((f) => !existingNames.has(f.name)); if (newFiles.length === 0) { - toast.error("이미 선택된 파일입니다"); + toast.error(t("bulkUpload.duplicateFileError")); return; } setSelectedFiles((prev) => [...prev, ...newFiles]); - toast.success(`${newFiles.length}개 파일이 선택되었습니다`); + toast.success(t("bulkUpload.filesSelectedSuccess", { count: newFiles.length })); }; // Drag & Drop 핸들러 @@ -152,7 +157,7 @@ export function B4BulkUploadDialog({ // 1단계 완료 (설정) const handleSettingsNext = () => { if (!registerKind) { - toast.error("등록종류를 선택하세요"); + toast.error(t("bulkUpload.selectRegisterKindError")); return; } setCurrentStep("files"); @@ -161,7 +166,7 @@ export function B4BulkUploadDialog({ // 2단계 완료 (파일 선택) const handleFilesNext = () => { if (selectedFiles.length === 0) { - toast.error("파일을 선택해주세요"); + toast.error(t("bulkUpload.selectFilesError")); return; } setCurrentStep("validation"); @@ -219,7 +224,7 @@ export function B4BulkUploadDialog({ return { ...parseResult, mappingStatus: "not_found" as const, - error: "DOLCE 시스템에서 도면을 찾을 수 없습니다", + error: t("validation.notFound"), }; } @@ -228,7 +233,7 @@ export function B4BulkUploadDialog({ return { ...parseResult, mappingStatus: "not_found" as const, - error: "해당 도면번호가 프로젝트에 등록되어 있지 않습니다", + error: t("validation.notRegistered"), }; } @@ -237,7 +242,7 @@ export function B4BulkUploadDialog({ return { ...parseResult, mappingStatus: "not_found" as const, - error: "도면입수(GTT Deliverables)인 도면만 업로드 가능합니다", + error: t("validation.notGttDeliverables"), }; } @@ -255,7 +260,7 @@ export function B4BulkUploadDialog({ } catch (error) { console.error("검증 실패:", error); toast.error( - error instanceof Error ? error.message : "검증 중 오류가 발생했습니다" + error instanceof Error ? error.message : t("bulkUpload.validationError") ); } }; @@ -266,69 +271,146 @@ export function B4BulkUploadDialog({ setCurrentStep("uploading"); setShowValidationDialog(false); - // 진행률 시뮬레이션 - const progressInterval = setInterval(() => { - setUploadProgress((prev) => { - if (prev >= 90) { - clearInterval(progressInterval); - return prev; + try { + console.log(`[B4 일괄 업로드] 시작: ${validFiles.length}개 파일`); + + // 파일을 DrawingNo + RevNo로 그룹화 + const uploadGroups = new Map< + string, + Array<{ + file: File; + drawingNo: string; + revNo: string; + fileName: string; + registerGroupId: number; + }> + >(); + + validFiles.forEach((fileResult) => { + const groupKey = `${fileResult.parsed!.drawingNo}_${fileResult.parsed!.revNo}`; + if (!uploadGroups.has(groupKey)) { + uploadGroups.set(groupKey, []); } - return prev + 10; + uploadGroups.get(groupKey)!.push({ + file: fileResult.file, + drawingNo: fileResult.parsed!.drawingNo, + revNo: fileResult.parsed!.revNo, + fileName: fileResult.file.name, + registerGroupId: fileResult.registerGroupId || 0, + }); }); - }, 500); - try { - // FormData 생성 - const formData = new FormData(); - formData.append("projectNo", projectNo); - formData.append("userId", userId); - formData.append("userName", userName); - formData.append("userEmail", userEmail); - formData.append("vendorCode", vendorCode); - formData.append("registerKind", registerKind); // RegisterKind 추가 - - // 파일 및 메타데이터 추가 - validFiles.forEach((fileResult, index) => { - formData.append(`file_${index}`, fileResult.file); - formData.append(`drawingNo_${index}`, fileResult.parsed!.drawingNo); - formData.append(`revNo_${index}`, fileResult.parsed!.revNo); - formData.append(`fileName_${index}`, fileResult.file.name); - formData.append( - `registerGroupId_${index}`, - String(fileResult.registerGroupId || 0) - ); - }); + console.log(`[B4 일괄 업로드] ${uploadGroups.size}개 그룹으로 묶임`); + + let successCount = 0; + let failCount = 0; + let completedGroups = 0; + + // 각 그룹별로 순차 처리 + for (const [groupKey, files] of uploadGroups.entries()) { + const { drawingNo, revNo, registerGroupId } = files[0]; + + try { + console.log(`[B4 업로드] 그룹 ${groupKey}: ${files.length}개 파일`); + + // 1. UploadId 생성 + const uploadId = uuidv4(); + + // 2. 파일 업로드 (공통 API 사용) + const formData = new FormData(); + formData.append("uploadId", uploadId); + formData.append("userId", userId); + formData.append("fileCount", String(files.length)); + + files.forEach((fileInfo, index) => { + formData.append(`file_${index}`, fileInfo.file); + }); + + const uploadResponse = await fetch("/api/dolce/upload-files", { + method: "POST", + body: formData, + }); + + if (!uploadResponse.ok) { + throw new Error(`파일 업로드 실패: ${uploadResponse.status}`); + } + + const uploadResult = await uploadResponse.json(); + + if (!uploadResult.success) { + throw new Error(uploadResult.error || "파일 업로드 실패"); + } + + console.log(`[B4 업로드] 그룹 ${groupKey} 파일 업로드 완료`); + + // 3. 상세도면 등록 + await editDetailDwgReceipt({ + dwgList: [ + { + Mode: "ADD", + Status: "Draft", + RegisterId: 0, + ProjectNo: projectNo, + Discipline: "", + DrawingKind: "B4", + DrawingNo: drawingNo, + DrawingName: "", + RegisterGroupId: registerGroupId, + RegisterSerialNo: 0, + RegisterKind: registerKind, + DrawingRevNo: revNo, + Category: "TS", + Receiver: null, + Manager: "", + RegisterDesc: "", + UploadId: uploadId, + RegCompanyCode: vendorCode, + }, + ], + userId, + userNm: userName, + vendorCode, + email: userEmail, + }); + + console.log(`[B4 업로드] 그룹 ${groupKey} 상세도면 등록 완료`); + + successCount += files.length; + } catch (error) { + console.error(`[B4 업로드] 그룹 ${groupKey} 실패:`, error); + failCount += files.length; + } - formData.append("fileCount", String(validFiles.length)); + // 진행도 업데이트 + completedGroups++; + const progress = Math.round((completedGroups / uploadGroups.size) * 100); + setUploadProgress(progress); + } - // 서버 액션 호출 - const result: B4BulkUploadResult = await bulkUploadB4Files(formData); + console.log(`[B4 일괄 업로드] ✅ 완료: 성공 ${successCount}, 실패 ${failCount}`); - clearInterval(progressInterval); - setUploadProgress(100); - setUploadResult(result); + const result: B4BulkUploadResult = { + success: true, + successCount, + failCount, + }; - if (result.success) { - setCurrentStep("complete"); - toast.success( - `${result.successCount}/${validFiles.length}개 파일 업로드 완료` - ); - } else { - setCurrentStep("files"); - toast.error(result.error || "업로드 실패"); - } + setUploadResult(result); + setCurrentStep("complete"); + toast.success(t("bulkUpload.uploadSuccessToast", { successCount, total: validFiles.length })); } catch (error) { - console.error("업로드 실패:", error); + console.error("[B4 일괄 업로드] 실패:", error); toast.error( - error instanceof Error ? error.message : "업로드 중 오류가 발생했습니다" + error instanceof Error ? error.message : t("bulkUpload.uploadError") ); + setCurrentStep("files"); } finally { setIsUploading(false); } }; const registerKindOptions = drawingUsage - ? B4_REGISTER_KIND_OPTIONS[drawingUsage] || [] + ? registerKindOptionsMap[drawingUsage] || [] : []; const handleDrawingUsageChange = (value: string) => { @@ -341,11 +423,11 @@ export function B4BulkUploadDialog({ <Dialog open={open} onOpenChange={onOpenChange}> <DialogContent className="max-w-2xl"> <DialogHeader> - <DialogTitle>B4 일괄 업로드</DialogTitle> + <DialogTitle>{t("bulkUpload.title")}</DialogTitle> <DialogDescription> - {currentStep === "settings" && "업로드 설정을 선택하세요"} - {currentStep === "files" && "파일명 형식: [버림] [DrawingNo] [RevNo].[확장자] (예: testfile GTT-DE-007 R01.pdf)"} - {currentStep === "validation" && "파일 검증 중..."} + {currentStep === "settings" && t("bulkUpload.stepSettings")} + {currentStep === "files" && t("bulkUpload.stepFiles")} + {currentStep === "validation" && t("bulkUpload.stepValidation")} </DialogDescription> </DialogHeader> @@ -355,13 +437,13 @@ export function B4BulkUploadDialog({ <> {/* 도면용도 선택 */} <div className="space-y-2"> - <Label>도면용도 (Drawing Usage) *</Label> + <Label>{t("bulkUpload.drawingUsage")} *</Label> <Select value={drawingUsage} onValueChange={handleDrawingUsageChange}> <SelectTrigger> - <SelectValue placeholder="도면용도를 선택하세요" /> + <SelectValue placeholder={t("bulkUpload.drawingUsagePlaceholder")} /> </SelectTrigger> <SelectContent> - {B4_DRAWING_USAGE_OPTIONS.map((option) => ( + {drawingUsageOptions.map((option) => ( <SelectItem key={option.value} value={option.value}> {option.label} </SelectItem> @@ -372,14 +454,14 @@ export function B4BulkUploadDialog({ {/* 등록종류 선택 */} <div className="space-y-2"> - <Label>등록종류 (Register Kind) *</Label> + <Label>{t("bulkUpload.registerKind")} *</Label> <Select value={registerKind} onValueChange={setRegisterKind} disabled={!drawingUsage} > <SelectTrigger> - <SelectValue placeholder="등록종류를 선택하세요" /> + <SelectValue placeholder={t("bulkUpload.registerKindPlaceholder")} /> </SelectTrigger> <SelectContent> {registerKindOptions.map((option) => ( @@ -390,7 +472,7 @@ export function B4BulkUploadDialog({ </SelectContent> </Select> <p className="text-sm text-muted-foreground"> - 선택한 등록종류가 모든 파일에 적용됩니다 + {t("bulkUpload.registerKindNote")} </p> </div> </> @@ -436,11 +518,11 @@ export function B4BulkUploadDialog({ }`} > {isDragging - ? "파일을 여기에 놓으세요" - : "클릭하거나 파일을 드래그하여 선택"} + ? t("bulkUpload.fileDropHere") + : t("bulkUpload.fileSelectArea")} </p> <p className="text-xs text-muted-foreground mt-1"> - PDF, DOC, DOCX, XLS, XLSX, DWG, DXF, ZIP + {t("bulkUpload.fileTypes")} </p> </label> </div> @@ -450,17 +532,17 @@ export function B4BulkUploadDialog({ <div className="border rounded-lg p-4"> <div className="flex items-center justify-between mb-3"> <h4 className="text-sm font-medium"> - 선택된 파일 ({selectedFiles.length}개) + {t("bulkUpload.selectedFiles", { count: selectedFiles.length })} </h4> <Button variant="ghost" size="sm" onClick={() => setSelectedFiles([])} > - 전체 제거 + {t("bulkUpload.removeAll")} </Button> </div> - <div className="max-h-48 overflow-auto space-y-2"> + <div className="max-h-60 overflow-y-auto space-y-2"> {selectedFiles.map((file, index) => ( <div key={index} @@ -477,7 +559,7 @@ export function B4BulkUploadDialog({ size="sm" onClick={() => handleRemoveFile(index)} > - 제거 + {t("bulkUpload.removeFile")} </Button> </div> ))} @@ -492,7 +574,7 @@ export function B4BulkUploadDialog({ <div className="flex flex-col items-center justify-center py-12"> <Loader2 className="h-12 w-12 animate-spin text-primary mb-4" /> <p className="text-sm text-muted-foreground"> - 파일 검증 중입니다... + {t("bulkUpload.validating")} </p> </div> )} @@ -502,17 +584,23 @@ export function B4BulkUploadDialog({ <div className="space-y-6 py-8"> <div className="flex flex-col items-center"> <Loader2 className="h-12 w-12 animate-spin text-primary mb-4" /> - <h3 className="text-lg font-semibold mb-2">파일 업로드 중...</h3> + <h3 className="text-lg font-semibold mb-2">{t("bulkUpload.uploading")}</h3> <p className="text-sm text-muted-foreground"> - 잠시만 기다려주세요 + {t("bulkUpload.uploadingWait")} </p> </div> <div className="space-y-2"> <div className="flex justify-between text-sm"> - <span>진행률</span> + <span>{t("bulkUpload.uploadProgress")}</span> <span>{uploadProgress}%</span> </div> <Progress value={uploadProgress} className="h-2" /> + {/* 90% 이상일 때 추가 안내 메시지 */} + {uploadProgress >= 90 && uploadProgress < 100 && ( + <p className="text-xs text-muted-foreground text-center pt-2"> + {t("bulkUpload.uploadingToServer")} + </p> + )} </div> </div> )} @@ -522,16 +610,16 @@ export function B4BulkUploadDialog({ <div className="space-y-6 py-8"> <div className="flex flex-col items-center"> <CheckCircle2 className="h-16 w-16 text-green-500 mb-4" /> - <h3 className="text-lg font-semibold mb-2">업로드 완료!</h3> + <h3 className="text-lg font-semibold mb-2">{t("bulkUpload.uploadComplete")}</h3> <p className="text-sm text-muted-foreground"> - {uploadResult.successCount}개 파일이 성공적으로 업로드되었습니다 + {t("bulkUpload.uploadSuccessMessage", { count: uploadResult.successCount })} </p> </div> {uploadResult.failCount && uploadResult.failCount > 0 && ( <div className="bg-yellow-50 dark:bg-yellow-950/30 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4"> <p className="text-sm text-yellow-800 dark:text-yellow-200"> - {uploadResult.failCount}개 파일 업로드 실패 + {t("bulkUpload.uploadFailMessage", { count: uploadResult.failCount })} </p> </div> )} @@ -543,7 +631,7 @@ export function B4BulkUploadDialog({ onUploadComplete?.(); }} > - 확인 + {t("bulkUpload.confirmButton")} </Button> </div> </div> @@ -559,13 +647,13 @@ export function B4BulkUploadDialog({ variant="outline" onClick={() => onOpenChange(false)} > - 취소 + {t("bulkUpload.cancelButton")} </Button> <Button onClick={handleSettingsNext} disabled={!registerKind} > - 다음 + {t("bulkUpload.nextButton")} <ChevronRight className="ml-2 h-4 w-4" /> </Button> </> @@ -578,13 +666,13 @@ export function B4BulkUploadDialog({ onClick={() => setCurrentStep("settings")} > <ChevronLeft className="mr-2 h-4 w-4" /> - 이전 + {t("bulkUpload.previousButton")} </Button> <Button onClick={handleFilesNext} disabled={selectedFiles.length === 0} > - 검증 시작 + {t("bulkUpload.validateButton")} <ChevronRight className="ml-2 h-4 w-4" /> </Button> </> @@ -601,6 +689,7 @@ export function B4BulkUploadDialog({ validationResults={validationResults} onConfirmUpload={handleConfirmUpload} isUploading={isUploading} + lng={lng} /> </> ); diff --git a/lib/dolce/dialogs/b4-upload-validation-dialog.tsx b/lib/dolce/dialogs/b4-upload-validation-dialog.tsx index b274d604..f3a7c70a 100644 --- a/lib/dolce/dialogs/b4-upload-validation-dialog.tsx +++ b/lib/dolce/dialogs/b4-upload-validation-dialog.tsx @@ -39,8 +39,11 @@ interface B4UploadValidationDialogProps { /** * B4 파일명 검증 함수 - * 형식: [버림] [DrawingNo] [RevNo].[확장자] - * 예시: "testfile GTT-DE-007 R01.pdf" → DrawingNo: "GTT-DE-007", RevNo: "R01" + * 형식: [버림] [문서번호토큰1] [문서번호토큰2] ... [리비전번호].[확장자] + * 예시: "testfile GTT DE 007 R01.pdf" → DrawingNo: "GTT-DE-007", RevNo: "R01" + * - 첫 번째 토큰은 버림 + * - 마지막 토큰은 RevNo + * - 중간 토큰들을 "-"로 연결하여 DrawingNo 생성 */ export function validateB4FileName(fileName: string): { valid: boolean; @@ -57,23 +60,25 @@ export function validateB4FileName(fileName: string): { }; } - const extension = fileName.substring(lastDotIndex + 1); const nameWithoutExt = fileName.substring(0, lastDotIndex); // 공백으로 분리 const parts = nameWithoutExt.split(" ").filter(p => p.trim() !== ""); - // 최소 3개 파트 필요: [버림], DrawingNo, RevNo + // 최소 3개 파트 필요: [버림], [문서번호토큰], [RevNo] if (parts.length < 3) { return { valid: false, - error: `공백이 최소 2개 있어야 합니다 (현재: ${parts.length - 1}개). 형식: [버림] [DrawingNo] [RevNo].[확장자]`, + error: `공백이 최소 2개 있어야 합니다 (현재: ${parts.length - 1}개). 형식: [버림] [문서번호토큰들...] [RevNo].[확장자]`, }; } // 첫 번째 토큰은 버림 - const drawingNo = parts[1]; - const revNo = parts[2]; + // 마지막 토큰은 RevNo + // 중간 토큰들을 "-"로 연결하여 DrawingNo 생성 + const revNo = parts[parts.length - 1]; + const drawingTokens = parts.slice(1, parts.length - 1); + const drawingNo = drawingTokens.join("-"); // 필수 항목이 비어있지 않은지 확인 if (!drawingNo || drawingNo.trim() === "") { @@ -307,16 +312,19 @@ export function B4UploadValidationDialog({ 📋 올바른 파일명 형식 </div> <code className="text-xs text-blue-700 dark:text-blue-300"> - [버림] [DrawingNo] [RevNo].[확장자] + [버림] [문서번호토큰1] [문서번호토큰2] ... [RevNo].[확장자] </code> <div className="text-xs text-blue-600 dark:text-blue-400 mt-1"> - 예: testfile GTT-DE-007 R01.pdf + 예: testfile GTT DE 007 R01.pdf → DrawingNo: GTT-DE-007, Rev: R01 </div> <div className="text-xs text-blue-600 dark:text-blue-400 mt-1"> - ※ 첫 번째 단어는 무시되며, 공백으로 구분됩니다 + ※ 첫 번째 단어는 무시됩니다 </div> <div className="text-xs text-blue-600 dark:text-blue-400 mt-1"> - ※ 네 번째 이상의 단어가 있으면 무시됩니다 + ※ 마지막 단어는 리비전 번호(RevNo)입니다 + </div> + <div className="text-xs text-blue-600 dark:text-blue-400 mt-1"> + ※ 중간의 모든 단어는 "-"로 연결되어 문서번호(DrawingNo)가 됩니다 </div> </div> </div> diff --git a/lib/dolce/dialogs/detail-drawing-dialog.tsx b/lib/dolce/dialogs/detail-drawing-dialog.tsx index a06c9688..d9df58db 100644 --- a/lib/dolce/dialogs/detail-drawing-dialog.tsx +++ b/lib/dolce/dialogs/detail-drawing-dialog.tsx @@ -12,6 +12,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import { Plus, RefreshCw, Upload, Loader2 } from "lucide-react"; import { toast } from "sonner"; +import { useTranslation } from "@/i18n/client"; import { UnifiedDwgReceiptItem, DetailDwgReceiptItem, @@ -20,7 +21,7 @@ import { fetchFileInfoList, } from "../actions"; import { DrawingListTable } from "../table/drawing-list-table"; -import { detailDrawingColumns } from "../table/detail-drawing-columns"; +import { createDetailDrawingColumns } from "../table/detail-drawing-columns"; import { createFileListColumns } from "../table/file-list-columns"; import { AddDetailDrawingDialog } from "./add-detail-drawing-dialog"; import { UploadFilesToDetailDialog } from "./upload-files-to-detail-dialog"; @@ -34,6 +35,7 @@ interface DetailDrawingDialogProps { userName: string; userEmail: string; drawingKind: "B3" | "B4"; + lng: string; } export function DetailDrawingDialog({ @@ -45,7 +47,9 @@ export function DetailDrawingDialog({ userName, userEmail, drawingKind, + lng, }: DetailDrawingDialogProps) { + const { t } = useTranslation(lng, "dolce"); const [detailDrawings, setDetailDrawings] = useState<DetailDwgReceiptItem[]>([]); const [selectedDetail, setSelectedDetail] = useState<DetailDwgReceiptItem | null>(null); const [files, setFiles] = useState<FileInfoItem[]>([]); @@ -75,11 +79,11 @@ export function DetailDrawingDialog({ } } catch (error) { console.error("상세도면 로드 실패:", error); - toast.error("상세도면 로드에 실패했습니다"); + toast.error(t("detailDialog.detailLoadError")); } finally { setIsLoading(false); } - }, [drawing, selectedDetail]); + }, [drawing, selectedDetail, t]); // 파일 목록 로드 const loadFiles = useCallback(async () => { @@ -94,11 +98,11 @@ export function DetailDrawingDialog({ setFiles(data); } catch (error) { console.error("파일 목록 로드 실패:", error); - toast.error("파일 목록 로드에 실패했습니다"); + toast.error(t("detailDialog.fileLoadError")); } finally { setIsLoadingFiles(false); } - }, [selectedDetail]); + }, [selectedDetail, t]); // 다이얼로그 열릴 때 데이터 로드 useEffect(() => { @@ -120,7 +124,7 @@ export function DetailDrawingDialog({ const handleDownload = async (file: FileInfoItem) => { try { - toast.info("파일 다운로드를 준비 중입니다..."); + toast.info(t("detailDialog.downloadPreparing")); // 파일 생성자의 userId를 사용하여 다운로드 const response = await fetch("/api/dolce/download", { @@ -136,7 +140,7 @@ export function DetailDrawingDialog({ }); if (!response.ok) { - throw new Error("파일 다운로드 실패"); + throw new Error(t("detailDialog.downloadError")); } const blob = await response.blob(); @@ -149,10 +153,10 @@ export function DetailDrawingDialog({ window.URL.revokeObjectURL(url); document.body.removeChild(a); - toast.success("파일 다운로드가 완료되었습니다"); + toast.success(t("detailDialog.downloadSuccess")); } catch (error) { console.error("파일 다운로드 실패:", error); - toast.error("파일 다운로드에 실패했습니다"); + toast.error(t("detailDialog.downloadError")); } }; @@ -170,7 +174,7 @@ export function DetailDrawingDialog({ loadFiles(); }; - const fileColumns = createFileListColumns({ onDownload: handleDownload }); + const fileColumns = createFileListColumns({ onDownload: handleDownload, lng }); // RegisterId + UploadId 조합으로 고유 ID 생성 const getDetailDrawingId = (detail: DetailDwgReceiptItem) => { @@ -188,10 +192,15 @@ export function DetailDrawingDialog({ <DialogContent className="max-w-[95vw] h-[90vh] flex flex-col"> <DialogHeader> <DialogTitle className="flex flex-col gap-1"> - <span>상세도면 정보</span> + <span>{t("detailDialog.title")}</span> {drawing && ( <span className="text-sm font-normal text-muted-foreground"> - {drawing.DrawingNo} | 프로젝트: {drawing.ProjectNo} | Discipline: {drawing.Discipline} | 종류: {drawing.DrawingKind} + {t("detailDialog.subtitle", { + drawingNo: drawing.DrawingNo, + projectNo: drawing.ProjectNo, + discipline: drawing.Discipline, + drawingKind: drawing.DrawingKind + })} </span> )} </DialogTitle> @@ -201,7 +210,7 @@ export function DetailDrawingDialog({ {/* 상단: 상세도면 리스트 */} <Card className="flex-1 overflow-hidden flex flex-col"> <CardHeader className="flex-row items-center justify-between py-3"> - <CardTitle className="text-base">상세도면 목록</CardTitle> + <CardTitle className="text-base">{t("detailDialog.detailListTitle")}</CardTitle> <div className="flex gap-2"> <Button variant="outline" @@ -210,7 +219,7 @@ export function DetailDrawingDialog({ disabled={isLoading} > <RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? "animate-spin" : ""}`} /> - 새로고침 + {t("detailDialog.refreshButton")} </Button> {canAddDetailDrawing && ( <Button @@ -219,14 +228,14 @@ export function DetailDrawingDialog({ onClick={() => setAddDialogOpen(true)} > <Plus className="h-4 w-4 mr-2" /> - 상세도면 추가 + {t("detailDialog.addDetailButton")} </Button> )} </div> </CardHeader> <CardContent className="flex-1 overflow-y-auto p-4"> <DrawingListTable<DetailDwgReceiptItem, unknown> - columns={detailDrawingColumns} + columns={createDetailDrawingColumns(lng, t)} data={detailDrawings} onRowClick={setSelectedDetail} selectedRow={selectedDetail || undefined} @@ -239,8 +248,8 @@ export function DetailDrawingDialog({ <Card className="flex-1 overflow-hidden flex flex-col"> <CardHeader className="flex-row items-center justify-between py-3"> <CardTitle className="text-base"> - 첨부파일 목록 - {selectedDetail && ` - Rev. ${selectedDetail.DrawingRevNo}`} + {t("detailDialog.fileListTitle")} + {selectedDetail && t("detailDialog.fileListSubtitle", { revNo: selectedDetail.DrawingRevNo })} </CardTitle> {selectedDetail && canAddDetailDrawing && ( <Button @@ -249,20 +258,20 @@ export function DetailDrawingDialog({ onClick={() => setUploadFilesDialogOpen(true)} > <Upload className="h-4 w-4 mr-2" /> - 파일 업로드 + {t("detailDialog.uploadFilesButton")} </Button> )} </CardHeader> <CardContent className="flex-1 overflow-y-auto p-4"> {!selectedDetail ? ( <div className="h-full flex items-center justify-center text-muted-foreground"> - 상세도면을 선택하세요 + {t("detailDialog.selectDetailDrawing")} </div> ) : isLoadingFiles ? ( <div className="space-y-4"> <div className="flex items-center justify-center gap-2 text-muted-foreground py-8"> <Loader2 className="h-5 w-5 animate-spin" /> - <span>Loading files...</span> + <span>{t("detailDialog.loadingFiles")}</span> </div> <div className="space-y-2"> <Skeleton className="h-10 w-full" /> @@ -292,6 +301,7 @@ export function DetailDrawingDialog({ userEmail={userEmail} onComplete={handleAddComplete} drawingKind={drawingKind} + lng={lng} /> {selectedDetail && ( @@ -303,6 +313,7 @@ export function DetailDrawingDialog({ revNo={selectedDetail.DrawingRevNo} userId={userId} onUploadComplete={handleUploadComplete} + lng={lng} /> )} </> diff --git a/lib/dolce/dialogs/upload-files-to-detail-dialog.tsx b/lib/dolce/dialogs/upload-files-to-detail-dialog.tsx index af73aea6..09f68614 100644 --- a/lib/dolce/dialogs/upload-files-to-detail-dialog.tsx +++ b/lib/dolce/dialogs/upload-files-to-detail-dialog.tsx @@ -26,6 +26,7 @@ interface UploadFilesToDetailDialogProps { revNo: string; userId: string; onUploadComplete?: () => void; + lng?: string; // i18n support } export function UploadFilesToDetailDialog({ @@ -181,7 +182,7 @@ export function UploadFilesToDetailDialog({ 전체 제거 </Button> </div> - <div className="max-h-48 overflow-auto space-y-2"> + <div className="max-h-60 overflow-y-auto space-y-2"> {selectedFiles.map((file, index) => ( <div key={index} diff --git a/lib/dolce/hooks/use-file-upload.ts b/lib/dolce/hooks/use-file-upload.ts deleted file mode 100644 index 38556cb9..00000000 --- a/lib/dolce/hooks/use-file-upload.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { useState, useCallback } from "react"; -import { useDropzone, FileRejection } from "react-dropzone"; -import { toast } from "sonner"; - -interface UseFileUploadOptions { - onFilesAdded?: (files: File[]) => void; -} - -export function useFileUpload(options: UseFileUploadOptions = {}) { - const [files, setFiles] = useState<File[]>([]); - - // 파일 검증 - const validateFiles = useCallback((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 validFiles: File[] = []; - const invalidFiles: string[] = []; - - filesToValidate.forEach((file) => { - // 크기 검증 - if (file.size > MAX_FILE_SIZE) { - invalidFiles.push(`${file.name}: 파일 크기가 1GB를 초과합니다`); - return; - } - - // 확장자 검증 (블랙리스트) - const extension = file.name.split('.').pop()?.toLowerCase(); - if (extension && FORBIDDEN_EXTENSIONS.includes(extension)) { - invalidFiles.push(`${file.name}: 금지된 파일 형식입니다 (.${extension})`); - return; - } - - validFiles.push(file); - }); - - return { valid: validFiles, invalid: invalidFiles }; - }, []); - - // 파일 드롭 핸들러 - const onDrop = useCallback((acceptedFiles: File[], rejectedFiles: FileRejection[]) => { - const { valid: validFiles, invalid: invalidMessages } = validateFiles(acceptedFiles); - - // 거부된 파일 처리 - if (rejectedFiles.length > 0) { - rejectedFiles.forEach((rejected) => { - const errorMsg = rejected.errors?.[0]?.message || "파일이 거부되었습니다"; - toast.error(`${rejected.file.name}: ${errorMsg}`); - }); - } - - // 유효하지 않은 파일 메시지 표시 - if (invalidMessages.length > 0) { - invalidMessages.forEach((msg) => toast.error(msg)); - } - - if (validFiles.length > 0) { - // 중복 제거 - const existingNames = new Set(files.map((f) => f.name)); - const newFiles = validFiles.filter((f) => !existingNames.has(f.name)); - - if (newFiles.length === 0) { - toast.error("이미 선택된 파일입니다"); - return; - } - - setFiles((prev) => { - const updated = [...prev, ...newFiles]; - options.onFilesAdded?.(updated); - return updated; - }); - toast.success(`${newFiles.length}개 파일이 선택되었습니다`); - } - }, [files, validateFiles, options]); - - const { getRootProps, getInputProps, isDragActive } = useDropzone({ - onDrop, - multiple: true, - maxSize: 1024 * 1024 * 1024, // 1GB - }); - - // 파일 제거 - const removeFile = useCallback((index: number) => { - setFiles((prev) => prev.filter((_, i) => i !== index)); - }, []); - - // 전체 파일 제거 - const clearFiles = useCallback(() => { - setFiles([]); - }, []); - - // 파일 배열 직접 설정 - const setFileList = useCallback((newFiles: File[]) => { - setFiles(newFiles); - }, []); - - return { - files, - setFiles: setFileList, - removeFile, - clearFiles, - getRootProps, - getInputProps, - isDragActive, - }; -} - diff --git a/lib/dolce/table/detail-drawing-columns.tsx b/lib/dolce/table/detail-drawing-columns.tsx index 7f519179..77d25953 100644 --- a/lib/dolce/table/detail-drawing-columns.tsx +++ b/lib/dolce/table/detail-drawing-columns.tsx @@ -2,7 +2,41 @@ import { ColumnDef } from "@tanstack/react-table"; import { DetailDwgReceiptItem } from "../actions"; +import { formatDolceDateTime } from "../utils/date-formatter"; +// DOLCE API ENM 필드가 제대로 번역되지 않아 직접 매핑 +const DRAWING_USAGE_MAP: Record<string, { ko: string; en: string }> = { + APP: { ko: "승인용", en: "Approval" }, + WOR: { ko: "작업용", en: "Working" }, + REC: { ko: "입수용 / GTT→SHI", en: "GTT→SHI" }, + SUB: { ko: "제출용 / SHI→GTT", en: "SHI→GTT" }, +}; + +const REGISTER_KIND_MAP: Record<string, { ko: string; en: string }> = { + APPR: { ko: "승인 제출용 도면(Full)", en: "For Approval(Full)" }, + APPP: { ko: "승인 제출용 도면(Partial)", en: "For Approval(Partial)" }, + WORK: { ko: "작업용 입수도면(Full)", en: "For Working(Full)" }, + WORP: { ko: "작업용 입수도면(Partial)", en: "For Working(Partial)" }, + RECW: { ko: "Working 도면입수(GTT→SHI)", en: "Working Dwg(GTT→SHI)" }, + RECP: { ko: "Pre. 도면입수(GTT→SHI)", en: "Pre. Dwg(GTT→SHI)" }, +}; + +// 카테고리는 API에서 ENM이 제공되는 것으로 가정 (필요시 추가) +const translateDrawingUsage = (code: string | null, lng: string): string => { + if (!code) return ""; + const mapped = DRAWING_USAGE_MAP[code]; + if (!mapped) return code; + return lng === "en" ? mapped.en : mapped.ko; +}; + +const translateRegisterKind = (code: string | null, lng: string): string => { + if (!code) return ""; + const mapped = REGISTER_KIND_MAP[code]; + if (!mapped) return code; + return lng === "en" ? mapped.en : mapped.ko; +}; + +// 기본 컬럼 (기존 호환성 유지) export const detailDrawingColumns: ColumnDef<DetailDwgReceiptItem>[] = [ { accessorKey: "RegisterSerialNo", @@ -78,3 +112,88 @@ export const detailDrawingColumns: ColumnDef<DetailDwgReceiptItem>[] = [ }, ]; +// 다국어 지원 컬럼 생성 함수 +export function createDetailDrawingColumns( + lng: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + t: any +): ColumnDef<DetailDwgReceiptItem>[] { + return [ + { + accessorKey: "RegisterSerialNo", + header: t("detailDrawing.columns.serialNo"), + minSize: 80, + cell: ({ row }) => { + return <div className="text-center">{row.getValue("RegisterSerialNo")}</div>; + }, + }, + { + accessorKey: "DrawingRevNo", + header: t("detailDrawing.columns.revNo"), + minSize: 100, + cell: ({ row }) => { + return <div className="font-medium">{row.getValue("DrawingRevNo")}</div>; + }, + }, + { + accessorKey: "Status", + header: t("detailDrawing.columns.status"), + minSize: 120, + cell: ({ row }) => { + return <div className="text-center">{row.getValue("Status")}</div>; + }, + }, + { + accessorKey: "CategoryENM", + header: t("detailDrawing.columns.category"), + minSize: 120, + cell: ({ row }) => { + const categoryENM = row.getValue("CategoryENM") as string; + const categoryNM = row.original.CategoryNM; + // 영어인 경우 ENM, 한국어인 경우 NM 사용 + return <div>{lng === "en" ? (categoryENM || categoryNM) : (categoryNM || categoryENM)}</div>; + }, + }, + { + accessorKey: "DrawingUsageENM", + header: t("detailDrawing.columns.drawingUsage"), + minSize: 100, + cell: ({ row }) => { + // API의 ENM이 제대로 번역되지 않아 코드 값으로 직접 변환 + const usageCode = row.getValue("DrawingUsageENM") as string | null; + const translated = translateDrawingUsage(usageCode, lng); + return <div>{translated || usageCode || row.original.DrawingUsageNM}</div>; + }, + }, + { + accessorKey: "RegisterKindENM", + header: t("detailDrawing.columns.registerKind"), + minSize: 180, + cell: ({ row }) => { + // API의 ENM이 제대로 번역되지 않아 코드 값으로 직접 변환 + const kindCode = row.getValue("RegisterKindENM") as string | null; + const translated = translateRegisterKind(kindCode, lng); + return <div>{translated || kindCode || row.original.RegisterKindNM}</div>; + }, + }, + { + accessorKey: "CreateUserNM", + header: t("detailDrawing.columns.createdBy"), + minSize: 150, + cell: ({ row }) => { + const userENM = row.original.CreateUserENM; + const userNM = row.getValue("CreateUserNM") as string; + return <div>{lng === "en" ? (userENM || userNM) : (userNM || userENM)}</div>; + }, + }, + { + accessorKey: "CreateDt", + header: t("detailDrawing.columns.createdAt"), + minSize: 200, + cell: ({ row }) => { + const date = row.getValue("CreateDt") as string; + return <div className="text-sm text-muted-foreground">{formatDolceDateTime(date)}</div>; + }, + }, + ]; +} diff --git a/lib/dolce/table/drawing-list-columns.tsx b/lib/dolce/table/drawing-list-columns.tsx index 0e18266d..58631084 100644 --- a/lib/dolce/table/drawing-list-columns.tsx +++ b/lib/dolce/table/drawing-list-columns.tsx @@ -2,86 +2,86 @@ import { ColumnDef } from "@tanstack/react-table"; import { DwgReceiptItem } from "../actions"; +import { formatDolceDateYYYYMMDD, formatDolceDateTime } from "../utils/date-formatter"; -export const drawingListColumns: ColumnDef<DwgReceiptItem>[] = [ - { - accessorKey: "DrawingNo", - header: "도면번호", - minSize: 200, - cell: ({ row }) => { - return <div className="font-medium">{row.getValue("DrawingNo")}</div>; +export function drawingListColumns(lng: string, t: any): ColumnDef<DwgReceiptItem>[] { + return [ + { + accessorKey: "DrawingNo", + header: t("drawingList.columns.drawingNo"), + minSize: 200, + cell: ({ row }) => { + return <div className="font-medium">{row.getValue("DrawingNo")}</div>; + }, }, - }, - { - accessorKey: "DrawingName", - header: "도면명", - minSize: 400, - cell: ({ row }) => { - return <div>{row.getValue("DrawingName")}</div>; + { + accessorKey: "DrawingName", + header: t("drawingList.columns.drawingName"), + minSize: 600, + cell: ({ row }) => { + return <div>{row.getValue("DrawingName")}</div>; + }, }, - }, - { - accessorKey: "Discipline", - header: "설계공종", - minSize: 80, - }, - { - accessorKey: "Manager", - header: "담당자명", - minSize: 200, - cell: ({ row }) => { - const managerENM = row.original.ManagerENM; - const manager = row.getValue("Manager"); - return <div>{managerENM || manager}</div>; + { + accessorKey: "Discipline", + header: t("drawingList.columns.discipline"), + minSize: 80, }, - }, - { - accessorKey: "AppDwg_PlanDate", - header: "승인도면 예정일", - minSize: 140, - cell: ({ row }) => { - const date = row.getValue("AppDwg_PlanDate") as string; - if (!date || date.length !== 8) return null; - return `${date.substring(0, 4)}-${date.substring(4, 6)}-${date.substring(6, 8)}`; + { + accessorKey: "Manager", + header: t("drawingList.columns.manager"), + minSize: 200, + cell: ({ row }) => { + const managerENM = row.original.ManagerENM; + const manager = row.getValue("Manager"); + return <div>{managerENM || manager}</div>; + }, }, - }, - { - accessorKey: "AppDwg_ResultDate", - header: "승인도면 결과일", - minSize: 140, - cell: ({ row }) => { - const date = row.getValue("AppDwg_ResultDate") as string; - if (!date || date.length !== 8) return null; - return `${date.substring(0, 4)}-${date.substring(4, 6)}-${date.substring(6, 8)}`; + { + accessorKey: "AppDwg_PlanDate", + header: t("drawingList.columns.appDwgPlanDate"), + minSize: 140, + cell: ({ row }) => { + const date = row.getValue("AppDwg_PlanDate") as string; + return formatDolceDateYYYYMMDD(date); + }, }, - }, - { - accessorKey: "WorDwg_PlanDate", - header: "작업도면 예정일", - minSize: 140, - cell: ({ row }) => { - const date = row.getValue("WorDwg_PlanDate") as string; - if (!date || date.length !== 8) return null; - return `${date.substring(0, 4)}-${date.substring(4, 6)}-${date.substring(6, 8)}`; + { + accessorKey: "AppDwg_ResultDate", + header: t("drawingList.columns.appDwgResultDate"), + minSize: 140, + cell: ({ row }) => { + const date = row.getValue("AppDwg_ResultDate") as string; + return formatDolceDateYYYYMMDD(date); + }, }, - }, - { - accessorKey: "WorDwg_ResultDate", - header: "작업도면 결과일", - minSize: 140, - cell: ({ row }) => { - const date = row.getValue("WorDwg_ResultDate") as string; - if (!date || date.length !== 8) return null; - return `${date.substring(0, 4)}-${date.substring(4, 6)}-${date.substring(6, 8)}`; + { + accessorKey: "WorDwg_PlanDate", + header: t("drawingList.columns.worDwgPlanDate"), + minSize: 140, + cell: ({ row }) => { + const date = row.getValue("WorDwg_PlanDate") as string; + return formatDolceDateYYYYMMDD(date); + }, }, - }, - { - accessorKey: "CreateDt", - header: "생성일시", - minSize: 200, - cell: ({ row }) => { - return <div className="text-sm text-muted-foreground">{row.getValue("CreateDt")}</div>; + { + accessorKey: "WorDwg_ResultDate", + header: t("drawingList.columns.worDwgResultDate"), + minSize: 140, + cell: ({ row }) => { + const date = row.getValue("WorDwg_ResultDate") as string; + return formatDolceDateYYYYMMDD(date); + }, }, - }, -]; + { + accessorKey: "CreateDt", + header: t("drawingList.columns.createDt"), + minSize: 260, + cell: ({ row }) => { + const date = row.getValue("CreateDt") as string; + return <div className="text-sm text-muted-foreground">{formatDolceDateTime(date)}</div>; + }, + }, + ]; +} diff --git a/lib/dolce/table/file-list-columns.tsx b/lib/dolce/table/file-list-columns.tsx index f703d56d..36a579a3 100644 --- a/lib/dolce/table/file-list-columns.tsx +++ b/lib/dolce/table/file-list-columns.tsx @@ -4,17 +4,20 @@ import { ColumnDef } from "@tanstack/react-table"; import { FileInfoItem } from "../actions"; import { Button } from "@/components/ui/button"; import { Download } from "lucide-react"; +import { formatDolceDateTime } from "../utils/date-formatter"; interface FileListColumnsProps { onDownload: (file: FileInfoItem) => void; + lng?: string; } export const createFileListColumns = ({ onDownload, + lng = "ko", }: FileListColumnsProps): ColumnDef<FileInfoItem>[] => [ { accessorKey: "FileSeq", - header: "순번", + header: lng === "ko" ? "순번" : "No.", minSize: 80, cell: ({ row }) => { return <div className="text-center">{row.getValue("FileSeq")}</div>; @@ -22,7 +25,7 @@ export const createFileListColumns = ({ }, { accessorKey: "FileName", - header: "파일명", + header: lng === "ko" ? "파일명" : "File Name", minSize: 300, cell: ({ row }) => { return <div className="font-medium">{row.getValue("FileName")}</div>; @@ -30,7 +33,7 @@ export const createFileListColumns = ({ }, { accessorKey: "FileSize", - header: "파일크기", + header: lng === "ko" ? "파일크기" : "File Size", minSize: 100, cell: ({ row }) => { const size = parseInt(row.getValue("FileSize") as string); @@ -43,15 +46,16 @@ export const createFileListColumns = ({ }, { accessorKey: "CreateDt", - header: "생성일시", + header: lng === "ko" ? "생성일시" : "Created Date", minSize: 200, cell: ({ row }) => { - return <div className="text-sm text-muted-foreground">{row.getValue("CreateDt")}</div>; + const date = row.getValue("CreateDt") as string; + return <div className="text-sm text-muted-foreground">{formatDolceDateTime(date)}</div>; }, }, { id: "actions", - header: "다운로드", + header: lng === "ko" ? "다운로드" : "Download", minSize: 120, cell: ({ row }) => { return ( @@ -61,7 +65,7 @@ export const createFileListColumns = ({ onClick={() => onDownload(row.original)} > <Download className="h-4 w-4 mr-2" /> - 다운로드 + {lng === "ko" ? "다운로드" : "Download"} </Button> ); }, diff --git a/lib/dolce/table/gtt-drawing-list-columns.tsx b/lib/dolce/table/gtt-drawing-list-columns.tsx index 2ff2d7e2..093fc10c 100644 --- a/lib/dolce/table/gtt-drawing-list-columns.tsx +++ b/lib/dolce/table/gtt-drawing-list-columns.tsx @@ -2,27 +2,27 @@ import { ColumnDef } from "@tanstack/react-table"; import { GttDwgReceiptItem } from "../actions"; - -// 날짜 포맷 헬퍼 -function formatDate(dateStr: string | null): string | null { - if (!dateStr || dateStr.length !== 8) return null; - return `${dateStr.substring(0, 4)}-${dateStr.substring(4, 6)}-${dateStr.substring(6, 8)}`; -} +import { translateDrawingMoveGbn } from "../utils/code-translator"; +import { formatDolceDateYYYYMMDD, formatDolceDateTime } from "../utils/date-formatter"; // Document Type 필터 export type DocumentType = "ALL" | "GTT_DELIVERABLES" | "SHI_INPUT"; interface GttDrawingListColumnsOptions { documentType: DocumentType; + lng: string; + t: any; } export function createGttDrawingListColumns({ documentType, + lng, + t, }: GttDrawingListColumnsOptions): ColumnDef<GttDwgReceiptItem>[] { const baseColumns: ColumnDef<GttDwgReceiptItem>[] = [ { accessorKey: "DrawingNo", - header: "도면번호", + header: t("drawingList.columns.drawingNo"), minSize: 200, cell: ({ row }) => { return <div className="font-medium">{row.getValue("DrawingNo")}</div>; @@ -30,7 +30,7 @@ export function createGttDrawingListColumns({ }, { accessorKey: "DrawingName", - header: "도면명", + header: t("drawingList.columns.drawingName"), minSize: 400, cell: ({ row }) => { return <div>{row.getValue("DrawingName")}</div>; @@ -38,12 +38,12 @@ export function createGttDrawingListColumns({ }, { accessorKey: "Discipline", - header: "설계공종", + header: t("drawingList.columns.discipline"), minSize: 80, }, { accessorKey: "Manager", - header: "담당자명", + header: t("drawingList.columns.manager"), minSize: 200, cell: ({ row }) => { const managerENM = row.original.ManagerENM; @@ -53,8 +53,12 @@ export function createGttDrawingListColumns({ }, { accessorKey: "DrawingMoveGbn", - header: "구분", + header: t("drawingList.columns.category"), minSize: 120, + cell: ({ row }) => { + const value = row.getValue("DrawingMoveGbn") as string; + return <div>{translateDrawingMoveGbn(value, lng)}</div>; + }, }, ]; @@ -66,39 +70,39 @@ export function createGttDrawingListColumns({ dateColumns.push( { accessorKey: "GTTInput_PlanDate", - header: "GTT Input 예정일", + header: t("drawingList.columns.gttInputPlanDate"), minSize: 150, - cell: ({ row }) => formatDate(row.getValue("GTTInput_PlanDate")), + cell: ({ row }) => formatDolceDateYYYYMMDD(row.getValue("GTTInput_PlanDate")), }, { accessorKey: "GTTInput_ResultDate", - header: "GTT Input 결과일", + header: t("drawingList.columns.gttInputResultDate"), minSize: 150, - cell: ({ row }) => formatDate(row.getValue("GTTInput_ResultDate")), + cell: ({ row }) => formatDolceDateYYYYMMDD(row.getValue("GTTInput_ResultDate")), }, { accessorKey: "GTTPreDwg_PlanDate", - header: "GTT Pre 예정일", + header: t("drawingList.columns.gttPreDwgPlanDate"), minSize: 140, - cell: ({ row }) => formatDate(row.getValue("GTTPreDwg_PlanDate")), + cell: ({ row }) => formatDolceDateYYYYMMDD(row.getValue("GTTPreDwg_PlanDate")), }, { accessorKey: "GTTPreDwg_ResultDate", - header: "GTT Pre 결과일", + header: t("drawingList.columns.gttPreDwgResultDate"), minSize: 140, - cell: ({ row }) => formatDate(row.getValue("GTTPreDwg_ResultDate")), + cell: ({ row }) => formatDolceDateYYYYMMDD(row.getValue("GTTPreDwg_ResultDate")), }, { accessorKey: "GTTWorkingDwg_PlanDate", - header: "GTT Working 예정일", + header: t("drawingList.columns.gttWorkingDwgPlanDate"), minSize: 160, - cell: ({ row }) => formatDate(row.getValue("GTTWorkingDwg_PlanDate")), + cell: ({ row }) => formatDolceDateYYYYMMDD(row.getValue("GTTWorkingDwg_PlanDate")), }, { accessorKey: "GTTWorkingDwg_ResultDate", - header: "GTT Working 결과일", + header: t("drawingList.columns.gttWorkingDwgResultDate"), minSize: 160, - cell: ({ row }) => formatDate(row.getValue("GTTWorkingDwg_ResultDate")), + cell: ({ row }) => formatDolceDateYYYYMMDD(row.getValue("GTTWorkingDwg_ResultDate")), } ); } @@ -107,15 +111,15 @@ export function createGttDrawingListColumns({ dateColumns.push( { accessorKey: "GTTInput_PlanDate", - header: "Input 예정일", + header: t("drawingList.columns.inputPlanDate"), minSize: 120, - cell: ({ row }) => formatDate(row.getValue("GTTInput_PlanDate")), + cell: ({ row }) => formatDolceDateYYYYMMDD(row.getValue("GTTInput_PlanDate")), }, { accessorKey: "GTTInput_ResultDate", - header: "Input 결과일", + header: t("drawingList.columns.inputResultDate"), minSize: 120, - cell: ({ row }) => formatDate(row.getValue("GTTInput_ResultDate")), + cell: ({ row }) => formatDolceDateYYYYMMDD(row.getValue("GTTInput_ResultDate")), } ); } @@ -124,27 +128,27 @@ export function createGttDrawingListColumns({ dateColumns.push( { accessorKey: "GTTPreDwg_PlanDate", - header: "Pre 예정일", + header: t("drawingList.columns.prePlanDate"), minSize: 120, - cell: ({ row }) => formatDate(row.getValue("GTTPreDwg_PlanDate")), + cell: ({ row }) => formatDolceDateYYYYMMDD(row.getValue("GTTPreDwg_PlanDate")), }, { accessorKey: "GTTPreDwg_ResultDate", - header: "Pre 결과일", + header: t("drawingList.columns.preResultDate"), minSize: 120, - cell: ({ row }) => formatDate(row.getValue("GTTPreDwg_ResultDate")), + cell: ({ row }) => formatDolceDateYYYYMMDD(row.getValue("GTTPreDwg_ResultDate")), }, { accessorKey: "GTTWorkingDwg_PlanDate", - header: "Working 예정일", + header: t("drawingList.columns.workingPlanDate"), minSize: 130, - cell: ({ row }) => formatDate(row.getValue("GTTWorkingDwg_PlanDate")), + cell: ({ row }) => formatDolceDateYYYYMMDD(row.getValue("GTTWorkingDwg_PlanDate")), }, { accessorKey: "GTTWorkingDwg_ResultDate", - header: "Working 결과일", + header: t("drawingList.columns.workingResultDate"), minSize: 130, - cell: ({ row }) => formatDate(row.getValue("GTTWorkingDwg_ResultDate")), + cell: ({ row }) => formatDolceDateYYYYMMDD(row.getValue("GTTWorkingDwg_ResultDate")), } ); } @@ -153,10 +157,11 @@ export function createGttDrawingListColumns({ const endColumns: ColumnDef<GttDwgReceiptItem>[] = [ { accessorKey: "CreateDt", - header: "생성일시", + header: t("drawingList.columns.createDt"), minSize: 200, cell: ({ row }) => { - return <div className="text-sm text-muted-foreground">{row.getValue("CreateDt")}</div>; + const date = row.getValue("CreateDt") as string; + return <div className="text-sm text-muted-foreground">{formatDolceDateTime(date)}</div>; }, }, ]; diff --git a/lib/dolce/utils/code-translator.ts b/lib/dolce/utils/code-translator.ts new file mode 100644 index 00000000..19cb4217 --- /dev/null +++ b/lib/dolce/utils/code-translator.ts @@ -0,0 +1,208 @@ +/** + * DOLCE 코드 값 번역 유틸리티 + * + * 코드 값을 다국어로 번역하고, 검색 시 번역된 텍스트도 매칭할 수 있도록 지원 + */ + +// B3 DrawingUsage 번역 +export function translateB3DrawingUsage(code: string, lng: string): string { + const translations: Record<string, Record<string, string>> = { + "Approval": { + ko: "승인용", + en: "Approval", + }, + "Working": { + ko: "작업용", + en: "Working", + }, + "APP": { + ko: "승인용", + en: "Approval", + }, + "WOR": { + ko: "작업용", + en: "Working", + }, + }; + + return translations[code]?.[lng] || code; +} + +// B3 RegisterKind 번역 +export function translateB3RegisterKind(code: string, lng: string): string { + const translations: Record<string, Record<string, string>> = { + "APPR": { + ko: "승인 제출용 도면(Full)", + en: "For Approval(Full)", + }, + "APPP": { + ko: "승인 제출용 도면(Partial)", + en: "For Approval(Partial)", + }, + "WORK": { + ko: "작업용 입수도면(Full)", + en: "For Working(Full)", + }, + "WORP": { + ko: "작업용 입수도면(Partial)", + en: "For Working(Partial)", + }, + }; + + return translations[code]?.[lng] || code; +} + +// B4 DrawingUsage 번역 +export function translateB4DrawingUsage(code: string, lng: string): string { + const translations: Record<string, Record<string, string>> = { + "REC": { + ko: "입수용", + en: "GTT→SHI", + }, + "SUB": { + ko: "제출용", + en: "SHI→GTT", + }, + }; + + return translations[code]?.[lng] || code; +} + +// B4 RegisterKind 번역 +export function translateB4RegisterKind(code: string, lng: string): string { + const translations: Record<string, Record<string, string>> = { + "RECW": { + ko: "Working 도면입수(GTT→SHI)", + en: "Working Dwg(GTT→SHI)", + }, + "RECP": { + ko: "Pre. 도면입수(GTT→SHI)", + en: "Pre. Dwg(GTT→SHI)", + }, + "SUBW": { + ko: "Working 제출용(SHI→GTT)", + en: "Working Submission(SHI→GTT)", + }, + "SUBP": { + ko: "Pre. 제출용(SHI→GTT)", + en: "Pre. Submission(SHI→GTT)", + }, + }; + + return translations[code]?.[lng] || code; +} + +// DrawingMoveGbn 번역 (B4 GTT) +export function translateDrawingMoveGbn(code: string, lng: string): string { + const translations: Record<string, Record<string, string>> = { + "도면입수": { + ko: "도면입수", + en: "Receipt", + }, + "도면제출": { + ko: "도면제출", + en: "Submission", + }, + }; + + return translations[code]?.[lng] || code; +} + +// 통합 번역 함수 +export function translateDolceCode( + codeType: "B3_DrawingUsage" | "B3_RegisterKind" | "B4_DrawingUsage" | "B4_RegisterKind" | "DrawingMoveGbn", + code: string, + lng: string +): string { + switch (codeType) { + case "B3_DrawingUsage": + return translateB3DrawingUsage(code, lng); + case "B3_RegisterKind": + return translateB3RegisterKind(code, lng); + case "B4_DrawingUsage": + return translateB4DrawingUsage(code, lng); + case "B4_RegisterKind": + return translateB4RegisterKind(code, lng); + case "DrawingMoveGbn": + return translateDrawingMoveGbn(code, lng); + default: + return code; + } +} + +// 검색용: 코드와 번역된 텍스트 모두 매칭 +export function matchesTranslatedCode( + codeType: "B3_DrawingUsage" | "B3_RegisterKind" | "B4_DrawingUsage" | "B4_RegisterKind" | "DrawingMoveGbn", + code: string, + searchTerm: string, + lng: string +): boolean { + if (!searchTerm) return true; + + const normalizedSearch = searchTerm.toLowerCase(); + + // 원본 코드로 검색 + if (code.toLowerCase().includes(normalizedSearch)) { + return true; + } + + // 번역된 텍스트로 검색 + const translated = translateDolceCode(codeType, code, lng); + if (translated.toLowerCase().includes(normalizedSearch)) { + return true; + } + + // 다른 언어의 번역도 검색 (한국어 사용자가 영어로 검색하는 경우) + const otherLng = lng === "ko" ? "en" : "ko"; + const otherTranslated = translateDolceCode(codeType, code, otherLng); + if (otherTranslated.toLowerCase().includes(normalizedSearch)) { + return true; + } + + return false; +} + +// 옵션 목록 생성 (Select 컴포넌트용) +export function getB3DrawingUsageOptions(lng: string) { + return [ + { value: "APP", label: translateB3DrawingUsage("APP", lng) }, + { value: "WOR", label: translateB3DrawingUsage("WOR", lng) }, + ]; +} + +export function getB3RegisterKindOptions(drawingUsage: string, lng: string) { + if (drawingUsage === "APP") { + return [ + { value: "APPR", label: translateB3RegisterKind("APPR", lng) }, + { value: "APPP", label: translateB3RegisterKind("APPP", lng) }, + ]; + } else if (drawingUsage === "WOR") { + return [ + { value: "WORK", label: translateB3RegisterKind("WORK", lng) }, + { value: "WORP", label: translateB3RegisterKind("WORP", lng) }, + ]; + } + return []; +} + +export function getB4DrawingUsageOptions(lng: string) { + return [ + { value: "REC", label: translateB4DrawingUsage("REC", lng) }, + ]; +} + +export function getB4RegisterKindOptions(drawingUsage: string, lng: string) { + if (drawingUsage === "REC") { + return [ + { value: "RECP", label: translateB4RegisterKind("RECP", lng) }, + { value: "RECW", label: translateB4RegisterKind("RECW", lng) }, + ]; + } else if (drawingUsage === "SUB") { + return [ + { value: "SUBP", label: translateB4RegisterKind("SUBP", lng) }, + { value: "SUBW", label: translateB4RegisterKind("SUBW", lng) }, + ]; + } + return []; +} + diff --git a/lib/dolce/utils/date-formatter.ts b/lib/dolce/utils/date-formatter.ts new file mode 100644 index 00000000..83e78b0d --- /dev/null +++ b/lib/dolce/utils/date-formatter.ts @@ -0,0 +1,54 @@ +/** + * DOLCE 날짜 포맷팅 유틸리티 + * + * SWP의 날짜 포맷팅 함수를 재사용 + * 모든 날짜는 KST (Korea Standard Time, GMT+9) 타임존 + */ + +import { formatSwpDate } from "@/lib/swp/utils"; + +/** + * SWP와 동일한 방식 + */ +export function formatDolceDateTime(dateStr: string | null): string { + if (!dateStr) return "-"; + return formatSwpDate(dateStr); +} + +/** + * YYYYMMDD 형식을 YYYY-MM-DD로 변환 + * + * @param dateStr "20170220" 형식 + * @returns "2017-02-20" + */ +export function formatDolceDateYYYYMMDD(dateStr: string | null): string | null { + if (!dateStr || dateStr.length !== 8) return null; + + try { + const year = dateStr.substring(0, 4); + const month = dateStr.substring(4, 6); + const day = dateStr.substring(6, 8); + + return `${year}-${month}-${day}`; + } catch { + return dateStr; + } +} + +/** + * 통합 날짜 포맷팅 + * + * @param dateStr 날짜 문자열 (다양한 형식 지원) + * @returns 포맷팅된 날짜 문자열 + */ +export function formatDolceDate(dateStr: string | null): string { + if (!dateStr) return "-"; + + // YYYYMMDD 형식 (8자리 숫자) + if (/^\d{8}$/.test(dateStr)) { + return formatDolceDateYYYYMMDD(dateStr) || dateStr; + } + + // 날짜+시간 형식 + return formatDolceDateTime(dateStr); +} diff --git a/lib/dolce/utils/upload-with-progress.ts b/lib/dolce/utils/upload-with-progress.ts index 8e36afe4..c86ed8a0 100644 --- a/lib/dolce/utils/upload-with-progress.ts +++ b/lib/dolce/utils/upload-with-progress.ts @@ -40,24 +40,31 @@ export async function uploadFilesWithProgress({ }); const xhr = new XMLHttpRequest(); + + // 타임아웃 설정 (1시간) + xhr.timeout = 3600000; // 1시간 (밀리초) - // 전체 업로드 진행도 (단순화: 전체 진행도를 각 파일에 분배) + // 전체 업로드 진행도 + // 주의: xhr.upload.progress는 클라이언트→서버 전송만 추적 + // 서버에서 DOLCE API로 재업로드하는 과정은 별도 (Node.js fetch는 업로드 진행도 추적 미지원) + // → UI에서 90% 이상일 때 "서버에서 DOLCE API로 전송 중..." 메시지 표시 xhr.upload.addEventListener("progress", (event) => { if (event.lengthComputable) { - const totalProgress = (event.loaded / event.total) * 100; + // 전송 완료 = 서버에 도착 (실제 DOLCE API 업로드 시작) + // 서버 처리를 위해 최대 95%까지만 표시 (나머지 5%는 서버→DOLCE 업로드) + const totalProgress = Math.min((event.loaded / event.total) * 95, 95); // 현재 업로드 중인 파일 인덱스 추정 - const filesCompleted = Math.floor((totalProgress / 100) * files.length); + const filesCompleted = Math.floor((totalProgress / 95) * files.length); const currentFileIndex = Math.min(filesCompleted, files.length - 1); // 각 파일별 진행도 계산 files.forEach((_, index) => { if (index < filesCompleted) { - callbacks.onProgress(index, 100); - callbacks.onFileComplete(index); + callbacks.onProgress(index, 95); } else if (index === currentFileIndex) { - const fileProgress = ((totalProgress / 100) * files.length - filesCompleted) * 100; - callbacks.onProgress(index, Math.min(fileProgress, 99)); + const fileProgress = ((totalProgress / 95) * files.length - filesCompleted) * 95; + callbacks.onProgress(index, Math.min(fileProgress, 94)); } else { callbacks.onProgress(index, 0); } @@ -70,15 +77,35 @@ export async function uploadFilesWithProgress({ try { const response = JSON.parse(xhr.responseText); - // 모든 파일 완료 처리 - files.forEach((_, index) => { - callbacks.onProgress(index, 100); - callbacks.onFileComplete(index); - }); + // 서버 응답 검증 + if (response.success) { + console.log(`[업로드 클라이언트] 서버 처리 완료: ${response.uploadedCount}개 파일`); + + // 서버에서 실제 처리 완료 시에만 100% + files.forEach((_, index) => { + callbacks.onProgress(index, 100); + callbacks.onFileComplete(index); + }); - resolve(response); + resolve(response); + } else { + // 서버에서 에러 응답 + const errorMsg = response.error || "서버에서 업로드 실패"; + console.error(`[업로드 클라이언트] 서버 에러:`, errorMsg); + + files.forEach((_, index) => { + callbacks.onFileError(index, errorMsg); + }); + + resolve({ + success: false, + error: errorMsg, + }); + } } catch (error) { - const errorMsg = "응답 파싱 실패"; + const errorMsg = `응답 파싱 실패: ${xhr.responseText?.substring(0, 100)}`; + console.error(`[업로드 클라이언트] 파싱 에러:`, error, xhr.responseText); + files.forEach((_, index) => { callbacks.onFileError(index, errorMsg); }); @@ -89,6 +116,8 @@ export async function uploadFilesWithProgress({ } } else { const errorMsg = `업로드 실패: ${xhr.status} ${xhr.statusText}`; + console.error(`[업로드 클라이언트] HTTP 에러:`, errorMsg, xhr.responseText); + files.forEach((_, index) => { callbacks.onFileError(index, errorMsg); }); @@ -101,6 +130,8 @@ export async function uploadFilesWithProgress({ xhr.addEventListener("error", () => { const errorMsg = "네트워크 오류"; + console.error(`[업로드 클라이언트] 네트워크 에러`); + files.forEach((_, index) => { callbacks.onFileError(index, errorMsg); }); @@ -112,6 +143,21 @@ export async function uploadFilesWithProgress({ xhr.addEventListener("abort", () => { const errorMsg = "업로드가 취소되었습니다"; + console.warn(`[업로드 클라이언트] 업로드 취소됨`); + + files.forEach((_, index) => { + callbacks.onFileError(index, errorMsg); + }); + resolve({ + success: false, + error: errorMsg, + }); + }); + + xhr.addEventListener("timeout", () => { + const errorMsg = "업로드 타임아웃 (1시간 초과)"; + console.error(`[업로드 클라이언트] 타임아웃 발생 (1시간 초과)`); + files.forEach((_, index) => { callbacks.onFileError(index, errorMsg); }); @@ -121,6 +167,7 @@ export async function uploadFilesWithProgress({ }); }); + console.log(`[업로드 클라이언트] 시작: ${files.length}개 파일`); xhr.open("POST", "/api/dolce/upload-files"); xhr.send(formData); }); |
