summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/swp/actions.ts458
-rw-r--r--lib/swp/sync-service.ts119
-rw-r--r--lib/swp/table/swp-help-dialog.tsx153
-rw-r--r--lib/swp/table/swp-table-columns.tsx93
-rw-r--r--lib/swp/table/swp-table-toolbar.tsx142
-rw-r--r--lib/swp/table/swp-table.tsx309
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>
);
}