summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/[lng]/partners/(partners)/dolce-upload/dolce-upload-page.tsx110
-rw-r--r--app/[lng]/partners/(partners)/dolce-upload/page.tsx19
-rw-r--r--app/api/dolce/upload-files/route.ts325
-rw-r--r--app/api/revisions/max-serial-no/route.ts13
-rw-r--r--components/layout/Header.tsx19
-rw-r--r--components/ship-vendor-document/new-revision-dialog.tsx41
-rw-r--r--components/ship-vendor-document/user-vendor-document-table-container.tsx20
-rw-r--r--lib/compliance/approval-actions.ts179
-rw-r--r--lib/compliance/approval-handlers.ts183
-rw-r--r--lib/compliance/red-flag-resolution.ts44
-rw-r--r--lib/dolce/actions.ts14
-rw-r--r--lib/dolce/components/file-upload-progress-list.tsx13
-rw-r--r--lib/dolce/dialogs/add-detail-drawing-dialog.tsx136
-rw-r--r--lib/dolce/dialogs/b4-bulk-upload-dialog.tsx283
-rw-r--r--lib/dolce/dialogs/b4-upload-validation-dialog.tsx30
-rw-r--r--lib/dolce/dialogs/detail-drawing-dialog.tsx53
-rw-r--r--lib/dolce/dialogs/upload-files-to-detail-dialog.tsx3
-rw-r--r--lib/dolce/hooks/use-file-upload.ts107
-rw-r--r--lib/dolce/table/detail-drawing-columns.tsx119
-rw-r--r--lib/dolce/table/drawing-list-columns.tsx146
-rw-r--r--lib/dolce/table/file-list-columns.tsx18
-rw-r--r--lib/dolce/table/gtt-drawing-list-columns.tsx79
-rw-r--r--lib/dolce/utils/code-translator.ts208
-rw-r--r--lib/dolce/utils/date-formatter.ts54
-rw-r--r--lib/dolce/utils/upload-with-progress.ts75
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">
+ ※ 중간의 모든 단어는 &quot;-&quot;로 연결되어 문서번호(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);
});