summaryrefslogtreecommitdiff
path: root/lib/swp
diff options
context:
space:
mode:
Diffstat (limited to 'lib/swp')
-rw-r--r--lib/swp/api-client.ts307
-rw-r--r--lib/swp/table/swp-document-detail-dialog.tsx567
-rw-r--r--lib/swp/table/swp-inbox-history-dialog.tsx509
-rw-r--r--lib/swp/table/swp-inbox-table.tsx703
-rw-r--r--lib/swp/utils.ts68
-rw-r--r--lib/swp/vendor-actions.ts67
6 files changed, 1766 insertions, 455 deletions
diff --git a/lib/swp/api-client.ts b/lib/swp/api-client.ts
index 39ce02b0..17cfbb7c 100644
--- a/lib/swp/api-client.ts
+++ b/lib/swp/api-client.ts
@@ -15,6 +15,7 @@
* ## 주요 API
* - `fetchGetVDRDocumentList`: 문서 마스터 조회 (필터링 지원)
* - `fetchGetExternalInboxList`: 파일 정보 조회 (업로드된 파일 포함)
+ * - `fetchGetRevTreeCompleteList`: 문서 리비전 트리 조회 (NEW)
* - `fetchGetActivityFileList`: Rev-Activity-File 계층 구조 조회
* - `callSaveInBoxList`: 파일 업로드 메타데이터 등록
* - `callSaveInBoxListCancelStatus`: Standby 파일 취소
@@ -28,16 +29,17 @@
* vndrCd: "SE00100"
* });
*
- * // 파일 목록 조회
- * const files = await fetchGetExternalInboxList({
- * projNo: "SN2190",
- * vndrCd: "SE00100"
+ * // 리비전 트리 조회
+ * const tree = await fetchGetRevTreeCompleteList({
+ * proj_no: "SN2190",
+ * doc_no: "C168-SH-SBN08-XG-20118-01"
* });
* ```
*
* @see lib/swp/document-service.ts - 비즈니스 로직 레이어
* @see lib/swp/vendor-actions.ts - 서버 액션 (권한 체크)
* @see lib/swp/README.md - 전체 시스템 문서
+ * @see lib/swp/REVISION_TREE_USAGE.md - 리비전 트리 API 활용 가이드
*/
// ============================================================================
@@ -147,6 +149,7 @@ export interface SwpFileApiResponse {
STAT: string | null;
STAT_NM: string | null;
IDX: string | null;
+ NOTE: string | null;
}
// ============================================================================
@@ -454,6 +457,96 @@ export async function analyzeSwpData(
}
// ============================================================================
+// 서버 액션: 리비전 트리 조회 (GetRevTreeCompleteList)
+// ============================================================================
+
+/**
+ * 리비전 트리 조회 필터
+ */
+export interface GetRevTreeCompleteListFilter {
+ proj_no: string; // 필수 (예: "SN2190")
+ doc_no: string; // 필수 (예: "C168-SH-SBN08-XG-20118-01")
+ stat?: string; // 상태 코드 필터 (선택)
+ lang_gb?: string; // 언어 구분 (선택)
+}
+
+/**
+ * 리비전 트리 노드 API 응답
+ *
+ * 트리 구조:
+ * - Root: 문서 자체
+ * - Revision: 문서의 버전 (01, 02, 03...)
+ * - Activity: 각 리비전에 속한 송수신 활동
+ */
+export interface RevTreeNodeApiResponse {
+ // 노드 식별 정보
+ SearchType: "Root" | "Revision" | "Activity"; // 노드 타입
+ NodeName: string; // 노드 이름 (Activity의 경우 ACTV_NO)
+ ParentName: string | null; // 부모 노드 이름
+ SearchName: string; // 검색용 이름
+
+ // Revision 정보 (SearchType이 "Revision"일 때)
+ Stage: string; // IFA, IFC 등
+
+ // Activity 정보 (SearchType이 "Activity"일 때)
+ InOut: string; // "IN"=수신, "OUT"=송신
+ StatusCode: string; // R00=Ready, S00=Ready, S30=Completed
+ StatusName: string; // 상태명
+ TransmittalNo: string; // 송장번호
+ RefActivityNo: string; // 참조 Activity 번호
+
+ // 메타 정보
+ CreateDate: string;
+ CreateEmpNo: string;
+ ModifyDate: string;
+ ModifyEmpNo: string;
+}
+
+/**
+ * 리비전 트리 조회 (GetRevTreeCompleteList)
+ *
+ * 문서의 전체 리비전 히스토리와 각 리비전별 Activity 목록을 트리 구조로 조회합니다.
+ *
+ * @param filter 조회 필터
+ * @returns 트리 구조의 노드 배열
+ *
+ * @example
+ * ```typescript
+ * const tree = await fetchGetRevTreeCompleteList({
+ * proj_no: "SN2190",
+ * doc_no: "C168-SH-SBN08-XG-20118-01"
+ * });
+ *
+ * // Root 노드 찾기
+ * const root = tree.find(n => n.SearchType === "Root");
+ *
+ * // Revision 목록
+ * const revisions = tree.filter(n => n.SearchType === "Revision");
+ *
+ * // 특정 Revision의 Activity 목록
+ * const rev04Activities = tree.filter(n =>
+ * n.SearchType === "Activity" && n.ParentName === "3" // Rev 04
+ * );
+ * ```
+ */
+export async function fetchGetRevTreeCompleteList(
+ filter: GetRevTreeCompleteListFilter
+): Promise<RevTreeNodeApiResponse[]> {
+ const body = {
+ proj_no: filter.proj_no,
+ doc_no: filter.doc_no,
+ stat: filter.stat || "",
+ lang_gb: filter.lang_gb || "",
+ };
+
+ return callSwpApi<RevTreeNodeApiResponse>(
+ "GetRevTreeCompleteList",
+ body,
+ "GetRevTreeCompleteListResult"
+ );
+}
+
+// ============================================================================
// 서버 액션: Activity 및 파일 리스트 조회 (GetActivityFileList)
// ============================================================================
@@ -546,3 +639,209 @@ export async function callSaveInBoxListCancelStatus(
return result;
}
+// ============================================================================
+// 유틸리티: GetRevTreeCompleteList + GetActivityFileList 연동
+// ============================================================================
+
+/**
+ * 리비전 트리 구조 파싱 결과
+ */
+export interface ParsedRevisionTree {
+ docNo: string;
+ revisions: Array<{
+ revSeq: string; // "0", "1", "2", "3" (내부 시퀀스)
+ revNo: string; // "01", "02", "03", "04" (표시용)
+ stage: string; // "IFA", "IFC"
+ activities: Array<{
+ actvNo: string; // NodeName (예: "SHIK2017022008520078313")
+ inOut: "IN" | "OUT";
+ statusCode: string;
+ statusName: string;
+ transmittalNo: string;
+ refActivityNo: string;
+ createDate: string;
+ createEmpNo: string;
+ }>;
+ }>;
+}
+
+/**
+ * 리비전 트리를 구조화된 형태로 파싱
+ *
+ * @param tree GetRevTreeCompleteList 응답
+ * @returns 파싱된 리비전 트리
+ */
+export async function parseRevisionTree(
+ tree: RevTreeNodeApiResponse[]
+): Promise<ParsedRevisionTree> {
+ const root = tree.find((n) => n.SearchType === "Root");
+ if (!root) {
+ throw new Error("Root 노드를 찾을 수 없습니다");
+ }
+
+ // Revision 목록
+ const revisions = tree
+ .filter((n) => n.SearchType === "Revision")
+ .map((rev) => {
+ // 해당 Revision의 Activity 목록
+ const activities = tree
+ .filter(
+ (n) => n.SearchType === "Activity" && n.ParentName === rev.NodeName
+ )
+ .map((act) => ({
+ actvNo: act.NodeName,
+ inOut: act.InOut as "IN" | "OUT",
+ statusCode: act.StatusCode,
+ statusName: act.StatusName,
+ transmittalNo: act.TransmittalNo,
+ refActivityNo: act.RefActivityNo.trim(),
+ createDate: act.CreateDate,
+ createEmpNo: act.CreateEmpNo,
+ }));
+
+ return {
+ revSeq: rev.NodeName, // "0", "1", "2", "3"
+ revNo: rev.SearchName, // "01", "02", "03", "04"
+ stage: rev.Stage,
+ activities,
+ };
+ });
+
+ return {
+ docNo: root.NodeName,
+ revisions,
+ };
+}
+
+/**
+ * 특정 문서의 모든 리비전과 파일 정보를 조회
+ *
+ * GetRevTreeCompleteList로 트리 구조를 가져온 후,
+ * 각 Activity의 NodeName(ACTV_NO)을 사용하여 GetActivityFileList를 호출합니다.
+ *
+ * ⚠️ 주의: Activity가 많을 경우 API 호출이 많아질 수 있습니다.
+ *
+ * @param projNo 프로젝트 번호
+ * @param docNo 문서 번호
+ * @returns 리비전 트리 + 각 Activity의 파일 목록
+ *
+ * @example
+ * ```typescript
+ * const fullData = await fetchDocumentRevisionTreeWithFiles(
+ * "SN2190",
+ * "C168-SH-SBN08-XG-20118-01"
+ * );
+ *
+ * console.log(`문서: ${fullData.docNo}`);
+ * fullData.revisions.forEach(rev => {
+ * console.log(`\nRevision ${rev.revNo} (${rev.stage})`);
+ * rev.activities.forEach(act => {
+ * console.log(` Activity: ${act.actvNo} (${act.inOut})`);
+ * console.log(` 파일 ${act.files.length}개`);
+ * });
+ * });
+ * ```
+ */
+export async function fetchDocumentRevisionTreeWithFiles(
+ projNo: string,
+ docNo: string
+): Promise<{
+ docNo: string;
+ revisions: Array<{
+ revSeq: string;
+ revNo: string;
+ stage: string;
+ activities: Array<{
+ actvNo: string;
+ inOut: "IN" | "OUT";
+ statusCode: string;
+ statusName: string;
+ transmittalNo: string;
+ files: ActivityFileApiResponse[];
+ }>;
+ }>;
+}> {
+ console.log(`[SWP API] 문서 ${docNo} 전체 히스토리 조회 시작`);
+
+ // 1단계: 리비전 트리 조회
+ const tree = await fetchGetRevTreeCompleteList({
+ proj_no: projNo,
+ doc_no: docNo,
+ });
+
+ const parsed = await parseRevisionTree(tree);
+
+ // 2단계: 각 Activity의 파일 목록 조회
+ // Activity가 많을 경우 병렬 처리 고려 (단, API 부하 주의)
+ const revisionsWithFiles = await Promise.all(
+ parsed.revisions.map(async (rev) => {
+ const activitiesWithFiles = await Promise.all(
+ rev.activities.map(async (act) => {
+ try {
+ // GetActivityFileList는 rev_seq가 필요할 수 있음
+ const files = await fetchGetActivityFileList({
+ proj_no: projNo,
+ doc_no: docNo,
+ rev_seq: rev.revSeq,
+ });
+
+ // Activity 번호로 필터링
+ const activityFiles = files.filter(
+ (f) => f.ACTV_NO === act.actvNo
+ );
+
+ return {
+ ...act,
+ files: activityFiles,
+ };
+ } catch (error) {
+ console.error(
+ `Activity ${act.actvNo} 파일 조회 실패:`,
+ error
+ );
+ return {
+ ...act,
+ files: [],
+ };
+ }
+ })
+ );
+
+ return {
+ ...rev,
+ activities: activitiesWithFiles,
+ };
+ })
+ );
+
+ console.log(`[SWP API] 조회 완료: ${revisionsWithFiles.length}개 리비전`);
+
+ return {
+ docNo: parsed.docNo,
+ revisions: revisionsWithFiles,
+ };
+}
+
+/**
+ * 특정 Revision의 Activity 목록만 조회 (경량 버전)
+ *
+ * @param projNo 프로젝트 번호
+ * @param docNo 문서 번호
+ * @param revNo Revision 번호 (예: "04")
+ * @returns 해당 Revision의 Activity 목록
+ */
+export async function fetchRevisionActivities(
+ projNo: string,
+ docNo: string,
+ revNo: string
+): Promise<ParsedRevisionTree["revisions"][0] | null> {
+ const tree = await fetchGetRevTreeCompleteList({
+ proj_no: projNo,
+ doc_no: docNo,
+ });
+
+ const parsed = await parseRevisionTree(tree);
+
+ return parsed.revisions.find((r) => r.revNo === revNo) || null;
+}
+
diff --git a/lib/swp/table/swp-document-detail-dialog.tsx b/lib/swp/table/swp-document-detail-dialog.tsx
index 418ddea9..d69e2986 100644
--- a/lib/swp/table/swp-document-detail-dialog.tsx
+++ b/lib/swp/table/swp-document-detail-dialog.tsx
@@ -1,6 +1,6 @@
"use client";
-import React, { useState, useEffect } from "react";
+import React, { useState, useEffect, useMemo } from "react";
import {
Dialog,
DialogContent,
@@ -12,20 +12,31 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Loader2,
- ChevronDown,
- ChevronRight,
Download,
FileIcon,
- XCircle,
AlertCircle,
+ ArrowDownToLine,
+ ArrowUpFromLine,
} from "lucide-react";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
import {
- fetchVendorDocumentDetail,
- cancelVendorFile,
- downloadVendorFile,
-} from "@/lib/swp/vendor-actions";
-import type { DocumentListItem, DocumentDetail } from "@/lib/swp/document-service";
+ fetchGetRevTreeCompleteList,
+ parseRevisionTree,
+ fetchGetActivityFileList,
+ type ActivityFileApiResponse,
+} from "@/lib/swp/api-client";
+import { downloadVendorFile } from "@/lib/swp/vendor-actions";
+import type { DocumentListItem } from "@/lib/swp/document-service";
import { toast } from "sonner";
+import { cn } from "@/lib/utils";
+import { formatSwpDateShort, formatFileSize } from "@/lib/swp/utils";
interface SwpDocumentDetailDialogProps {
open: boolean;
@@ -36,22 +47,41 @@ interface SwpDocumentDetailDialogProps {
userId: string;
}
+// Activity 행 데이터
+interface ActivityRow {
+ revNo: string;
+ revSeq: string;
+ stage: string;
+ actvNo: string;
+ inOut: "IN" | "OUT";
+ statusCode: string;
+ statusName: string;
+ transmittalNo: string;
+ refActivityNo: string;
+ createDate: string;
+ createEmpNo: string;
+}
+
export function SwpDocumentDetailDialog({
open,
onOpenChange,
document,
projNo,
}: SwpDocumentDetailDialogProps) {
- const [detail, setDetail] = useState<DocumentDetail | null>(null);
+ const [activities, setActivities] = useState<ActivityRow[]>([]);
const [isLoading, setIsLoading] = useState(false);
- const [expandedRevisions, setExpandedRevisions] = useState<Set<string>>(new Set());
- const [expandedActivities, setExpandedActivities] = useState<Set<string>>(new Set());
- const [isAllExpanded, setIsAllExpanded] = useState(true); // 기본값 true
+ const [selectedActivity, setSelectedActivity] = useState<ActivityRow | null>(null);
+ const [activityFiles, setActivityFiles] = useState<ActivityFileApiResponse[]>([]);
+ const [isLoadingFiles, setIsLoadingFiles] = useState(false);
// 문서 상세 로드
useEffect(() => {
if (open && document) {
loadDocumentDetail();
+ } else {
+ // 다이얼로그 닫힐 때 초기화
+ setSelectedActivity(null);
+ setActivityFiles([]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, document?.DOC_NO]);
@@ -60,100 +90,79 @@ export function SwpDocumentDetailDialog({
if (!document) return;
setIsLoading(true);
+ setSelectedActivity(null);
+ setActivityFiles([]);
+
try {
- const detailData = await fetchVendorDocumentDetail(projNo, document.DOC_NO);
- setDetail(detailData);
-
- // 모든 리비전 자동 펼치기
- const allRevKeys = new Set<string>();
- const allActKeys = new Set<string>();
-
- detailData.revisions.forEach((revision) => {
- const revKey = `${revision.revNo}|${revision.revSeq}`;
- allRevKeys.add(revKey);
-
- // 모든 액티비티도 자동 펼치기
- revision.activities.forEach((activity) => {
- const actKey = `${revKey}|${activity.actvNo}`;
- allActKeys.add(actKey);
+ // GetRevTreeCompleteList 호출
+ const tree = await fetchGetRevTreeCompleteList({
+ proj_no: projNo,
+ doc_no: document.DOC_NO,
+ });
+
+ const parsed = await parseRevisionTree(tree);
+
+ // Activity를 flat한 배열로 변환 (테이블용)
+ const flatActivities: ActivityRow[] = [];
+ parsed.revisions.forEach((rev) => {
+ rev.activities.forEach((act) => {
+ flatActivities.push({
+ revNo: rev.revNo,
+ revSeq: rev.revSeq,
+ stage: rev.stage,
+ actvNo: act.actvNo,
+ inOut: act.inOut,
+ statusCode: act.statusCode,
+ statusName: act.statusName,
+ transmittalNo: act.transmittalNo,
+ refActivityNo: act.refActivityNo,
+ createDate: act.createDate,
+ createEmpNo: act.createEmpNo,
+ });
});
});
-
- setExpandedRevisions(allRevKeys);
- setExpandedActivities(allActKeys);
- setIsAllExpanded(true);
+
+ setActivities(flatActivities);
} catch (error) {
console.error("문서 상세 조회 실패:", error);
- toast.error("문서 상세 정보를 불러오는데 실패했습니다");
+ toast.error("문서 리비전 트리를 불러오는데 실패했습니다");
} finally {
setIsLoading(false);
}
};
- const toggleRevision = (revKey: string) => {
- setExpandedRevisions((prev) => {
- const newSet = new Set(prev);
- if (newSet.has(revKey)) {
- newSet.delete(revKey);
- } else {
- newSet.add(revKey);
- }
- return newSet;
- });
- };
-
- const toggleActivity = (actKey: string) => {
- setExpandedActivities((prev) => {
- const newSet = new Set(prev);
- if (newSet.has(actKey)) {
- newSet.delete(actKey);
- } else {
- newSet.add(actKey);
- }
- return newSet;
- });
- };
+ // Activity 선택 및 파일 로드
+ const handleActivityClick = async (activity: ActivityRow) => {
+ if (selectedActivity?.actvNo === activity.actvNo) {
+ // 같은 Activity 클릭 시 토글
+ setSelectedActivity(null);
+ setActivityFiles([]);
+ return;
+ }
- // 일괄 열기/닫기
- const handleToggleAll = () => {
- if (!detail) return;
+ setSelectedActivity(activity);
+ setIsLoadingFiles(true);
+ setActivityFiles([]);
- if (isAllExpanded) {
- // 모두 닫기
- setExpandedRevisions(new Set());
- setExpandedActivities(new Set());
- setIsAllExpanded(false);
- } else {
- // 모두 열기
- const allRevKeys = new Set<string>();
- const allActKeys = new Set<string>();
-
- detail.revisions.forEach((revision) => {
- const revKey = `${revision.revNo}|${revision.revSeq}`;
- allRevKeys.add(revKey);
-
- revision.activities.forEach((activity) => {
- const actKey = `${revKey}|${activity.actvNo}`;
- allActKeys.add(actKey);
- });
+ try {
+ // GetActivityFileList 호출
+ const files = await fetchGetActivityFileList({
+ proj_no: projNo,
+ doc_no: document?.DOC_NO || "",
+ rev_seq: activity.revSeq,
});
-
- setExpandedRevisions(allRevKeys);
- setExpandedActivities(allActKeys);
- setIsAllExpanded(true);
- }
- };
- const handleCancelFile = async (boxSeq: string, actvSeq: string, fileName: string) => {
- try {
- await cancelVendorFile(boxSeq, actvSeq);
- toast.success(`파일 취소 완료: ${fileName}`);
-
- // 문서 상세 재로드
- await loadDocumentDetail();
+ // 해당 Activity의 파일만 필터링
+ const activitySpecificFiles = files.filter(
+ (f) => f.ACTV_NO === activity.actvNo
+ );
+
+ setActivityFiles(activitySpecificFiles);
} catch (error) {
- console.error("파일 취소 실패:", error);
- toast.error("파일 취소에 실패했습니다");
+ console.error("파일 목록 조회 실패:", error);
+ toast.error("파일 목록을 불러오는데 실패했습니다");
+ } finally {
+ setIsLoadingFiles(false);
}
};
@@ -185,11 +194,24 @@ export function SwpDocumentDetailDialog({
}
};
+ // Revision별로 Activity 그룹핑 (rowspan용)
+ const groupedActivities = useMemo(() => {
+ const groups: Map<string, ActivityRow[]> = new Map();
+ activities.forEach((activity) => {
+ const key = `${activity.revNo}|${activity.stage}`;
+ if (!groups.has(key)) {
+ groups.set(key, []);
+ }
+ groups.get(key)!.push(activity);
+ });
+ return groups;
+ }, [activities]);
+
return (
<Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
+ <DialogContent className="max-w-[95vw] max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
- <DialogTitle>문서 상세</DialogTitle>
+ <DialogTitle>문서 리비전 히스토리</DialogTitle>
{document && (
<DialogDescription>
{document.DOC_NO} - {document.DOC_TITLE}
@@ -198,9 +220,9 @@ export function SwpDocumentDetailDialog({
</DialogHeader>
{document && (
- <div className="space-y-4">
+ <div className="flex-1 flex flex-col space-y-4 overflow-hidden min-h-0">
{/* 문서 정보 */}
- <div className="grid grid-cols-1 md:grid-cols-4 gap-4 p-4 bg-muted/30 rounded-lg">
+ <div className="grid grid-cols-1 md:grid-cols-5 gap-4 p-4 bg-muted/30 rounded-lg">
<div>
<span className="text-sm font-semibold">프로젝트:</span>
<div className="text-sm">{document.PROJ_NO}</div>
@@ -223,174 +245,216 @@ export function SwpDocumentDetailDialog({
<span className="text-sm font-semibold">최신 리비전:</span>
<div className="text-sm">{document.LTST_REV_NO || "-"}</div>
</div>
+ <div>
+ <span className="text-sm font-semibold">총 Activity:</span>
+ <div className="text-sm">{activities.length}개</div>
+ </div>
</div>
- {/* 리비전 및 액티비티 트리 */}
+ {/* Activity 테이블 */}
{isLoading ? (
<div className="flex items-center justify-center p-8">
<Loader2 className="h-6 w-6 animate-spin" />
- <span className="ml-2">문서 상세 로딩 중...</span>
+ <span className="ml-2">리비전 트리 로딩 중...</span>
</div>
- ) : detail && detail.revisions.length > 0 ? (
- <div className="space-y-2">
- {/* 일괄 열기/닫기 버튼 */}
- <div className="flex justify-end">
- <Button
- variant="outline"
- size="sm"
- onClick={handleToggleAll}
- >
- {isAllExpanded ? (
+ ) : activities.length > 0 ? (
+ <>
+ {/* Activity 테이블 (위) */}
+ <div className="flex-1 overflow-auto border rounded-lg min-h-0">
+ <Table>
+ <TableHeader className="sticky top-0 bg-background z-10">
+ <TableRow>
+ <TableHead className="w-[80px]">Rev</TableHead>
+ <TableHead className="w-[80px]">Stage</TableHead>
+ <TableHead className="w-[80px]">IN/OUT</TableHead>
+ <TableHead className="w-[100px]">Status</TableHead>
+ <TableHead className="min-w-[150px]">Transmittal No</TableHead>
+ <TableHead className="min-w-[150px]">Activity No</TableHead>
+ <TableHead className="min-w-[100px]">Ref Activity</TableHead>
+ <TableHead className="w-[120px]">Modified</TableHead>
+ <TableHead className="w-[80px]">By</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {Array.from(groupedActivities.entries()).map(([key, groupActivities]) => {
+ const [revNo, stage] = key.split("|");
+ return groupActivities.map((activity, idx) => (
+ <TableRow
+ key={activity.actvNo}
+ className={cn(
+ "cursor-pointer hover:bg-muted/50",
+ selectedActivity?.actvNo === activity.actvNo &&
+ "bg-blue-50 hover:bg-blue-100"
+ )}
+ onClick={() => handleActivityClick(activity)}
+ >
+ {/* Rev 컬럼 (첫 행만 표시, rowspan) */}
+ {idx === 0 && (
+ <TableCell
+ className="font-mono text-sm font-semibold align-top border-r"
+ rowSpan={groupActivities.length}
+ >
+ {revNo}
+ </TableCell>
+ )}
+ {/* Stage 컬럼 (첫 행만 표시, rowspan) */}
+ {idx === 0 && (
+ <TableCell
+ className="align-top border-r"
+ rowSpan={groupActivities.length}
+ >
+ <Badge
+ variant="outline"
+ className={
+ stage === "IFC"
+ ? "bg-green-100 text-green-800"
+ : stage === "IFA"
+ ? "bg-blue-100 text-blue-800"
+ : "bg-gray-100 text-gray-800"
+ }
+ >
+ {stage}
+ </Badge>
+ </TableCell>
+ )}
+ <TableCell>
+ <Badge
+ variant="outline"
+ className={
+ activity.inOut === "IN"
+ ? "bg-blue-100 text-blue-800"
+ : "bg-green-100 text-green-800"
+ }
+ >
+ {activity.inOut === "IN" ? (
+ <>
+ <ArrowDownToLine className="h-3 w-3 mr-1" />
+ IN
+ </>
+ ) : (
+ <>
+ <ArrowUpFromLine className="h-3 w-3 mr-1" />
+ OUT
+ </>
+ )}
+ </Badge>
+ </TableCell>
+ <TableCell>
+ <div className="text-sm">
+ <div className="font-medium">{activity.statusName}</div>
+ <div className="text-xs text-muted-foreground">
+ {activity.statusCode}
+ </div>
+ </div>
+ </TableCell>
+ <TableCell className="text-sm">
+ {activity.transmittalNo || "-"}
+ </TableCell>
+ <TableCell className="font-mono text-xs">
+ {activity.actvNo}
+ </TableCell>
+ <TableCell className="font-mono text-xs">
+ {activity.refActivityNo || "-"}
+ </TableCell>
+ <TableCell className="text-xs">
+ {formatSwpDateShort(activity.createDate)}
+ </TableCell>
+ <TableCell className="text-xs">
+ {activity.createEmpNo}
+ </TableCell>
+ </TableRow>
+ ));
+ })}
+ </TableBody>
+ </Table>
+ </div>
+
+ {/* 파일 목록 (아래) */}
+ <div className="border rounded-lg overflow-hidden" style={{ height: "250px" }}>
+ <div className="p-3 bg-muted/50 border-b">
+ <h3 className="font-semibold text-sm">파일 목록</h3>
+ {selectedActivity ? (
<>
- <ChevronDown className="h-4 w-4 mr-2" />
- 일괄 닫기
+ <p className="text-xs text-muted-foreground mt-1">
+ Activity: {selectedActivity.actvNo}
+ </p>
+ <p className="text-xs text-muted-foreground">
+ Rev {selectedActivity.revNo} ({selectedActivity.stage}) / {selectedActivity.inOut}
+ </p>
</>
) : (
- <>
- <ChevronRight className="h-4 w-4 mr-2" />
- 일괄 열기
- </>
+ <p className="text-xs text-muted-foreground mt-1">
+ Activity를 선택하면 파일 목록이 표시됩니다
+ </p>
)}
- </Button>
- </div>
- {detail.revisions.map((revision) => {
- const revKey = `${revision.revNo}|${revision.revSeq}`;
- const isRevExpanded = expandedRevisions.has(revKey);
-
- return (
- <div key={revKey} className="border rounded-lg">
- {/* 리비전 헤더 */}
- <div
- className="flex items-center justify-between p-3 bg-muted/50 cursor-pointer hover:bg-muted"
- onClick={() => toggleRevision(revKey)}
- >
- <div className="flex items-center gap-3">
- {isRevExpanded ? (
- <ChevronDown className="h-4 w-4" />
- ) : (
- <ChevronRight className="h-4 w-4" />
- )}
- <Badge variant="secondary" className="font-mono">
- REV {revision.revNo}
- </Badge>
- <Badge variant="outline" className={
- revision.stage === "IFC" ? "bg-green-100 text-green-800" :
- revision.stage === "IFA" ? "bg-blue-100 text-blue-800" :
- "bg-gray-100 text-gray-800"
- }>
- {revision.stage}
- </Badge>
- <span className="text-sm text-muted-foreground">
- {revision.activities.length}개 액티비티 / {revision.totalFiles}개 파일
- </span>
+ </div>
+ <div className="overflow-auto p-3" style={{ height: "calc(250px - 80px)" }}>
+ {selectedActivity ? (
+ isLoadingFiles ? (
+ <div className="flex items-center justify-center h-full">
+ <Loader2 className="h-5 w-5 animate-spin" />
+ <span className="ml-2 text-sm">파일 로딩 중...</span>
</div>
- </div>
-
- {/* 액티비티 목록 */}
- {isRevExpanded && (
- <div className="p-2 space-y-2">
- {revision.activities.map((activity) => {
- const actKey = `${revKey}|${activity.actvNo}`;
- const isActExpanded = expandedActivities.has(actKey);
-
- // Activity 타입에 따른 색상
- const activityColor =
- activity.type === "Receive" ? "bg-blue-100 text-blue-800" :
- activity.type === "Send" ? "bg-green-100 text-green-800" :
- "bg-purple-100 text-purple-800";
-
- return (
- <div key={actKey} className="border rounded-md">
- {/* 액티비티 헤더 */}
- <div
- className="flex items-center justify-between p-2 bg-muted/30 cursor-pointer hover:bg-muted/50"
- onClick={() => toggleActivity(actKey)}
- >
- <div className="flex items-center gap-2">
- {isActExpanded ? (
- <ChevronDown className="h-4 w-4" />
- ) : (
- <ChevronRight className="h-4 w-4" />
- )}
- <Badge variant="outline" className={activityColor}>
- {activity.type}
- </Badge>
- <span className="text-xs text-muted-foreground font-mono">
- {activity.actvNo}
- </span>
- <span className="text-sm text-muted-foreground">
- {activity.toFrom}
- </span>
- <span className="text-xs text-muted-foreground">
- {activity.files.length}개 파일
- </span>
+ ) : activityFiles.length > 0 ? (
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
+ {activityFiles.map((file) => (
+ <div
+ key={file.FILE_SEQ}
+ className="flex flex-col gap-2 p-3 border rounded bg-background hover:bg-muted/30"
+ >
+ <div className="flex items-start gap-2">
+ <FileIcon className="h-4 w-4 text-blue-500 mt-0.5 flex-shrink-0" />
+ <div className="flex-1 min-w-0">
+ <div className="text-sm font-medium break-all line-clamp-2">
+ {file.FILE_NM}
</div>
+ {file.FILE_SZ && (
+ <div className="text-xs text-muted-foreground">
+ {formatFileSize(file.FILE_SZ)}
+ </div>
+ )}
+ {file.STAT && (
+ <Badge variant="outline" className="text-xs mt-1">
+ {file.STAT}
+ </Badge>
+ )}
</div>
-
- {/* 파일 목록 */}
- {isActExpanded && (
- <div className="p-2 space-y-1">
- {activity.files.map((file, idx) => (
- <div
- key={idx}
- className="flex items-center justify-between p-2 border rounded bg-background hover:bg-muted/30"
- >
- <div className="flex items-center gap-2 flex-1">
- <FileIcon className="h-4 w-4 text-blue-500" />
- <span className="text-sm font-mono">{file.fileNm}</span>
- {file.fileSz && (
- <span className="text-xs text-muted-foreground">
- ({formatFileSize(file.fileSz)})
- </span>
- )}
- {file.stat && (
- <Badge variant="outline" className={
- file.stat === "SCW01" ? "bg-yellow-100 text-yellow-800" :
- file.stat === "SCW03" ? "bg-green-100 text-green-800" :
- file.stat === "SCW09" ? "bg-gray-100 text-gray-800" :
- "bg-gray-100 text-gray-800"
- }>
- {file.statNm || file.stat}
- </Badge>
- )}
- </div>
- <div className="flex items-center gap-1">
- {file.canCancel && file.boxSeq && file.actvSeq && (
- <Button
- variant="outline"
- size="sm"
- onClick={() => handleCancelFile(file.boxSeq!, file.actvSeq!, file.fileNm)}
- >
- <XCircle className="h-4 w-4 mr-1" />
- 취소
- </Button>
- )}
- <Button
- variant="outline"
- size="sm"
- onClick={() => handleDownloadFile(file.fileNm, document.DOC_NO)}
- >
- <Download className="h-4 w-4 mr-1" />
- 다운로드
- </Button>
- </div>
- </div>
- ))}
- </div>
- )}
</div>
- );
- })}
+ <Button
+ variant="outline"
+ size="sm"
+ className="w-full"
+ onClick={() => handleDownloadFile(file.FILE_NM, document.OWN_DOC_NO || document.DOC_NO)}
+ >
+ <Download className="h-3 w-3 mr-1" />
+ 다운로드
+ </Button>
+ </div>
+ ))}
</div>
- )}
- </div>
- );
- })}
- </div>
+ ) : (
+ <div className="flex items-center justify-center h-full text-sm text-muted-foreground">
+ <div className="text-center">
+ <AlertCircle className="h-8 w-8 mx-auto mb-2 opacity-50" />
+ <p>파일이 없습니다</p>
+ </div>
+ </div>
+ )
+ ) : (
+ <div className="flex items-center justify-center h-full text-sm text-muted-foreground">
+ <div className="text-center">
+ <FileIcon className="h-12 w-12 mx-auto mb-2 opacity-30" />
+ <p>Activity를 선택해주세요</p>
+ </div>
+ </div>
+ )}
+ </div>
+ </div>
+ </>
) : (
<div className="p-8 text-center text-muted-foreground">
<AlertCircle className="h-12 w-12 mx-auto mb-2 opacity-50" />
- <p>리비전 정보가 없습니다</p>
+ <p>Activity 정보가 없습니다</p>
</div>
)}
</div>
@@ -399,14 +463,3 @@ export function SwpDocumentDetailDialog({
</Dialog>
);
}
-
-function formatFileSize(sizeStr: string): string {
- const bytes = parseInt(sizeStr, 10);
- if (isNaN(bytes)) return sizeStr;
-
- const kb = bytes / 1024;
- const mb = kb / 1024;
-
- return mb >= 1 ? `${mb.toFixed(2)} MB` : `${kb.toFixed(2)} KB`;
-}
-
diff --git a/lib/swp/table/swp-inbox-history-dialog.tsx b/lib/swp/table/swp-inbox-history-dialog.tsx
new file mode 100644
index 00000000..fbb75f3c
--- /dev/null
+++ b/lib/swp/table/swp-inbox-history-dialog.tsx
@@ -0,0 +1,509 @@
+"use client";
+
+import React, { useState, useMemo } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import {
+ ChevronDown,
+ ChevronRight,
+ Download,
+ FileIcon,
+ Search,
+ XCircle,
+ Loader2,
+} from "lucide-react";
+import { cancelVendorUploadedFile } from "@/lib/swp/vendor-actions";
+import type { SwpFileApiResponse } from "@/lib/swp/api-client";
+import { toast } from "sonner";
+import { formatSwpDate } from "@/lib/swp/utils";
+
+interface SwpInboxHistoryDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ docNo: string | null;
+ files: SwpFileApiResponse[]; // 전체 파일 목록
+ projNo: string;
+ userId: string;
+}
+
+// Rev별 그룹 타입
+interface RevisionGroup {
+ revNo: string;
+ stage: string;
+ activities: ActivityGroup[];
+ totalFiles: number;
+}
+
+// Activity별 그룹 타입 (activity가 null일 수 있음)
+interface ActivityGroup {
+ actvNo: string | null;
+ files: SwpFileApiResponse[];
+}
+
+export function SwpInboxHistoryDialog({
+ open,
+ onOpenChange,
+ docNo,
+ files,
+ projNo,
+ userId,
+}: SwpInboxHistoryDialogProps) {
+ const [expandedRevisions, setExpandedRevisions] = useState<Set<string>>(new Set());
+ const [expandedActivities, setExpandedActivities] = useState<Set<string>>(new Set());
+ const [isAllExpanded, setIsAllExpanded] = useState(true);
+ const [searchQuery, setSearchQuery] = useState("");
+ const [cancellingFiles, setCancellingFiles] = useState<Set<string>>(new Set());
+ const [cancelledFiles, setCancelledFiles] = useState<Set<string>>(new Set()); // 취소된 파일 추적
+
+ // 해당 Document No의 모든 파일 필터링
+ const documentFiles = useMemo(() => {
+ if (!docNo) return [];
+ return files.filter((file) => file.OWN_DOC_NO === docNo);
+ }, [docNo, files]);
+
+ // 검색어 필터링
+ const filteredFiles = useMemo(() => {
+ if (!searchQuery.trim()) return documentFiles;
+
+ const query = searchQuery.toLowerCase();
+ return documentFiles.filter((file) => {
+ return (
+ file.REV_NO?.toLowerCase().includes(query) ||
+ file.ACTV_NO?.toLowerCase().includes(query) ||
+ file.FILE_NM?.toLowerCase().includes(query) ||
+ file.STAGE?.toLowerCase().includes(query)
+ );
+ });
+ }, [documentFiles, searchQuery]);
+
+ // 파일들을 Rev > Activity 구조로 그룹핑
+ const revisionGroups = useMemo(() => {
+ const revMap = new Map<string, SwpFileApiResponse[]>();
+
+ filteredFiles.forEach((file) => {
+ const revKey = `${file.REV_NO}|${file.STAGE}`;
+ if (!revMap.has(revKey)) {
+ revMap.set(revKey, []);
+ }
+ revMap.get(revKey)!.push(file);
+ });
+
+ const result: RevisionGroup[] = [];
+
+ revMap.forEach((revFiles, revKey) => {
+ const [revNo, stage] = revKey.split("|");
+
+ // Activity별로 그룹핑 (null 가능)
+ const actMap = new Map<string | null, SwpFileApiResponse[]>();
+
+ revFiles.forEach((file) => {
+ const actvNo = file.ACTV_NO || null;
+ if (!actMap.has(actvNo)) {
+ actMap.set(actvNo, []);
+ }
+ actMap.get(actvNo)!.push(file);
+ });
+
+ const activities: ActivityGroup[] = [];
+ actMap.forEach((files, actvNo) => {
+ // Upload Date 기준 정렬
+ const sortedFiles = [...files].sort((a, b) =>
+ (b.CRTE_DTM || "").localeCompare(a.CRTE_DTM || "")
+ );
+ activities.push({ actvNo, files: sortedFiles });
+ });
+
+ // Activity가 없는 것을 먼저, 있는 것을 나중에 정렬
+ activities.sort((a, b) => {
+ if (a.actvNo === null && b.actvNo !== null) return -1;
+ if (a.actvNo !== null && b.actvNo === null) return 1;
+ if (a.actvNo === null && b.actvNo === null) return 0;
+ return (a.actvNo || "").localeCompare(b.actvNo || "");
+ });
+
+ result.push({
+ revNo,
+ stage,
+ activities,
+ totalFiles: revFiles.length,
+ });
+ });
+
+ // 리비전 번호로 정렬 (최신이 위로)
+ return result.sort((a, b) => b.revNo.localeCompare(a.revNo));
+ }, [filteredFiles]);
+
+ // Dialog가 열릴 때 모두 펼치기
+ React.useEffect(() => {
+ if (open && revisionGroups.length > 0) {
+ const allRevKeys = new Set<string>();
+ const allActKeys = new Set<string>();
+
+ revisionGroups.forEach((revision) => {
+ const revKey = revision.revNo;
+ allRevKeys.add(revKey);
+
+ revision.activities.forEach((activity) => {
+ const actKey = `${revKey}|${activity.actvNo || "NO_ACTIVITY"}`;
+ allActKeys.add(actKey);
+ });
+ });
+
+ setExpandedRevisions(allRevKeys);
+ setExpandedActivities(allActKeys);
+ setIsAllExpanded(true);
+ }
+ }, [open, revisionGroups]);
+
+ const toggleRevision = (revKey: string) => {
+ setExpandedRevisions((prev) => {
+ const newSet = new Set(prev);
+ if (newSet.has(revKey)) {
+ newSet.delete(revKey);
+ } else {
+ newSet.add(revKey);
+ }
+ return newSet;
+ });
+ };
+
+ const toggleActivity = (actKey: string) => {
+ setExpandedActivities((prev) => {
+ const newSet = new Set(prev);
+ if (newSet.has(actKey)) {
+ newSet.delete(actKey);
+ } else {
+ newSet.add(actKey);
+ }
+ return newSet;
+ });
+ };
+
+ // 일괄 열기/닫기
+ const handleToggleAll = () => {
+ if (isAllExpanded) {
+ // 모두 닫기
+ setExpandedRevisions(new Set());
+ setExpandedActivities(new Set());
+ setIsAllExpanded(false);
+ } else {
+ // 모두 열기
+ const allRevKeys = new Set<string>();
+ const allActKeys = new Set<string>();
+
+ revisionGroups.forEach((revision) => {
+ const revKey = revision.revNo;
+ allRevKeys.add(revKey);
+
+ revision.activities.forEach((activity) => {
+ const actKey = `${revKey}|${activity.actvNo || "NO_ACTIVITY"}`;
+ allActKeys.add(actKey);
+ });
+ });
+
+ setExpandedRevisions(allRevKeys);
+ setExpandedActivities(allActKeys);
+ setIsAllExpanded(true);
+ }
+ };
+
+ const handleCancelFile = async (file: SwpFileApiResponse) => {
+ if (!file.BOX_SEQ || !file.ACTV_SEQ) {
+ toast.error("취소할 수 없는 파일입니다 (BOX_SEQ 또는 ACTV_SEQ 없음)");
+ return;
+ }
+
+ const fileKey = `${file.BOX_SEQ}_${file.FILE_SEQ}`;
+
+ if (cancellingFiles.has(fileKey)) {
+ return; // 이미 취소 중
+ }
+
+ try {
+ setCancellingFiles((prev) => new Set(prev).add(fileKey));
+
+ await cancelVendorUploadedFile({
+ boxSeq: file.BOX_SEQ,
+ actvSeq: file.ACTV_SEQ,
+ userId,
+ });
+
+ toast.success(`파일 취소 완료: ${file.FILE_NM}`);
+
+ // 취소된 파일로 마킹 (상태 변경)
+ setCancelledFiles((prev) => new Set(prev).add(fileKey));
+ } catch (error) {
+ console.error("파일 취소 실패:", error);
+ toast.error("파일 취소에 실패했습니다");
+ } finally {
+ setCancellingFiles((prev) => {
+ const newSet = new Set(prev);
+ newSet.delete(fileKey);
+ return newSet;
+ });
+ }
+ };
+
+ const handleDownloadFile = async (file: SwpFileApiResponse) => {
+ try {
+ toast.info("파일 다운로드 준비 중...");
+
+ // API route를 통해 다운로드
+ const downloadUrl = `/api/swp/download/${encodeURIComponent(file.OWN_DOC_NO)}?projNo=${encodeURIComponent(projNo)}&fileName=${encodeURIComponent(file.FILE_NM)}`;
+
+ // 새 탭에서 다운로드
+ window.open(downloadUrl, "_blank");
+
+ toast.success(`파일 다운로드 시작: ${file.FILE_NM}`);
+ } catch (error) {
+ console.error("파일 다운로드 실패:", error);
+ toast.error("파일 다운로드에 실패했습니다");
+ }
+ };
+
+ const formatFileSize = (sizeStr: string | null): string => {
+ if (!sizeStr) return "-";
+ const bytes = parseInt(sizeStr, 10);
+ if (isNaN(bytes)) return sizeStr;
+
+ const kb = bytes / 1024;
+ const mb = kb / 1024;
+
+ return mb >= 1 ? `${mb.toFixed(2)} MB` : `${kb.toFixed(2)} KB`;
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>Document 전체 이력</DialogTitle>
+ {docNo && (
+ <DialogDescription>
+ {docNo} - 총 {documentFiles.length}개 파일
+ </DialogDescription>
+ )}
+ </DialogHeader>
+
+ {docNo && (
+ <div className="space-y-4">
+ {/* 검색 및 제어 */}
+ <div className="flex items-center gap-2">
+ <div className="relative flex-1">
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
+ <Input
+ placeholder="Rev No, Activity No, File Name, Stage로 검색..."
+ value={searchQuery}
+ onChange={(e) => setSearchQuery(e.target.value)}
+ className="pl-10"
+ />
+ </div>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleToggleAll}
+ >
+ {isAllExpanded ? (
+ <>
+ <ChevronDown className="h-4 w-4 mr-2" />
+ 일괄 닫기
+ </>
+ ) : (
+ <>
+ <ChevronRight className="h-4 w-4 mr-2" />
+ 일괄 열기
+ </>
+ )}
+ </Button>
+ </div>
+
+ {/* 검색 결과 안내 */}
+ {searchQuery && (
+ <div className="text-sm text-muted-foreground">
+ 검색 결과: {filteredFiles.length}개 파일 (전체 {documentFiles.length}개)
+ </div>
+ )}
+
+ {/* 리비전 및 액티비티 트리 */}
+ {revisionGroups.length > 0 ? (
+ <div className="space-y-2">
+ {revisionGroups.map((revision) => {
+ const revKey = revision.revNo;
+ const isRevExpanded = expandedRevisions.has(revKey);
+
+ return (
+ <div key={revKey} className="border rounded-lg">
+ {/* 리비전 헤더 */}
+ <div
+ className="flex items-center justify-between p-3 bg-muted/50 cursor-pointer hover:bg-muted"
+ onClick={() => toggleRevision(revKey)}
+ >
+ <div className="flex items-center gap-3">
+ {isRevExpanded ? (
+ <ChevronDown className="h-4 w-4" />
+ ) : (
+ <ChevronRight className="h-4 w-4" />
+ )}
+ <Badge variant="secondary" className="font-mono">
+ REV {revision.revNo}
+ </Badge>
+ <Badge variant="outline">
+ {revision.stage}
+ </Badge>
+ <span className="text-sm text-muted-foreground">
+ {revision.activities.length}개 그룹 / {revision.totalFiles}개 파일
+ </span>
+ </div>
+ </div>
+
+ {/* 액티비티 목록 */}
+ {isRevExpanded && (
+ <div className="p-2 space-y-2">
+ {revision.activities.map((activity) => {
+ const actKey = `${revKey}|${activity.actvNo || "NO_ACTIVITY"}`;
+ const isActExpanded = expandedActivities.has(actKey);
+
+ return (
+ <div key={actKey} className="border rounded-md">
+ {/* 액티비티 헤더 */}
+ <div
+ className="flex items-center justify-between p-2 bg-muted/30 cursor-pointer hover:bg-muted/50"
+ onClick={() => toggleActivity(actKey)}
+ >
+ <div className="flex items-center gap-2">
+ {isActExpanded ? (
+ <ChevronDown className="h-4 w-4" />
+ ) : (
+ <ChevronRight className="h-4 w-4" />
+ )}
+ {activity.actvNo ? (
+ <>
+ <Badge variant="outline" className="bg-blue-100 text-blue-800">
+ Activity
+ </Badge>
+ <span className="text-xs text-muted-foreground font-mono">
+ {activity.actvNo}
+ </span>
+ </>
+ ) : (
+ <Badge variant="outline" className="bg-gray-100 text-gray-800">
+ Activity 없음
+ </Badge>
+ )}
+ <span className="text-xs text-muted-foreground">
+ {activity.files.length}개 파일
+ </span>
+ </div>
+ </div>
+
+ {/* 파일 목록 */}
+ {isActExpanded && (
+ <div className="p-2 space-y-1">
+ {activity.files.map((file, idx) => {
+ const fileKey = `${file.BOX_SEQ}_${file.FILE_SEQ}`;
+ const isCancelling = cancellingFiles.has(fileKey);
+ const isCancelled = cancelledFiles.has(fileKey);
+ const currentStatus = isCancelled ? "SCW09" : file.STAT;
+ const currentStatusNm = isCancelled ? "Cancelled" : file.STAT_NM;
+ const canCancel = currentStatus === "SCW01"; // Standby만 취소 가능
+
+ return (
+ <div
+ key={idx}
+ className="flex items-center justify-between p-2 border rounded bg-background hover:bg-muted/30"
+ >
+ <div className="flex items-center gap-2 flex-1 min-w-0">
+ <FileIcon className="h-4 w-4 text-blue-500 flex-shrink-0" />
+ <div className="flex-1 min-w-0">
+ <div className="text-sm font-mono truncate" title={file.FILE_NM}>
+ {file.FILE_NM}
+ </div>
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
+ <span>{formatFileSize(file.FILE_SZ)}</span>
+ <span>•</span>
+ <span>{formatSwpDate(file.CRTE_DTM)}</span>
+ {currentStatusNm && (
+ <>
+ <span>•</span>
+ <Badge
+ variant="outline"
+ className={
+ currentStatus === "SCW03" || currentStatus === "SCW08" ? "bg-green-100 text-green-800" :
+ currentStatus === "SCW02" ? "bg-blue-100 text-blue-800" :
+ currentStatus === "SCW01" ? "bg-yellow-100 text-yellow-800" :
+ currentStatus === "SCW04" || currentStatus === "SCW05" || currentStatus === "SCW06" ? "bg-red-100 text-red-800" :
+ currentStatus === "SCW07" ? "bg-purple-100 text-purple-800" :
+ currentStatus === "SCW09" ? "bg-gray-100 text-gray-800" :
+ currentStatus === "SCW00" ? "bg-orange-100 text-orange-800" :
+ "bg-gray-100 text-gray-800"
+ }
+ >
+ {currentStatusNm}
+ </Badge>
+ </>
+ )}
+ </div>
+ </div>
+ </div>
+ <div className="flex items-center gap-1 ml-2 flex-shrink-0">
+ {canCancel && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => handleCancelFile(file)}
+ disabled={isCancelling}
+ >
+ {isCancelling ? (
+ <>
+ <Loader2 className="h-4 w-4 mr-1 animate-spin" />
+ 취소 중...
+ </>
+ ) : (
+ <>
+ <XCircle className="h-4 w-4 mr-1" />
+ 취소
+ </>
+ )}
+ </Button>
+ )}
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handleDownloadFile(file)}
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+ );
+ })}
+ </div>
+ )}
+ </div>
+ );
+ })}
+ </div>
+ )}
+ </div>
+ );
+ })}
+ </div>
+ ) : (
+ <div className="p-8 text-center text-muted-foreground">
+ {searchQuery ? "검색 결과가 없습니다" : "파일 정보가 없습니다"}
+ </div>
+ )}
+ </div>
+ )}
+ </DialogContent>
+ </Dialog>
+ );
+}
+
diff --git a/lib/swp/table/swp-inbox-table.tsx b/lib/swp/table/swp-inbox-table.tsx
index c3f3a243..a2fedeed 100644
--- a/lib/swp/table/swp-inbox-table.tsx
+++ b/lib/swp/table/swp-inbox-table.tsx
@@ -1,11 +1,6 @@
"use client";
-import React, { useState, useMemo } from "react";
-import {
- useReactTable,
- getCoreRowModel,
- flexRender,
-} from "@tanstack/react-table";
+import React, { useMemo, useState } from "react";
import {
Table,
TableBody,
@@ -15,230 +10,560 @@ import {
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
-import { ColumnDef } from "@tanstack/react-table";
-import { SwpInboxDocumentDetailDialog } from "./swp-inbox-document-detail-dialog";
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Download, XCircle } from "lucide-react";
+import { cancelVendorUploadedFile } from "@/lib/swp/vendor-actions";
import type { SwpFileApiResponse } from "@/lib/swp/api-client";
+import { toast } from "sonner";
+import { cn } from "@/lib/utils";
+import { formatSwpDate } from "@/lib/swp/utils";
+import { SwpInboxHistoryDialog } from "./swp-inbox-history-dialog";
+
+// 업로드 필요 문서 타입 (DB stageDocuments에서 조회)
+interface RequiredDocument {
+ vendorDocNumber: string;
+ title: string;
+ buyerSystemComment: string | null;
+}
interface SwpInboxTableProps {
files: SwpFileApiResponse[];
+ requiredDocs: RequiredDocument[];
projNo: string;
vendorCode: string;
userId: string;
}
-// 문서별로 그룹핑된 데이터 타입
-export interface InboxDocumentItem {
- ownDocNo: string;
- latestRevFileCount: number; // 최신 REV의 파일 개수
- latestStage: string;
- latestRevNo: string;
- latestStatus: string | null;
- latestStatusNm: string | null;
- files: SwpFileApiResponse[];
+// 테이블 행 데이터 (플랫하게 펼침)
+interface TableRowData {
+ uploadId: string | null; // 업로드 필요 문서는 null
+ docNo: string;
+ revNo: string | null;
+ stage: string | null;
+ status: string | null;
+ statusNm: string | null;
+ actvNo: string | null;
+ crter: string | null; // CRTER (그대로 표시)
+ note: string | null; // 첫 번째 파일의 note 또는 buyerSystemComment
+ file: SwpFileApiResponse | null; // 업로드 필요 문서는 null
+ uploadDate: string | null;
+ // 각 행이 속한 그룹의 정보
+ isFirstFileInRev: boolean;
+ fileCountInRev: number;
+ // 업로드 필요 문서 여부
+ isRequiredDoc: boolean;
}
-// 테이블 컬럼 정의
-const inboxDocumentColumns: ColumnDef<InboxDocumentItem>[] = [
- {
- accessorKey: "latestStatusNm",
- header: "상태",
- cell: ({ row }) => {
- const statNm = row.original.latestStatusNm;
- const stat = row.original.latestStatus;
- const displayStatus = statNm || stat || "-";
-
- if (!stat) return displayStatus;
-
- // STAT 코드 기반 색상 결정
- const color =
- stat === "SCW03" || stat === "SCW08" ? "bg-green-100 text-green-800" : // Complete, Checked
- stat === "SCW02" ? "bg-blue-100 text-blue-800" : // Processing
- stat === "SCW01" ? "bg-yellow-100 text-yellow-800" : // Standby
- stat === "SCW04" || stat === "SCW05" || stat === "SCW06" ? "bg-red-100 text-red-800" : // Reject, Error Zip, Error Meta
- stat === "SCW07" ? "bg-purple-100 text-purple-800" : // Send for Eng Verification
- stat === "SCW09" ? "bg-gray-100 text-gray-800" : // Cancelled
- stat === "SCW00" ? "bg-orange-100 text-orange-800" : // Upload
- "bg-gray-100 text-gray-800"; // 기타
-
- return (
- <Badge variant="outline" className={color}>
- {displayStatus}
- </Badge>
- );
- },
- size: 120,
- },
- {
- accessorKey: "ownDocNo",
- header: "OWN_DOC_NO",
- cell: ({ row }) => (
- <div className="font-mono text-sm">{row.original.ownDocNo}</div>
- ),
- size: 300,
- },
- {
- accessorKey: "latestStage",
- header: "최신 스테이지",
- cell: ({ row }) => {
- const stage = row.original.latestStage;
- if (!stage) return "-";
-
- const color =
- stage === "IFC" ? "bg-green-100 text-green-800" :
- stage === "IFA" ? "bg-blue-100 text-blue-800" :
- "bg-gray-100 text-gray-800";
-
- return (
- <Badge variant="outline" className={color}>
- {stage}
- </Badge>
- );
- },
- size: 120,
- },
- {
- accessorKey: "latestRevNo",
- header: "최신 REV",
- cell: ({ row }) => row.original.latestRevNo || "-",
- size: 100,
- },
- {
- accessorKey: "latestRevFileCount",
- header: "최신 REV 파일 수",
- cell: ({ row }) => (
- <div className="text-center">
- <div className="text-sm font-medium">
- {row.original.latestRevFileCount}개
- </div>
- </div>
- ),
- size: 100,
- },
-];
+// Status 집계 타입
+interface StatusCount {
+ status: string;
+ statusNm: string;
+ count: number;
+ color: string;
+}
export function SwpInboxTable({
files,
+ requiredDocs,
projNo,
- vendorCode,
userId,
}: SwpInboxTableProps) {
- const [dialogOpen, setDialogOpen] = useState(false);
- const [selectedDocument, setSelectedDocument] = useState<InboxDocumentItem | null>(null);
-
- // 파일들을 문서별로 그룹핑
- const documents = useMemo(() => {
- const docMap = new Map<string, SwpFileApiResponse[]>();
+ const [selectedStatus, setSelectedStatus] = useState<string | null>(null);
+ const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set()); // 선택된 파일 (fileKey)
+ const [historyDialogOpen, setHistoryDialogOpen] = useState(false);
+ const [selectedDocNo, setSelectedDocNo] = useState<string | null>(null);
+ // Status 집계 (API 응답 + 업로드 필요 문서)
+ const statusCounts = useMemo(() => {
+ const statusMap = new Map<string, { statusNm: string; count: number }>();
+
+ // API 응답 파일 집계
files.forEach((file) => {
- const docNo = file.OWN_DOC_NO;
- if (!docMap.has(docNo)) {
- docMap.set(docNo, []);
+ const status = file.STAT || "UNKNOWN";
+ const statusNm = file.STAT_NM || status;
+
+ if (statusMap.has(status)) {
+ statusMap.get(status)!.count++;
+ } else {
+ statusMap.set(status, { statusNm, count: 1 });
}
- docMap.get(docNo)!.push(file);
});
- const result: InboxDocumentItem[] = [];
+ // 업로드 필요 문서 집계
+ if (requiredDocs.length > 0) {
+ const status = "UPLOAD_REQUIRED";
+ const statusNm = "Upload Required";
+ statusMap.set(status, { statusNm, count: requiredDocs.length });
+ }
- docMap.forEach((docFiles, ownDocNo) => {
- // 최신 REV 찾기 (REV_NO 기준으로 정렬)
- const sortedByRev = [...docFiles].sort((a, b) =>
- (b.REV_NO || "").localeCompare(a.REV_NO || "")
- );
- const latestRevNo = sortedByRev[0].REV_NO || "";
+ const counts: StatusCount[] = [];
+ statusMap.forEach((value, status) => {
+ const color =
+ status === "SCW03" || status === "SCW08" ? "bg-green-100 text-green-800 hover:bg-green-200" :
+ status === "SCW02" ? "bg-blue-100 text-blue-800 hover:bg-blue-200" :
+ status === "SCW01" ? "bg-yellow-100 text-yellow-800 hover:bg-yellow-200" :
+ status === "SCW04" || status === "SCW05" || status === "SCW06" ? "bg-red-100 text-red-800 hover:bg-red-200" :
+ status === "SCW07" ? "bg-purple-100 text-purple-800 hover:bg-purple-200" :
+ status === "SCW09" ? "bg-gray-100 text-gray-800 hover:bg-gray-200" :
+ status === "SCW00" ? "bg-orange-100 text-orange-800 hover:bg-orange-200" :
+ status === "UPLOAD_REQUIRED" ? "bg-amber-100 text-amber-800 hover:bg-amber-200" :
+ "bg-gray-100 text-gray-800 hover:bg-gray-200";
- // 최신 REV의 파일들만 필터링
- const latestRevFiles = docFiles.filter(file => file.REV_NO === latestRevNo);
+ counts.push({
+ status,
+ statusNm: value.statusNm,
+ count: value.count,
+ color,
+ });
+ });
- // 최신 REV 내에서 가장 최근 생성된 파일 찾기 (상태 표시용)
- const sortedLatestRevFiles = [...latestRevFiles].sort((a, b) =>
- (b.CRTE_DTM || "").localeCompare(a.CRTE_DTM || "")
- );
- const latestFile = sortedLatestRevFiles[0];
-
- result.push({
- ownDocNo,
- latestRevFileCount: latestRevFiles.length, // 최신 REV의 파일 개수
- latestStage: latestFile.STAGE || "",
- latestRevNo: latestRevNo,
- latestStatus: latestFile.STAT,
- latestStatusNm: latestFile.STAT_NM,
- files: docFiles, // 전체 파일 목록 (상세보기용)
+ // 개수 순으로 정렬 (Upload Required를 맨 앞으로)
+ return counts.sort((a, b) => {
+ if (a.status === "UPLOAD_REQUIRED") return -1;
+ if (b.status === "UPLOAD_REQUIRED") return 1;
+ return b.count - a.count;
+ });
+ }, [files, requiredDocs]);
+
+ // 데이터 그룹화 및 플랫 변환 (API 응답 + 업로드 필요 문서)
+ const tableRows = useMemo(() => {
+ const rows: TableRowData[] = [];
+
+ // 1. API 응답 파일 처리
+ // Status 필터링
+ let filteredFiles = files;
+ if (selectedStatus && selectedStatus !== "UPLOAD_REQUIRED") {
+ filteredFiles = files.filter((file) => file.STAT === selectedStatus);
+ }
+
+ // BOX_SEQ 기준으로 그룹화
+ const uploadGroups = new Map<string, SwpFileApiResponse[]>();
+
+ if (!selectedStatus || selectedStatus !== "UPLOAD_REQUIRED") {
+ filteredFiles.forEach((file) => {
+ const uploadId = file.BOX_SEQ || "NO_UPLOAD_ID";
+ if (!uploadGroups.has(uploadId)) {
+ uploadGroups.set(uploadId, []);
+ }
+ uploadGroups.get(uploadId)!.push(file);
+ });
+ }
+
+ uploadGroups.forEach((uploadFiles, uploadId) => {
+ // 2. Document No 기준으로 그룹화
+ const docGroups = new Map<string, SwpFileApiResponse[]>();
+
+ uploadFiles.forEach((file) => {
+ const docNo = file.OWN_DOC_NO;
+ if (!docGroups.has(docNo)) {
+ docGroups.set(docNo, []);
+ }
+ docGroups.get(docNo)!.push(file);
+ });
+
+ docGroups.forEach((docFiles, docNo) => {
+ // 3. 최신 RevNo 찾기 (CRTE_DTM 기준)
+ const sortedByDate = [...docFiles].sort((a, b) =>
+ (b.CRTE_DTM || "").localeCompare(a.CRTE_DTM || "")
+ );
+ const latestRevNo = sortedByDate[0]?.REV_NO || "";
+
+ // 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 || "")
+ );
+
+ // 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,
+ });
+ });
});
});
- return result.sort((a, b) => a.ownDocNo.localeCompare(b.ownDocNo));
- }, [files]);
+ // 2. 업로드 필요 문서 추가 (Upload Required 필터일 때만 또는 필터 없을 때)
+ if (!selectedStatus || selectedStatus === "UPLOAD_REQUIRED") {
+ requiredDocs.forEach((doc) => {
+ rows.push({
+ uploadId: null,
+ docNo: doc.vendorDocNumber,
+ revNo: null,
+ stage: null,
+ status: "UPLOAD_REQUIRED",
+ statusNm: "Upload Required",
+ actvNo: null,
+ crter: null,
+ note: doc.buyerSystemComment,
+ file: null,
+ uploadDate: null,
+ isFirstFileInRev: true,
+ fileCountInRev: 1,
+ isRequiredDoc: true,
+ });
+ });
+ }
+
+ // Upload Date 기준 전체 정렬 (null은 맨 뒤로)
+ return rows.sort((a, b) => {
+ if (!a.uploadDate) return 1;
+ if (!b.uploadDate) return -1;
+ return b.uploadDate.localeCompare(a.uploadDate);
+ });
+ }, [files, requiredDocs, selectedStatus]);
+
+ // 선택 가능한 파일들 (Standby 상태만)
+ const selectableFiles = useMemo(() => {
+ return tableRows
+ .filter((row) => row.file && row.status === "SCW01")
+ .map((row) => `${row.file!.BOX_SEQ}_${row.file!.FILE_SEQ}`);
+ }, [tableRows]);
+
+ // 전체 선택/해제
+ const handleSelectAll = (checked: boolean) => {
+ if (checked) {
+ setSelectedFiles(new Set(selectableFiles));
+ } else {
+ setSelectedFiles(new Set());
+ }
+ };
+
+ // 개별 선택/해제
+ const handleSelectFile = (fileKey: string, checked: boolean | "indeterminate") => {
+ const isChecked = checked === true;
+ setSelectedFiles((prev) => {
+ const newSet = new Set(prev);
+ if (isChecked) {
+ newSet.add(fileKey);
+ } else {
+ newSet.delete(fileKey);
+ }
+ return newSet;
+ });
+ };
+
+ // 선택된 파일 일괄 취소
+ const handleBulkCancel = async () => {
+ if (selectedFiles.size === 0) {
+ toast.error("취소할 파일을 선택해주세요");
+ return;
+ }
- const table = useReactTable({
- data: documents,
- columns: inboxDocumentColumns,
- getCoreRowModel: getCoreRowModel(),
- });
+ const filesToCancel = tableRows.filter((row) =>
+ row.file && selectedFiles.has(`${row.file.BOX_SEQ}_${row.file.FILE_SEQ}`)
+ );
- // 문서 클릭 핸들러
- const handleDocumentClick = (document: InboxDocumentItem) => {
- setSelectedDocument(document);
- setDialogOpen(true);
+ if (filesToCancel.length === 0) {
+ toast.error("취소할 파일이 없습니다");
+ return;
+ }
+
+ try {
+ toast.info(`${filesToCancel.length}개 파일 취소 중...`);
+
+ // 병렬 취소
+ const cancelPromises = filesToCancel.map((row) =>
+ cancelVendorUploadedFile({
+ boxSeq: row.file!.BOX_SEQ!,
+ actvSeq: row.file!.ACTV_SEQ!,
+ userId,
+ })
+ );
+
+ await Promise.all(cancelPromises);
+
+ toast.success(`${filesToCancel.length}개 파일 취소 완료`);
+ setSelectedFiles(new Set()); // 선택 초기화
+
+ // 페이지 리프레시
+ window.location.reload();
+ } catch (error) {
+ console.error("일괄 취소 실패:", error);
+ toast.error("일부 파일 취소에 실패했습니다");
+ }
+ };
+
+
+ const handleDownloadFile = async (file: SwpFileApiResponse) => {
+ try {
+ toast.info("파일 다운로드 준비 중...");
+
+ // API route를 통해 다운로드
+ const downloadUrl = `/api/swp/download/${encodeURIComponent(file.OWN_DOC_NO)}?projNo=${encodeURIComponent(projNo)}&fileName=${encodeURIComponent(file.FILE_NM)}`;
+
+ // 새 탭에서 다운로드
+ window.open(downloadUrl, "_blank");
+
+ toast.success(`파일 다운로드 시작: ${file.FILE_NM}`);
+ } catch (error) {
+ console.error("파일 다운로드 실패:", error);
+ toast.error("파일 다운로드에 실패했습니다");
+ }
};
+
+ // 행 클릭 핸들러 (Document No 기준 전체 이력 보기)
+ const handleRowClick = (docNo: string) => {
+ setSelectedDocNo(docNo);
+ setHistoryDialogOpen(true);
+ };
+
+ const getStatusBadge = (status: string | null, statusNm: string | null) => {
+ const displayStatus = statusNm || status || "-";
+
+ if (!status) return <span className="text-muted-foreground">{displayStatus}</span>;
+
+ const color =
+ status === "SCW03" || status === "SCW08" ? "bg-green-100 text-green-800" :
+ status === "SCW02" ? "bg-blue-100 text-blue-800" :
+ status === "SCW01" ? "bg-yellow-100 text-yellow-800" :
+ status === "SCW04" || status === "SCW05" || status === "SCW06" ? "bg-red-100 text-red-800" :
+ status === "SCW07" ? "bg-purple-100 text-purple-800" :
+ status === "SCW09" ? "bg-gray-100 text-gray-800" :
+ status === "SCW00" ? "bg-orange-100 text-orange-800" :
+ "bg-gray-100 text-gray-800";
+
+ return (
+ <Badge variant="outline" className={color}>
+ {displayStatus}
+ </Badge>
+ );
+ };
+
+ if (files.length === 0 && requiredDocs.length === 0) {
+ return (
+ <div className="border rounded-lg p-8 text-center text-muted-foreground">
+ 업로드한 파일이 없습니다.
+ </div>
+ );
+ }
+
return (
<div className="space-y-4">
+ {/* Status 필터 UI */}
+ <div className="flex items-center justify-between gap-4">
+ <div className="flex flex-wrap gap-2 p-4 bg-muted/30 rounded-lg flex-1">
+ <Button
+ variant={selectedStatus === null ? "default" : "outline"}
+ size="sm"
+ onClick={() => setSelectedStatus(null)}
+ className="h-9"
+ >
+ 전체 ({files.length + requiredDocs.length})
+ </Button>
+ {statusCounts.map((statusCount) => (
+ <Button
+ key={statusCount.status}
+ variant="outline"
+ size="sm"
+ onClick={() => setSelectedStatus(statusCount.status)}
+ className={cn(
+ "h-9",
+ selectedStatus === statusCount.status ? statusCount.color : "",
+ selectedStatus !== statusCount.status && "hover:opacity-80"
+ )}
+ >
+ {statusCount.statusNm} ({statusCount.count})
+ </Button>
+ ))}
+ </div>
+
+ {/* 선택된 파일 정보 및 일괄 취소 버튼 */}
+ {selectedFiles.size > 0 && (
+ <div className="flex items-center gap-2">
+ <span className="text-sm text-muted-foreground">
+ {selectedFiles.size}개 선택됨
+ </span>
+ <Button
+ variant="destructive"
+ size="sm"
+ onClick={handleBulkCancel}
+ >
+ <XCircle className="h-4 w-4 mr-1" />
+ Cancel
+ </Button>
+ </div>
+ )}
+ </div>
+
{/* 테이블 */}
- <div className="rounded-md border">
- <Table>
- <TableHeader>
- {table.getHeaderGroups().map((headerGroup) => (
- <TableRow key={headerGroup.id}>
- {headerGroup.headers.map((header) => (
- <TableHead key={header.id}>
- {header.isPlaceholder
- ? null
- : flexRender(
- header.column.columnDef.header,
- header.getContext()
- )}
- </TableHead>
- ))}
- </TableRow>
- ))}
- </TableHeader>
- <TableBody>
- {table.getRowModel().rows?.length ? (
- table.getRowModel().rows.map((row) => (
- <TableRow
- key={row.id}
- data-state={row.getIsSelected() && "selected"}
- className="hover:bg-muted/50 cursor-pointer"
- onClick={() => handleDocumentClick(row.original)}
- >
- {row.getVisibleCells().map((cell) => (
- <TableCell key={cell.id}>
- {flexRender(cell.column.columnDef.cell, cell.getContext())}
- </TableCell>
- ))}
- </TableRow>
- ))
- ) : (
+ {tableRows.length === 0 ? (
+ <div className="border rounded-lg p-8 text-center text-muted-foreground">
+ 해당 상태의 파일이 없습니다.
+ </div>
+ ) : (
+ <div className="rounded-md border overflow-x-auto">
+ <Table>
+ <TableHeader>
<TableRow>
- <TableCell colSpan={inboxDocumentColumns.length} className="h-24 text-center">
- 업로드한 파일이 없습니다.
- </TableCell>
+ <TableHead className="w-[50px]">
+ <Checkbox
+ checked={selectableFiles.length > 0 && selectedFiles.size === selectableFiles.length}
+ onCheckedChange={handleSelectAll}
+ disabled={selectableFiles.length === 0}
+ />
+ </TableHead>
+ <TableHead className="w-[100px]">Upload ID</TableHead>
+ <TableHead className="w-[200px]">Document No</TableHead>
+ <TableHead className="w-[80px]">Rev No</TableHead>
+ <TableHead className="w-[80px]">Stage</TableHead>
+ <TableHead className="w-[120px]">Status</TableHead>
+ <TableHead className="w-[100px]">Activity</TableHead>
+ <TableHead className="w-[120px]">Upload ID (User)</TableHead>
+ <TableHead className="w-[150px]">Note</TableHead>
+ <TableHead className="w-[400px]">Attachment File</TableHead>
+ <TableHead className="w-[180px]">Upload Date</TableHead>
</TableRow>
- )}
- </TableBody>
- </Table>
- </div>
+ </TableHeader>
+ <TableBody>
+ {tableRows.map((row, idx) => {
+ const fileKey = row.file ? `${row.file.BOX_SEQ}_${row.file.FILE_SEQ}` : `required_${idx}`;
+ const canSelect = row.status === "SCW01" && row.file; // Standby이고 file이 있는 경우만 선택 가능
+ const isSelected = canSelect && selectedFiles.has(fileKey);
+
+ return (
+ <TableRow
+ key={`${row.uploadId}_${row.docNo}_${row.revNo}_${idx}`}
+ className="cursor-pointer hover:bg-muted/50"
+ onClick={() => handleRowClick(row.docNo)}
+ >
+ {/* Select Checkbox */}
+ <TableCell onClick={(e) => e.stopPropagation()}>
+ {canSelect ? (
+ <Checkbox
+ checked={!!isSelected}
+ onCheckedChange={(checked) => handleSelectFile(fileKey, checked)}
+ />
+ ) : null}
+ </TableCell>
+
+ {/* Upload ID - 같은 Rev의 첫 파일에만 표시 */}
+ {row.isFirstFileInRev ? (
+ <TableCell rowSpan={row.fileCountInRev} className="font-mono text-sm align-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">
+ {row.docNo}
+ </TableCell>
+ ) : null}
+
+ {/* Rev No - 같은 Rev의 첫 파일에만 표시 */}
+ {row.isFirstFileInRev ? (
+ <TableCell rowSpan={row.fileCountInRev} className="align-top">
+ {row.revNo || <span className="text-muted-foreground">-</span>}
+ </TableCell>
+ ) : null}
- {/* 문서 상세 Dialog */}
- <SwpInboxDocumentDetailDialog
- open={dialogOpen}
- onOpenChange={setDialogOpen}
- document={selectedDocument}
+ {/* Stage - 같은 Rev의 첫 파일에만 표시 (텍스트로만 표시) */}
+ {row.isFirstFileInRev ? (
+ <TableCell rowSpan={row.fileCountInRev} className="align-top text-sm">
+ {row.stage || <span className="text-muted-foreground">-</span>}
+ </TableCell>
+ ) : null}
+
+ {/* Status - 같은 Rev의 첫 파일에만 표시 */}
+ {row.isFirstFileInRev ? (
+ <TableCell rowSpan={row.fileCountInRev} className="align-top">
+ {getStatusBadge(row.status, row.statusNm)}
+ </TableCell>
+ ) : null}
+
+ {/* Activity - 같은 Rev의 첫 파일에만 표시 */}
+ {row.isFirstFileInRev ? (
+ <TableCell rowSpan={row.fileCountInRev} className="font-mono text-xs align-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">
+ {row.crter || <span className="text-muted-foreground">-</span>}
+ </TableCell>
+ ) : null}
+
+ {/* Note - 같은 Rev의 첫 파일에만 표시 (개행문자 처리) */}
+ {row.isFirstFileInRev ? (
+ <TableCell
+ rowSpan={row.fileCountInRev}
+ className="text-xs max-w-[150px] align-top whitespace-pre-wrap"
+ >
+ {row.note || <span className="text-muted-foreground">-</span>}
+ </TableCell>
+ ) : null}
+
+ {/* Attachment File - 각 파일마다 표시 (줄바꿈 허용) */}
+ <TableCell className="max-w-[400px]">
+ {row.file ? (
+ <div className="flex items-center gap-2">
+ <span className="text-sm font-mono break-words" style={{ wordBreak: "break-all" }}>
+ {row.file.FILE_NM}
+ </span>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={(e) => {
+ e.stopPropagation();
+ handleDownloadFile(row.file!);
+ }}
+ className="h-7 w-7 p-0 flex-shrink-0"
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ </div>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )}
+ </TableCell>
+
+ {/* Upload Date - 각 파일마다 표시 */}
+ <TableCell className="text-xs">
+ {row.uploadDate ? formatSwpDate(row.uploadDate) : <span className="text-muted-foreground">-</span>}
+ </TableCell>
+ </TableRow>
+ );
+ })}
+ </TableBody>
+ </Table>
+ </div>
+ )}
+
+ {/* Document 전체 이력 Dialog */}
+ <SwpInboxHistoryDialog
+ open={historyDialogOpen}
+ onOpenChange={setHistoryDialogOpen}
+ docNo={selectedDocNo}
+ files={files}
projNo={projNo}
- vendorCode={vendorCode}
userId={userId}
/>
</div>
);
}
-
diff --git a/lib/swp/utils.ts b/lib/swp/utils.ts
new file mode 100644
index 00000000..1856628c
--- /dev/null
+++ b/lib/swp/utils.ts
@@ -0,0 +1,68 @@
+/**
+ * SWP 관련 유틸리티 함수
+ */
+
+/**
+ * SWP API 날짜 포맷팅 (간단 버전)
+ * 오전/오후 → AM/PM 변환 및 (KST) 추가
+ *
+ * @param dateStr "2017-02-20 오전 8:52:00" 형식
+ * @returns "2017-02-20 AM 8:52:00 (KST)"
+ */
+export function formatSwpDate(dateStr: string): string {
+ if (!dateStr || dateStr === "1900-01-01 오전 12:00:00") return "-";
+ return dateStr.replace("오후", "PM").replace("오전", "AM") + " (KST)";
+}
+
+/**
+ * SWP API 날짜 포맷팅 (간결 버전)
+ * 오전/오후를 24시간 형식으로 변환하고 초 제거
+ *
+ * @param dateStr "2017-02-20 오전 8:52:00" 형식
+ * @returns "2017-02-20 08:52"
+ */
+export function formatSwpDateShort(dateStr: string): string {
+ if (!dateStr || dateStr === "1900-01-01 오전 12:00:00") return "-";
+
+ try {
+ // 날짜와 시간 분리
+ const parts = dateStr.split(" ");
+ if (parts.length < 3) return formatSwpDate(dateStr);
+
+ const datePart = parts[0]; // "2017-02-20"
+ const ampm = parts[1]; // "오전" or "오후"
+ const timePart = parts[2]; // "8:52:00"
+
+ // 시간 파싱
+ const [hours, minutes] = timePart.split(":");
+ let hour = parseInt(hours, 10);
+
+ // 오후인 경우 12시간 더하기 (12시는 제외)
+ if (ampm === "오후" && hour !== 12) {
+ hour += 12;
+ } else if (ampm === "오전" && hour === 12) {
+ hour = 0;
+ }
+
+ return `${datePart} ${hour.toString().padStart(2, "0")}:${minutes}`;
+ } catch {
+ return formatSwpDate(dateStr);
+ }
+}
+
+/**
+ * 파일 크기 포맷팅
+ *
+ * @param sizeStr 바이트 단위 파일 크기 문자열
+ * @returns "1.23 MB" 또는 "456.78 KB" 형식
+ */
+export function formatFileSize(sizeStr: string): string {
+ const bytes = parseInt(sizeStr, 10);
+ if (isNaN(bytes)) return sizeStr;
+
+ const kb = bytes / 1024;
+ const mb = kb / 1024;
+
+ return mb >= 1 ? `${mb.toFixed(2)} MB` : `${kb.toFixed(2)} KB`;
+}
+
diff --git a/lib/swp/vendor-actions.ts b/lib/swp/vendor-actions.ts
index f87c41a8..78521fed 100644
--- a/lib/swp/vendor-actions.ts
+++ b/lib/swp/vendor-actions.ts
@@ -18,7 +18,8 @@ import db from "@/db/db";
import { vendors } from "@/db/schema/vendors";
import { contracts } from "@/db/schema/contract";
import { projects } from "@/db/schema/projects";
-import { eq } from "drizzle-orm";
+import { stageDocuments } from "@/db/schema/vendorDocu";
+import { eq, and } from "drizzle-orm";
import {
getDocumentList,
getDocumentDetail,
@@ -347,6 +348,11 @@ export async function fetchVendorSwpStats(projNo?: string) {
// ============================================================================
// 벤더가 업로드한 파일 목록 조회 (Inbox)
+//
+// API 응답 파일 목록 + DB의 업로드 필요 문서 목록을 함께 반환
+// - DB 조회: stageDocuments에서 buyerSystemStatus='Completed'인 문서 중
+// 아직 업로드되지 않은 문서 (vendorDocNumber가 API 응답의 OWN_DOC_NO에 없는 것)
+// - 목적: 벤더에게 업로드를 위한 문서번호 기준(vendorDocNumber)을 제공
// ============================================================================
export async function fetchVendorUploadedFiles(projNo: string) {
@@ -362,7 +368,7 @@ export async function fetchVendorUploadedFiles(projNo: string) {
if (!projNo) {
debugWarn("프로젝트 번호 없음");
- return [];
+ return { files: [], requiredDocs: [] };
}
debugLog("업로드 파일 목록 조회 시작", {
@@ -370,15 +376,66 @@ export async function fetchVendorUploadedFiles(projNo: string) {
vendorCode: vendorInfo.vendorCode
});
- // api-client의 fetchGetExternalInboxList 사용
+ // 1. API에서 업로드된 파일 목록 조회
const { fetchGetExternalInboxList } = await import("./api-client");
const files = await fetchGetExternalInboxList({
projNo,
vndrCd: vendorInfo.vendorCode,
});
- debugSuccess("업로드 파일 목록 조회 성공", { count: files.length });
- return files;
+ debugLog("API 파일 목록 조회 완료", { count: files.length });
+
+ // 2. 프로젝트 ID 조회
+ const project = await db
+ .select({ id: projects.id })
+ .from(projects)
+ .where(eq(projects.code, projNo))
+ .limit(1);
+
+ if (!project[0]) {
+ debugWarn("프로젝트를 찾을 수 없음", { projNo });
+ return { files, requiredDocs: [] };
+ }
+
+ const projectId = project[0].id;
+
+ // 3. stageDocuments에서 buyerSystemStatus='Completed'인 문서 조회
+ const completedDocs = await db
+ .select({
+ vendorDocNumber: stageDocuments.vendorDocNumber,
+ title: stageDocuments.title,
+ buyerSystemComment: stageDocuments.buyerSystemComment,
+ })
+ .from(stageDocuments)
+ .where(
+ and(
+ eq(stageDocuments.projectId, projectId),
+ eq(stageDocuments.vendorId, vendorInfo.vendorId),
+ eq(stageDocuments.buyerSystemStatus, "Completed")
+ )
+ );
+
+ debugLog("stageDocuments 조회 완료", { count: completedDocs.length });
+
+ // 4. API 응답에 이미 존재하는 vendorDocNumber 필터링
+ const uploadedDocNumbers = new Set(
+ files.map((file) => file.OWN_DOC_NO).filter(Boolean)
+ );
+
+ const requiredDocs = completedDocs
+ .filter((doc) => doc.vendorDocNumber && !uploadedDocNumbers.has(doc.vendorDocNumber))
+ .map((doc) => ({
+ vendorDocNumber: doc.vendorDocNumber!,
+ title: doc.title,
+ buyerSystemComment: doc.buyerSystemComment || null,
+ }));
+
+ debugSuccess("업로드 파일 목록 조회 성공", {
+ filesCount: files.length,
+ requiredDocsCount: requiredDocs.length
+ });
+
+ return { files, requiredDocs };
} catch (error) {
debugError("업로드 파일 목록 조회 실패", error);
console.error("[fetchVendorUploadedFiles] 오류:", error);