summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/dolce/actions.ts914
-rw-r--r--lib/dolce/crypto-utils-legacy.ts131
-rw-r--r--lib/dolce/crypto-utils.ts142
-rw-r--r--lib/dolce/dialogs/add-detail-drawing-dialog.tsx376
-rw-r--r--lib/dolce/dialogs/b4-bulk-upload-dialog.tsx608
-rw-r--r--lib/dolce/dialogs/b4-upload-validation-dialog.tsx353
-rw-r--r--lib/dolce/dialogs/detail-drawing-dialog.tsx311
-rw-r--r--lib/dolce/dialogs/upload-files-to-detail-dialog.tsx314
-rw-r--r--lib/dolce/table/detail-drawing-columns.tsx80
-rw-r--r--lib/dolce/table/drawing-list-columns.tsx87
-rw-r--r--lib/dolce/table/drawing-list-table.tsx144
-rw-r--r--lib/dolce/table/file-list-columns.tsx70
-rw-r--r--lib/dolce/table/gtt-drawing-list-columns.tsx166
13 files changed, 3696 insertions, 0 deletions
diff --git a/lib/dolce/actions.ts b/lib/dolce/actions.ts
new file mode 100644
index 00000000..a9cda76a
--- /dev/null
+++ b/lib/dolce/actions.ts
@@ -0,0 +1,914 @@
+"use server";
+
+import { getServerSession } from "next-auth/next";
+import { authOptions } from "@/app/api/auth/[...nextauth]/route";
+import db from "@/db/db";
+import { vendors, users, contracts, projects } from "@/db/schema";
+import { eq } from "drizzle-orm";
+
+const DOLCE_API_URL = process.env.DOLCE_API_URL || "http://60.100.99.217:1111";
+
+// ============================================================================
+// 타입 정의
+// ============================================================================
+
+// B3 벤더 도면 아이템
+export interface DwgReceiptItem {
+ AppDwg_PlanDate: string;
+ AppDwg_ResultDate: string;
+ CreateDt: string;
+ CreateUserENM: string | null;
+ CreateUserId: string | null;
+ CreateUserNo: string;
+ Discipline: string;
+ DrawingKind: string;
+ DrawingMoveGbn: string;
+ DrawingName: string;
+ DrawingNo: string;
+ Manager: string;
+ ManagerENM: string;
+ ManagerNo: string;
+ ProjectNo: string;
+ RegisterGroup: number;
+ RegisterGroupId: number;
+ VendorCode: string;
+ VendorName: string;
+ WorDwg_PlanDate: string;
+ WorDwg_ResultDate: string;
+}
+
+// B4 (GTT) 벤더 도면 아이템
+export interface GttDwgReceiptItem {
+ CGbn: string | null;
+ CreateDt: string;
+ CreateUserENM: string;
+ CreateUserId: string;
+ CreateUserNo: string;
+ DGbn: string | null;
+ DegreeGbn: string | null;
+ DeptGbn: string | null;
+ Discipline: string;
+ DrawingKind: string;
+ DrawingMoveGbn: string; // "도면제출" 또는 "도면입수"
+ DrawingName: string;
+ DrawingNo: string;
+ GTTInput_PlanDate: string | null;
+ GTTInput_ResultDate: string | null;
+ GTTPreDwg_PlanDate: string | null;
+ GTTPreDwg_ResultDate: string | null;
+ GTTWorkingDwg_PlanDate: string | null;
+ GTTWorkingDwg_ResultDate: string | null;
+ JGbn: string | null;
+ Manager: string;
+ ManagerENM: string;
+ ManagerNo: string;
+ ProjectNo: string;
+ RegisterGroup: number;
+ RegisterGroupId: number;
+ SGbn: string | null;
+ SHIDrawingNo: string | null;
+}
+
+// 통합 도면 아이템 타입
+export type UnifiedDwgReceiptItem = DwgReceiptItem | GttDwgReceiptItem;
+
+// 타입 가드는 클라이언트에서 직접 DrawingKind === "B4" 체크로 대체
+// (서버 액션 export 함수는 반드시 async여야 하므로 타입 가드는 export 불가)
+
+export interface DetailDwgReceiptItem {
+ Category: string;
+ CategoryENM: string;
+ CategoryNM: string;
+ CreateDt: string;
+ CreateUserENM: string;
+ CreateUserId: string;
+ CreateUserNM: string;
+ Discipline: string;
+ DrawingKind: string;
+ DrawingName: string;
+ DrawingNo: string;
+ DrawingRevNo: string;
+ DrawingUsage: string;
+ DrawingUsageENM: string | null;
+ DrawingUsageNM: string;
+ Manager: string;
+ Mode: string | null;
+ OFDC_NO: string;
+ ProjectNo: string;
+ Receiver: string | null;
+ RegCompanyCode: string | null;
+ RegCompanyENM: string | null;
+ RegCompanyNM: string | null;
+ RegisterDesc: string;
+ RegisterGroup: number;
+ RegisterGroupId: number;
+ RegisterId: number;
+ RegisterKind: string;
+ RegisterKindENM: string | null;
+ RegisterKindNM: string;
+ RegisterSerialNo: number;
+ SHINote: string | null;
+ Status: string;
+ UploadId: string;
+}
+
+export interface FileInfoItem {
+ CreateDt: string;
+ CreateUserId: string;
+ Deleted: string;
+ FileDescription: string;
+ FileId: string;
+ FileName: string;
+ FileRelativePath: string;
+ FileSeq: string;
+ FileServerId: string;
+ FileSize: string;
+ FileTitle: string | null;
+ FileWriteDT: string;
+ OwnerUserId: string;
+ SourceDrmYn: string | null;
+ SystemId: string;
+ TableName: string;
+ UploadId: string;
+ UseYn: string;
+}
+
+export interface DetailDwgEditRequest {
+ Mode: "ADD" | "MOD";
+ Status: string;
+ RegisterId: number;
+ ProjectNo: string;
+ Discipline: string;
+ DrawingKind: string;
+ DrawingNo: string;
+ DrawingName: string;
+ RegisterGroupId: number;
+ RegisterSerialNo: number;
+ RegisterKind: string;
+ DrawingRevNo: string;
+ Category: string;
+ Receiver: string | null;
+ Manager: string;
+ RegisterDesc: string;
+ UploadId: string;
+ RegCompanyCode: string;
+}
+
+// ============================================================================
+// 유틸리티 함수
+// ============================================================================
+
+async function dolceApiCall<T>(endpoint: string, body: Record<string, unknown>): Promise<T> {
+ const url = `${DOLCE_API_URL}/Services/VDCSWebService.svc/${endpoint}`;
+
+ console.log(`[DOLCE API] Calling ${endpoint}:`, JSON.stringify(body, null, 2));
+
+ const response = await fetch(url, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(body),
+ });
+
+ if (!response.ok) {
+ throw new Error(`DOLCE API 오류 (${endpoint}): ${response.status} ${response.statusText}`);
+ }
+
+ const data = await response.json();
+ console.log(`[DOLCE API] Response from ${endpoint}:`, JSON.stringify(data, null, 2));
+
+ return data;
+}
+
+// ============================================================================
+// 서버 액션
+// ============================================================================
+
+/**
+ * 1. 도면 리스트 조회 (B3 및 B4 통합)
+ */
+export async function fetchDwgReceiptList(params: {
+ project: string;
+ drawingKind: string;
+ drawingMoveGbn?: string;
+ drawingNo?: string;
+ drawingName?: string;
+ drawingVendor?: string;
+ discipline?: string;
+}): Promise<UnifiedDwgReceiptItem[]> {
+ try {
+ const response = await dolceApiCall<{
+ DwgReceiptMgmtResult: {
+ FMEADwgList: unknown[];
+ GTTDwgList: GttDwgReceiptItem[];
+ VendorDwgList: DwgReceiptItem[];
+ };
+ }>("DwgReceiptMgmt", {
+ project: params.project,
+ drawingKind: params.drawingKind,
+ drawingMoveGbn: params.drawingMoveGbn || "",
+ drawingNo: params.drawingNo || "",
+ drawingName: params.drawingName || "",
+ drawingVendor: params.drawingVendor || "",
+ discipline: params.discipline || "",
+ });
+
+ // B4(GTT)인 경우 GTTDwgList 반환, B3인 경우 VendorDwgList 반환
+ if (params.drawingKind === "B4") {
+ return response.DwgReceiptMgmtResult.GTTDwgList;
+ } else {
+ return response.DwgReceiptMgmtResult.VendorDwgList;
+ }
+ } catch (error) {
+ console.error("도면 리스트 조회 실패:", error);
+ throw error;
+ }
+}
+
+/**
+ * 2. 상세도면 리스트 조회
+ */
+export async function fetchDetailDwgReceiptList(params: {
+ project: string;
+ drawingNo: string;
+ discipline: string;
+ drawingKind: string;
+ userId?: string;
+}): Promise<DetailDwgReceiptItem[]> {
+ try {
+ const response = await dolceApiCall<{
+ DetailDwgReceiptMgmtResult: DetailDwgReceiptItem[];
+ }>("DetailDwgReceiptMgmt", {
+ project: params.project,
+ drawingNo: params.drawingNo,
+ discipline: params.discipline,
+ drawingKind: params.drawingKind,
+ userId: params.userId || "",
+ });
+
+ return response.DetailDwgReceiptMgmtResult;
+ } catch (error) {
+ console.error("상세도면 리스트 조회 실패:", error);
+ throw error;
+ }
+}
+
+/**
+ * 3. 개별 상세도면 파일 리스트 조회
+ */
+export async function fetchFileInfoList(uploadId: string): Promise<FileInfoItem[]> {
+ try {
+ const response = await dolceApiCall<{
+ FileInfoListResult: FileInfoItem[];
+ }>("FileInfoList", {
+ uploadId,
+ });
+
+ // UseYn이 "True"인 파일만 반환
+ return response.FileInfoListResult.filter((file) => file.UseYn === "True");
+ } catch (error) {
+ console.error("파일 리스트 조회 실패:", error);
+ throw error;
+ }
+}
+
+/**
+ * 4. 상세도면 추가/수정
+ */
+export async function editDetailDwgReceipt(params: {
+ dwgList: DetailDwgEditRequest[];
+ userId: string;
+ userNm: string;
+ vendorCode: string;
+ email: string;
+}): Promise<number> {
+ try {
+ const response = await dolceApiCall<{
+ DetailDwgReceiptMmgtEditResult: number;
+ }>("DetailDwgReceiptMgmtEdit", {
+ DwgList: params.dwgList,
+ UserID: params.userId,
+ UserNM: params.userNm,
+ VENDORCODE: params.vendorCode,
+ EMAIL: params.email,
+ });
+
+ return response.DetailDwgReceiptMmgtEditResult;
+ } catch (error) {
+ console.error("상세도면 수정 실패:", error);
+ throw error;
+ }
+}
+
+/**
+ * 5. 파일 다운로드 (프록시)
+ */
+export async function downloadDolceFile(params: {
+ fileId: string;
+ userId: string;
+ fileName: string;
+}): Promise<{ blob: Blob; fileName: string }> {
+ try {
+ const { createDolceDownloadKey } = await import("./crypto-utils");
+
+ // DES 암호화된 키 생성
+ const encryptedKey = createDolceDownloadKey(
+ params.fileId,
+ params.userId,
+ params.fileName
+ );
+
+ const url = `${DOLCE_API_URL}/Download.aspx`;
+
+ // 디버깅용 상세 로그
+ const plainText = `${params.fileId}↔${params.userId}↔${params.fileName}`;
+ console.log("[DOLCE] 파일 다운로드 상세:", {
+ fileId: params.fileId,
+ userId: params.userId,
+ fileName: params.fileName,
+ plainText,
+ encryptedKey,
+ fullUrl: `${url}?key=${encryptedKey}`,
+ });
+
+ // 주의: encodeURIComponent 사용하지 않음!
+ // C#에서는 +를 |||로 변환 후 그대로 URL에 사용
+ const response = await fetch(`${url}?key=${encryptedKey}`, {
+ method: "GET",
+ });
+
+ console.log("[DOLCE] 응답 상태:", response.status, response.statusText);
+ console.log("[DOLCE] Content-Type:", response.headers.get("content-type"));
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ console.error("[DOLCE] 에러 응답:", errorText);
+ throw new Error(`파일 다운로드 실패: ${response.status} - ${errorText}`);
+ }
+
+ // HTML/텍스트 응답인 경우 에러일 수 있음
+ const contentType = response.headers.get("content-type") || "";
+ if (contentType.includes("text") || contentType.includes("html")) {
+ const errorText = await response.text();
+ console.error("[DOLCE] 텍스트 응답 (에러):", errorText);
+ throw new Error(`예상치 못한 응답: ${errorText.substring(0, 200)}`);
+ }
+
+ const blob = await response.blob();
+ console.log("[DOLCE] 다운로드 성공:", blob.size, "bytes");
+
+ return {
+ blob,
+ fileName: params.fileName,
+ };
+ } catch (error) {
+ console.error("[DOLCE] 파일 다운로드 실패:", error);
+ throw error;
+ }
+}
+
+/**
+ * 벤더 세션 정보 조회
+ */
+export async function getVendorSessionInfo() {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ throw new Error("로그인이 필요합니다");
+ }
+
+ // DB에서 사용자 정보 조회
+ const userId = parseInt(session.user.id);
+ const userInfo = await db.query.users.findFirst({
+ where: eq(users.id, userId),
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ companyId: true,
+ },
+ });
+
+ if (!userInfo || !userInfo.companyId) {
+ throw new Error("벤더 정보를 찾을 수 없습니다");
+ }
+
+ // 벤더 정보 조회
+ const vendorInfo = await db.query.vendors.findFirst({
+ where: eq(vendors.id, userInfo.companyId),
+ columns: {
+ id: true,
+ vendorCode: true,
+ vendorName: true,
+ },
+ });
+
+ if (!vendorInfo) {
+ throw new Error("벤더 정보를 찾을 수 없습니다");
+ }
+
+ // GTT 벤더 확인 (A0016193)
+ const drawingKind = vendorInfo.vendorCode === "A0016193" ? "B4" : "B3";
+
+ return {
+ userId: String(userInfo.id),
+ userName: userInfo.name || "",
+ email: userInfo.email || "",
+ vendorCode: vendorInfo.vendorCode || "",
+ vendorName: vendorInfo.vendorName || "",
+ drawingKind, // B3 또는 B4
+ };
+ } catch (error) {
+ console.error("세션 정보 조회 실패:", error);
+ throw error;
+ }
+}
+
+/**
+ * 벤더의 프로젝트 목록 조회 (계약이 있는 프로젝트)
+ */
+export async function fetchVendorProjects(): Promise<
+ Array<{ code: string; name: string }>
+> {
+ try {
+ const vendorInfo = await getVendorSessionInfo();
+
+ // 벤더와 계약이 있는 프로젝트 목록 조회
+ const projectList = await db
+ .selectDistinct({
+ code: projects.code,
+ name: projects.name,
+ })
+ .from(contracts)
+ .innerJoin(projects, eq(contracts.projectId, projects.id))
+ .innerJoin(vendors, eq(contracts.vendorId, vendors.id))
+ .where(eq(vendors.vendorCode, vendorInfo.vendorCode));
+
+ return projectList.map((p) => ({
+ code: p.code || "",
+ name: p.name || "",
+ }));
+ } catch (error) {
+ console.error("프로젝트 목록 조회 실패:", error);
+ throw error;
+ }
+}
+
+// ============================================================================
+// B4 일괄 업로드 관련
+// ============================================================================
+
+/**
+ * MatchBatchFileDwg 매핑 체크 아이템
+ */
+export interface MappingCheckItem {
+ DrawingNo: string;
+ RevNo: string;
+ FileNm: string;
+}
+
+/**
+ * MatchBatchFileDwg 응답 아이템
+ */
+export interface MappingCheckResult {
+ CGbn: string | null;
+ Category: string | null;
+ CheckBox: string;
+ CreateDt: string | null;
+ CreateUserId: string | null;
+ DGbn: string | null;
+ DegreeGbn: string | null;
+ DeptGbn: string | null;
+ Discipline: string | null;
+ DrawingKind: string;
+ DrawingMoveGbn: string | null;
+ DrawingName: string | null;
+ DrawingNo: string;
+ DrawingUsage: string | null;
+ FileNm: string;
+ JGbn: string | null;
+ Manager: string | null;
+ MappingYN: "Y" | "N";
+ NewOrNot: string | null;
+ ProjectNo: string;
+ RegisterGroup: number;
+ RegisterGroupId: number;
+ RegisterKindCode: string | null;
+ RegisterSerialNo: number;
+ RevNo: string | null;
+ SGbn: string | null;
+ Status: string | null;
+ UploadId: string | null;
+}
+
+/**
+ * B4 일괄 업로드 결과
+ */
+export interface B4BulkUploadResult {
+ success: boolean;
+ successCount?: number;
+ failCount?: number;
+ error?: string;
+ results?: Array<{
+ drawingNo: string;
+ revNo: string;
+ fileName: string;
+ success: boolean;
+ error?: string;
+ }>;
+}
+
+/**
+ * B4 파일 매핑 상태 확인 (MatchBatchFileDwg)
+ */
+export async function checkB4MappingStatus(
+ projectNo: string,
+ fileList: MappingCheckItem[]
+): Promise<MappingCheckResult[]> {
+ try {
+ const response = await dolceApiCall<{
+ MatchBatchFileDwgResult: MappingCheckResult[];
+ }>("MatchBatchFileDwg", {
+ ProjectNo: projectNo,
+ DrawingKind: "B4",
+ drawingMoveGbn: "도면입수",
+ FileList: fileList.map((item) => ({
+ ProjectNo: projectNo,
+ DrawingKind: "B4",
+ DrawingNo: item.DrawingNo,
+ RevNo: item.RevNo,
+ FileNm: item.FileNm,
+ })),
+ });
+
+ return response.MatchBatchFileDwgResult;
+ } catch (error) {
+ console.error("매핑 상태 확인 실패:", error);
+ throw error;
+ }
+}
+
+/**
+ * 상세도면에 파일 업로드 결과
+ */
+export interface UploadFilesResult {
+ success: boolean;
+ uploadedCount?: number;
+ error?: string;
+}
+
+/**
+ * 상세도면에 파일 업로드 (기존 UploadId에 파일 추가)
+ */
+export async function uploadFilesToDetailDrawing(
+ formData: FormData
+): Promise<UploadFilesResult> {
+ try {
+ 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) {
+ throw new Error("필수 파라미터가 누락되었습니다");
+ }
+
+ const uploadResults: Array<{
+ FileId: string;
+ UploadId: string;
+ FileSeq: number;
+ FileName: string;
+ FileRelativePath: string;
+ FileSize: number;
+ FileCreateDT: string;
+ FileWriteDT: string;
+ OwnerUserId: string;
+ }> = [];
+
+ // 기존 파일 개수 조회 (FileSeq를 이어서 사용하기 위함)
+ const existingFiles = await dolceApiCall<{
+ FileInfoListResult: Array<{ FileSeq: string }>;
+ }>("FileInfoList", {
+ uploadId: uploadId,
+ });
+
+ const startSeq = existingFiles.FileInfoListResult.length + 1;
+
+ // 각 파일 업로드
+ for (let i = 0; i < fileCount; i++) {
+ const file = formData.get(`file_${i}`) as File;
+ if (!file) continue;
+
+ const fileId = crypto.randomUUID();
+
+ // 파일을 ArrayBuffer로 변환
+ const arrayBuffer = await file.arrayBuffer();
+
+ // PWPUploadService.ashx 호출
+ const uploadUrl = `${DOLCE_API_URL}/PWPUploadService.ashx?UploadId=${uploadId}&FileId=${fileId}`;
+
+ const uploadResponse = await fetch(uploadUrl, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/octet-stream",
+ },
+ body: arrayBuffer,
+ });
+
+ if (!uploadResponse.ok) {
+ throw new Error(
+ `파일 업로드 실패: ${uploadResponse.status} ${uploadResponse.statusText}`
+ );
+ }
+
+ const fileRelativePath = await uploadResponse.text();
+
+ uploadResults.push({
+ FileId: fileId,
+ UploadId: uploadId,
+ FileSeq: startSeq + i,
+ FileName: file.name,
+ FileRelativePath: fileRelativePath,
+ FileSize: file.size,
+ FileCreateDT: new Date().toISOString(),
+ FileWriteDT: new Date().toISOString(),
+ OwnerUserId: userId,
+ });
+ }
+
+ // 업로드 완료 통지 (PWPUploadResultService.ashx)
+ 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}`
+ );
+ }
+
+ const resultText = await resultResponse.text();
+ if (resultText !== "Success") {
+ throw new Error(`업로드 완료 통지 실패: ${resultText}`);
+ }
+
+ return {
+ success: true,
+ uploadedCount: fileCount,
+ };
+ } catch (error) {
+ console.error("파일 업로드 실패:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "알 수 없는 오류",
+ };
+ }
+}
+
+/**
+ * B4 파일 일괄 업로드
+ * 주의: formData를 사용하여 대용량 파일 처리
+ */
+export async function bulkUploadB4Files(
+ formData: FormData
+): Promise<B4BulkUploadResult> {
+ try {
+ // FormData에서 메타데이터 추출
+ const projectNo = formData.get("projectNo") as string;
+ const userId = formData.get("userId") as string;
+ const fileCount = parseInt(formData.get("fileCount") as string);
+ const registerKind = formData.get("registerKind") as string;
+
+ if (!projectNo || !userId || !fileCount || !registerKind) {
+ throw new Error("필수 파라미터가 누락되었습니다");
+ }
+
+ const results: Array<{
+ drawingNo: string;
+ revNo: string;
+ fileName: string;
+ success: boolean;
+ error?: string;
+ }> = [];
+
+ let successCount = 0;
+ let failCount = 0;
+
+ // 파일별로 그룹화 (DrawingNo + RevNo 기준)
+ const uploadGroups = new Map<
+ string,
+ Array<{
+ file: File;
+ drawingNo: string;
+ revNo: string;
+ fileName: string;
+ registerGroupId: number;
+ }>
+ >();
+
+ for (let i = 0; i < fileCount; i++) {
+ const file = formData.get(`file_${i}`) as File;
+ const drawingNo = formData.get(`drawingNo_${i}`) as string;
+ const revNo = formData.get(`revNo_${i}`) as string;
+ const fileName = formData.get(`fileName_${i}`) as string;
+ const registerGroupId = parseInt(
+ formData.get(`registerGroupId_${i}`) as string
+ );
+
+ if (!file || !drawingNo || !revNo) {
+ continue;
+ }
+
+ const groupKey = `${drawingNo}_${revNo}`;
+ if (!uploadGroups.has(groupKey)) {
+ uploadGroups.set(groupKey, []);
+ }
+
+ uploadGroups.get(groupKey)!.push({
+ file,
+ drawingNo,
+ revNo,
+ fileName,
+ registerGroupId,
+ });
+ }
+
+ // 각 그룹별로 업로드 처리
+ for (const [, files] of uploadGroups.entries()) {
+ const { drawingNo, revNo, registerGroupId } = files[0];
+
+ try {
+ // 1. UploadId 생성 (그룹당 하나)
+ const uploadId = crypto.randomUUID();
+
+ // 2. 파일 업로드 (PWPUploadService.ashx)
+ const uploadResults: Array<{
+ FileId: string;
+ UploadId: string;
+ FileSeq: number;
+ FileName: string;
+ FileRelativePath: string;
+ FileSize: number;
+ FileCreateDT: string;
+ FileWriteDT: string;
+ OwnerUserId: string;
+ }> = [];
+
+ for (let i = 0; i < files.length; i++) {
+ const fileInfo = files[i];
+ const fileId = crypto.randomUUID();
+
+ // 파일을 ArrayBuffer로 변환
+ const arrayBuffer = await fileInfo.file.arrayBuffer();
+
+ // PWPUploadService.ashx 호출
+ const uploadUrl = `${DOLCE_API_URL}/PWPUploadService.ashx?UploadId=${uploadId}&FileId=${fileId}`;
+
+ const uploadResponse = await fetch(uploadUrl, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/octet-stream",
+ },
+ body: arrayBuffer,
+ });
+
+ if (!uploadResponse.ok) {
+ throw new Error(
+ `파일 업로드 실패: ${uploadResponse.status} ${uploadResponse.statusText}`
+ );
+ }
+
+ const fileRelativePath = await uploadResponse.text();
+
+ uploadResults.push({
+ FileId: fileId,
+ UploadId: uploadId,
+ FileSeq: i + 1,
+ FileName: fileInfo.file.name,
+ FileRelativePath: fileRelativePath,
+ FileSize: fileInfo.file.size,
+ FileCreateDT: new Date().toISOString(),
+ FileWriteDT: new Date().toISOString(),
+ OwnerUserId: userId,
+ });
+ }
+
+ // 3. 업로드 완료 통지 (PWPUploadResultService.ashx)
+ 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}`
+ );
+ }
+
+ const resultText = await resultResponse.text();
+ if (resultText !== "Success") {
+ throw new Error(`업로드 완료 통지 실패: ${resultText}`);
+ }
+
+ // 4. 매핑 현황 재조회 (MatchBatchFileDwg)
+ const mappingCheckResults = await checkB4MappingStatus(projectNo, [
+ {
+ DrawingNo: drawingNo,
+ RevNo: revNo,
+ FileNm: files[0].fileName,
+ },
+ ]);
+
+ const mappingData = mappingCheckResults[0];
+ if (!mappingData || mappingData.RegisterGroupId === 0) {
+ throw new Error("매핑 정보를 찾을 수 없습니다");
+ }
+
+ // 5. 매핑 정보 저장 (MatchBatchFileDwgEdit)
+ await dolceApiCall("MatchBatchFileDwgEdit", {
+ mappingSaveLists: [
+ {
+ CGbn: mappingData.CGbn,
+ Category: mappingData.Category,
+ CheckBox: "0",
+ DGbn: mappingData.DGbn,
+ DegreeGbn: mappingData.DegreeGbn,
+ DeptGbn: mappingData.DeptGbn,
+ Discipline: mappingData.Discipline,
+ DrawingKind: "B4",
+ DrawingMoveGbn: "도면입수",
+ DrawingName: mappingData.DrawingName,
+ DrawingNo: drawingNo,
+ DrawingUsage: "입수용",
+ FileNm: files[0].fileName,
+ JGbn: mappingData.JGbn,
+ Manager: mappingData.Manager || "970043",
+ MappingYN: "Y",
+ NewOrNot: "N",
+ ProjectNo: projectNo,
+ RegisterGroup: 0,
+ RegisterGroupId: registerGroupId,
+ RegisterKindCode: registerKind, // 사용자가 선택한 RegisterKind 사용
+ RegisterSerialNo: mappingData.RegisterSerialNo,
+ RevNo: revNo,
+ SGbn: mappingData.SGbn,
+ UploadId: uploadId,
+ },
+ ],
+ UserID: parseInt(userId),
+ });
+
+ // 성공 처리
+ for (const fileInfo of files) {
+ results.push({
+ drawingNo,
+ revNo,
+ fileName: fileInfo.fileName,
+ success: true,
+ });
+ successCount++;
+ }
+ } catch (error) {
+ // 실패 처리
+ const errorMessage =
+ error instanceof Error ? error.message : "알 수 없는 오류";
+
+ for (const fileInfo of files) {
+ results.push({
+ drawingNo,
+ revNo,
+ fileName: fileInfo.fileName,
+ success: false,
+ error: errorMessage,
+ });
+ failCount++;
+ }
+ }
+ }
+
+ return {
+ success: successCount > 0,
+ successCount,
+ failCount,
+ results,
+ };
+ } catch (error) {
+ console.error("일괄 업로드 실패:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "알 수 없는 오류",
+ };
+ }
+}
+
diff --git a/lib/dolce/crypto-utils-legacy.ts b/lib/dolce/crypto-utils-legacy.ts
new file mode 100644
index 00000000..c5ee496f
--- /dev/null
+++ b/lib/dolce/crypto-utils-legacy.ts
@@ -0,0 +1,131 @@
+/**
+ * DOLCE 파일 다운로드용 DES 암호화 유틸리티
+ * Node.js crypto 모듈 사용 (OpenSSL 3.0 호환)
+ */
+
+import crypto from "crypto";
+
+// 암호화 키 (8바이트)
+const DES_KEY = Buffer.from("4fkkdijg", "ascii");
+
+/**
+ * DES ECB 수동 구현 (OpenSSL 3.0 호환)
+ * createCipher 대신 수동 블록 처리
+ */
+function desEncryptBlock(block: Buffer, key: Buffer): Buffer {
+ // DES 블록 암호화 (8바이트 블록)
+ const cipher = crypto.createCipheriv("des-ecb", key, Buffer.alloc(0));
+ cipher.setAutoPadding(false); // 수동 패딩
+ return Buffer.concat([cipher.update(block), cipher.final()]);
+}
+
+/**
+ * PKCS7 패딩 추가
+ */
+function addPKCS7Padding(data: Buffer): Buffer {
+ const blockSize = 8;
+ const paddingLength = blockSize - (data.length % blockSize);
+ const padding = Buffer.alloc(paddingLength, paddingLength);
+ return Buffer.concat([data, padding]);
+}
+
+/**
+ * PKCS7 패딩 제거
+ */
+function removePKCS7Padding(data: Buffer): Buffer {
+ const paddingLength = data[data.length - 1];
+ return data.slice(0, data.length - paddingLength);
+}
+
+/**
+ * DES ECB 모드로 문자열 암호화
+ */
+export function encryptDES(plainText: string): string {
+ try {
+ // UTF-8로 인코딩
+ let data = Buffer.from(plainText, "utf8");
+
+ // PKCS7 패딩 추가
+ data = addPKCS7Padding(data);
+
+ // 8바이트 블록으로 분할하여 암호화
+ const encrypted: Buffer[] = [];
+ for (let i = 0; i < data.length; i += 8) {
+ const block = data.slice(i, i + 8);
+ encrypted.push(desEncryptBlock(block, DES_KEY));
+ }
+
+ // Base64 인코딩
+ const base64 = Buffer.concat(encrypted).toString("base64");
+
+ // + 문자를 ||| 로 변환
+ return base64.replace(/\+/g, "|||");
+ } catch (error) {
+ console.error("DES 암호화 실패:", error);
+ throw new Error(`암호화 중 오류가 발생했습니다: ${error}`);
+ }
+}
+
+/**
+ * DES ECB 모드로 문자열 복호화
+ */
+export function decryptDES(encryptedText: string): string {
+ try {
+ // ||| 를 + 로 복원
+ const restored = encryptedText.replace(/\|\|\|/g, "+");
+
+ // Base64 디코딩
+ const data = Buffer.from(restored, "base64");
+
+ // 8바이트 블록으로 분할하여 복호화
+ const decrypted: Buffer[] = [];
+ for (let i = 0; i < data.length; i += 8) {
+ const block = data.slice(i, i + 8);
+ const decipher = crypto.createDecipheriv("des-ecb", DES_KEY, Buffer.alloc(0));
+ decipher.setAutoPadding(false);
+ decrypted.push(Buffer.concat([decipher.update(block), decipher.final()]));
+ }
+
+ // PKCS7 패딩 제거
+ const unpaddedData = removePKCS7Padding(Buffer.concat(decrypted));
+
+ // UTF-8 디코딩
+ return unpaddedData.toString("utf8").replace(/\0+$/, "");
+ } catch (error) {
+ console.error("DES 복호화 실패:", error);
+ throw new Error(`복호화 중 오류가 발생했습니다: ${error}`);
+ }
+}
+
+/**
+ * DOLCE 파일 다운로드용 암호화 키 생성
+ */
+export function createDolceDownloadKey(
+ fileId: string,
+ userId: string,
+ fileName: string
+): string {
+ const plainText = `${fileId}↔${userId}↔${fileName}`;
+ return encryptDES(plainText);
+}
+
+/**
+ * 테스트용: 암호화/복호화 검증
+ */
+export function testDESEncryption(testString: string): {
+ original: string;
+ encrypted: string;
+ decrypted: string;
+ match: boolean;
+} {
+ const encrypted = encryptDES(testString);
+ const decrypted = decryptDES(encrypted);
+
+ return {
+ original: testString,
+ encrypted,
+ decrypted,
+ match: testString === decrypted,
+ };
+}
+
diff --git a/lib/dolce/crypto-utils.ts b/lib/dolce/crypto-utils.ts
new file mode 100644
index 00000000..1fb310b2
--- /dev/null
+++ b/lib/dolce/crypto-utils.ts
@@ -0,0 +1,142 @@
+/**
+ * DOLCE 파일 다운로드용 DES 암호화 유틸리티
+ * C# DESCryptoServiceProvider와 호환되는 Node.js 구현
+ *
+ * OpenSSL 3.0 호환 처리:
+ * - Node.js 17+ 환경에서 DES는 레거시 알고리즘으로 분류됨
+ * - 에러 발생 시 crypto-utils-legacy.ts의 수동 구현 사용
+ */
+
+import crypto from "crypto";
+
+// 암호화 키 (8바이트)
+const DES_KEY = Buffer.from("4fkkdijg", "ascii");
+
+// OpenSSL 3.0 환경 감지
+let useModernCrypto = true;
+
+/**
+ * DES ECB 모드로 문자열 암호화
+ * @param plainText 암호화할 평문
+ * @returns Base64 인코딩된 암호문
+ */
+export function encryptDES(plainText: string): string {
+ // 처음 호출 시 OpenSSL 지원 여부 확인
+ if (useModernCrypto) {
+ try {
+ const cipher = crypto.createCipheriv("des-ecb", DES_KEY, Buffer.alloc(0));
+ cipher.setAutoPadding(true);
+
+ let encrypted = cipher.update(plainText, "utf8", "base64");
+ encrypted += cipher.final("base64");
+
+ return encrypted.replace(/\+/g, "|||");
+ } catch (error: any) {
+ // OpenSSL 3.0 에러 감지
+ if (error.message?.includes("unsupported") || error.message?.includes("digital envelope")) {
+ console.warn("[DOLCE] OpenSSL 3.0 감지, 레거시 구현으로 전환합니다.");
+ useModernCrypto = false;
+ // 레거시 구현으로 재시도
+ return encryptDESLegacy(plainText);
+ }
+ throw error;
+ }
+ } else {
+ return encryptDESLegacy(plainText);
+ }
+}
+
+/**
+ * 레거시 DES 암호화 (OpenSSL 3.0 대체)
+ */
+function encryptDESLegacy(plainText: string): string {
+ try {
+ const { encryptDES: legacyEncrypt } = require("./crypto-utils-legacy");
+ return legacyEncrypt(plainText);
+ } catch (error) {
+ console.error("DES 암호화 실패:", error);
+ throw new Error(`암호화 중 오류가 발생했습니다: ${error}`);
+ }
+}
+
+/**
+ * DES ECB 모드로 문자열 복호화
+ * @param encryptedText Base64 인코딩된 암호문
+ * @returns 복호화된 평문
+ */
+export function decryptDES(encryptedText: string): string {
+ if (useModernCrypto) {
+ try {
+ const restored = encryptedText.replace(/\|\|\|/g, "+");
+ const decipher = crypto.createDecipheriv("des-ecb", DES_KEY, Buffer.alloc(0));
+ decipher.setAutoPadding(true);
+
+ let decrypted = decipher.update(restored, "base64", "utf8");
+ decrypted += decipher.final("utf8");
+
+ return decrypted.replace(/\0+$/, "");
+ } catch (error: any) {
+ if (error.message?.includes("unsupported") || error.message?.includes("digital envelope")) {
+ console.warn("[DOLCE] OpenSSL 3.0 감지, 레거시 구현으로 전환합니다.");
+ useModernCrypto = false;
+ return decryptDESLegacy(encryptedText);
+ }
+ throw error;
+ }
+ } else {
+ return decryptDESLegacy(encryptedText);
+ }
+}
+
+/**
+ * 레거시 DES 복호화 (OpenSSL 3.0 대체)
+ */
+function decryptDESLegacy(encryptedText: string): string {
+ try {
+ const { decryptDES: legacyDecrypt } = require("./crypto-utils-legacy");
+ return legacyDecrypt(encryptedText);
+ } catch (error) {
+ console.error("DES 복호화 실패:", error);
+ throw new Error(`복호화 중 오류가 발생했습니다: ${error}`);
+ }
+}
+
+/**
+ * DOLCE 파일 다운로드용 암호화 키 생성
+ * @param fileId 파일 ID
+ * @param userId 사용자 ID
+ * @param fileName 파일명
+ * @returns 암호화된 키
+ */
+export function createDolceDownloadKey(
+ fileId: string,
+ userId: string,
+ fileName: string
+): string {
+ // FileId↔UserId↔FileName 형식으로 조합
+ const plainText = `${fileId}↔${userId}↔${fileName}`;
+
+ // DES 암호화
+ return encryptDES(plainText);
+}
+
+/**
+ * 테스트용: 암호화/복호화 검증
+ */
+export function testDESEncryption(testString: string): {
+ original: string;
+ encrypted: string;
+ decrypted: string;
+ match: boolean;
+} {
+ const encrypted = encryptDES(testString);
+ const decrypted = decryptDES(encrypted);
+
+ return {
+ original: testString,
+ encrypted,
+ decrypted,
+ match: testString === decrypted,
+ };
+}
+
diff --git a/lib/dolce/dialogs/add-detail-drawing-dialog.tsx b/lib/dolce/dialogs/add-detail-drawing-dialog.tsx
new file mode 100644
index 00000000..290a226b
--- /dev/null
+++ b/lib/dolce/dialogs/add-detail-drawing-dialog.tsx
@@ -0,0 +1,376 @@
+"use client";
+
+import { useState, useCallback } from "react";
+import { useDropzone } from "react-dropzone";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogFooter,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Label } from "@/components/ui/label";
+import { Input } from "@/components/ui/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Upload, X, FileIcon, Info } from "lucide-react";
+import { toast } from "sonner";
+import { UnifiedDwgReceiptItem, editDetailDwgReceipt, uploadFilesToDetailDrawing } from "../actions";
+import { v4 as uuidv4 } from "uuid";
+
+interface AddDetailDrawingDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ drawing: UnifiedDwgReceiptItem | null;
+ vendorCode: string;
+ userId: string;
+ userName: string;
+ userEmail: string;
+ onComplete: () => void;
+ drawingKind: "B3" | "B4"; // 추가
+}
+
+// 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,
+ drawing,
+ vendorCode,
+ userId,
+ userName,
+ userEmail,
+ onComplete,
+ drawingKind,
+}: AddDetailDrawingDialogProps) {
+ const [drawingUsage, setDrawingUsage] = useState<string>("");
+ const [registerKind, setRegisterKind] = useState<string>("");
+ const [revision, setRevision] = useState<string>("");
+ const [files, setFiles] = useState<File[]>([]);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ // 파일 드롭 핸들러
+ const onDrop = useCallback((acceptedFiles: File[]) => {
+ setFiles((prev) => [...prev, ...acceptedFiles]);
+ }, []);
+
+ const { getRootProps, getInputProps, isDragActive } = useDropzone({
+ onDrop,
+ multiple: true,
+ });
+
+ // 파일 제거
+ const removeFile = (index: number) => {
+ setFiles((prev) => prev.filter((_, i) => i !== index));
+ };
+
+ // 폼 초기화
+ const resetForm = () => {
+ setDrawingUsage("");
+ setRegisterKind("");
+ setRevision("");
+ setFiles([]);
+ };
+
+ // 제출
+ const handleSubmit = async () => {
+ if (!drawing) return;
+
+ // 유효성 검사
+ if (!drawingUsage) {
+ toast.error("도면용도를 선택하세요");
+ return;
+ }
+ if (!registerKind) {
+ toast.error("등록종류를 선택하세요");
+ return;
+ }
+ if (!revision.trim()) {
+ toast.error("Revision을 입력하세요");
+ return;
+ }
+ if (files.length === 0) {
+ toast.error("최소 1개 이상의 파일을 첨부해야 합니다");
+ return;
+ }
+
+ try {
+ setIsSubmitting(true);
+
+ // 파일 업로드 ID 생성
+ const uploadId = uuidv4();
+
+ // 상세도면 추가
+ const result = await editDetailDwgReceipt({
+ dwgList: [
+ {
+ Mode: "ADD",
+ Status: "Draft",
+ RegisterId: 0,
+ ProjectNo: drawing.ProjectNo,
+ Discipline: drawing.Discipline,
+ DrawingKind: drawing.DrawingKind,
+ DrawingNo: drawing.DrawingNo,
+ DrawingName: drawing.DrawingName,
+ RegisterGroupId: drawing.RegisterGroupId,
+ RegisterSerialNo: 0, // 자동 증가
+ RegisterKind: registerKind,
+ DrawingRevNo: revision,
+ Category: "TS", // To SHI (벤더가 SHI에게 제출)
+ Receiver: null,
+ Manager: "",
+ RegisterDesc: "",
+ UploadId: uploadId,
+ RegCompanyCode: vendorCode,
+ },
+ ],
+ userId,
+ userNm: userName,
+ vendorCode,
+ email: userEmail,
+ });
+
+ if (result > 0) {
+ // 파일 업로드 처리 (상세도면 추가 후)
+ if (files.length > 0) {
+ toast.info(`${files.length}개 파일 업로드를 진행합니다...`);
+
+ const formData = new FormData();
+ formData.append("uploadId", uploadId);
+ formData.append("userId", userId);
+ formData.append("fileCount", String(files.length));
+
+ files.forEach((file, index) => {
+ formData.append(`file_${index}`, file);
+ });
+
+ const uploadResult = await uploadFilesToDetailDrawing(formData);
+
+ if (uploadResult.success) {
+ toast.success(`상세도면 추가 및 ${uploadResult.uploadedCount}개 파일 업로드 완료`);
+ } else {
+ toast.warning(`상세도면은 추가되었으나 파일 업로드 실패: ${uploadResult.error}`);
+ }
+ } else {
+ toast.success("상세도면이 추가되었습니다");
+ }
+
+ resetForm();
+ onComplete();
+ } else {
+ toast.error("상세도면 추가에 실패했습니다");
+ }
+ } catch (error) {
+ console.error("상세도면 추가 실패:", error);
+ toast.error("상세도면 추가 중 오류가 발생했습니다");
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const handleCancel = () => {
+ resetForm();
+ onOpenChange(false);
+ };
+
+ // DrawingUsage가 변경되면 RegisterKind 초기화
+ const handleDrawingUsageChange = (value: string) => {
+ setDrawingUsage(value);
+ setRegisterKind("");
+ };
+
+ // 현재 선택 가능한 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 || "";
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-2xl">
+ <DialogHeader>
+ <DialogTitle>상세도면 추가</DialogTitle>
+ </DialogHeader>
+
+ <div className="space-y-6">
+ {/* 도면 정보 표시 */}
+ {drawing && (
+ <Alert>
+ <Info className="h-4 w-4" />
+ <AlertDescription>
+ <div className="font-medium">{drawing.DrawingNo}</div>
+ <div className="text-sm text-muted-foreground">{drawing.DrawingName}</div>
+ </AlertDescription>
+ </Alert>
+ )}
+
+ {/* 도면용도 선택 */}
+ <div className="space-y-2">
+ <Label>도면용도 (Drawing Usage)</Label>
+ <Select value={drawingUsage} onValueChange={handleDrawingUsageChange}>
+ <SelectTrigger>
+ <SelectValue placeholder="도면용도를 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ {drawingUsageOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ {/* 등록종류 선택 */}
+ <div className="space-y-2">
+ <Label>등록종류 (Register Kind)</Label>
+ <Select
+ value={registerKind}
+ onValueChange={setRegisterKind}
+ disabled={!drawingUsage}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="등록종류를 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ {registerKindOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ {revisionRule && (
+ <p className="text-sm text-muted-foreground">
+ Revision 입력 형식: {revisionRule}
+ </p>
+ )}
+ </div>
+
+ {/* Revision 입력 */}
+ <div className="space-y-2">
+ <Label>Revision</Label>
+ <Input
+ value={revision}
+ onChange={(e) => setRevision(e.target.value)}
+ placeholder="예: A, B, R00, R01"
+ disabled={!registerKind}
+ />
+ </div>
+
+ {/* 파일 업로드 */}
+ <div className="space-y-2">
+ <Label>첨부파일 (필수) *</Label>
+ <div
+ {...getRootProps()}
+ className={`
+ border-2 border-dashed rounded-lg p-8 text-center cursor-pointer
+ transition-colors
+ ${isDragActive ? "border-primary bg-primary/5" : "border-muted-foreground/25"}
+ ${files.length > 0 ? "py-4" : ""}
+ `}
+ >
+ <input {...getInputProps()} />
+ {files.length === 0 ? (
+ <div className="space-y-2">
+ <Upload className="h-8 w-8 mx-auto text-muted-foreground" />
+ <div>
+ <p className="text-sm font-medium">
+ 파일을 드래그하거나 클릭하여 선택
+ </p>
+ <p className="text-xs text-muted-foreground">
+ 여러 파일을 한 번에 업로드할 수 있습니다
+ </p>
+ </div>
+ </div>
+ ) : (
+ <div className="space-y-2">
+ <p className="text-sm font-medium">
+ {files.length}개 파일 선택됨
+ </p>
+ <p className="text-xs text-muted-foreground">
+ 추가로 파일을 드래그하거나 클릭하여 더 추가할 수 있습니다
+ </p>
+ </div>
+ )}
+ </div>
+
+ {/* 선택된 파일 목록 */}
+ {files.length > 0 && (
+ <div className="space-y-2 mt-4">
+ {files.map((file, index) => (
+ <div
+ key={index}
+ className="flex items-center gap-2 p-2 border rounded-lg"
+ >
+ <FileIcon className="h-4 w-4 text-muted-foreground" />
+ <span className="flex-1 text-sm truncate">{file.name}</span>
+ <span className="text-xs text-muted-foreground">
+ {(file.size / 1024).toFixed(2)} KB
+ </span>
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={() => removeFile(index)}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button variant="outline" onClick={handleCancel} disabled={isSubmitting}>
+ 취소
+ </Button>
+ <Button onClick={handleSubmit} disabled={isSubmitting}>
+ {isSubmitting ? "처리 중..." : "추가"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
+
diff --git a/lib/dolce/dialogs/b4-bulk-upload-dialog.tsx b/lib/dolce/dialogs/b4-bulk-upload-dialog.tsx
new file mode 100644
index 00000000..f4816328
--- /dev/null
+++ b/lib/dolce/dialogs/b4-bulk-upload-dialog.tsx
@@ -0,0 +1,608 @@
+"use client";
+
+import * as React from "react";
+import { useState } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Label } from "@/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { FolderOpen, Loader2, ChevronRight, ChevronLeft, CheckCircle2 } from "lucide-react";
+import { toast } from "sonner";
+import { Progress } from "@/components/ui/progress";
+import {
+ validateB4FileName,
+ B4UploadValidationDialog,
+ type FileValidationResult,
+} from "./b4-upload-validation-dialog";
+import {
+ checkB4MappingStatus,
+ bulkUploadB4Files,
+ type MappingCheckItem,
+ type B4BulkUploadResult,
+} from "../actions";
+
+interface B4BulkUploadDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ projectNo: string;
+ userId: string;
+ userName: string;
+ userEmail: string;
+ vendorCode: string;
+ onUploadComplete?: () => void;
+}
+
+// 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({
+ open,
+ onOpenChange,
+ projectNo,
+ userId,
+ userName,
+ userEmail,
+ vendorCode,
+ onUploadComplete,
+}: B4BulkUploadDialogProps) {
+ const [currentStep, setCurrentStep] = useState<UploadStep>("settings");
+ const [drawingUsage, setDrawingUsage] = useState<string>("REC");
+ const [registerKind, setRegisterKind] = useState<string>("");
+ const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
+ const [isUploading, setIsUploading] = useState(false);
+ const [validationResults, setValidationResults] = useState<FileValidationResult[]>([]);
+ const [showValidationDialog, setShowValidationDialog] = useState(false);
+ const [isDragging, setIsDragging] = useState(false);
+ const [uploadProgress, setUploadProgress] = useState(0);
+ const [uploadResult, setUploadResult] = useState<B4BulkUploadResult | null>(null);
+
+ // 다이얼로그 닫을 때 초기화
+ React.useEffect(() => {
+ if (!open) {
+ setCurrentStep("settings");
+ setDrawingUsage("REC");
+ setRegisterKind("");
+ setSelectedFiles([]);
+ setValidationResults([]);
+ setShowValidationDialog(false);
+ setIsDragging(false);
+ setUploadProgress(0);
+ setUploadResult(null);
+ }
+ }, [open]);
+
+ // 파일 선택 핸들러
+ const handleFilesChange = (files: File[]) => {
+ if (files.length === 0) return;
+
+ // 중복 제거
+ const existingNames = new Set(selectedFiles.map((f) => f.name));
+ const newFiles = files.filter((f) => !existingNames.has(f.name));
+
+ if (newFiles.length === 0) {
+ toast.error("이미 선택된 파일입니다");
+ return;
+ }
+
+ setSelectedFiles((prev) => [...prev, ...newFiles]);
+ toast.success(`${newFiles.length}개 파일이 선택되었습니다`);
+ };
+
+ // Drag & Drop 핸들러
+ const handleDragEnter = (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDragging(true);
+ };
+
+ const handleDragLeave = (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (e.currentTarget === e.target) {
+ setIsDragging(false);
+ }
+ };
+
+ const handleDragOver = (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ e.dataTransfer.dropEffect = "copy";
+ };
+
+ const handleDrop = (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDragging(false);
+
+ const droppedFiles = Array.from(e.dataTransfer.files);
+ if (droppedFiles.length > 0) {
+ handleFilesChange(droppedFiles);
+ }
+ };
+
+ // 파일 제거
+ const handleRemoveFile = (index: number) => {
+ setSelectedFiles((prev) => prev.filter((_, i) => i !== index));
+ };
+
+ // 1단계 완료 (설정)
+ const handleSettingsNext = () => {
+ if (!registerKind) {
+ toast.error("등록종류를 선택하세요");
+ return;
+ }
+ setCurrentStep("files");
+ };
+
+ // 2단계 완료 (파일 선택)
+ const handleFilesNext = () => {
+ if (selectedFiles.length === 0) {
+ toast.error("파일을 선택해주세요");
+ return;
+ }
+ setCurrentStep("validation");
+ handleValidate();
+ };
+
+ // 검증 시작
+ const handleValidate = async () => {
+ try {
+ // 1단계: 파일명 파싱
+ const parseResults: FileValidationResult[] = selectedFiles.map((file) => {
+ const validation = validateB4FileName(file.name);
+ return {
+ file,
+ valid: validation.valid,
+ parsed: validation.parsed,
+ error: validation.error,
+ };
+ });
+
+ // 파싱에 실패한 파일이 있으면 바로 검증 다이얼로그 표시
+ const parsedFiles = parseResults.filter((r) => r.valid && r.parsed);
+ if (parsedFiles.length === 0) {
+ setValidationResults(parseResults);
+ setShowValidationDialog(true);
+ return;
+ }
+
+ // 2단계: 매핑 현황 조회
+ const mappingCheckItems: MappingCheckItem[] = parsedFiles.map((r) => ({
+ DrawingNo: r.parsed!.drawingNo,
+ RevNo: r.parsed!.revNo,
+ FileNm: r.file.name,
+ }));
+
+ const mappingResults = await checkB4MappingStatus(
+ projectNo,
+ mappingCheckItems
+ );
+
+ // 3단계: 검증 결과 병합
+ const finalResults: FileValidationResult[] = parseResults.map((parseResult) => {
+ if (!parseResult.valid || !parseResult.parsed) {
+ return parseResult;
+ }
+
+ // 매핑 결과 찾기
+ const mappingResult = mappingResults.find(
+ (m) =>
+ m.DrawingNo === parseResult.parsed!.drawingNo &&
+ m.RevNo === parseResult.parsed!.revNo
+ );
+
+ if (!mappingResult) {
+ return {
+ ...parseResult,
+ mappingStatus: "not_found" as const,
+ error: "DOLCE 시스템에서 도면을 찾을 수 없습니다",
+ };
+ }
+
+ // RegisterGroupId가 0이거나 MappingYN이 N이면 도면이 존재하지 않음
+ if (mappingResult.RegisterGroupId === 0 || mappingResult.MappingYN === "N") {
+ return {
+ ...parseResult,
+ mappingStatus: "not_found" as const,
+ error: "해당 도면번호가 프로젝트에 등록되어 있지 않습니다",
+ };
+ }
+
+ // DrawingMoveGbn이 "도면입수"가 아니면 업로드 불가
+ if (mappingResult.DrawingMoveGbn !== "도면입수") {
+ return {
+ ...parseResult,
+ mappingStatus: "not_found" as const,
+ error: "도면입수(GTT Deliverables)인 도면만 업로드 가능합니다",
+ };
+ }
+
+ // MappingYN이 Y이고 도면입수인 경우 업로드 가능
+ return {
+ ...parseResult,
+ mappingStatus: "available" as const,
+ drawingName: mappingResult.DrawingName || undefined,
+ registerGroupId: mappingResult.RegisterGroupId,
+ };
+ });
+
+ setValidationResults(finalResults);
+ setShowValidationDialog(true);
+ } catch (error) {
+ console.error("검증 실패:", error);
+ toast.error(
+ error instanceof Error ? error.message : "검증 중 오류가 발생했습니다"
+ );
+ }
+ };
+
+ // 업로드 확인
+ const handleConfirmUpload = async (validFiles: FileValidationResult[]) => {
+ setIsUploading(true);
+ setCurrentStep("uploading");
+ setShowValidationDialog(false);
+
+ // 진행률 시뮬레이션
+ const progressInterval = setInterval(() => {
+ setUploadProgress((prev) => {
+ if (prev >= 90) {
+ clearInterval(progressInterval);
+ return prev;
+ }
+ return prev + 10;
+ });
+ }, 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)
+ );
+ });
+
+ formData.append("fileCount", String(validFiles.length));
+
+ // 서버 액션 호출
+ const result: B4BulkUploadResult = await bulkUploadB4Files(formData);
+
+ clearInterval(progressInterval);
+ setUploadProgress(100);
+ setUploadResult(result);
+
+ if (result.success) {
+ setCurrentStep("complete");
+ toast.success(
+ `${result.successCount}/${validFiles.length}개 파일 업로드 완료`
+ );
+ } else {
+ setCurrentStep("files");
+ toast.error(result.error || "업로드 실패");
+ }
+ } catch (error) {
+ console.error("업로드 실패:", error);
+ toast.error(
+ error instanceof Error ? error.message : "업로드 중 오류가 발생했습니다"
+ );
+ } finally {
+ setIsUploading(false);
+ }
+ };
+
+ const registerKindOptions = drawingUsage
+ ? B4_REGISTER_KIND_OPTIONS[drawingUsage] || []
+ : [];
+
+ const handleDrawingUsageChange = (value: string) => {
+ setDrawingUsage(value);
+ setRegisterKind("");
+ };
+
+ return (
+ <>
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-2xl">
+ <DialogHeader>
+ <DialogTitle>B4 일괄 업로드</DialogTitle>
+ <DialogDescription>
+ {currentStep === "settings" && "업로드 설정을 선택하세요"}
+ {currentStep === "files" && "파일명 형식: [버림] [DrawingNo] [RevNo].[확장자] (예: testfile GTT-DE-007 R01.pdf)"}
+ {currentStep === "validation" && "파일 검증 중..."}
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ {/* 1단계: 설정 입력 */}
+ {currentStep === "settings" && (
+ <>
+ {/* 도면용도 선택 */}
+ <div className="space-y-2">
+ <Label>도면용도 (Drawing Usage) *</Label>
+ <Select value={drawingUsage} onValueChange={handleDrawingUsageChange}>
+ <SelectTrigger>
+ <SelectValue placeholder="도면용도를 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ {B4_DRAWING_USAGE_OPTIONS.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ {/* 등록종류 선택 */}
+ <div className="space-y-2">
+ <Label>등록종류 (Register Kind) *</Label>
+ <Select
+ value={registerKind}
+ onValueChange={setRegisterKind}
+ disabled={!drawingUsage}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="등록종류를 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ {registerKindOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <p className="text-sm text-muted-foreground">
+ 선택한 등록종류가 모든 파일에 적용됩니다
+ </p>
+ </div>
+ </>
+ )}
+
+ {/* 2단계: 파일 선택 */}
+ {currentStep === "files" && (
+ <>
+ {/* 파일 선택 영역 */}
+ <div
+ className={`border-2 border-dashed rounded-lg p-8 transition-all duration-200 ${
+ isDragging
+ ? "border-primary bg-primary/5 scale-[1.02]"
+ : "border-muted-foreground/30 hover:border-muted-foreground/50"
+ }`}
+ onDragEnter={handleDragEnter}
+ onDragLeave={handleDragLeave}
+ onDragOver={handleDragOver}
+ onDrop={handleDrop}
+ >
+ <input
+ type="file"
+ multiple
+ accept=".pdf,.doc,.docx,.xls,.xlsx,.dwg,.dxf,.zip"
+ onChange={(e) => handleFilesChange(Array.from(e.target.files || []))}
+ className="hidden"
+ id="b4-file-upload"
+ />
+ <label
+ htmlFor="b4-file-upload"
+ className="flex flex-col items-center justify-center cursor-pointer"
+ >
+ <FolderOpen
+ className={`h-12 w-12 mb-3 transition-colors ${
+ isDragging ? "text-primary" : "text-muted-foreground"
+ }`}
+ />
+ <p
+ className={`text-sm transition-colors ${
+ isDragging
+ ? "text-primary font-medium"
+ : "text-muted-foreground"
+ }`}
+ >
+ {isDragging
+ ? "파일을 여기에 놓으세요"
+ : "클릭하거나 파일을 드래그하여 선택"}
+ </p>
+ <p className="text-xs text-muted-foreground mt-1">
+ PDF, DOC, DOCX, XLS, XLSX, DWG, DXF, ZIP
+ </p>
+ </label>
+ </div>
+
+ {/* 선택된 파일 목록 */}
+ {selectedFiles.length > 0 && (
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center justify-between mb-3">
+ <h4 className="text-sm font-medium">
+ 선택된 파일 ({selectedFiles.length}개)
+ </h4>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => setSelectedFiles([])}
+ >
+ 전체 제거
+ </Button>
+ </div>
+ <div className="max-h-48 overflow-auto space-y-2">
+ {selectedFiles.map((file, index) => (
+ <div
+ key={index}
+ className="flex items-center justify-between p-2 rounded bg-muted/50"
+ >
+ <div className="flex-1 min-w-0">
+ <p className="text-sm truncate">{file.name}</p>
+ <p className="text-xs text-muted-foreground">
+ {(file.size / 1024 / 1024).toFixed(2)} MB
+ </p>
+ </div>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handleRemoveFile(index)}
+ >
+ 제거
+ </Button>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+ </>
+ )}
+
+ {/* 3단계: 검증 중 표시 */}
+ {currentStep === "validation" && (
+ <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">
+ 파일 검증 중입니다...
+ </p>
+ </div>
+ )}
+
+ {/* 4단계: 업로드 진행 중 */}
+ {currentStep === "uploading" && (
+ <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>
+ <p className="text-sm text-muted-foreground">
+ 잠시만 기다려주세요
+ </p>
+ </div>
+ <div className="space-y-2">
+ <div className="flex justify-between text-sm">
+ <span>진행률</span>
+ <span>{uploadProgress}%</span>
+ </div>
+ <Progress value={uploadProgress} className="h-2" />
+ </div>
+ </div>
+ )}
+
+ {/* 5단계: 업로드 완료 */}
+ {currentStep === "complete" && uploadResult && (
+ <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>
+ <p className="text-sm text-muted-foreground">
+ {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}개 파일 업로드 실패
+ </p>
+ </div>
+ )}
+
+ <div className="flex justify-center">
+ <Button
+ onClick={() => {
+ onOpenChange(false);
+ onUploadComplete?.();
+ }}
+ >
+ 확인
+ </Button>
+ </div>
+ </div>
+ )}
+ </div>
+
+ {/* 푸터 버튼 (uploading, complete 단계에서는 숨김) */}
+ {currentStep !== "uploading" && currentStep !== "complete" && currentStep !== "validation" && (
+ <DialogFooter>
+ {currentStep === "settings" && (
+ <>
+ <Button
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ >
+ 취소
+ </Button>
+ <Button
+ onClick={handleSettingsNext}
+ disabled={!registerKind}
+ >
+ 다음
+ <ChevronRight className="ml-2 h-4 w-4" />
+ </Button>
+ </>
+ )}
+
+ {currentStep === "files" && (
+ <>
+ <Button
+ variant="outline"
+ onClick={() => setCurrentStep("settings")}
+ >
+ <ChevronLeft className="mr-2 h-4 w-4" />
+ 이전
+ </Button>
+ <Button
+ onClick={handleFilesNext}
+ disabled={selectedFiles.length === 0}
+ >
+ 검증 시작
+ <ChevronRight className="ml-2 h-4 w-4" />
+ </Button>
+ </>
+ )}
+ </DialogFooter>
+ )}
+ </DialogContent>
+ </Dialog>
+
+ {/* 검증 다이얼로그 */}
+ <B4UploadValidationDialog
+ open={showValidationDialog}
+ onOpenChange={setShowValidationDialog}
+ validationResults={validationResults}
+ onConfirmUpload={handleConfirmUpload}
+ isUploading={isUploading}
+ />
+ </>
+ );
+}
+
diff --git a/lib/dolce/dialogs/b4-upload-validation-dialog.tsx b/lib/dolce/dialogs/b4-upload-validation-dialog.tsx
new file mode 100644
index 00000000..b274d604
--- /dev/null
+++ b/lib/dolce/dialogs/b4-upload-validation-dialog.tsx
@@ -0,0 +1,353 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Badge } from "@/components/ui/badge";
+import { CheckCircle2, XCircle, AlertCircle, Upload } from "lucide-react";
+
+export interface ParsedFileInfo {
+ drawingNo: string;
+ revNo: string;
+ fileName: string;
+}
+
+export interface FileValidationResult {
+ file: File;
+ valid: boolean;
+ parsed?: ParsedFileInfo;
+ error?: string;
+ mappingStatus?: "available" | "not_found";
+ drawingName?: string;
+ registerGroupId?: number;
+}
+
+interface B4UploadValidationDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ validationResults: FileValidationResult[];
+ onConfirmUpload: (validFiles: FileValidationResult[]) => void;
+ isUploading: boolean;
+}
+
+/**
+ * B4 파일명 검증 함수
+ * 형식: [버림] [DrawingNo] [RevNo].[확장자]
+ * 예시: "testfile GTT-DE-007 R01.pdf" → DrawingNo: "GTT-DE-007", RevNo: "R01"
+ */
+export function validateB4FileName(fileName: string): {
+ valid: boolean;
+ parsed?: ParsedFileInfo;
+ error?: string;
+} {
+ try {
+ // 확장자 분리
+ const lastDotIndex = fileName.lastIndexOf(".");
+ if (lastDotIndex === -1) {
+ return {
+ valid: false,
+ error: "파일 확장자가 없습니다",
+ };
+ }
+
+ const extension = fileName.substring(lastDotIndex + 1);
+ const nameWithoutExt = fileName.substring(0, lastDotIndex);
+
+ // 공백으로 분리
+ const parts = nameWithoutExt.split(" ").filter(p => p.trim() !== "");
+
+ // 최소 3개 파트 필요: [버림], DrawingNo, RevNo
+ if (parts.length < 3) {
+ return {
+ valid: false,
+ error: `공백이 최소 2개 있어야 합니다 (현재: ${parts.length - 1}개). 형식: [버림] [DrawingNo] [RevNo].[확장자]`,
+ };
+ }
+
+ // 첫 번째 토큰은 버림
+ const drawingNo = parts[1];
+ const revNo = parts[2];
+
+ // 필수 항목이 비어있지 않은지 확인
+ if (!drawingNo || drawingNo.trim() === "") {
+ return {
+ valid: false,
+ error: "도면번호(DrawingNo)가 비어있습니다",
+ };
+ }
+
+ if (!revNo || revNo.trim() === "") {
+ return {
+ valid: false,
+ error: "리비전 번호(RevNo)가 비어있습니다",
+ };
+ }
+
+ return {
+ valid: true,
+ parsed: {
+ drawingNo: drawingNo.trim(),
+ revNo: revNo.trim(),
+ fileName: fileName,
+ },
+ };
+ } catch (error) {
+ return {
+ valid: false,
+ error: error instanceof Error ? error.message : "알 수 없는 오류",
+ };
+ }
+}
+
+/**
+ * B4 업로드 전 파일 검증 다이얼로그
+ */
+export function B4UploadValidationDialog({
+ open,
+ onOpenChange,
+ validationResults,
+ onConfirmUpload,
+ isUploading,
+}: B4UploadValidationDialogProps) {
+ const validFiles = validationResults.filter((r) => r.valid && r.mappingStatus === "available");
+ const notFoundFiles = validationResults.filter((r) => r.valid && r.mappingStatus === "not_found");
+ const invalidFiles = validationResults.filter((r) => !r.valid);
+
+ const handleUpload = () => {
+ if (validFiles.length > 0) {
+ onConfirmUpload(validFiles);
+ }
+ };
+
+ const handleCancel = () => {
+ if (!isUploading) {
+ onOpenChange(false);
+ }
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl max-h-[85vh] flex flex-col">
+ <DialogHeader className="flex-shrink-0">
+ <DialogTitle>B4 일괄 업로드 검증</DialogTitle>
+ <DialogDescription>
+ 선택한 파일의 파일명 형식과 매핑 가능 여부를 검증합니다
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4 overflow-auto flex-1 pr-2">
+ {/* 요약 통계 */}
+ <div className="grid grid-cols-4 gap-3">
+ <div className="rounded-lg border p-3">
+ <div className="text-sm text-muted-foreground">전체</div>
+ <div className="text-2xl font-bold">{validationResults.length}</div>
+ </div>
+ <div className="rounded-lg border p-3 bg-green-50 dark:bg-green-950/30">
+ <div className="text-sm text-green-600 dark:text-green-400">업로드 가능</div>
+ <div className="text-2xl font-bold text-green-600 dark:text-green-400">
+ {validFiles.length}
+ </div>
+ </div>
+ <div className="rounded-lg border p-3 bg-orange-50 dark:bg-orange-950/30">
+ <div className="text-sm text-orange-600 dark:text-orange-400">도면 없음</div>
+ <div className="text-2xl font-bold text-orange-600 dark:text-orange-400">
+ {notFoundFiles.length}
+ </div>
+ </div>
+ <div className="rounded-lg border p-3 bg-red-50 dark:bg-red-950/30">
+ <div className="text-sm text-red-600 dark:text-red-400">형식 오류</div>
+ <div className="text-2xl font-bold text-red-600 dark:text-red-400">
+ {invalidFiles.length}
+ </div>
+ </div>
+ </div>
+
+ {/* 경고 메시지 */}
+ {validFiles.length === 0 && (
+ <Alert variant="destructive">
+ <XCircle className="h-4 w-4" />
+ <AlertDescription>
+ 업로드 가능한 파일이 없습니다. 파일명 형식을 확인하거나 이미 매핑된 파일은 제외해주세요.
+ </AlertDescription>
+ </Alert>
+ )}
+
+ {(notFoundFiles.length > 0 || invalidFiles.length > 0) && validFiles.length > 0 && (
+ <Alert>
+ <AlertCircle className="h-4 w-4" />
+ <AlertDescription>
+ 일부 파일에 문제가 있습니다. 업로드 가능한 {validFiles.length}개 파일만 업로드됩니다.
+ </AlertDescription>
+ </Alert>
+ )}
+
+ {/* 파일 목록 */}
+ <div className="max-h-[50vh] overflow-auto rounded-md border p-4">
+ <div className="space-y-4">
+ {/* 업로드 가능 파일 */}
+ {validFiles.length > 0 && (
+ <div className="space-y-2">
+ <h4 className="text-sm font-semibold text-green-600 dark:text-green-400 flex items-center gap-2">
+ <CheckCircle2 className="h-4 w-4" />
+ 업로드 가능 ({validFiles.length}개)
+ </h4>
+ {validFiles.map((result, index) => (
+ <div
+ key={index}
+ className="rounded-lg border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-950/30 p-3"
+ >
+ <div className="flex items-start justify-between gap-2">
+ <div className="flex-1 min-w-0">
+ <div className="font-mono text-sm break-all">
+ {result.file.name}
+ </div>
+ {result.parsed && (
+ <div className="flex flex-wrap gap-1 mt-2">
+ <Badge variant="outline" className="text-xs">
+ 도면: {result.parsed.drawingNo}
+ </Badge>
+ <Badge variant="outline" className="text-xs">
+ Rev: {result.parsed.revNo}
+ </Badge>
+ {result.drawingName && (
+ <Badge variant="outline" className="text-xs">
+ {result.drawingName}
+ </Badge>
+ )}
+ </div>
+ )}
+ </div>
+ <CheckCircle2 className="h-5 w-5 text-green-600 dark:text-green-400 shrink-0" />
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
+
+ {/* 도면을 찾을 수 없는 파일 */}
+ {notFoundFiles.length > 0 && (
+ <div className="space-y-2 mt-4">
+ <h4 className="text-sm font-semibold text-orange-600 dark:text-orange-400 flex items-center gap-2">
+ <XCircle className="h-4 w-4" />
+ 도면을 찾을 수 없음 ({notFoundFiles.length}개)
+ </h4>
+ {notFoundFiles.map((result, index) => (
+ <div
+ key={index}
+ className="rounded-lg border border-orange-200 dark:border-orange-800 bg-orange-50 dark:bg-orange-950/30 p-3"
+ >
+ <div className="flex items-start justify-between gap-2">
+ <div className="flex-1 min-w-0">
+ <div className="font-mono text-sm break-all">
+ {result.file.name}
+ </div>
+ <div className="text-xs text-orange-700 dark:text-orange-300 mt-1">
+ ✗ 해당 도면번호가 프로젝트에 등록되어 있지 않습니다
+ </div>
+ {result.parsed && (
+ <div className="flex flex-wrap gap-1 mt-2">
+ <Badge variant="outline" className="text-xs">
+ 도면: {result.parsed.drawingNo}
+ </Badge>
+ <Badge variant="outline" className="text-xs">
+ Rev: {result.parsed.revNo}
+ </Badge>
+ </div>
+ )}
+ </div>
+ <XCircle className="h-5 w-5 text-orange-600 dark:text-orange-400 shrink-0" />
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
+
+ {/* 형식 오류 파일 */}
+ {invalidFiles.length > 0 && (
+ <div className="space-y-2 mt-4">
+ <h4 className="text-sm font-semibold text-red-600 dark:text-red-400 flex items-center gap-2">
+ <XCircle className="h-4 w-4" />
+ 파일명 형식 오류 ({invalidFiles.length}개)
+ </h4>
+ {invalidFiles.map((result, index) => (
+ <div
+ key={index}
+ className="rounded-lg border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-950/30 p-3"
+ >
+ <div className="flex items-start justify-between gap-2">
+ <div className="flex-1 min-w-0">
+ <div className="font-mono text-sm break-all">
+ {result.file.name}
+ </div>
+ {result.error && (
+ <div className="text-xs text-red-600 dark:text-red-400 mt-1">
+ ✗ {result.error}
+ </div>
+ )}
+ </div>
+ <XCircle className="h-5 w-5 text-red-600 dark:text-red-400 shrink-0" />
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ </div>
+
+ {/* 형식 안내 */}
+ <div className="rounded-lg bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800 p-3">
+ <div className="text-sm font-medium text-blue-900 dark:text-blue-100 mb-1">
+ 📋 올바른 파일명 형식
+ </div>
+ <code className="text-xs text-blue-700 dark:text-blue-300">
+ [버림] [DrawingNo] [RevNo].[확장자]
+ </code>
+ <div className="text-xs text-blue-600 dark:text-blue-400 mt-1">
+ 예: testfile GTT-DE-007 R01.pdf
+ </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">
+ ※ 네 번째 이상의 단어가 있으면 무시됩니다
+ </div>
+ </div>
+ </div>
+
+ <DialogFooter className="flex-shrink-0">
+ <Button
+ variant="outline"
+ onClick={handleCancel}
+ disabled={isUploading}
+ >
+ 취소
+ </Button>
+ <Button
+ onClick={handleUpload}
+ disabled={validFiles.length === 0 || isUploading}
+ >
+ {isUploading ? (
+ <>
+ <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2" />
+ 업로드 중...
+ </>
+ ) : (
+ <>
+ <Upload className="h-4 w-4 mr-2" />
+ 업로드 ({validFiles.length}개)
+ </>
+ )}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
+
diff --git a/lib/dolce/dialogs/detail-drawing-dialog.tsx b/lib/dolce/dialogs/detail-drawing-dialog.tsx
new file mode 100644
index 00000000..a06c9688
--- /dev/null
+++ b/lib/dolce/dialogs/detail-drawing-dialog.tsx
@@ -0,0 +1,311 @@
+"use client";
+
+import { useState, useEffect, useCallback } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+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 {
+ UnifiedDwgReceiptItem,
+ DetailDwgReceiptItem,
+ FileInfoItem,
+ fetchDetailDwgReceiptList,
+ fetchFileInfoList,
+} from "../actions";
+import { DrawingListTable } from "../table/drawing-list-table";
+import { detailDrawingColumns } 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";
+
+interface DetailDrawingDialogProps {
+ drawing: UnifiedDwgReceiptItem | null;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ vendorCode: string;
+ userId: string;
+ userName: string;
+ userEmail: string;
+ drawingKind: "B3" | "B4";
+}
+
+export function DetailDrawingDialog({
+ drawing,
+ open,
+ onOpenChange,
+ vendorCode,
+ userId,
+ userName,
+ userEmail,
+ drawingKind,
+}: DetailDrawingDialogProps) {
+ const [detailDrawings, setDetailDrawings] = useState<DetailDwgReceiptItem[]>([]);
+ const [selectedDetail, setSelectedDetail] = useState<DetailDwgReceiptItem | null>(null);
+ const [files, setFiles] = useState<FileInfoItem[]>([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [isLoadingFiles, setIsLoadingFiles] = useState(false);
+ const [addDialogOpen, setAddDialogOpen] = useState(false);
+ const [uploadFilesDialogOpen, setUploadFilesDialogOpen] = useState(false);
+
+ // 상세도면 목록 로드
+ const loadDetailDrawings = useCallback(async () => {
+ if (!drawing) return;
+
+ try {
+ setIsLoading(true);
+ const data = await fetchDetailDwgReceiptList({
+ project: drawing.ProjectNo,
+ drawingNo: drawing.DrawingNo,
+ discipline: drawing.Discipline,
+ drawingKind: drawing.DrawingKind,
+ userId: "", // 조회 시 모든 사용자의 상세도면을 보기 위해 빈 문자열 전달
+ });
+ setDetailDrawings(data);
+
+ // 첫 번째 상세도면 자동 선택
+ if (data.length > 0 && !selectedDetail) {
+ setSelectedDetail(data[0]);
+ }
+ } catch (error) {
+ console.error("상세도면 로드 실패:", error);
+ toast.error("상세도면 로드에 실패했습니다");
+ } finally {
+ setIsLoading(false);
+ }
+ }, [drawing, selectedDetail]);
+
+ // 파일 목록 로드
+ const loadFiles = useCallback(async () => {
+ if (!selectedDetail) {
+ setFiles([]);
+ return;
+ }
+
+ try {
+ setIsLoadingFiles(true);
+ const data = await fetchFileInfoList(selectedDetail.UploadId);
+ setFiles(data);
+ } catch (error) {
+ console.error("파일 목록 로드 실패:", error);
+ toast.error("파일 목록 로드에 실패했습니다");
+ } finally {
+ setIsLoadingFiles(false);
+ }
+ }, [selectedDetail]);
+
+ // 다이얼로그 열릴 때 데이터 로드
+ useEffect(() => {
+ if (open && drawing) {
+ loadDetailDrawings();
+ } else {
+ setDetailDrawings([]);
+ setSelectedDetail(null);
+ setFiles([]);
+ }
+ }, [open, drawing, loadDetailDrawings]);
+
+ // 선택된 상세도면 변경 시 파일 목록 로드
+ useEffect(() => {
+ if (selectedDetail) {
+ loadFiles();
+ }
+ }, [selectedDetail, loadFiles]);
+
+ const handleDownload = async (file: FileInfoItem) => {
+ try {
+ toast.info("파일 다운로드를 준비 중입니다...");
+
+ // 파일 생성자의 userId를 사용하여 다운로드
+ const response = await fetch("/api/dolce/download", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ fileId: file.FileId,
+ userId: file.CreateUserId, // 파일 생성자의 ID 사용
+ fileName: file.FileName,
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error("파일 다운로드 실패");
+ }
+
+ const blob = await response.blob();
+ const url = window.URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = file.FileName;
+ document.body.appendChild(a);
+ a.click();
+ window.URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+
+ toast.success("파일 다운로드가 완료되었습니다");
+ } catch (error) {
+ console.error("파일 다운로드 실패:", error);
+ toast.error("파일 다운로드에 실패했습니다");
+ }
+ };
+
+ const handleRefresh = () => {
+ loadDetailDrawings();
+ };
+
+ const handleAddComplete = () => {
+ setAddDialogOpen(false);
+ loadDetailDrawings();
+ };
+
+ const handleUploadComplete = () => {
+ setUploadFilesDialogOpen(false);
+ loadFiles();
+ };
+
+ const fileColumns = createFileListColumns({ onDownload: handleDownload });
+
+ // RegisterId + UploadId 조합으로 고유 ID 생성
+ const getDetailDrawingId = (detail: DetailDwgReceiptItem) => {
+ return `${detail.RegisterId}_${detail.UploadId}`;
+ };
+
+ // B4인 경우 "도면입수"인 건만 상세도면 추가 및 파일 첨부 가능
+ // B3인 경우 모든 건에 대해 가능
+ const canAddDetailDrawing = drawingKind === "B3" ||
+ (drawingKind === "B4" && drawing && 'DrawingMoveGbn' in drawing && drawing.DrawingMoveGbn === "도면입수");
+
+ return (
+ <>
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-[95vw] h-[90vh] flex flex-col">
+ <DialogHeader>
+ <DialogTitle className="flex flex-col gap-1">
+ <span>상세도면 정보</span>
+ {drawing && (
+ <span className="text-sm font-normal text-muted-foreground">
+ {drawing.DrawingNo} | 프로젝트: {drawing.ProjectNo} | Discipline: {drawing.Discipline} | 종류: {drawing.DrawingKind}
+ </span>
+ )}
+ </DialogTitle>
+ </DialogHeader>
+
+ <div className="flex-1 overflow-hidden flex flex-col gap-4">
+ {/* 상단: 상세도면 리스트 */}
+ <Card className="flex-1 overflow-hidden flex flex-col">
+ <CardHeader className="flex-row items-center justify-between py-3">
+ <CardTitle className="text-base">상세도면 목록</CardTitle>
+ <div className="flex gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleRefresh}
+ disabled={isLoading}
+ >
+ <RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? "animate-spin" : ""}`} />
+ 새로고침
+ </Button>
+ {canAddDetailDrawing && (
+ <Button
+ variant="default"
+ size="sm"
+ onClick={() => setAddDialogOpen(true)}
+ >
+ <Plus className="h-4 w-4 mr-2" />
+ 상세도면 추가
+ </Button>
+ )}
+ </div>
+ </CardHeader>
+ <CardContent className="flex-1 overflow-y-auto p-4">
+ <DrawingListTable<DetailDwgReceiptItem, unknown>
+ columns={detailDrawingColumns}
+ data={detailDrawings}
+ onRowClick={setSelectedDetail}
+ selectedRow={selectedDetail || undefined}
+ getRowId={(row) => getDetailDrawingId(row)}
+ />
+ </CardContent>
+ </Card>
+
+ {/* 하단: 첨부파일 리스트 */}
+ <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}`}
+ </CardTitle>
+ {selectedDetail && canAddDetailDrawing && (
+ <Button
+ variant="default"
+ size="sm"
+ onClick={() => setUploadFilesDialogOpen(true)}
+ >
+ <Upload className="h-4 w-4 mr-2" />
+ 파일 업로드
+ </Button>
+ )}
+ </CardHeader>
+ <CardContent className="flex-1 overflow-y-auto p-4">
+ {!selectedDetail ? (
+ <div className="h-full flex items-center justify-center text-muted-foreground">
+ 상세도면을 선택하세요
+ </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>
+ </div>
+ <div className="space-y-2">
+ <Skeleton className="h-10 w-full" />
+ <Skeleton className="h-10 w-full" />
+ <Skeleton className="h-10 w-full" />
+ </div>
+ </div>
+ ) : (
+ <DrawingListTable
+ columns={fileColumns}
+ data={files}
+ />
+ )}
+ </CardContent>
+ </Card>
+ </div>
+ </DialogContent>
+ </Dialog>
+
+ <AddDetailDrawingDialog
+ open={addDialogOpen}
+ onOpenChange={setAddDialogOpen}
+ drawing={drawing}
+ vendorCode={vendorCode}
+ userId={userId}
+ userName={userName}
+ userEmail={userEmail}
+ onComplete={handleAddComplete}
+ drawingKind={drawingKind}
+ />
+
+ {selectedDetail && (
+ <UploadFilesToDetailDialog
+ open={uploadFilesDialogOpen}
+ onOpenChange={setUploadFilesDialogOpen}
+ uploadId={selectedDetail.UploadId}
+ drawingNo={selectedDetail.DrawingNo}
+ revNo={selectedDetail.DrawingRevNo}
+ userId={userId}
+ onUploadComplete={handleUploadComplete}
+ />
+ )}
+ </>
+ );
+}
+
diff --git a/lib/dolce/dialogs/upload-files-to-detail-dialog.tsx b/lib/dolce/dialogs/upload-files-to-detail-dialog.tsx
new file mode 100644
index 00000000..1d8ac582
--- /dev/null
+++ b/lib/dolce/dialogs/upload-files-to-detail-dialog.tsx
@@ -0,0 +1,314 @@
+"use client";
+
+import * as React from "react";
+import { useState } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Upload, FolderOpen, Loader2, X, FileText, AlertCircle } from "lucide-react";
+import { toast } from "sonner";
+import { uploadFilesToDetailDrawing, type UploadFilesResult } from "../actions";
+
+interface UploadFilesToDetailDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ uploadId: string;
+ drawingNo: string;
+ revNo: string;
+ userId: string;
+ onUploadComplete?: () => void;
+}
+
+export function UploadFilesToDetailDialog({
+ open,
+ onOpenChange,
+ uploadId,
+ drawingNo,
+ revNo,
+ userId,
+ onUploadComplete,
+}: UploadFilesToDetailDialogProps) {
+ const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
+ const [isUploading, setIsUploading] = useState(false);
+ const [isDragging, setIsDragging] = useState(false);
+
+ // 다이얼로그 닫을 때 초기화
+ React.useEffect(() => {
+ if (!open) {
+ setSelectedFiles([]);
+ setIsDragging(false);
+ }
+ }, [open]);
+
+ // 파일 선택 핸들러
+ const handleFilesChange = (files: File[]) => {
+ if (files.length === 0) return;
+
+ // 파일 크기 및 확장자 검증
+ 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[] = [];
+
+ files.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);
+ });
+
+ if (invalidFiles.length > 0) {
+ invalidFiles.forEach((msg) => toast.error(msg));
+ }
+
+ if (validFiles.length > 0) {
+ // 중복 제거
+ const existingNames = new Set(selectedFiles.map((f) => f.name));
+ const newFiles = validFiles.filter((f) => !existingNames.has(f.name));
+
+ if (newFiles.length === 0) {
+ toast.error("이미 선택된 파일입니다");
+ return;
+ }
+
+ setSelectedFiles((prev) => [...prev, ...newFiles]);
+ toast.success(`${newFiles.length}개 파일이 선택되었습니다`);
+ }
+ };
+
+ // Drag & Drop 핸들러
+ const handleDragEnter = (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDragging(true);
+ };
+
+ const handleDragLeave = (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (e.currentTarget === e.target) {
+ setIsDragging(false);
+ }
+ };
+
+ const handleDragOver = (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ e.dataTransfer.dropEffect = "copy";
+ };
+
+ const handleDrop = (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsDragging(false);
+
+ const droppedFiles = Array.from(e.dataTransfer.files);
+ if (droppedFiles.length > 0) {
+ handleFilesChange(droppedFiles);
+ }
+ };
+
+ // 파일 제거
+ const handleRemoveFile = (index: number) => {
+ setSelectedFiles((prev) => prev.filter((_, i) => i !== index));
+ };
+
+ // 업로드 처리
+ const handleUpload = async () => {
+ if (selectedFiles.length === 0) {
+ toast.error("파일을 선택해주세요");
+ return;
+ }
+
+ setIsUploading(true);
+
+ try {
+ // FormData 생성
+ const formData = new FormData();
+ formData.append("uploadId", uploadId);
+ formData.append("userId", userId);
+ formData.append("fileCount", String(selectedFiles.length));
+
+ selectedFiles.forEach((file, index) => {
+ formData.append(`file_${index}`, file);
+ });
+
+ // 서버 액션 호출
+ const result: UploadFilesResult = await uploadFilesToDetailDrawing(formData);
+
+ if (result.success) {
+ toast.success(`${result.uploadedCount}개 파일 업로드 완료`);
+ onOpenChange(false);
+ onUploadComplete?.();
+ } else {
+ toast.error(result.error || "업로드 실패");
+ }
+ } catch (error) {
+ console.error("업로드 실패:", error);
+ toast.error(
+ error instanceof Error ? error.message : "업로드 중 오류가 발생했습니다"
+ );
+ } finally {
+ setIsUploading(false);
+ }
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-2xl">
+ <DialogHeader>
+ <DialogTitle>파일 업로드</DialogTitle>
+ <DialogDescription>
+ {drawingNo} - Rev. {revNo}에 파일을 업로드합니다
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ {/* 안내 메시지 */}
+ <Alert>
+ <AlertCircle className="h-4 w-4" />
+ <AlertDescription>
+ 선택한 상세도면의 UploadId에 파일을 추가합니다. 파일 업로드 후 자동으로 메타데이터가 저장됩니다.
+ </AlertDescription>
+ </Alert>
+
+ {/* 파일 선택 영역 */}
+ <div
+ className={`border-2 border-dashed rounded-lg p-8 transition-all duration-200 ${
+ isDragging
+ ? "border-primary bg-primary/5 scale-[1.02]"
+ : "border-muted-foreground/30 hover:border-muted-foreground/50"
+ }`}
+ onDragEnter={handleDragEnter}
+ onDragLeave={handleDragLeave}
+ onDragOver={handleDragOver}
+ onDrop={handleDrop}
+ >
+ <input
+ type="file"
+ multiple
+ accept=".pdf,.doc,.docx,.xls,.xlsx,.dwg,.dxf,.zip"
+ onChange={(e) => handleFilesChange(Array.from(e.target.files || []))}
+ className="hidden"
+ id="detail-file-upload"
+ />
+ <label
+ htmlFor="detail-file-upload"
+ className="flex flex-col items-center justify-center cursor-pointer"
+ >
+ <FolderOpen
+ className={`h-12 w-12 mb-3 transition-colors ${
+ isDragging ? "text-primary" : "text-muted-foreground"
+ }`}
+ />
+ <p
+ className={`text-sm transition-colors ${
+ isDragging
+ ? "text-primary font-medium"
+ : "text-muted-foreground"
+ }`}
+ >
+ {isDragging
+ ? "파일을 여기에 놓으세요"
+ : "클릭하거나 파일을 드래그하여 선택"}
+ </p>
+ <p className="text-xs text-muted-foreground mt-1">
+ PDF, DOC, DOCX, XLS, XLSX, DWG, DXF, ZIP (max 1GB per file)
+ </p>
+ </label>
+ </div>
+
+ {/* 선택된 파일 목록 */}
+ {selectedFiles.length > 0 && (
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center justify-between mb-3">
+ <h4 className="text-sm font-medium">
+ 선택된 파일 ({selectedFiles.length}개)
+ </h4>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => setSelectedFiles([])}
+ disabled={isUploading}
+ >
+ 전체 제거
+ </Button>
+ </div>
+ <div className="max-h-48 overflow-auto space-y-2">
+ {selectedFiles.map((file, index) => (
+ <div
+ key={index}
+ className="flex items-center justify-between p-2 rounded bg-muted/50"
+ >
+ <div className="flex items-center gap-2 flex-1 min-w-0">
+ <FileText className="h-4 w-4 text-muted-foreground shrink-0" />
+ <div className="flex-1 min-w-0">
+ <p className="text-sm truncate">{file.name}</p>
+ <p className="text-xs text-muted-foreground">
+ {(file.size / 1024 / 1024).toFixed(2)} MB
+ </p>
+ </div>
+ </div>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handleRemoveFile(index)}
+ disabled={isUploading}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isUploading}
+ >
+ 취소
+ </Button>
+ <Button
+ onClick={handleUpload}
+ disabled={selectedFiles.length === 0 || isUploading}
+ >
+ {isUploading ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ 업로드 중...
+ </>
+ ) : (
+ <>
+ <Upload className="mr-2 h-4 w-4" />
+ 업로드 ({selectedFiles.length}개)
+ </>
+ )}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
+
diff --git a/lib/dolce/table/detail-drawing-columns.tsx b/lib/dolce/table/detail-drawing-columns.tsx
new file mode 100644
index 00000000..7f519179
--- /dev/null
+++ b/lib/dolce/table/detail-drawing-columns.tsx
@@ -0,0 +1,80 @@
+"use client";
+
+import { ColumnDef } from "@tanstack/react-table";
+import { DetailDwgReceiptItem } from "../actions";
+
+export const detailDrawingColumns: ColumnDef<DetailDwgReceiptItem>[] = [
+ {
+ accessorKey: "RegisterSerialNo",
+ header: "일련번호",
+ minSize: 80,
+ cell: ({ row }) => {
+ return <div className="text-center">{row.getValue("RegisterSerialNo")}</div>;
+ },
+ },
+ {
+ accessorKey: "DrawingRevNo",
+ header: "Revision",
+ minSize: 100,
+ cell: ({ row }) => {
+ return <div className="font-medium">{row.getValue("DrawingRevNo")}</div>;
+ },
+ },
+ {
+ accessorKey: "Status",
+ header: "상태",
+ minSize: 120,
+ cell: ({ row }) => {
+ return <div className="text-center">{row.getValue("Status")}</div>;
+ },
+ },
+ {
+ accessorKey: "CategoryENM",
+ header: "카테고리",
+ minSize: 120,
+ cell: ({ row }) => {
+ const categoryENM = row.getValue("CategoryENM") as string;
+ const categoryNM = row.original.CategoryNM;
+ return <div>{categoryENM || categoryNM}</div>;
+ },
+ },
+ {
+ accessorKey: "DrawingUsageENM",
+ header: "도면용도",
+ minSize: 100,
+ cell: ({ row }) => {
+ const usageENM = row.getValue("DrawingUsageENM") as string | null;
+ const usageNM = row.original.DrawingUsageNM;
+ return <div>{usageENM || usageNM}</div>;
+ },
+ },
+ {
+ accessorKey: "RegisterKindENM",
+ header: "등록종류",
+ minSize: 180,
+ cell: ({ row }) => {
+ const kindENM = row.getValue("RegisterKindENM") as string | null;
+ const kindNM = row.original.RegisterKindNM;
+ return <div>{kindENM || kindNM}</div>;
+ },
+ },
+ {
+ accessorKey: "CreateUserNM",
+ header: "생성자",
+ minSize: 150,
+ cell: ({ row }) => {
+ const userENM = row.original.CreateUserENM;
+ const userNM = row.getValue("CreateUserNM") as string;
+ return <div>{userENM || userNM}</div>;
+ },
+ },
+ {
+ accessorKey: "CreateDt",
+ header: "생성일시",
+ minSize: 200,
+ cell: ({ row }) => {
+ return <div className="text-sm text-muted-foreground">{row.getValue("CreateDt")}</div>;
+ },
+ },
+];
+
diff --git a/lib/dolce/table/drawing-list-columns.tsx b/lib/dolce/table/drawing-list-columns.tsx
new file mode 100644
index 00000000..0e18266d
--- /dev/null
+++ b/lib/dolce/table/drawing-list-columns.tsx
@@ -0,0 +1,87 @@
+"use client";
+
+import { ColumnDef } from "@tanstack/react-table";
+import { DwgReceiptItem } from "../actions";
+
+export const drawingListColumns: ColumnDef<DwgReceiptItem>[] = [
+ {
+ accessorKey: "DrawingNo",
+ header: "도면번호",
+ 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: "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: "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: "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: "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: "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: "CreateDt",
+ header: "생성일시",
+ minSize: 200,
+ cell: ({ row }) => {
+ return <div className="text-sm text-muted-foreground">{row.getValue("CreateDt")}</div>;
+ },
+ },
+];
+
diff --git a/lib/dolce/table/drawing-list-table.tsx b/lib/dolce/table/drawing-list-table.tsx
new file mode 100644
index 00000000..cc01f8ba
--- /dev/null
+++ b/lib/dolce/table/drawing-list-table.tsx
@@ -0,0 +1,144 @@
+"use client";
+
+import {
+ ColumnDef,
+ flexRender,
+ getCoreRowModel,
+ useReactTable,
+ getSortedRowModel,
+ SortingState,
+} from "@tanstack/react-table";
+import { useState } from "react";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react";
+
+interface DrawingListTableProps<TData, TValue> {
+ columns: ColumnDef<TData, TValue>[];
+ data: TData[];
+ onRowClick?: (row: TData) => void;
+ selectedRow?: TData;
+ getRowId?: (row: TData) => string;
+}
+
+export function DrawingListTable<TData, TValue>({
+ columns,
+ data,
+ onRowClick,
+ selectedRow,
+ getRowId,
+}: DrawingListTableProps<TData, TValue>) {
+ const [sorting, setSorting] = useState<SortingState>([]);
+
+ const table = useReactTable({
+ data,
+ columns,
+ getCoreRowModel: getCoreRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ onSortingChange: setSorting,
+ state: {
+ sorting,
+ },
+ });
+
+ // 행이 선택되었는지 확인하는 함수
+ const isRowSelected = (row: TData): boolean => {
+ if (!selectedRow || !getRowId) return false;
+ return getRowId(row) === getRowId(selectedRow);
+ };
+
+ return (
+ <div className="rounded-md border overflow-x-auto max-w-full max-h-[600px] overflow-y-auto">
+ <Table className="min-w-max">
+ <TableHeader className="sticky top-0 z-10 bg-background">
+ {table.getHeaderGroups().map((headerGroup) => (
+ <TableRow key={headerGroup.id}>
+ {headerGroup.headers.map((header) => {
+ const isSorted = header.column.getIsSorted();
+ const canSort = header.column.getCanSort();
+
+ return (
+ <TableHead
+ key={header.id}
+ style={{ minWidth: header.column.columnDef.minSize }}
+ className="bg-background"
+ >
+ {header.isPlaceholder ? null : (
+ <div
+ className={`flex items-center gap-2 ${
+ canSort ? "cursor-pointer select-none hover:text-primary" : ""
+ }`}
+ onClick={
+ canSort
+ ? header.column.getToggleSortingHandler()
+ : undefined
+ }
+ >
+ {flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+ {canSort && (
+ <span className="text-muted-foreground">
+ {isSorted === "asc" ? (
+ <ArrowUp className="h-4 w-4" />
+ ) : isSorted === "desc" ? (
+ <ArrowDown className="h-4 w-4" />
+ ) : (
+ <ArrowUpDown className="h-4 w-4 opacity-50" />
+ )}
+ </span>
+ )}
+ </div>
+ )}
+ </TableHead>
+ );
+ })}
+ </TableRow>
+ ))}
+ </TableHeader>
+ <TableBody>
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => {
+ const isSelected = isRowSelected(row.original);
+ return (
+ <TableRow
+ key={row.id}
+ data-state={row.getIsSelected() && "selected"}
+ onClick={() => onRowClick?.(row.original)}
+ className={`cursor-pointer transition-colors ${
+ isSelected
+ ? "bg-accent hover:bg-accent"
+ : "hover:bg-muted/50"
+ }`}
+ >
+ {row.getVisibleCells().map((cell) => (
+ <TableCell
+ key={cell.id}
+ style={{ minWidth: cell.column.columnDef.minSize }}
+ >
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+ </TableCell>
+ ))}
+ </TableRow>
+ );
+ })
+ ) : (
+ <TableRow>
+ <TableCell colSpan={columns.length} className="h-24 text-center">
+ 데이터가 없습니다.
+ </TableCell>
+ </TableRow>
+ )}
+ </TableBody>
+ </Table>
+ </div>
+ );
+}
+
diff --git a/lib/dolce/table/file-list-columns.tsx b/lib/dolce/table/file-list-columns.tsx
new file mode 100644
index 00000000..f703d56d
--- /dev/null
+++ b/lib/dolce/table/file-list-columns.tsx
@@ -0,0 +1,70 @@
+"use client";
+
+import { ColumnDef } from "@tanstack/react-table";
+import { FileInfoItem } from "../actions";
+import { Button } from "@/components/ui/button";
+import { Download } from "lucide-react";
+
+interface FileListColumnsProps {
+ onDownload: (file: FileInfoItem) => void;
+}
+
+export const createFileListColumns = ({
+ onDownload,
+}: FileListColumnsProps): ColumnDef<FileInfoItem>[] => [
+ {
+ accessorKey: "FileSeq",
+ header: "순번",
+ minSize: 80,
+ cell: ({ row }) => {
+ return <div className="text-center">{row.getValue("FileSeq")}</div>;
+ },
+ },
+ {
+ accessorKey: "FileName",
+ header: "파일명",
+ minSize: 300,
+ cell: ({ row }) => {
+ return <div className="font-medium">{row.getValue("FileName")}</div>;
+ },
+ },
+ {
+ accessorKey: "FileSize",
+ header: "파일크기",
+ minSize: 100,
+ cell: ({ row }) => {
+ const size = parseInt(row.getValue("FileSize") as string);
+ if (isNaN(size) || size === 0) return "-";
+
+ if (size < 1024) return `${size} B`;
+ if (size < 1024 * 1024) return `${(size / 1024).toFixed(2)} KB`;
+ return `${(size / (1024 * 1024)).toFixed(2)} MB`;
+ },
+ },
+ {
+ accessorKey: "CreateDt",
+ header: "생성일시",
+ minSize: 200,
+ cell: ({ row }) => {
+ return <div className="text-sm text-muted-foreground">{row.getValue("CreateDt")}</div>;
+ },
+ },
+ {
+ id: "actions",
+ header: "다운로드",
+ minSize: 120,
+ cell: ({ row }) => {
+ return (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => onDownload(row.original)}
+ >
+ <Download className="h-4 w-4 mr-2" />
+ 다운로드
+ </Button>
+ );
+ },
+ },
+];
+
diff --git a/lib/dolce/table/gtt-drawing-list-columns.tsx b/lib/dolce/table/gtt-drawing-list-columns.tsx
new file mode 100644
index 00000000..2ff2d7e2
--- /dev/null
+++ b/lib/dolce/table/gtt-drawing-list-columns.tsx
@@ -0,0 +1,166 @@
+"use client";
+
+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)}`;
+}
+
+// Document Type 필터
+export type DocumentType = "ALL" | "GTT_DELIVERABLES" | "SHI_INPUT";
+
+interface GttDrawingListColumnsOptions {
+ documentType: DocumentType;
+}
+
+export function createGttDrawingListColumns({
+ documentType,
+}: GttDrawingListColumnsOptions): ColumnDef<GttDwgReceiptItem>[] {
+ const baseColumns: ColumnDef<GttDwgReceiptItem>[] = [
+ {
+ accessorKey: "DrawingNo",
+ header: "도면번호",
+ 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: "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: "DrawingMoveGbn",
+ header: "구분",
+ minSize: 120,
+ },
+ ];
+
+ // Document Type에 따른 날짜 컬럼
+ const dateColumns: ColumnDef<GttDwgReceiptItem>[] = [];
+
+ // ALL: 모든 컬럼 표시
+ if (documentType === "ALL") {
+ dateColumns.push(
+ {
+ accessorKey: "GTTInput_PlanDate",
+ header: "GTT Input 예정일",
+ minSize: 150,
+ cell: ({ row }) => formatDate(row.getValue("GTTInput_PlanDate")),
+ },
+ {
+ accessorKey: "GTTInput_ResultDate",
+ header: "GTT Input 결과일",
+ minSize: 150,
+ cell: ({ row }) => formatDate(row.getValue("GTTInput_ResultDate")),
+ },
+ {
+ accessorKey: "GTTPreDwg_PlanDate",
+ header: "GTT Pre 예정일",
+ minSize: 140,
+ cell: ({ row }) => formatDate(row.getValue("GTTPreDwg_PlanDate")),
+ },
+ {
+ accessorKey: "GTTPreDwg_ResultDate",
+ header: "GTT Pre 결과일",
+ minSize: 140,
+ cell: ({ row }) => formatDate(row.getValue("GTTPreDwg_ResultDate")),
+ },
+ {
+ accessorKey: "GTTWorkingDwg_PlanDate",
+ header: "GTT Working 예정일",
+ minSize: 160,
+ cell: ({ row }) => formatDate(row.getValue("GTTWorkingDwg_PlanDate")),
+ },
+ {
+ accessorKey: "GTTWorkingDwg_ResultDate",
+ header: "GTT Working 결과일",
+ minSize: 160,
+ cell: ({ row }) => formatDate(row.getValue("GTTWorkingDwg_ResultDate")),
+ }
+ );
+ }
+ // SHI_INPUT: 도면제출 (GTTInput만)
+ else if (documentType === "SHI_INPUT") {
+ dateColumns.push(
+ {
+ accessorKey: "GTTInput_PlanDate",
+ header: "Input 예정일",
+ minSize: 120,
+ cell: ({ row }) => formatDate(row.getValue("GTTInput_PlanDate")),
+ },
+ {
+ accessorKey: "GTTInput_ResultDate",
+ header: "Input 결과일",
+ minSize: 120,
+ cell: ({ row }) => formatDate(row.getValue("GTTInput_ResultDate")),
+ }
+ );
+ }
+ // GTT_DELIVERABLES: 도면입수 (Pre, Working)
+ else if (documentType === "GTT_DELIVERABLES") {
+ dateColumns.push(
+ {
+ accessorKey: "GTTPreDwg_PlanDate",
+ header: "Pre 예정일",
+ minSize: 120,
+ cell: ({ row }) => formatDate(row.getValue("GTTPreDwg_PlanDate")),
+ },
+ {
+ accessorKey: "GTTPreDwg_ResultDate",
+ header: "Pre 결과일",
+ minSize: 120,
+ cell: ({ row }) => formatDate(row.getValue("GTTPreDwg_ResultDate")),
+ },
+ {
+ accessorKey: "GTTWorkingDwg_PlanDate",
+ header: "Working 예정일",
+ minSize: 130,
+ cell: ({ row }) => formatDate(row.getValue("GTTWorkingDwg_PlanDate")),
+ },
+ {
+ accessorKey: "GTTWorkingDwg_ResultDate",
+ header: "Working 결과일",
+ minSize: 130,
+ cell: ({ row }) => formatDate(row.getValue("GTTWorkingDwg_ResultDate")),
+ }
+ );
+ }
+
+ // 생성일시 컬럼
+ const endColumns: ColumnDef<GttDwgReceiptItem>[] = [
+ {
+ accessorKey: "CreateDt",
+ header: "생성일시",
+ minSize: 200,
+ cell: ({ row }) => {
+ return <div className="text-sm text-muted-foreground">{row.getValue("CreateDt")}</div>;
+ },
+ },
+ ];
+
+ return [...baseColumns, ...dateColumns, ...endColumns];
+}
+