diff options
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/swp/actions.ts | 458 | ||||
| -rw-r--r-- | lib/swp/sync-service.ts | 119 | ||||
| -rw-r--r-- | lib/swp/table/swp-help-dialog.tsx | 153 | ||||
| -rw-r--r-- | lib/swp/table/swp-table-columns.tsx | 93 | ||||
| -rw-r--r-- | lib/swp/table/swp-table-toolbar.tsx | 142 | ||||
| -rw-r--r-- | lib/swp/table/swp-table.tsx | 309 |
6 files changed, 1097 insertions, 177 deletions
diff --git a/lib/swp/actions.ts b/lib/swp/actions.ts index 694936ab..7411f414 100644 --- a/lib/swp/actions.ts +++ b/lib/swp/actions.ts @@ -5,6 +5,8 @@ import { swpDocuments, swpDocumentRevisions, swpDocumentFiles } from "@/db/schem import { eq, and, sql, like, desc, asc, type SQL } from "drizzle-orm"; import { fetchSwpProjectData } from "./api-client"; import { syncSwpProject } from "./sync-service"; +import * as fs from "fs/promises"; +import * as path from "path"; // ============================================================================ // 타입 정의 @@ -291,3 +293,459 @@ export async function fetchSwpStats(projNo?: string) { } } +// ============================================================================ +// 서버 액션: 파일 다운로드 +// ============================================================================ + +export interface DownloadFileResult { + success: boolean; + data?: Uint8Array; + fileName?: string; + mimeType?: string; + error?: string; +} + +export async function downloadSwpFile(fileId: number): Promise<DownloadFileResult> { + try { + // 1. 파일 정보 조회 + const fileInfo = await db + .select({ + FILE_NM: swpDocumentFiles.FILE_NM, + FLD_PATH: swpDocumentFiles.FLD_PATH, + }) + .from(swpDocumentFiles) + .where(eq(swpDocumentFiles.id, fileId)) + .limit(1); + + if (!fileInfo || fileInfo.length === 0) { + return { + success: false, + error: "파일 정보를 찾을 수 없습니다.", + }; + } + + const { FILE_NM, FLD_PATH } = fileInfo[0]; + + if (!FLD_PATH || !FILE_NM) { + return { + success: false, + error: "파일 경로 또는 파일명이 없습니다.", + }; + } + + // 2. NFS 마운트 경로 확인 + const nfsBasePath = process.env.DOCUMENTUM_NFS; + if (!nfsBasePath) { + console.error("[downloadSwpFile] DOCUMENTUM_NFS 환경변수가 설정되지 않았습니다."); + return { + success: false, + error: "서버 설정 오류: NFS 경로가 설정되지 않았습니다.", + }; + } + + // 3. 전체 파일 경로 생성 + // FLD_PATH가 절대 경로일 수도 있고 상대 경로일 수도 있으므로 처리 + const fullPath = path.join(nfsBasePath, FLD_PATH, FILE_NM); + + console.log("[downloadSwpFile] 파일 다운로드 시도:", { + fileId, + FILE_NM, + FLD_PATH, + fullPath, + }); + + // 4. 파일 존재 여부 확인 + try { + await fs.access(fullPath, fs.constants.R_OK); + } catch (accessError) { + console.error("[downloadSwpFile] 파일 접근 불가:", accessError); + return { + success: false, + error: `파일을 찾을 수 없습니다: ${FILE_NM}`, + }; + } + + // 5. 파일 읽기 + const fileBuffer = await fs.readFile(fullPath); + const fileData = new Uint8Array(fileBuffer); + + // 6. MIME 타입 결정 + const mimeType = getMimeType(FILE_NM); + + console.log("[downloadSwpFile] 파일 다운로드 성공:", { + fileName: FILE_NM, + size: fileData.length, + mimeType, + }); + + return { + success: true, + data: fileData, + fileName: FILE_NM, + mimeType, + }; + } catch (error) { + console.error("[downloadSwpFile] 오류:", error); + return { + success: false, + error: error instanceof Error ? error.message : "파일 다운로드 실패", + }; + } +} + +// ============================================================================ +// 헬퍼: MIME 타입 결정 +// ============================================================================ + +function getMimeType(fileName: string): string { + const ext = path.extname(fileName).toLowerCase(); + + const mimeTypes: Record<string, string> = { + ".pdf": "application/pdf", + ".doc": "application/msword", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".xls": "application/vnd.ms-excel", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".ppt": "application/vnd.ms-powerpoint", + ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ".txt": "text/plain", + ".csv": "text/csv", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".zip": "application/zip", + ".rar": "application/x-rar-compressed", + ".7z": "application/x-7z-compressed", + ".dwg": "application/acad", + ".dxf": "application/dxf", + }; + + return mimeTypes[ext] || "application/octet-stream"; +} + +// ============================================================================ +// 서버 액션: 파일 업로드 (네트워크 경로 기반) +// ============================================================================ + +export interface UploadFileInfo { + fileName: string; + fileBuffer: Buffer; +} + +export interface UploadFilesResult { + success: boolean; + message: string; + successCount: number; + failedCount: number; + details: Array<{ + fileName: string; + success: boolean; + error?: string; + networkPath?: string; + }>; +} + +/** + * 파일명 파싱: [OWN_DOC_NO]_[REV_NO]_[STAGE]_[YYYYMMDDhhmmss].[확장자] + */ +function parseFileName(fileName: string) { + // 확장자 분리 + const lastDotIndex = fileName.lastIndexOf("."); + const extension = lastDotIndex !== -1 ? fileName.substring(lastDotIndex + 1) : ""; + const nameWithoutExt = lastDotIndex !== -1 ? fileName.substring(0, lastDotIndex) : fileName; + + // _ 기준으로 분리 (정확히 3개의 _가 있어야 함) + const parts = nameWithoutExt.split("_"); + + if (parts.length !== 4) { + throw new Error( + `잘못된 파일명 형식입니다: ${fileName}. ` + + `형식: [OWN_DOC_NO]_[REV_NO]_[STAGE]_[YYYYMMDDhhmmss].확장자` + ); + } + + const [ownDocNo, revNo, stage, timestamp] = parts; + + // 타임스탬프 검증 (14자리 숫자) + if (!/^\d{14}$/.test(timestamp)) { + throw new Error( + `잘못된 타임스탬프 형식입니다: ${timestamp}. ` + + `YYYYMMDDhhmmss 형식이어야 합니다.` + ); + } + + return { + ownDocNo, + revNo, + stage, + timestamp, + extension, + }; +} + +/** + * CPY_CD 조회: swpDocuments 테이블에서 PROJ_NO와 VNDR_CD로 조회 + */ +async function getCpyCdForVendor(projNo: string, vndrCd: string): Promise<string> { + const result = await db + .select({ + CPY_CD: swpDocuments.CPY_CD, + }) + .from(swpDocuments) + .where( + and( + eq(swpDocuments.PROJ_NO, projNo), + eq(swpDocuments.VNDR_CD, vndrCd) + ) + ) + .limit(1); + + if (!result || result.length === 0 || !result[0].CPY_CD) { + throw new Error( + `프로젝트 ${projNo}에서 벤더 코드 ${vndrCd}에 해당하는 회사 코드(CPY_CD)를 찾을 수 없습니다.` + ); + } + + return result[0].CPY_CD; +} + +/** + * 네트워크 경로 생성 + */ +function generateNetworkPath( + projNo: string, + cpyCd: string, + timestamp: string, + fileName: string +): string { + const swpMountDir = process.env.SWP_MOUNT_DIR || "/mnt/swp-smb-dir/"; + return path.join(swpMountDir, projNo, cpyCd, timestamp, fileName); +} + +/** + * InBox 파일 정보 인터페이스 + */ +interface InBoxFileInfo { + CPY_CD: string; + FILE_NM: string; + OFDC_NO: string | null; + PROJ_NO: string; + OWN_DOC_NO: string; + REV_NO: string; + STAGE: string; + STAT: string; + FILE_SZ: string; + FLD_PATH: string; +} + +/** + * SaveInBoxList API 호출 + */ +async function callSaveInBoxList(fileInfos: InBoxFileInfo[]): Promise<void> { + const ddcUrl = process.env.DDC_BASE_URL || "http://60.100.99.217/DDC/Services/WebService.svc"; + const url = `${ddcUrl}/SaveInBoxList`; + + const request = { + externalInboxLists: fileInfos, + }; + + console.log("[callSaveInBoxList] 요청:", JSON.stringify(request, null, 2)); + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(request), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`SaveInBoxList API 호출 실패: ${response.statusText} - ${errorText}`); + } + + const data = await response.json(); + console.log("[callSaveInBoxList] 응답:", JSON.stringify(data, null, 2)); + + // 응답 검증 + if (data.SaveInBoxListResult && !data.SaveInBoxListResult.success) { + throw new Error( + `SaveInBoxList API 실패: ${data.SaveInBoxListResult.message || "알 수 없는 오류"}` + ); + } +} + +/** + * GetExternalInboxList API 응답 인터페이스 + */ +interface ExternalInboxItem { + DOC_NO?: string; + REV_NO?: string; + STAGE?: string; + FILE_NM?: string; + FILE_SZ?: string; + [key: string]: unknown; +} + +/** + * GetExternalInboxList API 호출 + */ +async function callGetExternalInboxList(projNo: string, cpyCd: string): Promise<ExternalInboxItem[]> { + const ddcUrl = process.env.DDC_BASE_URL || "http://60.100.99.217/DDC/Services/WebService.svc"; + const params = new URLSearchParams({ + PROJ_NO: projNo, + CPY_CD: cpyCd, + }); + const url = `${ddcUrl}/GetExternalInboxList?${params.toString()}`; + + console.log("[callGetExternalInboxList] 요청:", url); + + const response = await fetch(url, { + method: "GET", + headers: { + Accept: "application/json", + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`GetExternalInboxList API 호출 실패: ${response.statusText} - ${errorText}`); + } + + const data = await response.json(); + console.log("[callGetExternalInboxList] 응답:", JSON.stringify(data, null, 2)); + + return data.GetExternalInboxListResult || []; +} + +/** + * 파일 업로드 서버 액션 + */ +export async function uploadSwpFilesAction( + projNo: string, + vndrCd: string, + files: UploadFileInfo[] +): Promise<UploadFilesResult> { + const result: UploadFilesResult = { + success: true, + message: "", + successCount: 0, + failedCount: 0, + details: [], + }; + + try { + // 1. CPY_CD 조회 + console.log(`[uploadSwpFilesAction] CPY_CD 조회: projNo=${projNo}, vndrCd=${vndrCd}`); + const cpyCd = await getCpyCdForVendor(projNo, vndrCd); + console.log(`[uploadSwpFilesAction] CPY_CD: ${cpyCd}`); + + // 2. 각 파일 처리 + const inBoxFileInfos: InBoxFileInfo[] = []; + + for (const file of files) { + try { + // 2-1. 파일명 파싱 + const parsed = parseFileName(file.fileName); + console.log(`[uploadSwpFilesAction] 파일명 파싱:`, parsed); + + // 2-2. 네트워크 경로 생성 + const networkPath = generateNetworkPath( + projNo, + cpyCd, + parsed.timestamp, + file.fileName + ); + + // 2-3. 파일 중복 체크 + try { + await fs.access(networkPath, fs.constants.F_OK); + // 파일이 이미 존재하는 경우 + result.failedCount++; + result.details.push({ + fileName: file.fileName, + success: false, + error: "파일이 이미 존재합니다.", + }); + console.warn(`[uploadSwpFilesAction] 파일 중복: ${networkPath}`); + continue; + } catch { + // 파일이 존재하지 않음 (정상) + } + + // 2-4. 디렉토리 생성 + const directory = path.dirname(networkPath); + await fs.mkdir(directory, { recursive: true }); + + // 2-5. 파일 저장 + await fs.writeFile(networkPath, file.fileBuffer); + console.log(`[uploadSwpFilesAction] 파일 저장 완료: ${networkPath}`); + + // 2-6. InBox 파일 정보 준비 + const dateOnly = parsed.timestamp.substring(0, 8); // YYYYMMDD + const fldPath = `\\\\${projNo}\\\\${cpyCd}\\\\${dateOnly}`; + + inBoxFileInfos.push({ + CPY_CD: cpyCd, + FILE_NM: file.fileName, + OFDC_NO: null, + PROJ_NO: projNo, + OWN_DOC_NO: parsed.ownDocNo, + REV_NO: parsed.revNo, + STAGE: parsed.stage, + STAT: "SCW01", + FILE_SZ: String(file.fileBuffer.length), + FLD_PATH: fldPath, + }); + + result.successCount++; + result.details.push({ + fileName: file.fileName, + success: true, + networkPath, + }); + } catch (error) { + result.failedCount++; + result.details.push({ + fileName: file.fileName, + success: false, + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + console.error(`[uploadSwpFilesAction] 파일 처리 실패: ${file.fileName}`, error); + } + } + + // 3. SaveInBoxList API 호출 (성공한 파일만) + if (inBoxFileInfos.length > 0) { + console.log(`[uploadSwpFilesAction] SaveInBoxList API 호출: ${inBoxFileInfos.length}개 파일`); + await callSaveInBoxList(inBoxFileInfos); + } + + // 4. GetExternalInboxList API 호출 + console.log(`[uploadSwpFilesAction] GetExternalInboxList API 호출`); + const inboxList = await callGetExternalInboxList(projNo, cpyCd); + console.log(`[uploadSwpFilesAction] InBox 목록: ${inboxList.length}개`); + + // 5. 결과 메시지 생성 + if (result.failedCount === 0) { + result.message = `${result.successCount}개 파일이 성공적으로 업로드되었습니다.`; + } else if (result.successCount === 0) { + result.success = false; + result.message = `모든 파일 업로드에 실패했습니다. (${result.failedCount}개)`; + } else { + result.message = + `${result.successCount}개 파일 업로드 성공, ${result.failedCount}개 실패`; + } + + console.log(`[uploadSwpFilesAction] 완료:`, result); + return result; + } catch (error) { + console.error("[uploadSwpFilesAction] 오류:", error); + result.success = false; + result.message = error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다."; + return result; + } +} + diff --git a/lib/swp/sync-service.ts b/lib/swp/sync-service.ts index 0a801bd8..787b28ae 100644 --- a/lib/swp/sync-service.ts +++ b/lib/swp/sync-service.ts @@ -9,7 +9,6 @@ import { type SwpDocumentInsert, type SwpDocumentRevisionInsert, type SwpDocumentFileInsert, - swpSchema, } from "@/db/schema/SWP/swp-documents"; // ============================================================================ @@ -17,63 +16,69 @@ import { // ============================================================================ export interface SwpDocumentApiResponse { + // 필수 필드 DOC_NO: string; DOC_TITLE: string; - DOC_GB: string; - DOC_TYPE: string; - OWN_DOC_NO: string; - SHI_DOC_NO: string; PROJ_NO: string; - PROJ_NM: string; - PKG_NO: string; - MAT_CD: string; - MAT_NM: string; - DISPLN: string; - CTGRY: string; - VNDR_CD: string; CPY_CD: string; CPY_NM: string; PIC_NM: string; - PIC_DEPTCD: string; PIC_DEPTNM: string; - LTST_REV_NO: string; - LTST_REV_SEQ: string; - LTST_ACTV_STAT: string; - STAGE: string; SKL_CD: string; - MOD_TYPE: string; - ACT_TYPE_NM: string; - USE_YN: string; CRTER: string; CRTE_DTM: string; CHGR: string; CHG_DTM: string; + + // 선택적 필드 (null 가능) + DOC_GB: string | null; + DOC_TYPE: string | null; + OWN_DOC_NO: string | null; + SHI_DOC_NO: string | null; + PROJ_NM: string | null; + PKG_NO: string | null; + MAT_CD: string | null; + MAT_NM: string | null; + DISPLN: string | null; + CTGRY: string | null; + VNDR_CD: string | null; + PIC_DEPTCD: string | null; + LTST_REV_NO: string | null; + LTST_REV_SEQ: string | null; + LTST_ACTV_STAT: string | null; + STAGE: string | null; + MOD_TYPE: string | null; + ACT_TYPE_NM: string | null; + USE_YN: string | null; REV_DTM: string | null; } export interface SwpFileApiResponse { + // 필수 필드 OWN_DOC_NO: string; REV_NO: string; STAGE: string; FILE_NM: string; FILE_SEQ: string; - FILE_SZ: string; - FLD_PATH: string; - ACTV_NO: string | null; - ACTV_SEQ: string; - BOX_SEQ: string; - OFDC_NO: string; - PROJ_NO: string; - PKG_NO: string; - VNDR_CD: string; - CPY_CD: string; - STAT: string; - STAT_NM: string; - IDX: string; CRTER: string; CRTE_DTM: string; CHGR: string; CHG_DTM: string; + + // 선택적 필드 (null 가능) + FILE_SZ: string | null; + FLD_PATH: string | null; + ACTV_NO: string | null; + ACTV_SEQ: string | null; + BOX_SEQ: string | null; + OFDC_NO: string | null; + PROJ_NO: string | null; + PKG_NO: string | null; + VNDR_CD: string | null; + CPY_CD: string | null; + STAT: string | null; + STAT_NM: string | null; + IDX: string | null; } // ============================================================================ @@ -220,13 +225,13 @@ async function upsertDocument( ): Promise<{ id: string; inserted: boolean; updated: boolean }> { const data: SwpDocumentInsert = { DOC_NO: doc.DOC_NO, + PROJ_NO: doc.PROJ_NO, DOC_TITLE: doc.DOC_TITLE, DOC_GB: doc.DOC_GB || null, DOC_TYPE: doc.DOC_TYPE || null, - OWN_DOC_NO: doc.OWN_DOC_NO, - SHI_DOC_NO: doc.SHI_DOC_NO, - PROJ_NO: doc.PROJ_NO, - PROJ_NM: doc.PROJ_NM, + OWN_DOC_NO: doc.OWN_DOC_NO || null, + SHI_DOC_NO: doc.SHI_DOC_NO || null, + PROJ_NM: doc.PROJ_NM || null, PKG_NO: doc.PKG_NO || null, MAT_CD: doc.MAT_CD || null, MAT_NM: doc.MAT_NM || null, @@ -256,11 +261,16 @@ async function upsertDocument( updated_at: new Date(), }; - // 기존 문서 확인 + // 기존 문서 확인 (복합키: DOC_NO + PROJ_NO) const existing = await tx .select() .from(swpDocuments) - .where(eq(swpDocuments.DOC_NO, doc.DOC_NO)) + .where( + and( + eq(swpDocuments.DOC_NO, doc.DOC_NO), + eq(swpDocuments.PROJ_NO, doc.PROJ_NO) + ) + ) .limit(1); if (existing.length > 0) { @@ -268,12 +278,17 @@ async function upsertDocument( await tx .update(swpDocuments) .set(data) - .where(eq(swpDocuments.DOC_NO, doc.DOC_NO)); - return { id: doc.DOC_NO, inserted: false, updated: true }; + .where( + and( + eq(swpDocuments.DOC_NO, doc.DOC_NO), + eq(swpDocuments.PROJ_NO, doc.PROJ_NO) + ) + ); + return { id: `${doc.DOC_NO}|${doc.PROJ_NO}`, inserted: false, updated: true }; } else { // 삽입 await tx.insert(swpDocuments).values(data); - return { id: doc.DOC_NO, inserted: true, updated: false }; + return { id: `${doc.DOC_NO}|${doc.PROJ_NO}`, inserted: true, updated: false }; } } @@ -287,13 +302,13 @@ async function upsertRevision( REV_NO: file.REV_NO, STAGE: file.STAGE, ACTV_NO: file.ACTV_NO || null, - ACTV_SEQ: file.ACTV_SEQ, - BOX_SEQ: file.BOX_SEQ, - OFDC_NO: file.OFDC_NO, - PROJ_NO: file.PROJ_NO, + ACTV_SEQ: file.ACTV_SEQ || null, + BOX_SEQ: file.BOX_SEQ || null, + OFDC_NO: file.OFDC_NO || null, + PROJ_NO: file.PROJ_NO || null, PKG_NO: file.PKG_NO || null, VNDR_CD: file.VNDR_CD || null, - CPY_CD: file.CPY_CD, + CPY_CD: file.CPY_CD || null, sync_status: "synced", last_synced_at: new Date(), updated_at: new Date(), @@ -339,11 +354,11 @@ async function upsertFile( DOC_NO: docNo, FILE_NM: file.FILE_NM, FILE_SEQ: file.FILE_SEQ, - FILE_SZ: file.FILE_SZ, - FLD_PATH: file.FLD_PATH, - STAT: file.STAT, - STAT_NM: file.STAT_NM, - IDX: file.IDX, + FILE_SZ: file.FILE_SZ || null, + FLD_PATH: file.FLD_PATH || null, + STAT: file.STAT || null, + STAT_NM: file.STAT_NM || null, + IDX: file.IDX || null, ACTV_NO: file.ACTV_NO || null, CRTER: file.CRTER, CRTE_DTM: file.CRTE_DTM, diff --git a/lib/swp/table/swp-help-dialog.tsx b/lib/swp/table/swp-help-dialog.tsx new file mode 100644 index 00000000..18f29644 --- /dev/null +++ b/lib/swp/table/swp-help-dialog.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { HelpCircle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Badge } from "@/components/ui/badge"; + +export function SwpUploadHelpDialog() { + return ( + <Dialog> + <DialogTrigger asChild> + <Button variant="outline" size="sm" className="gap-2"> + <HelpCircle className="h-4 w-4" /> + 업로드 가이드 + </Button> + </DialogTrigger> + <DialogContent className="max-w-2xl" opacityControl={false}> + <DialogHeader> + <DialogTitle>파일 업로드 가이드</DialogTitle> + <DialogDescription> + 올바른 파일명 형식으로 업로드해주세요 + </DialogDescription> + </DialogHeader> + + <div className="space-y-6"> + {/* 파일명 형식 */} + <div className="space-y-2"> + <h3 className="text-sm font-semibold">📋 파일명 형식</h3> + <div className="rounded-lg bg-muted p-4 font-mono text-sm"> + [OWN_DOC_NO]_[REV_NO]_[STAGE]_[YYYYMMDDhhmmss].[확장자] + </div> + <p className="text-xs text-muted-foreground"> + ⚠️ 언더스코어(_)가 정확히 3개 있어야 합니다 + </p> + </div> + + {/* 각 항목 설명 - 1라인 형태 */} + <div className="space-y-3"> + <h3 className="text-sm font-semibold">📝 항목 설명</h3> + + <div className="flex items-center gap-3 rounded-lg border p-3"> + <Badge variant="secondary" className="font-mono shrink-0"> + OWN_DOC_NO + </Badge> + <div className="text-sm"> + <span className="font-medium">벤더의 문서번호</span> + <span className="text-muted-foreground"> - 프로젝트마다 유니크해야 합니다</span> + </div> + </div> + + <div className="flex items-center gap-3 rounded-lg border p-3"> + <Badge variant="secondary" className="font-mono shrink-0"> + REV_NO + </Badge> + <div className="text-sm"> + <span className="font-medium">리비전 번호</span> + <span className="text-muted-foreground"> - 보통 01, 02 같은 식으로 피드백에 따라 증가합니다</span> + </div> + </div> + + <div className="flex items-center gap-3 rounded-lg border p-3"> + <Badge variant="secondary" className="font-mono shrink-0"> + STAGE + </Badge> + <div className="text-sm"> + <span className="font-medium">스테이지</span> + <span className="text-muted-foreground"> - 중공업이 설정한 스테이지입니다 (예: IFA, IFC, AFC, BFC)</span> + </div> + </div> + + <div className="flex items-center gap-3 rounded-lg border p-3"> + <Badge variant="secondary" className="font-mono shrink-0"> + YYYYMMDDhhmmss + </Badge> + <div className="text-sm"> + <span className="font-medium">날짜 및 시간</span> + <span className="text-muted-foreground"> - 업로드 날짜 정보를 기입합니다 (14자리 숫자)</span> + </div> + </div> + </div> + + {/* 예시 */} + <div className="space-y-2"> + <h3 className="text-sm font-semibold">✅ 올바른 예시</h3> + <div className="space-y-2"> + <div className="rounded-lg bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-800 p-3"> + <code className="text-xs font-mono text-green-700 dark:text-green-300"> + VD-DOC-001_01_IFA_20250124143000.pdf + </code> + </div> + <div className="rounded-lg bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-800 p-3"> + <code className="text-xs font-mono text-green-700 dark:text-green-300"> + TECH-SPEC-002_02_IFC_20250124150000.dwg + </code> + </div> + </div> + </div> + + {/* 잘못된 예시 */} + <div className="space-y-2"> + <h3 className="text-sm font-semibold">❌ 잘못된 예시</h3> + <div className="space-y-2"> + <div className="rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 p-3"> + <code className="text-xs font-mono text-red-700 dark:text-red-300"> + VD-DOC-001-01-IFA-20250124.pdf + </code> + <p className="text-xs text-red-600 dark:text-red-400 mt-1"> + ✗ 언더스코어(_) 대신 하이픈(-) 사용 + </p> + </div> + <div className="rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 p-3"> + <code className="text-xs font-mono text-red-700 dark:text-red-300"> + VD-DOC-001_01_IFA.pdf + </code> + <p className="text-xs text-red-600 dark:text-red-400 mt-1"> + ✗ 날짜/시간 정보 누락 + </p> + </div> + <div className="rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 p-3"> + <code className="text-xs font-mono text-red-700 dark:text-red-300"> + VD-DOC-001_01_IFA_20250124.pdf + </code> + <p className="text-xs text-red-600 dark:text-red-400 mt-1"> + ✗ 시간 정보 누락 (14자리가 아님) + </p> + </div> + </div> + </div> + + {/* 주의사항 */} + <div className="rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 p-4"> + <h3 className="text-sm font-semibold text-amber-900 dark:text-amber-100 mb-2"> + ⚠️ 주의사항 + </h3> + <ul className="text-xs text-amber-800 dark:text-amber-200 space-y-1 list-disc list-inside"> + <li>파일명 형식이 올바르지 않으면 업로드가 실패합니다</li> + <li>같은 파일명으로 이미 업로드된 파일이 있으면 덮어쓰지 않고 오류 처리됩니다</li> + <li>프로젝트와 업체 코드를 먼저 선택해야 업로드 버튼이 활성화됩니다</li> + </ul> + </div> + </div> + </DialogContent> + </Dialog> + ); +} + diff --git a/lib/swp/table/swp-table-columns.tsx b/lib/swp/table/swp-table-columns.tsx index dd605453..573acf1b 100644 --- a/lib/swp/table/swp-table-columns.tsx +++ b/lib/swp/table/swp-table-columns.tsx @@ -3,30 +3,28 @@ import { ColumnDef } from "@tanstack/react-table"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { ChevronDown, ChevronRight, FileIcon } from "lucide-react"; +import { ChevronDown, ChevronRight, FileIcon, Download, Loader2 } from "lucide-react"; import { formatDistanceToNow } from "date-fns"; import { ko } from "date-fns/locale"; import type { SwpDocumentWithStats } from "../actions"; +import { downloadSwpFile } from "../actions"; +import { useState } from "react"; +import { toast } from "sonner"; export const swpDocumentColumns: ColumnDef<SwpDocumentWithStats>[] = [ { id: "expander", header: () => null, - cell: ({ row }) => { - return row.getCanExpand() ? ( + cell: () => { + return ( <Button variant="ghost" size="sm" - onClick={row.getToggleExpandedHandler()} className="h-8 w-8 p-0" > - {row.getIsExpanded() ? ( - <ChevronDown className="h-4 w-4" /> - ) : ( - <ChevronRight className="h-4 w-4" /> - )} + <ChevronRight className="h-4 w-4" /> </Button> - ) : null; + ); }, size: 50, }, @@ -182,7 +180,6 @@ export const swpRevisionColumns: ColumnDef<RevisionRow>[] = [ <Button variant="ghost" size="sm" - onClick={row.getToggleExpandedHandler()} className="h-8 w-8 p-0 ml-8" > {row.getIsExpanded() ? ( @@ -390,5 +387,79 @@ export const swpFileColumns: ColumnDef<FileRow>[] = [ ), size: 100, }, + { + id: "actions", + header: "작업", + cell: ({ row }) => ( + <DownloadButton fileId={row.original.id} fileName={row.original.FILE_NM} /> + ), + size: 120, + }, ]; +// ============================================================================ +// 다운로드 버튼 컴포넌트: 임시 구성. Download.aspx 동작 안해서 일단 네트워크드라이브 사용하도록 처리 +// ============================================================================ + +interface DownloadButtonProps { + fileId: number; + fileName: string; +} + +function DownloadButton({ fileId, fileName }: DownloadButtonProps) { + const [isDownloading, setIsDownloading] = useState(false); + + const handleDownload = async () => { + try { + setIsDownloading(true); + + // 서버 액션 호출 + const result = await downloadSwpFile(fileId); + + if (!result.success || !result.data) { + toast.error(result.error || "파일 다운로드 실패"); + return; + } + + // Blob 생성 및 다운로드 + const blob = new Blob([result.data as unknown as BlobPart], { type: result.mimeType }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = result.fileName || fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + + toast.success(`파일 다운로드 완료: ${result.fileName}`); + } catch (error) { + console.error("다운로드 오류:", error); + toast.error("파일 다운로드 중 오류가 발생했습니다."); + } finally { + setIsDownloading(false); + } + }; + + return ( + <Button + variant="outline" + size="sm" + onClick={handleDownload} + disabled={isDownloading} + > + {isDownloading ? ( + <> + <Loader2 className="h-4 w-4 mr-1 animate-spin" /> + 다운로드 중... + </> + ) : ( + <> + <Download className="h-4 w-4 mr-1" /> + 다운로드 + </> + )} + </Button> + ); +} + diff --git a/lib/swp/table/swp-table-toolbar.tsx b/lib/swp/table/swp-table-toolbar.tsx index 7c5f2f2e..03082b26 100644 --- a/lib/swp/table/swp-table-toolbar.tsx +++ b/lib/swp/table/swp-table-toolbar.tsx @@ -16,11 +16,13 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import { Label } from "@/components/ui/label"; -import { RefreshCw, Download, Search, X, Check, ChevronsUpDown } from "lucide-react"; -import { syncSwpProjectAction, type SwpTableFilters } from "../actions"; +import { RefreshCw, Search, X, Check, ChevronsUpDown, Upload } from "lucide-react"; +import { syncSwpProjectAction, uploadSwpFilesAction, type SwpTableFilters } from "../actions"; import { useToast } from "@/hooks/use-toast"; import { useRouter } from "next/navigation"; import { cn } from "@/lib/utils"; +import { useRef } from "react"; +import { SwpUploadHelpDialog } from "./swp-help-dialog"; interface SwpTableToolbarProps { filters: SwpTableFilters; @@ -34,11 +36,13 @@ export function SwpTableToolbar({ projects = [], }: SwpTableToolbarProps) { const [isSyncing, startSync] = useTransition(); + const [isUploading, startUpload] = useTransition(); const [localFilters, setLocalFilters] = useState<SwpTableFilters>(filters); const { toast } = useToast(); const router = useRouter(); const [projectSearchOpen, setProjectSearchOpen] = useState(false); const [projectSearch, setProjectSearch] = useState(""); + const fileInputRef = useRef<HTMLInputElement>(null); // 동기화 핸들러 const handleSync = () => { @@ -84,6 +88,115 @@ export function SwpTableToolbar({ }); }; + /** + * 파일 업로드 핸들러 + * 1) 네트워크 드라이브에 정해진 규칙대로, 파일이름 기반으로 파일 업로드하기 + * 2) 1~N개 파일 받아서, 파일 이름 기준으로 파싱해서 SaveInBoxList API를 통해 업로드 처리 + */ + const handleUploadFiles = () => { + // 프로젝트와 벤더 코드 체크 + const projectNo = localFilters.projNo; + const vndrCd = localFilters.vndrCd; + + if (!projectNo) { + toast({ + variant: "destructive", + title: "프로젝트 선택 필요", + description: "파일을 업로드할 프로젝트를 먼저 선택해주세요.", + }); + return; + } + + if (!vndrCd) { + toast({ + variant: "destructive", + title: "업체 코드 입력 필요", + description: "파일을 업로드할 업체 코드를 입력해주세요.", + }); + return; + } + + // 파일 선택 다이얼로그 열기 + fileInputRef.current?.click(); + }; + + /** + * 파일 선택 핸들러 + */ + const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => { + const selectedFiles = event.target.files; + if (!selectedFiles || selectedFiles.length === 0) { + return; + } + + const projectNo = localFilters.projNo!; + const vndrCd = localFilters.vndrCd!; + + startUpload(async () => { + try { + toast({ + title: "파일 업로드 시작", + description: `${selectedFiles.length}개 파일을 업로드합니다...`, + }); + + // 파일을 Buffer로 변환 + const fileInfos = await Promise.all( + Array.from(selectedFiles).map(async (file) => { + const arrayBuffer = await file.arrayBuffer(); + return { + fileName: file.name, + fileBuffer: Buffer.from(arrayBuffer), + }; + }) + ); + + // 서버 액션 호출 + const result = await uploadSwpFilesAction(projectNo, vndrCd, fileInfos); + + if (result.success) { + toast({ + title: "업로드 완료", + description: result.message, + }); + + // 페이지 새로고침 + router.refresh(); + } else { + toast({ + variant: "destructive", + title: "업로드 실패", + description: result.message, + }); + } + + // 실패한 파일이 있으면 상세 정보 표시 + const failedFiles = result.details.filter((d) => !d.success); + if (failedFiles.length > 0) { + console.error("실패한 파일:", failedFiles); + failedFiles.forEach((f) => { + toast({ + variant: "destructive", + title: `${f.fileName} 업로드 실패`, + description: f.error || "알 수 없는 오류", + }); + }); + } + } catch (error) { + console.error("파일 업로드 실패:", error); + toast({ + variant: "destructive", + title: "업로드 실패", + description: error instanceof Error ? error.message : "알 수 없는 오류", + }); + } finally { + // 파일 입력 초기화 + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + } + }); + }; + // 검색 적용 const handleSearch = () => { onFiltersChange(localFilters); @@ -122,15 +235,32 @@ export function SwpTableToolbar({ {isSyncing ? "동기화 중..." : "SWP 동기화"} </Button> - <Button variant="outline" size="sm" disabled> - <Download className="h-4 w-4 mr-2" /> - Excel 내보내기 - </Button> </div> <div className="text-sm text-muted-foreground"> SWP 문서 관리 시스템 </div> + <div className="flex items-center gap-2"> + <input + ref={fileInputRef} + type="file" + multiple + className="hidden" + onChange={handleFileChange} + accept="*/*" + /> + <Button + variant="outline" + size="sm" + onClick={handleUploadFiles} + disabled={isUploading || !localFilters.projNo || !localFilters.vndrCd} + > + <Upload className={`h-4 w-4 mr-2 ${isUploading ? "animate-pulse" : ""}`} /> + {isUploading ? "업로드 중..." : "파일 업로드"} + </Button> + + <SwpUploadHelpDialog /> + </div> </div> {/* 검색 필터 */} diff --git a/lib/swp/table/swp-table.tsx b/lib/swp/table/swp-table.tsx index 9e8f7f6a..8ae90bdd 100644 --- a/lib/swp/table/swp-table.tsx +++ b/lib/swp/table/swp-table.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import React, { useState } from "react"; import { useReactTable, getCoreRowModel, @@ -17,6 +17,13 @@ import { TableRow, } from "@/components/ui/table"; import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; import { Loader2 } from "lucide-react"; import { swpDocumentColumns, swpRevisionColumns, swpFileColumns, type RevisionRow, type FileRow } from "./swp-table-columns"; import { fetchDocumentRevisions, fetchRevisionFiles, type SwpDocumentWithStats } from "../actions"; @@ -43,6 +50,8 @@ export function SwpTable({ const [fileData, setFileData] = useState<Record<number, FileRow[]>>({}); const [loadingRevisions, setLoadingRevisions] = useState<Set<string>>(new Set()); const [loadingFiles, setLoadingFiles] = useState<Set<number>>(new Set()); + const [dialogOpen, setDialogOpen] = useState(false); + const [selectedDocument, setSelectedDocument] = useState<SwpDocumentWithStats | null>(null); const table = useReactTable({ data: initialData, @@ -53,7 +62,7 @@ export function SwpTable({ onExpandedChange: setExpanded, getCoreRowModel: getCoreRowModel(), getExpandedRowModel: getExpandedRowModel(), - getRowCanExpand: (row) => true, // 모든 문서는 확장 가능 + getRowCanExpand: () => true, // 모든 문서는 확장 가능 }); // 리비전 로드 @@ -104,10 +113,26 @@ export function SwpTable({ } }; - // 문서 행 확장 핸들러 - const handleDocumentExpand = (docNo: string, isExpanded: boolean) => { - if (!isExpanded) { - loadRevisions(docNo); + // 문서 클릭 핸들러 - Dialog 열기 + const handleDocumentClick = async (document: SwpDocumentWithStats) => { + setSelectedDocument(document); + setDialogOpen(true); + + // 리비전 데이터 로드 + if (!revisionData[document.DOC_NO]) { + await loadRevisions(document.DOC_NO); + } + }; + + // 모든 리비전의 파일을 로드 + const loadAllFiles = async (docNo: string) => { + const revisions = revisionData[docNo]; + if (!revisions) return; + + for (const revision of revisions) { + if (!fileData[revision.id]) { + await loadFiles(revision.id); + } } }; @@ -135,10 +160,9 @@ export function SwpTable({ <TableBody> {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( - <> + <React.Fragment key={row.id}> {/* 문서 행 */} <TableRow - key={row.id} data-state={row.getIsSelected() && "selected"} className="hover:bg-muted/50" > @@ -146,10 +170,8 @@ export function SwpTable({ <TableCell key={cell.id}> {cell.column.id === "expander" ? ( <div - onClick={() => { - row.toggleExpanded(); - handleDocumentExpand(row.original.DOC_NO, row.getIsExpanded()); - }} + onClick={() => handleDocumentClick(row.original)} + className="cursor-pointer" > {flexRender( cell.column.columnDef.cell, @@ -162,32 +184,7 @@ export function SwpTable({ </TableCell> ))} </TableRow> - - {/* 리비전 행들 (확장 시) */} - {row.getIsExpanded() && ( - <TableRow> - <TableCell colSpan={swpDocumentColumns.length} className="p-0 bg-muted/30"> - {loadingRevisions.has(row.original.DOC_NO) ? ( - <div className="flex items-center justify-center p-8"> - <Loader2 className="h-6 w-6 animate-spin" /> - <span className="ml-2">리비전 로딩 중...</span> - </div> - ) : revisionData[row.original.DOC_NO]?.length ? ( - <RevisionSubTable - revisions={revisionData[row.original.DOC_NO]} - fileData={fileData} - loadingFiles={loadingFiles} - onLoadFiles={loadFiles} - /> - ) : ( - <div className="p-8 text-center text-muted-foreground"> - 리비전 없음 - </div> - )} - </TableCell> - </TableRow> - )} - </> + </React.Fragment> )) ) : ( <TableRow> @@ -228,28 +225,95 @@ export function SwpTable({ </Button> </div> </div> + + {/* 문서 상세 Dialog */} + <Dialog open={dialogOpen} onOpenChange={setDialogOpen}> + <DialogContent className="max-w-6xl max-h-[90vh]"> + <DialogHeader> + <DialogTitle>문서 상세</DialogTitle> + {selectedDocument && ( + <DialogDescription> + {selectedDocument.DOC_NO} - {selectedDocument.DOC_TITLE} + </DialogDescription> + )} + </DialogHeader> + + {selectedDocument && ( + <div className="space-y-4 overflow-y-auto"> + {/* 문서 정보 */} + <div className="grid grid-cols-1 md:grid-cols-4 gap-4 p-4 bg-muted/30 rounded-lg"> + <div> + <span className="text-sm font-semibold">프로젝트:</span> + <div className="text-sm">{selectedDocument.PROJ_NO}</div> + {selectedDocument.PROJ_NM && ( + <div className="text-xs text-muted-foreground">{selectedDocument.PROJ_NM}</div> + )} + </div> + <div> + <span className="text-sm font-semibold">패키지:</span> + <div className="text-sm">{selectedDocument.PKG_NO || "-"}</div> + </div> + <div> + <span className="text-sm font-semibold">업체:</span> + <div className="text-sm">{selectedDocument.CPY_NM || "-"}</div> + {selectedDocument.VNDR_CD && ( + <div className="text-xs text-muted-foreground">{selectedDocument.VNDR_CD}</div> + )} + </div> + <div> + <span className="text-sm font-semibold">최신 리비전:</span> + <div className="text-sm">{selectedDocument.LTST_REV_NO || "-"}</div> + </div> + </div> + + {/* 리비전 및 파일 목록 */} + {loadingRevisions.has(selectedDocument.DOC_NO) ? ( + <div className="flex items-center justify-center p-8"> + <Loader2 className="h-6 w-6 animate-spin" /> + <span className="ml-2">리비전 로딩 중...</span> + </div> + ) : revisionData[selectedDocument.DOC_NO]?.length ? ( + <DocumentDetailView + revisions={revisionData[selectedDocument.DOC_NO]} + fileData={fileData} + loadingFiles={loadingFiles} + onLoadFiles={loadFiles} + onLoadAllFiles={() => loadAllFiles(selectedDocument.DOC_NO)} + /> + ) : ( + <div className="p-8 text-center text-muted-foreground"> + 리비전 없음 + </div> + )} + </div> + )} + </DialogContent> + </Dialog> </div> ); } // ============================================================================ -// 리비전 서브 테이블 +// 문서 상세 뷰 (Dialog용) // ============================================================================ -interface RevisionSubTableProps { +interface DocumentDetailViewProps { revisions: RevisionRow[]; fileData: Record<number, FileRow[]>; loadingFiles: Set<number>; onLoadFiles: (revisionId: number) => void; + onLoadAllFiles: () => void; } -function RevisionSubTable({ +function DocumentDetailView({ revisions, fileData, loadingFiles, onLoadFiles, -}: RevisionSubTableProps) { + onLoadAllFiles, +}: DocumentDetailViewProps) { const [expandedRevisions, setExpandedRevisions] = useState<ExpandedState>({}); + const [allExpanded, setAllExpanded] = useState(false); const revisionTable = useReactTable({ data: revisions, @@ -263,80 +327,109 @@ function RevisionSubTable({ getRowCanExpand: () => true, }); - const handleRevisionExpand = (revisionId: number, isExpanded: boolean) => { - if (!isExpanded) { - onLoadFiles(revisionId); + const handleExpandAll = () => { + if (allExpanded) { + setExpandedRevisions({}); + } else { + const expanded: ExpandedState = {}; + revisions.forEach((_, index) => { + expanded[index] = true; + }); + setExpandedRevisions(expanded); + onLoadAllFiles(); } + setAllExpanded(!allExpanded); + }; + + const handleRevisionExpand = (revisionId: number) => { + onLoadFiles(revisionId); }; return ( - <div className="border-l-4 border-blue-200"> - <Table> - <TableHeader> - {revisionTable.getHeaderGroups().map((headerGroup) => ( - <TableRow key={headerGroup.id} className="bg-muted/50"> - {headerGroup.headers.map((header) => ( - <TableHead key={header.id} className="font-semibold"> - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext() - )} - </TableHead> - ))} - </TableRow> - ))} - </TableHeader> - <TableBody> - {revisionTable.getRowModel().rows.map((row) => ( - <> - {/* 리비전 행 */} - <TableRow key={row.id} className="bg-muted/20"> - {row.getVisibleCells().map((cell) => ( - <TableCell key={cell.id}> - {cell.column.id === "expander" ? ( - <div - onClick={() => { - row.toggleExpanded(); - handleRevisionExpand(row.original.id, row.getIsExpanded()); - }} - > - {flexRender( - cell.column.columnDef.cell, - cell.getContext() + <div className="space-y-4"> + {/* 전체 펼치기/접기 버튼 */} + <div className="flex justify-end"> + <Button + variant="outline" + size="sm" + onClick={handleExpandAll} + > + {allExpanded ? "모두 접기" : "모두 펼치기"} + </Button> + </div> + + {/* 리비전 테이블 */} + <div className="rounded-md border"> + <Table> + <TableHeader> + {revisionTable.getHeaderGroups().map((headerGroup) => ( + <TableRow key={headerGroup.id} className="bg-muted/50"> + {headerGroup.headers.map((header) => ( + <TableHead key={header.id} className="font-semibold"> + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() )} - </div> - ) : ( - flexRender(cell.column.columnDef.cell, cell.getContext()) - )} - </TableCell> + </TableHead> ))} </TableRow> - - {/* 파일 행들 (확장 시) */} - {row.getIsExpanded() && ( - <TableRow> - <TableCell colSpan={swpRevisionColumns.length} className="p-0 bg-blue-50/30"> - {loadingFiles.has(row.original.id) ? ( - <div className="flex items-center justify-center p-4"> - <Loader2 className="h-5 w-5 animate-spin" /> - <span className="ml-2 text-sm">파일 로딩 중...</span> - </div> - ) : fileData[row.original.id]?.length ? ( - <FileSubTable files={fileData[row.original.id]} /> - ) : ( - <div className="p-4 text-center text-sm text-muted-foreground"> - 파일 없음 - </div> - )} - </TableCell> + ))} + </TableHeader> + <TableBody> + {revisionTable.getRowModel().rows.map((row) => ( + <React.Fragment key={row.id}> + {/* 리비전 행 */} + <TableRow className="bg-muted/20"> + {row.getVisibleCells().map((cell) => ( + <TableCell key={cell.id}> + {cell.column.id === "expander" ? ( + <div + onClick={() => { + row.toggleExpanded(); + if (!row.getIsExpanded()) { + handleRevisionExpand(row.original.id); + } + }} + className="cursor-pointer" + > + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + </div> + ) : ( + flexRender(cell.column.columnDef.cell, cell.getContext()) + )} + </TableCell> + ))} </TableRow> - )} - </> - ))} - </TableBody> - </Table> + + {/* 파일 행들 (확장 시) */} + {row.getIsExpanded() && ( + <TableRow> + <TableCell colSpan={swpRevisionColumns.length} className="p-0 bg-blue-50/30"> + {loadingFiles.has(row.original.id) ? ( + <div className="flex items-center justify-center p-4"> + <Loader2 className="h-5 w-5 animate-spin" /> + <span className="ml-2 text-sm">파일 로딩 중...</span> + </div> + ) : fileData[row.original.id]?.length ? ( + <FileSubTable files={fileData[row.original.id]} /> + ) : ( + <div className="p-4 text-center text-sm text-muted-foreground"> + 파일 없음 + </div> + )} + </TableCell> + </TableRow> + )} + </React.Fragment> + ))} + </TableBody> + </Table> + </div> </div> ); } |
