summaryrefslogtreecommitdiff
path: root/lib/swp/table/swp-document-detail-dialog.tsx
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-11-17 20:47:44 +0900
committerjoonhoekim <26rote@gmail.com>2025-11-17 20:47:44 +0900
commite6403e9610253b2bfc7a46e470c236e2ed10c626 (patch)
tree05f7e458ac8b0f3bef56553e4cfbd666f5134042 /lib/swp/table/swp-document-detail-dialog.tsx
parentf56eb799ecdcd2baae1db26460cd1deff0a69eee (diff)
(김준회) swp: VxDCS 와 동일하게 변경, Complete된 리스트 중 없는 건이 있으면 DOCUMENT LIST에서 보여줌
Diffstat (limited to 'lib/swp/table/swp-document-detail-dialog.tsx')
-rw-r--r--lib/swp/table/swp-document-detail-dialog.tsx567
1 files changed, 310 insertions, 257 deletions
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`;
-}
-