diff options
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/dolce/actions.ts | 914 | ||||
| -rw-r--r-- | lib/dolce/crypto-utils-legacy.ts | 131 | ||||
| -rw-r--r-- | lib/dolce/crypto-utils.ts | 142 | ||||
| -rw-r--r-- | lib/dolce/dialogs/add-detail-drawing-dialog.tsx | 376 | ||||
| -rw-r--r-- | lib/dolce/dialogs/b4-bulk-upload-dialog.tsx | 608 | ||||
| -rw-r--r-- | lib/dolce/dialogs/b4-upload-validation-dialog.tsx | 353 | ||||
| -rw-r--r-- | lib/dolce/dialogs/detail-drawing-dialog.tsx | 311 | ||||
| -rw-r--r-- | lib/dolce/dialogs/upload-files-to-detail-dialog.tsx | 314 | ||||
| -rw-r--r-- | lib/dolce/table/detail-drawing-columns.tsx | 80 | ||||
| -rw-r--r-- | lib/dolce/table/drawing-list-columns.tsx | 87 | ||||
| -rw-r--r-- | lib/dolce/table/drawing-list-table.tsx | 144 | ||||
| -rw-r--r-- | lib/dolce/table/file-list-columns.tsx | 70 | ||||
| -rw-r--r-- | lib/dolce/table/gtt-drawing-list-columns.tsx | 166 |
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]; +} + |
