summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-11-18 12:15:43 +0900
committerjoonhoekim <26rote@gmail.com>2025-11-18 12:15:43 +0900
commit1a8bf9c1c98454bd0e961b84d14299155ad67e7f (patch)
treea82663c3441811c1b3b90ae5136d3198f83a7697
parent2399d5e285bb76c68a2c3368b05ac40478886c2b (diff)
(김준회) swp: 그룹핑 로직 수정요청사항 반영, 대용량 파일업로드 formidable 사용한 스트리밍 방식으로 오류 수정
-rw-r--r--app/[lng]/partners/(partners)/swp-document-upload/vendor-document-page.tsx6
-rw-r--r--app/api/swp/upload/route.ts299
-rw-r--r--lib/swp/table/swp-inbox-table.tsx174
-rw-r--r--lib/swp/table/swp-table-toolbar.tsx41
-rw-r--r--lib/swp/table/swp-table.tsx64
-rw-r--r--lib/swp/table/swp-upload-result-dialog.tsx9
6 files changed, 467 insertions, 126 deletions
diff --git a/app/[lng]/partners/(partners)/swp-document-upload/vendor-document-page.tsx b/app/[lng]/partners/(partners)/swp-document-upload/vendor-document-page.tsx
index eaa1d4e0..3db64262 100644
--- a/app/[lng]/partners/(partners)/swp-document-upload/vendor-document-page.tsx
+++ b/app/[lng]/partners/(partners)/swp-document-upload/vendor-document-page.tsx
@@ -166,6 +166,11 @@ export default function VendorDocumentPage({ searchParams }: VendorDocumentPageP
loadDocuments();
};
+ const handleUploadComplete = () => {
+ // 업로드 완료 시 Inbox 탭으로 전환
+ setActiveTab("inbox");
+ };
+
// 클라이언트 사이드 필터링 - VDR Documents
const filteredDocuments = useMemo(() => {
return documents.filter((doc) => {
@@ -309,6 +314,7 @@ export default function VendorDocumentPage({ searchParams }: VendorDocumentPageP
onProjNoChange={handleProjectChange}
onFiltersChange={handleFiltersChange}
onRefresh={handleRefresh}
+ onUploadComplete={handleUploadComplete}
isRefreshing={isRefreshing}
projects={projects}
vendorCode={vendorInfo?.vendorCode}
diff --git a/app/api/swp/upload/route.ts b/app/api/swp/upload/route.ts
index 71c88cec..350678a7 100644
--- a/app/api/swp/upload/route.ts
+++ b/app/api/swp/upload/route.ts
@@ -4,11 +4,83 @@ import * as path from "path";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { debugLog, debugError, debugSuccess } from "@/lib/debug-utils";
+import formidable, { Fields, Files, File as FormidableFile } from "formidable";
+import { Readable } from "stream";
// API Route 설정
export const runtime = "nodejs";
export const maxDuration = 3600; // 1시간 타임아웃 (대용량 파일 업로드 대응)
+// Next.js 15 API Route body parsing 비활성화 (스트리밍 처리를 위해)
+export const dynamic = 'force-dynamic';
+
+/**
+ * formidable을 사용하여 스트리밍 방식으로 파일 파싱
+ * 메모리에 전체 파일을 올리지 않고 chunk 단위로 처리
+ */
+async function parseFormWithFormidable(req: NextRequest): Promise<{ fields: Fields; files: Files }> {
+ const uploadDir = process.env.TEMP_UPLOAD_DIR || "/tmp/swp-upload";
+
+ const form = formidable({
+ maxFileSize: 1024 * 1024 * 1024, // 1GB 제한
+ maxFieldsSize: 20 * 1024 * 1024, // 20MB 메타데이터 제한
+ allowEmptyFiles: false,
+ multiples: true, // 여러 파일 업로드 허용
+ keepExtensions: true,
+ // 임시 디렉토리에 파일 저장 (스트리밍 방식)
+ uploadDir,
+ filename: (_name, _ext, part) => {
+ // 원본 파일명 유지
+ return part.originalFilename || `upload_${Date.now()}`;
+ },
+ });
+
+ // 임시 디렉토리 생성
+ await fs.mkdir(uploadDir, { recursive: true });
+
+ // Next.js Request를 Node.js IncomingMessage 형태로 변환
+ // formidable은 headers 정보가 필요함 (특히 content-length, content-type)
+
+ // ReadableStream을 AsyncIterable로 변환
+ async function* streamToAsyncIterable(stream: ReadableStream<Uint8Array>) {
+ const reader = stream.getReader();
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ yield value;
+ }
+ } finally {
+ reader.releaseLock();
+ }
+ }
+
+ const bodyStream = req.body
+ ? Readable.from(streamToAsyncIterable(req.body as ReadableStream<Uint8Array>))
+ : Readable.from([]);
+
+ // headers 정보 추가 (formidable이 필요로 함)
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const mockIncomingMessage: any = Object.assign(bodyStream, {
+ headers: {
+ 'content-type': req.headers.get('content-type') || '',
+ 'content-length': req.headers.get('content-length') || '0',
+ },
+ method: req.method,
+ url: req.url,
+ });
+
+ return new Promise((resolve, reject) => {
+ form.parse(mockIncomingMessage, (err, fields, files) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+ resolve({ fields, files });
+ });
+ });
+}
+
interface InBoxFileInfo {
CPY_CD: string;
FILE_NM: string;
@@ -27,6 +99,11 @@ interface InBoxFileInfo {
* 자유 파일명은 선택사항이며, 포함될 경우 언더스코어를 포함할 수 있음
*/
function parseFileName(fileName: string) {
+ // 경로 순회 공격 방지 (Path Traversal Attack)
+ if (fileName.includes("..") || fileName.includes("/") || fileName.includes("\\")) {
+ throw new Error(`잘못된 파일명입니다 (경로 문자 포함): ${fileName}`);
+ }
+
const lastDotIndex = fileName.lastIndexOf(".");
// 확장자 검증
@@ -92,6 +169,26 @@ function generateTimestamp(): string {
return `${year}${month}${day}${hours}${minutes}${seconds}`;
}
+/**
+ * 디스크 공간 검사 (간단한 휴리스틱 방식)
+ * 디렉토리에 쓰기 권한이 있는지 확인
+ *
+ * 참고: 실제 디스크 공간 체크를 위해서는 'check-disk-space' 라이브러리 사용으로 변경하기
+ */
+async function checkDiskWritable(directory: string): Promise<boolean> {
+ try {
+ // 테스트 쓰기로 권한 검증
+ await fs.mkdir(directory, { recursive: true });
+ const testFile = path.join(directory, `.disk-check-${Date.now()}`);
+ await fs.writeFile(testFile, "test");
+ await fs.unlink(testFile);
+ return true;
+ } catch (error) {
+ console.error(`[checkDiskWritable] 디스크 쓰기 권한 검사 실패: ${error}`);
+ return false;
+ }
+}
+
/**
* SaveInBoxList API 호출
@@ -156,10 +253,28 @@ async function callSaveInBoxList(fileInfos: InBoxFileInfo[], crter: string): Pro
/**
* POST /api/swp/upload
- * FormData로 파일 업로드
+ * 스트리밍 방식으로 파일 업로드 (대용량 파일 지원)
*/
export async function POST(request: NextRequest) {
try {
+ // 환경 변수 검증
+ const swpMountDir = process.env.SWP_MOUNT_DIR;
+ const ddcBaseUrl = process.env.DDC_BASE_URL;
+
+ if (!swpMountDir) {
+ return NextResponse.json(
+ { success: false, message: "서버 설정 오류: SWP_MOUNT_DIR 환경 변수가 설정되지 않았습니다." },
+ { status: 500 }
+ );
+ }
+
+ if (!ddcBaseUrl) {
+ return NextResponse.json(
+ { success: false, message: "서버 설정 오류: DDC_BASE_URL 환경 변수가 설정되지 않았습니다." },
+ { status: 500 }
+ );
+ }
+
// 세션에서 사용자 ID 가져오기
const session = await getServerSession(authOptions);
@@ -173,10 +288,17 @@ export async function POST(request: NextRequest) {
const crter = String(session.user.id); // 사용자 ID를 문자열로 변환
console.log(`[upload] 사용자 ID (crter): ${crter}`);
- const formData = await request.formData();
+ // 스트리밍 방식으로 FormData 파싱
+ debugLog("[upload] 스트리밍 파싱 시작...");
+ const { fields, files } = await parseFormWithFormidable(request);
+ debugSuccess("[upload] 스트리밍 파싱 완료");
+
+ // 필드 추출
+ const projNoArray = fields.projNo;
+ const vndrCdArray = fields.vndrCd;
- const projNo = formData.get("projNo") as string;
- const vndrCd = formData.get("vndrCd") as string;
+ const projNo = Array.isArray(projNoArray) ? projNoArray[0] : projNoArray;
+ const vndrCd = Array.isArray(vndrCdArray) ? vndrCdArray[0] : vndrCdArray;
if (!projNo || !vndrCd) {
return NextResponse.json(
@@ -188,15 +310,19 @@ export async function POST(request: NextRequest) {
// vndrCd를 CPY_CD로 사용
console.log(`[upload] vndrCd를 CPY_CD로 사용: ${vndrCd}`);
- const files = formData.getAll("files") as File[];
+ // 파일 배열 추출 (formidable은 files.files로 저장)
+ const uploadedFiles = files.files;
- if (!files || files.length === 0) {
+ if (!uploadedFiles || (Array.isArray(uploadedFiles) && uploadedFiles.length === 0)) {
return NextResponse.json(
{ success: false, message: "업로드할 파일이 없습니다." },
{ status: 400 }
);
}
+ // 단일 파일인 경우 배열로 변환
+ const fileArray = Array.isArray(uploadedFiles) ? uploadedFiles : [uploadedFiles];
+
const result = {
successCount: 0,
failedCount: 0,
@@ -204,31 +330,51 @@ export async function POST(request: NextRequest) {
};
const inBoxFileInfos: InBoxFileInfo[] = [];
- const swpMountDir = process.env.SWP_MOUNT_DIR || "/mnt/swp-smb-dir/";
// 업로드 시점의 timestamp 생성 (모든 파일에 동일한 timestamp 사용)
const uploadTimestamp = generateTimestamp();
console.log(`[upload] 업로드 타임스탬프 생성: ${uploadTimestamp}`);
+
+ // 디스크 쓰기 권한 사전 검사
+ const targetDirectory = path.join(swpMountDir, projNo, vndrCd, uploadTimestamp);
+ const isWritable = await checkDiskWritable(targetDirectory);
+ if (!isWritable) {
+ return NextResponse.json(
+ { success: false, message: "파일 저장 경로에 쓰기 권한이 없습니다." },
+ { status: 500 }
+ );
+ }
- for (const file of files) {
+ // 임시 파일 경로 저장 (정리용)
+ const tempFilesToClean: string[] = [];
+ // 성공적으로 저장된 파일 경로 (롤백용)
+ const savedFiles: string[] = [];
+
+ for (const file of fileArray) {
try {
+ const formidableFile = file as FormidableFile;
+ const originalFileName = formidableFile.originalFilename || "unknown";
+
// 파일명 파싱
- const parsed = parseFileName(file.name);
- console.log(`[upload] 파일명 파싱:`, parsed);
+ const parsed = parseFileName(originalFileName);
+ debugLog(`[upload] 파일명 파싱:`, { originalFileName, parsed });
// 네트워크 경로 생성 (timestamp를 경로에만 사용)
- const networkPath = path.join(swpMountDir, projNo, vndrCd, uploadTimestamp, file.name);
+ const networkPath = path.join(swpMountDir, projNo, vndrCd, uploadTimestamp, originalFileName);
// 파일 중복 체크
try {
await fs.access(networkPath, fs.constants.F_OK);
result.failedCount++;
result.details.push({
- fileName: file.name,
+ fileName: originalFileName,
success: false,
error: "파일이 이미 존재합니다.",
});
console.warn(`[upload] 파일 중복: ${networkPath}`);
+
+ // 임시 파일 정리
+ tempFilesToClean.push(formidableFile.filepath);
continue;
} catch {
// 파일이 존재하지 않음 (정상)
@@ -238,92 +384,133 @@ export async function POST(request: NextRequest) {
const directory = path.dirname(networkPath);
await fs.mkdir(directory, { recursive: true });
- // 파일 저장 (스트리밍 방식)
- const arrayBuffer = await file.arrayBuffer();
- debugLog(`[upload] ArrayBuffer 변환 완료: ${file.name}`, {
- arrayBufferSize: arrayBuffer.byteLength,
- fileType: file.type,
- originalSize: file.size
- });
+ // 🚀 스트리밍 방식: 임시 파일을 최종 경로로 이동 (메모리 복사 없음)
+ const tempFilePath = formidableFile.filepath;
+ const fileSize = formidableFile.size;
- const buffer = Buffer.from(arrayBuffer);
- debugLog(`[upload] Buffer 생성 완료: ${file.name}`, {
- bufferLength: buffer.length,
- bufferType: typeof buffer,
- isBuffer: Buffer.isBuffer(buffer),
- first20Bytes: buffer.slice(0, 20).toString('hex')
+ debugLog(`[upload] 파일 이동 시작: ${originalFileName}`, {
+ tempPath: tempFilePath,
+ finalPath: networkPath,
+ size: fileSize
});
- console.log(`[upload] 파일 저장: ${file.name} (${buffer.length} bytes)`);
-
- // 저장 전 buffer 상태 확인
- debugLog(`[upload] 저장 직전 buffer 상태`, {
- constructor: buffer.constructor.name,
- isBuffer: Buffer.isBuffer(buffer),
- jsonStringified: JSON.stringify(buffer).substring(0, 100) + '...'
- });
-
- await fs.writeFile(networkPath, buffer);
- debugSuccess(`[upload] 파일 저장 완료: ${networkPath}`);
+ // 파일 이동 (rename이 더 빠르지만, 같은 파일시스템이 아니면 실패하므로 copyFile 사용)
+ // 리네임은 같은 시스템 안에서, 파일 메타데이터만 변경해서 빠른데 swp는 외부시스템이므로 항상 캐치문에 걸릴것임
+ try {
+ await fs.rename(tempFilePath, networkPath);
+ debugSuccess(`[upload] 파일 이동 완료 (rename): ${networkPath}`);
+ } catch (renameError) {
+ // rename 실패 시 copyFile + unlink 사용
+ debugLog(`[upload] rename 실패(네트워크경로 - 정상임), copyFile 사용: ${originalFileName}`);
+ await fs.copyFile(tempFilePath, networkPath);
+ tempFilesToClean.push(tempFilePath);
+ debugSuccess(`[upload] 파일 복사 완료: ${networkPath}`);
+ }
// 저장된 파일 검증
const savedFileStats = await fs.stat(networkPath);
- debugLog(`[upload] 저장된 파일 정보`, {
- size: savedFileStats.size,
- expectedSize: buffer.length,
- sizeMatch: savedFileStats.size === buffer.length
- });
-
- // 저장된 파일 첫 부분 읽어서 검증
- const verifyBuffer = await fs.readFile(networkPath);
debugLog(`[upload] 저장된 파일 검증`, {
- readSize: verifyBuffer.length,
- first20Bytes: verifyBuffer.slice(0, 20).toString('hex'),
- isBuffer: Buffer.isBuffer(verifyBuffer),
- matchesOriginal: buffer.slice(0, 20).equals(verifyBuffer.slice(0, 20))
+ fileName: originalFileName,
+ savedSize: savedFileStats.size,
+ expectedSize: fileSize,
+ sizeMatch: savedFileStats.size === fileSize
});
+
+ if (savedFileStats.size !== fileSize) {
+ throw new Error(`파일 크기 불일치: 예상 ${fileSize} bytes, 실제 ${savedFileStats.size} bytes`);
+ }
// InBox 파일 정보 준비 (FLD_PATH에 업로드 timestamp 사용)
+ // 시스템 요구사항: 중간 구분자만 이스케이프 (\\\\)
const fldPath = `\\${projNo}\\${vndrCd}\\\\${uploadTimestamp}`;
inBoxFileInfos.push({
CPY_CD: vndrCd,
- FILE_NM: file.name,
+ FILE_NM: originalFileName,
OFDC_NO: null,
PROJ_NO: projNo,
OWN_DOC_NO: parsed.ownDocNo,
REV_NO: parsed.revNo,
STAGE: parsed.stage,
STAT: "SCW01",
- FILE_SZ: String(buffer.length),
+ FILE_SZ: String(fileSize),
FLD_PATH: fldPath,
});
+ // 성공한 파일 경로 저장 (롤백용)
+ savedFiles.push(networkPath);
+
result.successCount++;
result.details.push({
- fileName: file.name,
+ fileName: originalFileName,
success: true,
networkPath,
});
} catch (error) {
+ const formidableFile = file as FormidableFile;
+ const originalFileName = formidableFile.originalFilename || "unknown";
+
result.failedCount++;
result.details.push({
- fileName: file.name,
+ fileName: originalFileName,
success: false,
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
- console.error(`[upload] 파일 처리 실패: ${file.name}`, error);
- debugError(`[upload] 파일 처리 실패: ${file.name}`, {
+ console.error(`[upload] 파일 처리 실패: ${originalFileName}`, error);
+ debugError(`[upload] 파일 처리 실패: ${originalFileName}`, {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined
});
+
+ // 임시 파일 정리
+ if (formidableFile.filepath) {
+ tempFilesToClean.push(formidableFile.filepath);
+ }
+ }
+ }
+
+ // 임시 파일 정리
+ for (const tempFile of tempFilesToClean) {
+ try {
+ await fs.unlink(tempFile);
+ debugLog(`[upload] 임시 파일 삭제: ${tempFile}`);
+ } catch (cleanError) {
+ console.warn(`[upload] 임시 파일 삭제 실패 (무시): ${tempFile}`, cleanError);
}
}
- // SaveInBoxList API 호출
+ // SaveInBoxList API 호출 (트랜잭션 방식)
if (inBoxFileInfos.length > 0) {
console.log(`[upload] SaveInBoxList API 호출: ${inBoxFileInfos.length}개 파일`);
- await callSaveInBoxList(inBoxFileInfos, crter);
+ try {
+ await callSaveInBoxList(inBoxFileInfos, crter);
+ } catch (apiError) {
+ // API 호출 실패 시 롤백: 저장된 파일 삭제
+ console.error(`[upload] SaveInBoxList API 실패, 롤백 시작...`, apiError);
+ debugError(`[upload] SaveInBoxList API 실패, 롤백 시작`, {
+ error: apiError instanceof Error ? apiError.message : String(apiError),
+ filesCount: savedFiles.length
+ });
+
+ for (const filePath of savedFiles) {
+ try {
+ await fs.unlink(filePath);
+ console.log(`[upload] 롤백: 파일 삭제 - ${filePath}`);
+ } catch (unlinkError) {
+ console.warn(`[upload] 롤백 중 파일 삭제 실패 (무시): ${filePath}`, unlinkError);
+ }
+ }
+
+ // 클라이언트에게 에러 반환
+ return NextResponse.json(
+ {
+ success: false,
+ message: `파일 등록 API 호출 실패: ${apiError instanceof Error ? apiError.message : "알 수 없는 오류"}`,
+ rollback: true,
+ },
+ { status: 500 }
+ );
+ }
}
// ⚠️ Full API 방식으로 전환했으므로 로컬 DB 동기화는 불필요
diff --git a/lib/swp/table/swp-inbox-table.tsx b/lib/swp/table/swp-inbox-table.tsx
index a2fedeed..a88ff656 100644
--- a/lib/swp/table/swp-inbox-table.tsx
+++ b/lib/swp/table/swp-inbox-table.tsx
@@ -45,12 +45,18 @@ interface TableRowData {
statusNm: string | null;
actvNo: string | null;
crter: string | null; // CRTER (그대로 표시)
- note: string | null; // 첫 번째 파일의 note 또는 buyerSystemComment
+ note: string | null; // Activity의 note 또는 buyerSystemComment
file: SwpFileApiResponse | null; // 업로드 필요 문서는 null
uploadDate: string | null;
- // 각 행이 속한 그룹의 정보
- isFirstFileInRev: boolean;
+ // 각 행이 속한 그룹의 정보 (계층적 rowSpan 처리)
+ isFirstInUpload: boolean;
+ fileCountInUpload: number;
+ isFirstInDoc: boolean;
+ fileCountInDoc: number;
+ isFirstInRev: boolean;
fileCountInRev: number;
+ isFirstInActivity: boolean;
+ fileCountInActivity: number;
// 업로드 필요 문서 여부
isRequiredDoc: boolean;
}
@@ -137,7 +143,7 @@ export function SwpInboxTable({
filteredFiles = files.filter((file) => file.STAT === selectedStatus);
}
- // BOX_SEQ 기준으로 그룹화
+ // 1단계: BOX_SEQ (Upload ID) 기준으로 그룹화
const uploadGroups = new Map<string, SwpFileApiResponse[]>();
if (!selectedStatus || selectedStatus !== "UPLOAD_REQUIRED") {
@@ -150,8 +156,9 @@ export function SwpInboxTable({
});
}
+ // Upload ID별로 처리
uploadGroups.forEach((uploadFiles, uploadId) => {
- // 2. Document No 기준으로 그룹화
+ // 2단계: Document No 기준으로 그룹화
const docGroups = new Map<string, SwpFileApiResponse[]>();
uploadFiles.forEach((file) => {
@@ -162,44 +169,86 @@ export function SwpInboxTable({
docGroups.get(docNo)!.push(file);
});
+ // 전체 Upload ID의 파일 수 계산
+ const totalUploadFileCount = uploadFiles.length;
+ let isFirstInUpload = true;
+
+ // Document No별로 처리
docGroups.forEach((docFiles, docNo) => {
- // 3. 최신 RevNo 찾기 (CRTE_DTM 기준)
+ // 3단계: 최신 RevNo 찾기 (CRTE_DTM 기준)
const sortedByDate = [...docFiles].sort((a, b) =>
(b.CRTE_DTM || "").localeCompare(a.CRTE_DTM || "")
);
const latestRevNo = sortedByDate[0]?.REV_NO || "";
+ const latestStage = sortedByDate[0]?.STAGE || null;
+ const latestStatus = sortedByDate[0]?.STAT || null;
+ const latestStatusNm = sortedByDate[0]?.STAT_NM || null;
- // 4. 최신 Rev의 파일들만 필터링
+ // 4단계: 최신 Rev의 파일들만 필터링
const latestRevFiles = docFiles.filter(
(file) => file.REV_NO === latestRevNo
);
- // 5. Upload Date 기준 DESC 정렬
- const sortedFiles = latestRevFiles.sort((a, b) =>
- (b.CRTE_DTM || "").localeCompare(a.CRTE_DTM || "")
- );
+ const totalDocFileCount = latestRevFiles.length;
+ let isFirstInDoc = true;
+
+ // 5단계: Activity 기준으로 그룹화
+ const activityGroups = new Map<string, SwpFileApiResponse[]>();
+
+ latestRevFiles.forEach((file) => {
+ const actvNo = file.ACTV_NO || "NO_ACTIVITY";
+ if (!activityGroups.has(actvNo)) {
+ activityGroups.set(actvNo, []);
+ }
+ activityGroups.get(actvNo)!.push(file);
+ });
- // 6. 최신 파일의 정보로 Rev 메타데이터 설정 (첫 번째 파일의 crter와 note 사용)
- const latestFile = sortedFiles[0];
- if (!latestFile) return;
-
- // 7. 각 파일을 테이블 행으로 변환 (crter와 note는 첫 번째 파일 것으로 통일)
- sortedFiles.forEach((file, idx) => {
- rows.push({
- uploadId,
- docNo,
- revNo: latestRevNo,
- stage: latestFile.STAGE,
- status: latestFile.STAT,
- statusNm: latestFile.STAT_NM,
- actvNo: latestFile.ACTV_NO,
- crter: latestFile.CRTER, // CRTER 그대로
- note: latestFile.NOTE || null, // 첫 번째 파일의 note
- file,
- uploadDate: file.CRTE_DTM,
- isFirstFileInRev: idx === 0,
- fileCountInRev: sortedFiles.length,
- isRequiredDoc: false,
+ let isFirstInRev = true;
+
+ // Activity별로 처리
+ activityGroups.forEach((activityFiles, actvNo) => {
+ // 6단계: Upload Date 기준 DESC 정렬
+ const sortedFiles = activityFiles.sort((a, b) =>
+ (b.CRTE_DTM || "").localeCompare(a.CRTE_DTM || "")
+ );
+
+ const totalActivityFileCount = sortedFiles.length;
+
+ // Activity의 첫 번째 파일에서 메타데이터 가져오기
+ const firstActivityFile = sortedFiles[0];
+ if (!firstActivityFile) return;
+
+ // 7단계: 각 파일을 테이블 행으로 변환
+ sortedFiles.forEach((file, idx) => {
+ rows.push({
+ uploadId,
+ docNo,
+ revNo: latestRevNo,
+ stage: latestStage,
+ status: latestStatus,
+ statusNm: latestStatusNm,
+ actvNo: actvNo === "NO_ACTIVITY" ? null : actvNo,
+ crter: firstActivityFile.CRTER, // Activity 첫 파일의 CRTER
+ note: firstActivityFile.NOTE || null, // Activity 첫 파일의 note
+ file,
+ uploadDate: file.CRTE_DTM,
+ isFirstInUpload,
+ fileCountInUpload: totalUploadFileCount,
+ isFirstInDoc,
+ fileCountInDoc: totalDocFileCount,
+ isFirstInRev,
+ fileCountInRev: totalDocFileCount, // Rev = Doc의 최신 Rev이므로 파일 수 동일
+ isFirstInActivity: idx === 0,
+ fileCountInActivity: totalActivityFileCount,
+ isRequiredDoc: false,
+ });
+
+ // 첫 번째 플래그들 업데이트
+ if (idx === 0) {
+ isFirstInUpload = false;
+ isFirstInDoc = false;
+ isFirstInRev = false;
+ }
});
});
});
@@ -220,8 +269,14 @@ export function SwpInboxTable({
note: doc.buyerSystemComment,
file: null,
uploadDate: null,
- isFirstFileInRev: true,
+ isFirstInUpload: true,
+ fileCountInUpload: 1,
+ isFirstInDoc: true,
+ fileCountInDoc: 1,
+ isFirstInRev: true,
fileCountInRev: 1,
+ isFirstInActivity: true,
+ fileCountInActivity: 1,
isRequiredDoc: true,
});
});
@@ -446,7 +501,7 @@ export function SwpInboxTable({
return (
<TableRow
- key={`${row.uploadId}_${row.docNo}_${row.revNo}_${idx}`}
+ key={`${row.uploadId}_${row.docNo}_${row.revNo}_${row.actvNo}_${idx}`}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleRowClick(row.docNo)}
>
@@ -460,60 +515,61 @@ export function SwpInboxTable({
) : null}
</TableCell>
- {/* Upload ID - 같은 Rev의 첫 파일에만 표시 */}
- {row.isFirstFileInRev ? (
- <TableCell rowSpan={row.fileCountInRev} className="font-mono text-sm align-top">
+ {/* Upload ID - Upload의 첫 파일에만 표시 */}
+ {row.isFirstInUpload ? (
+ <TableCell rowSpan={row.fileCountInUpload} className="font-mono text-sm align-top" style={{ verticalAlign: "top" }}>
{row.uploadId || <span className="text-muted-foreground">-</span>}
</TableCell>
) : null}
- {/* Document No - 같은 Rev의 첫 파일에만 표시 */}
- {row.isFirstFileInRev ? (
- <TableCell rowSpan={row.fileCountInRev} className="font-mono text-xs align-top">
+ {/* Document No - Document의 첫 파일에만 표시 */}
+ {row.isFirstInDoc ? (
+ <TableCell rowSpan={row.fileCountInDoc} className="font-mono text-xs align-top" style={{ verticalAlign: "top" }}>
{row.docNo}
</TableCell>
) : null}
- {/* Rev No - 같은 Rev의 첫 파일에만 표시 */}
- {row.isFirstFileInRev ? (
- <TableCell rowSpan={row.fileCountInRev} className="align-top">
+ {/* Rev No - Rev의 첫 파일에만 표시 */}
+ {row.isFirstInRev ? (
+ <TableCell rowSpan={row.fileCountInRev} className="align-top" style={{ verticalAlign: "top" }}>
{row.revNo || <span className="text-muted-foreground">-</span>}
</TableCell>
) : null}
- {/* Stage - 같은 Rev의 첫 파일에만 표시 (텍스트로만 표시) */}
- {row.isFirstFileInRev ? (
- <TableCell rowSpan={row.fileCountInRev} className="align-top text-sm">
+ {/* Stage - Rev의 첫 파일에만 표시 */}
+ {row.isFirstInRev ? (
+ <TableCell rowSpan={row.fileCountInRev} className="align-top text-sm" style={{ verticalAlign: "top" }}>
{row.stage || <span className="text-muted-foreground">-</span>}
</TableCell>
) : null}
- {/* Status - 같은 Rev의 첫 파일에만 표시 */}
- {row.isFirstFileInRev ? (
- <TableCell rowSpan={row.fileCountInRev} className="align-top">
+ {/* Status - Rev의 첫 파일에만 표시 */}
+ {row.isFirstInRev ? (
+ <TableCell rowSpan={row.fileCountInRev} className="align-top" style={{ verticalAlign: "top" }}>
{getStatusBadge(row.status, row.statusNm)}
</TableCell>
) : null}
- {/* Activity - 같은 Rev의 첫 파일에만 표시 */}
- {row.isFirstFileInRev ? (
- <TableCell rowSpan={row.fileCountInRev} className="font-mono text-xs align-top">
+ {/* Activity - Activity의 첫 파일에만 표시 */}
+ {row.isFirstInActivity ? (
+ <TableCell rowSpan={row.fileCountInActivity} className="font-mono text-xs align-top" style={{ verticalAlign: "top" }}>
{row.actvNo || <span className="text-muted-foreground">-</span>}
</TableCell>
) : null}
- {/* CRTER - 같은 Rev의 첫 파일에만 표시 */}
- {row.isFirstFileInRev ? (
- <TableCell rowSpan={row.fileCountInRev} className="text-sm font-mono align-top">
+ {/* CRTER (Upload ID User) - Activity의 첫 파일에만 표시 */}
+ {row.isFirstInActivity ? (
+ <TableCell rowSpan={row.fileCountInActivity} className="text-sm font-mono align-top" style={{ verticalAlign: "top" }}>
{row.crter || <span className="text-muted-foreground">-</span>}
</TableCell>
) : null}
- {/* Note - 같은 Rev의 첫 파일에만 표시 (개행문자 처리) */}
- {row.isFirstFileInRev ? (
+ {/* Note - Activity의 첫 파일에만 표시 (개행문자 처리) */}
+ {row.isFirstInActivity ? (
<TableCell
- rowSpan={row.fileCountInRev}
+ rowSpan={row.fileCountInActivity}
className="text-xs max-w-[150px] align-top whitespace-pre-wrap"
+ style={{ verticalAlign: "top" }}
>
{row.note || <span className="text-muted-foreground">-</span>}
</TableCell>
@@ -522,7 +578,7 @@ export function SwpInboxTable({
{/* Attachment File - 각 파일마다 표시 (줄바꿈 허용) */}
<TableCell className="max-w-[400px]">
{row.file ? (
- <div className="flex items-center gap-2">
+ <div className="flex items-center justify-between gap-2">
<span className="text-sm font-mono break-words" style={{ wordBreak: "break-all" }}>
{row.file.FILE_NM}
</span>
diff --git a/lib/swp/table/swp-table-toolbar.tsx b/lib/swp/table/swp-table-toolbar.tsx
index 594bdd77..08eda3ef 100644
--- a/lib/swp/table/swp-table-toolbar.tsx
+++ b/lib/swp/table/swp-table-toolbar.tsx
@@ -18,7 +18,7 @@ import {
SwpUploadValidationDialog,
validateFileName
} from "./swp-upload-validation-dialog";
-import { SwpUploadedFilesDialog } from "./swp-uploaded-files-dialog";
+// import { SwpUploadedFilesDialog } from "./swp-uploaded-files-dialog";
import { getDocumentClassInfoByProjectCode } from "@/lib/swp/swp-upload-server-actions";
import type { DocumentListItem } from "@/lib/swp/document-service";
@@ -36,6 +36,7 @@ interface SwpTableToolbarProps {
onProjNoChange: (projNo: string) => void;
onFiltersChange: (filters: SwpTableFilters) => void;
onRefresh: () => void;
+ onUploadComplete?: () => void; // 업로드 완료 시 콜백 (탭 전환용)
isRefreshing: boolean;
projects?: Array<{ PROJ_NO: string; PROJ_NM: string | null }>;
vendorCode?: string;
@@ -51,6 +52,7 @@ export function SwpTableToolbar({
onProjNoChange,
onFiltersChange,
onRefresh,
+ onUploadComplete,
isRefreshing,
projects = [],
vendorCode,
@@ -322,6 +324,26 @@ export function SwpTableToolbar({
title: result.success ? "업로드 완료" : "일부 업로드 실패",
description: result.message,
});
+
+ // 업로드 성공 시 자동 새로고침 (외부 시스템 처리 시간 고려)
+ if (result.success && result.successCount > 0) {
+ // 업로드 완료 시 Inbox 탭으로 전환
+ onUploadComplete?.();
+
+ toast({
+ title: "문서 목록 갱신 중",
+ description: "외부 시스템 처리를 기다리는 중입니다...",
+ });
+
+ // 2초 딜레이 후 새로고침
+ setTimeout(() => {
+ onRefresh();
+ toast({
+ title: "갱신 완료",
+ description: "업로드된 파일이 문서 목록에 반영되었습니다.",
+ });
+ }, 2000);
+ }
} catch (error) {
console.error("파일 업로드 실패:", error);
@@ -442,6 +464,7 @@ export function SwpTableToolbar({
size="sm"
onClick={handleReset}
className="h-8"
+ disabled={isRefreshing}
>
<X className="h-4 w-4 mr-1" />
초기화
@@ -460,6 +483,7 @@ export function SwpTableToolbar({
role="combobox"
aria-expanded={projectSearchOpen}
className="w-full justify-between"
+ disabled={isRefreshing}
>
{projNo ? (
<span>
@@ -538,6 +562,7 @@ export function SwpTableToolbar({
onChange={(e) =>
setLocalFilters({ ...localFilters, docNo: e.target.value })
}
+ disabled={isRefreshing}
/>
</div>
@@ -551,6 +576,7 @@ export function SwpTableToolbar({
onChange={(e) =>
setLocalFilters({ ...localFilters, docTitle: e.target.value })
}
+ disabled={isRefreshing}
/>
</div>
@@ -564,6 +590,7 @@ export function SwpTableToolbar({
onChange={(e) =>
setLocalFilters({ ...localFilters, pkgNo: e.target.value })
}
+ disabled={isRefreshing}
/>
</div>
@@ -577,6 +604,7 @@ export function SwpTableToolbar({
onChange={(e) =>
setLocalFilters({ ...localFilters, stage: e.target.value })
}
+ disabled={isRefreshing}
/>
</div>
@@ -590,14 +618,19 @@ export function SwpTableToolbar({
onChange={(e) =>
setLocalFilters({ ...localFilters, status: e.target.value })
}
+ disabled={isRefreshing}
/>
</div>
</div>
<div className="flex justify-end">
- <Button onClick={handleSearch} size="sm">
- <Search className="h-4 w-4 mr-2" />
- 검색
+ <Button
+ onClick={handleSearch}
+ size="sm"
+ disabled={isRefreshing}
+ >
+ <Search className={`h-4 w-4 mr-2 ${isRefreshing ? "animate-spin" : ""}`} />
+ {isRefreshing ? "로딩 중..." : "검색"}
</Button>
</div>
</div>
diff --git a/lib/swp/table/swp-table.tsx b/lib/swp/table/swp-table.tsx
index 21a1c775..b6c3558b 100644
--- a/lib/swp/table/swp-table.tsx
+++ b/lib/swp/table/swp-table.tsx
@@ -1,6 +1,6 @@
"use client";
-import React, { useState } from "react";
+import React, { useMemo, useState } from "react";
import {
useReactTable,
getCoreRowModel,
@@ -14,6 +14,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
+import { Button } from "@/components/ui/button";
import { swpDocumentColumns } from "./swp-table-columns";
import { SwpDocumentDetailDialog } from "./swp-document-detail-dialog";
import type { DocumentListItem } from "@/lib/swp/document-service";
@@ -25,6 +26,12 @@ interface SwpTableProps {
userId: string;
}
+// Status 집계 타입
+interface StatusCount {
+ status: string;
+ count: number;
+}
+
export function SwpTable({
documents,
projNo,
@@ -33,9 +40,39 @@ export function SwpTable({
}: SwpTableProps) {
const [dialogOpen, setDialogOpen] = useState(false);
const [selectedDocument, setSelectedDocument] = useState<DocumentListItem | null>(null);
+ const [selectedStatus, setSelectedStatus] = useState<string | null>(null);
+
+ // Status 집계
+ const statusCounts = useMemo(() => {
+ const statusMap = new Map<string, number>();
+
+ documents.forEach((doc) => {
+ const status = doc.LTST_ACTV_STAT || "UNKNOWN";
+ statusMap.set(status, (statusMap.get(status) || 0) + 1);
+ });
+
+ const counts: StatusCount[] = [];
+ statusMap.forEach((count, status) => {
+ counts.push({
+ status,
+ count,
+ });
+ });
+
+ // 개수 순으로 정렬
+ return counts.sort((a, b) => b.count - a.count);
+ }, [documents]);
+
+ // Status 필터링된 문서 목록
+ const filteredDocuments = useMemo(() => {
+ if (!selectedStatus) {
+ return documents;
+ }
+ return documents.filter((doc) => doc.LTST_ACTV_STAT === selectedStatus);
+ }, [documents, selectedStatus]);
const table = useReactTable({
- data: documents,
+ data: filteredDocuments,
columns: swpDocumentColumns,
getCoreRowModel: getCoreRowModel(),
});
@@ -48,6 +85,29 @@ export function SwpTable({
return (
<div className="space-y-4">
+ {/* Status 필터 UI */}
+ <div className="flex flex-wrap gap-2 p-4 bg-muted/30 rounded-lg">
+ <Button
+ variant={selectedStatus === null ? "default" : "outline"}
+ size="sm"
+ onClick={() => setSelectedStatus(null)}
+ className="h-9"
+ >
+ 전체 ({documents.length})
+ </Button>
+ {statusCounts.map((statusCount) => (
+ <Button
+ key={statusCount.status}
+ variant={selectedStatus === statusCount.status ? "default" : "outline"}
+ size="sm"
+ onClick={() => setSelectedStatus(statusCount.status)}
+ className="h-9"
+ >
+ {statusCount.status} ({statusCount.count})
+ </Button>
+ ))}
+ </div>
+
{/* 테이블 */}
<div className="rounded-md border">
<Table>
diff --git a/lib/swp/table/swp-upload-result-dialog.tsx b/lib/swp/table/swp-upload-result-dialog.tsx
index 7b79fa68..06caf66e 100644
--- a/lib/swp/table/swp-upload-result-dialog.tsx
+++ b/lib/swp/table/swp-upload-result-dialog.tsx
@@ -9,7 +9,6 @@ import {
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { CheckCircle2, XCircle, FileText } from "lucide-react";
-import { ScrollArea } from "@/components/ui/scroll-area";
interface UploadResult {
fileName: string;
@@ -34,7 +33,7 @@ export function SwpUploadResultDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-3xl max-h-[80vh]">
+ <DialogContent className="max-w-3xl max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>파일 업로드 결과</DialogTitle>
<DialogDescription>
@@ -42,7 +41,7 @@ export function SwpUploadResultDialog({
</DialogDescription>
</DialogHeader>
- <ScrollArea className="max-h-[500px] pr-4">
+ <div className="flex-1 overflow-auto pr-2">
<div className="space-y-3">
{results.map((result, index) => (
<div
@@ -89,9 +88,9 @@ export function SwpUploadResultDialog({
</div>
))}
</div>
- </ScrollArea>
+ </div>
- <div className="flex justify-between items-center pt-4 border-t">
+ <div className="flex justify-between items-center pt-4 border-t mt-4">
<div className="text-sm text-muted-foreground">
{failCount > 0 && (
<span className="text-red-600 dark:text-red-400 font-medium">