summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/[lng]/evcp/(evcp)/(eng)/swp-document-upload/page.tsx58
-rw-r--r--app/[lng]/evcp/(evcp)/(eng)/swp-document-upload/swp-document-page.tsx231
-rw-r--r--app/[lng]/partners/(partners)/swp-document-upload/vendor-document-page.tsx346
-rw-r--r--app/api/swp/upload/route.ts57
-rw-r--r--hooks/use-swp-documents.ts163
-rw-r--r--lib/swp/actions.ts60
-rw-r--r--lib/swp/api-client.ts160
-rw-r--r--lib/swp/document-service.ts476
-rw-r--r--lib/swp/table/swp-document-detail-dialog.tsx412
-rw-r--r--lib/swp/table/swp-help-dialog.tsx66
-rw-r--r--lib/swp/table/swp-table-columns.tsx353
-rw-r--r--lib/swp/table/swp-table-toolbar.tsx634
-rw-r--r--lib/swp/table/swp-table.tsx198
-rw-r--r--lib/swp/table/swp-upload-validation-dialog.tsx373
-rw-r--r--lib/swp/table/swp-uploaded-files-dialog.tsx358
-rw-r--r--lib/swp/vendor-actions.ts513
16 files changed, 2829 insertions, 1629 deletions
diff --git a/app/[lng]/evcp/(evcp)/(eng)/swp-document-upload/page.tsx b/app/[lng]/evcp/(evcp)/(eng)/swp-document-upload/page.tsx
deleted file mode 100644
index 25a0bfe6..00000000
--- a/app/[lng]/evcp/(evcp)/(eng)/swp-document-upload/page.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-import { Suspense } from "react";
-import { Skeleton } from "@/components/ui/skeleton";
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
-import SwpDocumentPage from "./swp-document-page";
-
-export const metadata = {
- title: "SWP 문서 관리",
- description: "SWP 시스템 문서 조회 및 동기화",
-};
-
-// ============================================================================
-// 로딩 스켈레톤
-// ============================================================================
-
-function SwpDocumentSkeleton() {
- return (
- <Card>
- <CardHeader>
- <div className="flex items-center justify-between">
- <Skeleton className="h-8 w-32" />
- <Skeleton className="h-10 w-40" />
- </div>
- </CardHeader>
- <CardContent className="space-y-4">
- <Skeleton className="h-32 w-full" />
- <Skeleton className="h-96 w-full" />
- </CardContent>
- </Card>
- );
-}
-
-export default async function SwpDocumentUploadPage({
- searchParams,
-}: {
- searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
-}) {
- const params = await searchParams;
-
- return (
- <div className="container mx-auto py-6 space-y-6">
- {/* 헤더 */}
- <Card>
- <CardHeader>
- <CardTitle className="text-2xl">SWP 문서 관리</CardTitle>
- <CardDescription>
- 외부 시스템(SWP)에서 문서 및 첨부파일을 조회하고 동기화합니다.
- 문서 → 리비전 → 파일 계층 구조로 확인할 수 있습니다.
- </CardDescription>
- </CardHeader>
- </Card>
-
- {/* 메인 컨텐츠 */}
- <Suspense fallback={<SwpDocumentSkeleton />}>
- <SwpDocumentPage searchParams={params} />
- </Suspense>
- </div>
- );
-}
diff --git a/app/[lng]/evcp/(evcp)/(eng)/swp-document-upload/swp-document-page.tsx b/app/[lng]/evcp/(evcp)/(eng)/swp-document-upload/swp-document-page.tsx
deleted file mode 100644
index eedb68e2..00000000
--- a/app/[lng]/evcp/(evcp)/(eng)/swp-document-upload/swp-document-page.tsx
+++ /dev/null
@@ -1,231 +0,0 @@
-"use client";
-
-import { useState, useEffect, useTransition } from "react";
-import { useRouter, useSearchParams } from "next/navigation";
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
-import { Alert, AlertDescription } from "@/components/ui/alert";
-import { Skeleton } from "@/components/ui/skeleton";
-import { InfoIcon } from "lucide-react";
-import { SwpTable } from "@/lib/swp/table/swp-table";
-import { SwpTableToolbar } from "@/lib/swp/table/swp-table-toolbar";
-import {
- fetchSwpDocuments,
- fetchProjectList,
- fetchSwpStats,
- type SwpTableFilters,
- type SwpDocumentWithStats,
-} from "@/lib/swp/actions";
-
-interface SwpDocumentPageProps {
- searchParams: { [key: string]: string | string[] | undefined };
-}
-
-export default function SwpDocumentPage({ searchParams }: SwpDocumentPageProps) {
- const router = useRouter();
- const params = useSearchParams();
- const [isPending, startTransition] = useTransition();
-
- // URL에서 필터 파라미터 추출
- const initialFilters: SwpTableFilters = {
- projNo: (searchParams.projNo as string) || "",
- docNo: (searchParams.docNo as string) || "",
- docTitle: (searchParams.docTitle as string) || "",
- pkgNo: (searchParams.pkgNo as string) || "",
- vndrCd: (searchParams.vndrCd as string) || "",
- stage: (searchParams.stage as string) || "",
- };
-
- const initialPage = parseInt((searchParams.page as string) || "1", 10);
- const initialPageSize = parseInt((searchParams.pageSize as string) || "100", 10);
-
- // 상태 관리
- const [documents, setDocuments] = useState<SwpDocumentWithStats[]>([]);
- const [total, setTotal] = useState(0);
- const [page, setPage] = useState(initialPage);
- const [pageSize] = useState(initialPageSize);
- const [totalPages, setTotalPages] = useState(0);
- const [filters, setFilters] = useState<SwpTableFilters>(initialFilters);
- const [projects, setProjects] = useState<Array<{ PROJ_NO: string; PROJ_NM: string }>>([]);
- const [stats, setStats] = useState({
- total_documents: 0,
- total_revisions: 0,
- total_files: 0,
- last_sync: null as Date | null,
- });
- const [isLoading, setIsLoading] = useState(true);
- const [error, setError] = useState<string | null>(null);
-
- // 초기 데이터 로드
- useEffect(() => {
- loadInitialData();
- }, []);
-
- // 필터 변경 시 데이터 재로드
- useEffect(() => {
- if (!isLoading) {
- loadDocuments();
- }
- }, [filters, page]);
-
- const loadInitialData = async () => {
- try {
- setIsLoading(true);
- setError(null);
-
- // 병렬로 데이터 로드
- const [projectsData, statsData, documentsData] = await Promise.all([
- fetchProjectList(),
- fetchSwpStats(),
- fetchSwpDocuments({
- page,
- pageSize,
- filters: Object.keys(initialFilters).length > 0 ? initialFilters : undefined,
- }),
- ]);
-
- setProjects(projectsData);
- setStats(statsData);
- setDocuments(documentsData.data);
- setTotal(documentsData.total);
- setTotalPages(documentsData.totalPages);
- } catch (err) {
- console.error("초기 데이터 로드 실패:", err);
- setError(err instanceof Error ? err.message : "데이터 로드 실패");
- } finally {
- setIsLoading(false);
- }
- };
-
- const loadDocuments = async () => {
- startTransition(async () => {
- try {
- const data = await fetchSwpDocuments({
- page,
- pageSize,
- filters: Object.keys(filters).some((key) => filters[key as keyof SwpTableFilters])
- ? filters
- : undefined,
- });
-
- setDocuments(data.data);
- setTotal(data.total);
- setTotalPages(data.totalPages);
-
- // URL 업데이트
- const params = new URLSearchParams();
- if (filters.projNo) params.set("projNo", filters.projNo);
- if (filters.docNo) params.set("docNo", filters.docNo);
- if (filters.docTitle) params.set("docTitle", filters.docTitle);
- if (filters.pkgNo) params.set("pkgNo", filters.pkgNo);
- if (filters.vndrCd) params.set("vndrCd", filters.vndrCd);
- if (filters.stage) params.set("stage", filters.stage);
- if (page !== 1) params.set("page", page.toString());
-
- router.push(`?${params.toString()}`, { scroll: false });
- } catch (err) {
- console.error("문서 로드 실패:", err);
- setError(err instanceof Error ? err.message : "문서 로드 실패");
- }
- });
- };
-
- const handleFiltersChange = (newFilters: SwpTableFilters) => {
- setFilters(newFilters);
- setPage(1); // 필터 변경 시 첫 페이지로
- };
-
- const handlePageChange = (newPage: number) => {
- setPage(newPage);
- };
-
- if (isLoading) {
- return (
- <Card>
- <CardHeader>
- <Skeleton className="h-8 w-48" />
- <Skeleton className="h-4 w-96" />
- </CardHeader>
- <CardContent className="space-y-4">
- <Skeleton className="h-32 w-full" />
- <Skeleton className="h-96 w-full" />
- </CardContent>
- </Card>
- );
- }
-
- if (error) {
- return (
- <Alert variant="destructive">
- <AlertDescription>{error}</AlertDescription>
- </Alert>
- );
- }
-
- return (
- <div className="space-y-6">
- {/* 통계 카드 */}
- <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
- <Card>
- <CardHeader className="pb-3">
- <CardDescription>총 문서</CardDescription>
- <CardTitle className="text-3xl">{stats.total_documents.toLocaleString()}</CardTitle>
- </CardHeader>
- </Card>
- <Card>
- <CardHeader className="pb-3">
- <CardDescription>총 리비전</CardDescription>
- <CardTitle className="text-3xl">{stats.total_revisions.toLocaleString()}</CardTitle>
- </CardHeader>
- </Card>
- <Card>
- <CardHeader className="pb-3">
- <CardDescription>총 파일</CardDescription>
- <CardTitle className="text-3xl">{stats.total_files.toLocaleString()}</CardTitle>
- </CardHeader>
- </Card>
- <Card>
- <CardHeader className="pb-3">
- <CardDescription>마지막 동기화</CardDescription>
- <CardTitle className="text-lg">
- {stats.last_sync
- ? new Date(stats.last_sync).toLocaleDateString("ko-KR")
- : "없음"}
- </CardTitle>
- </CardHeader>
- </Card>
- </div>
-
- {/* 안내 메시지 */}
- {documents.length === 0 && !filters.projNo && (
- <Alert>
- <InfoIcon className="h-4 w-4" />
- <AlertDescription>
- 시작하려면 프로젝트를 선택하고 <strong>SWP 동기화</strong> 버튼을 클릭하세요.
- </AlertDescription>
- </Alert>
- )}
-
- {/* 메인 테이블 */}
- <Card>
- <CardHeader>
- <SwpTableToolbar
- filters={filters}
- onFiltersChange={handleFiltersChange}
- projects={projects}
- />
- </CardHeader>
- <CardContent>
- <SwpTable
- initialData={documents}
- total={total}
- page={page}
- pageSize={pageSize}
- totalPages={totalPages}
- onPageChange={handlePageChange}
- />
- </CardContent>
- </Card>
- </div>
- );
-}
-
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 ba78bfdf..dc6fbe7c 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
@@ -1,11 +1,11 @@
"use client";
-import { useState, useEffect, useTransition } from "react";
-import { useRouter, useSearchParams } from "next/navigation";
+import { useState, useEffect, useMemo, useCallback } from "react";
+import { useDropzone } from "react-dropzone";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
-import { InfoIcon } from "lucide-react";
+import { InfoIcon, Upload } from "lucide-react";
import { SwpTable } from "@/lib/swp/table/swp-table";
import { SwpTableToolbar } from "@/lib/swp/table/swp-table-toolbar";
import {
@@ -14,36 +14,20 @@ import {
fetchVendorSwpStats,
getVendorSessionInfo,
} from "@/lib/swp/vendor-actions";
-import { type SwpTableFilters, type SwpDocumentWithStats } from "@/lib/swp/actions";
+import type { DocumentListItem } from "@/lib/swp/document-service";
+import { toast } from "sonner";
interface VendorDocumentPageProps {
searchParams: { [key: string]: string | string[] | undefined };
}
export default function VendorDocumentPage({ searchParams }: VendorDocumentPageProps) {
- const router = useRouter();
- const params = useSearchParams();
- const [isPending, startTransition] = useTransition();
-
- // URL에서 필터 파라미터 추출 (vndrCd는 제외 - 서버에서 자동 설정)
- const initialFilters: SwpTableFilters = {
- projNo: (searchParams.projNo as string) || "",
- docNo: (searchParams.docNo as string) || "",
- docTitle: (searchParams.docTitle as string) || "",
- pkgNo: (searchParams.pkgNo as string) || "",
- stage: (searchParams.stage as string) || "",
- };
-
- const initialPage = parseInt((searchParams.page as string) || "1", 10);
- const initialPageSize = parseInt((searchParams.pageSize as string) || "100", 10);
+ // URL에서 프로젝트 번호만 사용 (나머지는 클라이언트 필터링)
+ const initialProjNo = (searchParams.projNo as string) || "";
// 상태 관리
- const [documents, setDocuments] = useState<SwpDocumentWithStats[]>([]);
- const [total, setTotal] = useState(0);
- const [page, setPage] = useState(initialPage);
- const [pageSize] = useState(initialPageSize);
- const [totalPages, setTotalPages] = useState(0);
- const [filters, setFilters] = useState<SwpTableFilters>(initialFilters);
+ const [documents, setDocuments] = useState<DocumentListItem[]>([]);
+ const [projNo, setProjNo] = useState(initialProjNo);
const [projects, setProjects] = useState<Array<{ PROJ_NO: string; PROJ_NM: string }>>([]);
const [stats, setStats] = useState({
total_documents: 0,
@@ -59,91 +43,132 @@ export default function VendorDocumentPage({ searchParams }: VendorDocumentPageP
companyId: number;
} | null>(null);
const [isLoading, setIsLoading] = useState(true);
+ const [isRefreshing, setIsRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
- // 초기 데이터 로드
- useEffect(() => {
- loadInitialData();
- }, []);
+ // 클라이언트 필터
+ const [searchFilters, setSearchFilters] = useState({
+ docNo: (searchParams.docNo as string) || "",
+ docTitle: (searchParams.docTitle as string) || "",
+ pkgNo: (searchParams.pkgNo as string) || "",
+ stage: (searchParams.stage as string) || "",
+ });
- // 필터 변경 시 데이터 재로드
- useEffect(() => {
- if (!isLoading) {
- loadDocuments();
+ // Dropzone 설정
+ const [droppedFiles, setDroppedFiles] = useState<File[]>([]);
+
+ // 파일 드롭 핸들러
+ const onDrop = useCallback((acceptedFiles: File[]) => {
+ if (acceptedFiles.length > 0) {
+ setDroppedFiles(acceptedFiles);
}
- }, [filters, page]);
+ }, []);
+
+ const { getRootProps, getInputProps, isDragActive } = useDropzone({
+ onDrop,
+ noClick: true, // 클릭으로 파일 선택 비활성화 (버튼을 통해서만 선택)
+ noKeyboard: true,
+ });
- const loadInitialData = async () => {
+ const loadInitialData = useCallback(async () => {
try {
setIsLoading(true);
setError(null);
- // 병렬로 데이터 로드 (벤더 정보 포함)
- const [vendorInfoData, projectsData, statsData, documentsData] = await Promise.all([
+ // 병렬로 데이터 로드
+ const [vendorInfoData, projectsData] = await Promise.all([
getVendorSessionInfo(),
fetchVendorProjects(),
- fetchVendorSwpStats(),
- fetchVendorDocuments({
- page,
- pageSize,
- filters: Object.keys(initialFilters).length > 0 ? initialFilters : undefined,
- }),
]);
setVendorInfo(vendorInfoData);
setProjects(projectsData);
- setStats(statsData);
- setDocuments(documentsData.data);
- setTotal(documentsData.total);
- setTotalPages(documentsData.totalPages);
+
+ // 초기 프로젝트가 있으면 문서 로드
+ if (initialProjNo) {
+ const [documentsData, statsData] = await Promise.all([
+ fetchVendorDocuments(initialProjNo),
+ fetchVendorSwpStats(initialProjNo),
+ ]);
+ setDocuments(documentsData);
+ setStats(statsData);
+ }
} catch (err) {
console.error("초기 데이터 로드 실패:", err);
setError(err instanceof Error ? err.message : "데이터 로드 실패");
+ toast.error("데이터 로드 실패");
+ } finally {
+ setIsLoading(false);
}
- setIsLoading(false); // finally 대신 여기서 호출
- };
+ }, [initialProjNo]);
- const loadDocuments = async () => {
- startTransition(async () => {
- try {
- const data = await fetchVendorDocuments({
- page,
- pageSize,
- filters: Object.keys(filters).some((key) => filters[key as keyof SwpTableFilters])
- ? filters
- : undefined,
- });
-
- setDocuments(data.data);
- setTotal(data.total);
- setTotalPages(data.totalPages);
-
- // URL 업데이트
- const params = new URLSearchParams();
- if (filters.projNo) params.set("projNo", filters.projNo);
- if (filters.docNo) params.set("docNo", filters.docNo);
- if (filters.docTitle) params.set("docTitle", filters.docTitle);
- if (filters.pkgNo) params.set("pkgNo", filters.pkgNo);
- if (filters.stage) params.set("stage", filters.stage);
- if (page !== 1) params.set("page", page.toString());
-
- router.push(`?${params.toString()}`, { scroll: false });
- } catch (err) {
- console.error("문서 로드 실패:", err);
- setError(err instanceof Error ? err.message : "문서 로드 실패");
- }
- });
+ const loadDocuments = useCallback(async () => {
+ if (!projNo) return;
+
+ try {
+ setIsRefreshing(true);
+ setError(null);
+
+ const [documentsData, statsData] = await Promise.all([
+ fetchVendorDocuments(projNo),
+ fetchVendorSwpStats(projNo),
+ ]);
+
+ setDocuments(documentsData);
+ setStats(statsData);
+ toast.success("문서 목록을 갱신했습니다");
+ } catch (err) {
+ console.error("문서 로드 실패:", err);
+ setError(err instanceof Error ? err.message : "문서 로드 실패");
+ toast.error("문서 로드 실패");
+ } finally {
+ setIsRefreshing(false);
+ }
+ }, [projNo]);
+
+ // 초기 데이터 로드
+ useEffect(() => {
+ loadInitialData();
+ }, [loadInitialData]);
+
+ // 프로젝트 변경 시 문서 재로드
+ useEffect(() => {
+ if (!isLoading && projNo) {
+ loadDocuments();
+ }
+ }, [projNo, isLoading, loadDocuments]);
+
+ const handleProjectChange = (newProjNo: string) => {
+ setProjNo(newProjNo);
};
- const handleFiltersChange = (newFilters: SwpTableFilters) => {
- setFilters(newFilters);
- setPage(1); // 필터 변경 시 첫 페이지로
+ const handleFiltersChange = (filters: typeof searchFilters) => {
+ setSearchFilters(filters);
};
- const handlePageChange = (newPage: number) => {
- setPage(newPage);
+ const handleRefresh = () => {
+ loadDocuments();
};
+ // 클라이언트 사이드 필터링
+ const filteredDocuments = useMemo(() => {
+ return documents.filter((doc) => {
+ if (searchFilters.docNo && !doc.DOC_NO.toLowerCase().includes(searchFilters.docNo.toLowerCase())) {
+ return false;
+ }
+ if (searchFilters.docTitle && !doc.DOC_TITLE.toLowerCase().includes(searchFilters.docTitle.toLowerCase())) {
+ return false;
+ }
+ if (searchFilters.pkgNo && !doc.PKG_NO?.toLowerCase().includes(searchFilters.pkgNo.toLowerCase())) {
+ return false;
+ }
+ if (searchFilters.stage && doc.STAGE !== searchFilters.stage) {
+ return false;
+ }
+ return true;
+ });
+ }, [documents, searchFilters]);
+
if (isLoading) {
return (
<Card>
@@ -159,78 +184,101 @@ export default function VendorDocumentPage({ searchParams }: VendorDocumentPageP
);
}
-
return (
- <div className="space-y-6">
- {/* 에러 메시지 */}
- {error && (
- <Alert variant="destructive">
- <AlertDescription>{error}</AlertDescription>
- </Alert>
+ <div {...getRootProps()} className="relative">
+ <input {...getInputProps()} />
+
+ {/* 드래그 오버레이 */}
+ {isDragActive && (
+ <div className="fixed inset-0 z-50 bg-primary/20 backdrop-blur-sm flex items-center justify-center">
+ <div className="bg-background border-2 border-dashed border-primary rounded-lg p-12 text-center space-y-4">
+ <Upload className="h-16 w-16 mx-auto text-primary animate-bounce" />
+ <div className="space-y-2">
+ <p className="text-2xl font-semibold">파일을 여기에 드롭하세요</p>
+ <p className="text-muted-foreground">
+ 여러 파일을 한 번에 업로드할 수 있습니다
+ </p>
+ </div>
+ </div>
+ </div>
)}
- {/* 통계 카드 */}
- <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
- <Card>
- <CardHeader className="pb-3">
- <CardDescription>할당된 문서</CardDescription>
- <CardTitle className="text-3xl">{stats.total_documents.toLocaleString()}</CardTitle>
- </CardHeader>
- </Card>
- <Card>
- <CardHeader className="pb-3">
- <CardDescription>총 리비전</CardDescription>
- <CardTitle className="text-3xl">{stats.total_revisions.toLocaleString()}</CardTitle>
- </CardHeader>
- </Card>
- <Card>
- <CardHeader className="pb-3">
- <CardDescription>총 파일</CardDescription>
- <CardTitle className="text-3xl">{stats.total_files.toLocaleString()}</CardTitle>
- </CardHeader>
- </Card>
+ <div className="space-y-6">
+ {/* 에러 메시지 */}
+ {error && (
+ <Alert variant="destructive">
+ <AlertDescription>{error}</AlertDescription>
+ </Alert>
+ )}
+
+ {/* 통계 카드 */}
+ <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
+ <Card>
+ <CardHeader className="pb-3">
+ <CardDescription>할당된 문서</CardDescription>
+ <CardTitle className="text-3xl">{stats.total_documents.toLocaleString()}</CardTitle>
+ </CardHeader>
+ </Card>
+ <Card>
+ <CardHeader className="pb-3">
+ <CardDescription>총 리비전</CardDescription>
+ <CardTitle className="text-3xl">{stats.total_revisions.toLocaleString()}</CardTitle>
+ </CardHeader>
+ </Card>
+ <Card>
+ <CardHeader className="pb-3">
+ <CardDescription>총 파일</CardDescription>
+ <CardTitle className="text-3xl">{stats.total_files.toLocaleString()}</CardTitle>
+ </CardHeader>
+ </Card>
+ <Card>
+ <CardHeader className="pb-3">
+ <CardDescription>업로드한 파일</CardDescription>
+ <CardTitle className="text-3xl text-green-600">
+ {stats.uploaded_files.toLocaleString()}
+ </CardTitle>
+ </CardHeader>
+ </Card>
+ </div>
+
+ {/* 안내 메시지 */}
+ {documents.length === 0 && !projNo && (
+ <Alert>
+ <InfoIcon className="h-4 w-4" />
+ <AlertDescription>
+ 프로젝트를 선택하여 할당된 문서를 확인하세요.
+ </AlertDescription>
+ </Alert>
+ )}
+
+ {/* 메인 테이블 */}
<Card>
- <CardHeader className="pb-3">
- <CardDescription>업로드한 파일</CardDescription>
- <CardTitle className="text-3xl text-green-600">
- {stats.uploaded_files.toLocaleString()}
- </CardTitle>
+ <CardHeader>
+ <SwpTableToolbar
+ projNo={projNo}
+ filters={searchFilters}
+ onProjNoChange={handleProjectChange}
+ onFiltersChange={handleFiltersChange}
+ onRefresh={handleRefresh}
+ isRefreshing={isRefreshing}
+ projects={projects}
+ vendorCode={vendorInfo?.vendorCode}
+ droppedFiles={droppedFiles}
+ onFilesProcessed={() => setDroppedFiles([])}
+ documents={filteredDocuments}
+ userId={String(vendorInfo?.vendorId || "")}
+ />
</CardHeader>
+ <CardContent>
+ <SwpTable
+ documents={filteredDocuments}
+ projNo={projNo}
+ vendorCode={vendorInfo?.vendorCode || ""}
+ userId={String(vendorInfo?.vendorId || "")}
+ />
+ </CardContent>
</Card>
</div>
-
- {/* 안내 메시지 */}
- {documents.length === 0 && !filters.projNo && (
- <Alert>
- <InfoIcon className="h-4 w-4" />
- <AlertDescription>
- 프로젝트를 선택하여 할당된 문서를 확인하세요.
- </AlertDescription>
- </Alert>
- )}
-
- {/* 메인 테이블 */}
- <Card>
- <CardHeader>
- <SwpTableToolbar
- filters={filters}
- onFiltersChange={handleFiltersChange}
- projects={projects}
- vendorCode={vendorInfo?.vendorCode}
- />
- </CardHeader>
- <CardContent>
- <SwpTable
- initialData={documents}
- total={total}
- page={page}
- pageSize={pageSize}
- totalPages={totalPages}
- onPageChange={handlePageChange}
- />
- </CardContent>
- </Card>
</div>
);
}
-
diff --git a/app/api/swp/upload/route.ts b/app/api/swp/upload/route.ts
index d17fcff7..b38c4ff4 100644
--- a/app/api/swp/upload/route.ts
+++ b/app/api/swp/upload/route.ts
@@ -26,7 +26,8 @@ interface InBoxFileInfo {
}
/**
- * 파일명 파싱: [OWN_DOC_NO]_[REV_NO]_[STAGE]_[YYYYMMDDhhmmss].[확장자]
+ * 파일명 파싱: [DOC_NO]_[REV_NO]_[STAGE]_[자유-파일명].[확장자]
+ * 자유 파일명에는 언더스코어가 포함될 수 있음
*/
function parseFileName(fileName: string) {
const lastDotIndex = fileName.lastIndexOf(".");
@@ -35,23 +36,38 @@ function parseFileName(fileName: string) {
const parts = nameWithoutExt.split("_");
- if (parts.length !== 4) {
+ // 최소 4개 파트 필요: docNo, revNo, stage, fileName
+ if (parts.length < 4) {
throw new Error(
`잘못된 파일명 형식입니다: ${fileName}. ` +
- `형식: [OWN_DOC_NO]_[REV_NO]_[STAGE]_[YYYYMMDDhhmmss].확장자`
+ `형식: [DOC_NO]_[REV_NO]_[STAGE]_[파일명].확장자 (언더스코어 최소 3개 필요)`
);
}
- const [ownDocNo, revNo, stage, timestamp] = parts;
+ // 앞에서부터 3개는 고정: docNo, revNo, stage
+ const ownDocNo = parts[0];
+ const revNo = parts[1];
+ const stage = parts[2];
+
+ // 나머지는 자유 파일명 (언더스코어 포함 가능)
+ const customFileName = parts.slice(3).join("_");
- if (!/^\d{14}$/.test(timestamp)) {
- throw new Error(
- `잘못된 타임스탬프 형식입니다: ${timestamp}. ` +
- `YYYYMMDDhhmmss 형식이어야 합니다.`
- );
- }
+ return { ownDocNo, revNo, stage, fileName: customFileName, extension };
+}
- return { ownDocNo, revNo, stage, timestamp, extension };
+/**
+ * 현재 시간을 YYYYMMDDhhmmss 형식으로 반환
+ */
+function generateTimestamp(): string {
+ const now = new Date();
+ const year = now.getFullYear().toString();
+ const month = (now.getMonth() + 1).toString().padStart(2, "0");
+ const day = now.getDate().toString().padStart(2, "0");
+ const hours = now.getHours().toString().padStart(2, "0");
+ const minutes = now.getMinutes().toString().padStart(2, "0");
+ const seconds = now.getSeconds().toString().padStart(2, "0");
+
+ return `${year}${month}${day}${hours}${minutes}${seconds}`;
}
/**
@@ -171,6 +187,10 @@ 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}`);
for (const file of files) {
try {
@@ -178,8 +198,8 @@ export async function POST(request: NextRequest) {
const parsed = parseFileName(file.name);
console.log(`[upload] 파일명 파싱:`, parsed);
- // 네트워크 경로 생성
- const networkPath = path.join(swpMountDir, projNo, cpyCd, parsed.timestamp, file.name);
+ // 네트워크 경로 생성 (timestamp를 경로에만 사용)
+ const networkPath = path.join(swpMountDir, projNo, cpyCd, uploadTimestamp, file.name);
// 파일 중복 체크
try {
@@ -245,8 +265,8 @@ export async function POST(request: NextRequest) {
matchesOriginal: buffer.slice(0, 20).equals(verifyBuffer.slice(0, 20))
});
- // InBox 파일 정보 준비
- const fldPath = `\\\\${projNo}\\\\${cpyCd}\\\\${parsed.timestamp}`;
+ // InBox 파일 정보 준비 (FLD_PATH에 업로드 timestamp 사용)
+ const fldPath = `\\\\${projNo}\\\\${cpyCd}\\\\${uploadTimestamp}`;
inBoxFileInfos.push({
CPY_CD: cpyCd,
@@ -339,12 +359,19 @@ export async function POST(request: NextRequest) {
console.log(`[upload] 완료:`, { success, message, result });
+ // 동기화 완료 정보 추가
+ const syncCompleted = result.successCount > 0;
+ const syncTimestamp = new Date().toISOString();
+
return NextResponse.json({
success,
message,
successCount: result.successCount,
failedCount: result.failedCount,
details: result.details,
+ syncCompleted,
+ syncTimestamp,
+ affectedVndrCd: vndrCd,
});
} catch (error) {
console.error("[upload] 오류:", error);
diff --git a/hooks/use-swp-documents.ts b/hooks/use-swp-documents.ts
new file mode 100644
index 00000000..dca0ec9e
--- /dev/null
+++ b/hooks/use-swp-documents.ts
@@ -0,0 +1,163 @@
+"use client";
+
+import useSWR, { mutate } from "swr";
+import {
+ getDocumentList,
+ getDocumentDetail,
+ cancelStandbyFile,
+ downloadDocumentFile,
+ type DocumentListItem,
+ type DocumentDetail,
+ type DownloadFileResult,
+} from "@/lib/swp/document-service";
+
+// ============================================================================
+// SWR Hooks
+// ============================================================================
+
+/**
+ * 문서 목록 조회 Hook
+ * @param projNo 프로젝트 번호
+ * @param vndrCd 벤더 코드 (선택)
+ */
+export function useDocumentList(projNo: string | null, vndrCd?: string) {
+ const key = projNo ? ["swp-documents", projNo, vndrCd] : null;
+
+ return useSWR<DocumentListItem[]>(
+ key,
+ async () => {
+ if (!projNo) return [];
+ return getDocumentList(projNo, vndrCd);
+ },
+ {
+ revalidateOnFocus: false, // 포커스시 재검증 안함
+ revalidateOnReconnect: true, // 재연결시 재검증
+ dedupingInterval: 5000, // 5초간 중복 요청 방지
+ }
+ );
+}
+
+/**
+ * 문서 상세 조회 Hook (Rev-Activity-File 트리)
+ * @param projNo 프로젝트 번호
+ * @param docNo 문서 번호
+ */
+export function useDocumentDetail(
+ projNo: string | null,
+ docNo: string | null
+) {
+ const key = projNo && docNo ? ["swp-document-detail", projNo, docNo] : null;
+
+ return useSWR<DocumentDetail | null>(
+ key,
+ async () => {
+ if (!projNo || !docNo) throw new Error("projNo and docNo required");
+ return getDocumentDetail(projNo, docNo);
+ },
+ {
+ revalidateOnFocus: false,
+ revalidateOnReconnect: true,
+ dedupingInterval: 2000, // 2초간 중복 요청 방지
+ shouldRetryOnError: false,
+ }
+ );
+}
+
+// ============================================================================
+// Mutation Helpers
+// ============================================================================
+
+/**
+ * 파일 취소
+ */
+export async function useCancelFile(
+ boxSeq: string,
+ actvSeq: string,
+ userId: string,
+ options?: {
+ onSuccess?: () => void;
+ onError?: (error: Error) => void;
+ }
+) {
+ try {
+ await cancelStandbyFile(boxSeq, actvSeq, userId);
+
+ // 문서 상세 캐시 무효화 (재조회)
+ await mutate(
+ (key: unknown) => Array.isArray(key) && key[0] === "swp-document-detail",
+ undefined,
+ { revalidate: true }
+ );
+
+ // 문서 목록 캐시도 무효화
+ await mutate(
+ (key: unknown) => Array.isArray(key) && key[0] === "swp-documents",
+ undefined,
+ { revalidate: true }
+ );
+
+ options?.onSuccess?.();
+ } catch (error) {
+ options?.onError?.(
+ error instanceof Error ? error : new Error("파일 취소 실패")
+ );
+ throw error;
+ }
+}
+
+/**
+ * 파일 다운로드
+ */
+export async function useDownloadFile(
+ projNo: string,
+ ownDocNo: string,
+ fileName: string,
+ options?: {
+ onSuccess?: () => void;
+ onError?: (error: string) => void;
+ }
+) {
+ try {
+ const result: DownloadFileResult = await downloadDocumentFile(
+ projNo,
+ ownDocNo,
+ fileName
+ );
+
+ if (!result.success || !result.data) {
+ const errorMsg = result.error || "파일 다운로드 실패";
+ options?.onError?.(errorMsg);
+ throw new Error(errorMsg);
+ }
+
+ // Blob을 다운로드
+ const blob = new Blob([Buffer.from(result.data)], { type: result.mimeType });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = result.fileName || fileName;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+
+ options?.onSuccess?.();
+ } catch (error) {
+ const errorMsg =
+ error instanceof Error ? error.message : "파일 다운로드 실패";
+ options?.onError?.(errorMsg);
+ throw error;
+ }
+}
+
+/**
+ * 수동 새로고침 헬퍼
+ */
+export function refreshDocumentList(projNo: string, vndrCd?: string) {
+ return mutate(["swp-documents", projNo, vndrCd]);
+}
+
+export function refreshDocumentDetail(projNo: string, docNo: string) {
+ return mutate(["swp-document-detail", projNo, docNo]);
+}
+
diff --git a/lib/swp/actions.ts b/lib/swp/actions.ts
index a7b4d3a3..eea8c9c2 100644
--- a/lib/swp/actions.ts
+++ b/lib/swp/actions.ts
@@ -474,3 +474,63 @@ function getMimeType(fileName: string): string {
return mimeTypes[ext] || "application/octet-stream";
}
+
+// ============================================================================
+// 서버 액션: 벤더 업로드 파일 목록 조회
+// ============================================================================
+
+export async function fetchVendorUploadedFiles(projNo: string, vndrCd: string) {
+ try {
+ debugLog(`[fetchVendorUploadedFiles] 조회 시작`, { projNo, vndrCd });
+
+ // fetchGetExternalInboxList 호출
+ const { fetchGetExternalInboxList } = await import("./api-client");
+ const files = await fetchGetExternalInboxList({
+ projNo,
+ vndrCd,
+ });
+
+ debugLog(`[fetchVendorUploadedFiles] 조회 완료`, {
+ fileCount: files.length
+ });
+
+ return files;
+ } catch (error) {
+ debugError(`[fetchVendorUploadedFiles] 조회 실패`, { error });
+ throw new Error(
+ error instanceof Error ? error.message : "업로드 파일 목록 조회 실패"
+ );
+ }
+}
+
+// ============================================================================
+// 서버 액션: 벤더 업로드 파일 취소
+// ============================================================================
+
+export interface CancelUploadedFileParams {
+ boxSeq: string;
+ actvSeq: string;
+ userId: string;
+}
+
+export async function cancelVendorUploadedFile(params: CancelUploadedFileParams) {
+ try {
+ debugLog(`[cancelVendorUploadedFile] 취소 시작`, params);
+
+ const { callSaveInBoxListCancelStatus } = await import("./api-client");
+ await callSaveInBoxListCancelStatus({
+ boxSeq: params.boxSeq,
+ actvSeq: params.actvSeq,
+ chgr: `evcp${params.userId}`,
+ });
+
+ debugSuccess(`[cancelVendorUploadedFile] 취소 완료`, params);
+
+ return { success: true };
+ } catch (error) {
+ debugError(`[cancelVendorUploadedFile] 취소 실패`, { error });
+ throw new Error(
+ error instanceof Error ? error.message : "파일 취소 실패"
+ );
+ }
+}
diff --git a/lib/swp/api-client.ts b/lib/swp/api-client.ts
index 9ce8c5c1..3ac980fb 100644
--- a/lib/swp/api-client.ts
+++ b/lib/swp/api-client.ts
@@ -1,10 +1,5 @@
"use server";
-import type {
- SwpDocumentApiResponse,
- SwpFileApiResponse,
-} from "./sync-service";
-
// ============================================================================
// SWP API 클라이언트
// ============================================================================
@@ -47,6 +42,72 @@ export interface GetExternalInboxListFilter {
doctitle?: string;
}
+export interface SwpDocumentApiResponse {
+ // 필수 필드
+ DOC_NO: string;
+ DOC_TITLE: string;
+ PROJ_NO: string;
+ CPY_CD: string;
+ CPY_NM: string;
+ PIC_NM: string;
+ PIC_DEPTNM: string;
+ SKL_CD: string;
+ CRTER: string;
+ CRTE_DTM: string;
+ CHGR: string;
+ CHG_DTM: string;
+
+ // 선택적 필드 (null 가능)
+ DOC_GB: string | null;
+ DOC_TYPE: string | null;
+ OWN_DOC_NO: string | null;
+ SHI_DOC_NO: string | null;
+ PROJ_NM: string | null;
+ PKG_NO: string | null;
+ MAT_CD: string | null;
+ MAT_NM: string | null;
+ DISPLN: string | null;
+ CTGRY: string | null;
+ VNDR_CD: string | null;
+ PIC_DEPTCD: string | null;
+ LTST_REV_NO: string | null;
+ LTST_REV_SEQ: string | null;
+ LTST_ACTV_STAT: string | null;
+ STAGE: string | null;
+ MOD_TYPE: string | null;
+ ACT_TYPE_NM: string | null;
+ USE_YN: string | null;
+ REV_DTM: string | null;
+}
+
+export interface SwpFileApiResponse {
+ // 필수 필드
+ OWN_DOC_NO: string;
+ REV_NO: string;
+ STAGE: string;
+ FILE_NM: string;
+ FILE_SEQ: string;
+ CRTER: string;
+ CRTE_DTM: string;
+ CHGR: string;
+ CHG_DTM: string;
+
+ // 선택적 필드 (null 가능)
+ FILE_SZ: string | null;
+ FLD_PATH: string | null;
+ ACTV_NO: string | null;
+ ACTV_SEQ: string | null;
+ BOX_SEQ: string | null;
+ OFDC_NO: string | null;
+ PROJ_NO: string | null;
+ PKG_NO: string | null;
+ VNDR_CD: string | null;
+ CPY_CD: string | null;
+ STAT: string | null;
+ STAT_NM: string | null;
+ IDX: string | null;
+}
+
// ============================================================================
// 공통 API 호출 함수
// ============================================================================
@@ -302,3 +363,92 @@ export async function analyzeSwpData(
};
}
+// ============================================================================
+// 서버 액션: Activity 및 파일 리스트 조회 (GetActivityFileList)
+// ============================================================================
+
+/**
+ * Activity 파일 리스트 조회 필터
+ */
+export interface GetActivityFileListFilter {
+ proj_no: string;
+ doc_no: string;
+ rev_seq?: string; // 선택적
+}
+
+/**
+ * Activity 파일 API 응답
+ */
+export interface ActivityFileApiResponse {
+ ACTV_NO: string;
+ ACT_TYPE: string;
+ DOC_NO: string;
+ DOC_TITLE: string;
+ REV_NO: string;
+ REV_SEQ: string;
+ STAGE: string;
+ STAT: string; // R00=Receive, S30=Send, V00=Review
+ FILE_TYPE: string; // "Receive", "Send", "Review"
+ FILE_NM: string;
+ FILE_SEQ: string;
+ FILE_SZ: string;
+ FILE_FMT: string;
+ OWN_DOC_NO: string;
+ TO_FROM: string; // 업체명
+ OBJT_ID: string;
+ DSC: string | null;
+ BATCHUPLOAD_ID: string | null;
+ TRNS_DTM: string | null;
+ CRTER: string;
+ CRTE_DTM: string;
+}
+
+/**
+ * Activity 파일 리스트 조회 (GetActivityFileList)
+ * @param filter 조회 필터
+ */
+export async function fetchGetActivityFileList(
+ filter: GetActivityFileListFilter
+): Promise<ActivityFileApiResponse[]> {
+ const body = {
+ proj_no: filter.proj_no,
+ doc_no: filter.doc_no,
+ rev_seq: filter.rev_seq || "",
+ };
+
+ return callSwpApi<ActivityFileApiResponse>(
+ "GetActivityFileList",
+ body,
+ "GetActivityFileListResult"
+ );
+}
+
+// ============================================================================
+// 서버 액션: 파일 취소 (SaveInBoxListCancelStatus)
+// ============================================================================
+
+export interface CancelFileParams {
+ boxSeq: string;
+ actvSeq: string;
+ chgr: string; // 취소 요청자 (evcp${userId})
+}
+
+/**
+ * 파일 취소 API (SaveInBoxListCancelStatus)
+ */
+export async function callSaveInBoxListCancelStatus(
+ params: CancelFileParams
+): Promise<void> {
+ const body = {
+ boxSeq: params.boxSeq,
+ actvSeq: params.actvSeq,
+ chgr: params.chgr,
+ };
+
+ await callSwpApi(
+ "SaveInBoxListCancelStatus",
+ body,
+ "SaveInBoxListCancelStatusResult"
+ );
+}
+
diff --git a/lib/swp/document-service.ts b/lib/swp/document-service.ts
new file mode 100644
index 00000000..49e4da4c
--- /dev/null
+++ b/lib/swp/document-service.ts
@@ -0,0 +1,476 @@
+"use server";
+
+import {
+ fetchGetVDRDocumentList,
+ fetchGetExternalInboxList,
+ fetchGetActivityFileList,
+ callSaveInBoxListCancelStatus,
+ type SwpDocumentApiResponse,
+ type SwpFileApiResponse,
+ type ActivityFileApiResponse,
+} from "./api-client";
+import { debugLog, debugError, debugSuccess, debugWarn } from "@/lib/debug-utils";
+import * as fs from "fs/promises";
+import * as path from "path";
+
+// ============================================================================
+// 타입 정의
+// ============================================================================
+
+/**
+ * 파일 정보 (Activity 파일 + Inbox 파일 결합)
+ */
+export interface DocumentFile {
+ fileNm: string;
+ fileSeq: string;
+ fileSz: string;
+ fileFmt: string;
+ fldPath?: string;
+ stat?: string; // SCW01=Standby, SCW03=Complete 등
+ statNm?: string;
+ canCancel: boolean; // STAT=SCW01인 경우만 취소 가능
+ canDownload: boolean; // FLD_PATH가 있으면 다운로드 가능
+ boxSeq?: string;
+ actvSeq?: string;
+ objId: string;
+ crteDate: string;
+}
+
+/**
+ * Activity 정보
+ */
+export interface Activity {
+ actvNo: string;
+ type: "Receive" | "Send" | "Review"; // STAT 첫 글자로 판단
+ stat: string; // R00, S30 등
+ toFrom: string; // 업체명
+ createDate: string;
+ files: DocumentFile[];
+}
+
+/**
+ * Revision 정보
+ */
+export interface Revision {
+ revNo: string;
+ revSeq: string;
+ stage: string;
+ activities: Activity[];
+ totalFiles: number;
+}
+
+/**
+ * 문서 상세 (Rev-Activity-File 트리)
+ */
+export interface DocumentDetail {
+ docNo: string;
+ docTitle: string;
+ projNo: string;
+ revisions: Revision[];
+}
+
+/**
+ * 문서 목록 아이템 (통계 포함)
+ */
+export interface DocumentListItem extends SwpDocumentApiResponse {
+ fileCount: number;
+ standbyFileCount: number; // STAT=SCW01
+ latestFiles: SwpFileApiResponse[];
+}
+
+// ============================================================================
+// 문서 목록 조회 (시나리오 1)
+// ============================================================================
+
+/**
+ * 문서 목록 조회
+ * - GetVDRDocumentList + GetExternalInboxList 병합
+ * - 파일 통계 계산
+ */
+export async function getDocumentList(
+ projNo: string,
+ vndrCd?: string
+): Promise<DocumentListItem[]> {
+ debugLog("[getDocumentList] 시작", { projNo, vndrCd });
+
+ try {
+ // 병렬 API 호출
+ const [documents, allFiles] = await Promise.all([
+ fetchGetVDRDocumentList({
+ proj_no: projNo,
+ doc_gb: "V",
+ vndrCd: vndrCd,
+ }),
+ fetchGetExternalInboxList({
+ projNo: projNo,
+ vndrCd: vndrCd,
+ }),
+ ]);
+
+ debugLog("[getDocumentList] API 조회 완료", {
+ documents: documents.length,
+ files: allFiles.length,
+ });
+
+ // 파일을 문서별로 그룹핑
+ const filesByDoc = new Map<string, SwpFileApiResponse[]>();
+ for (const file of allFiles) {
+ const docNo = file.OWN_DOC_NO;
+ if (!filesByDoc.has(docNo)) {
+ filesByDoc.set(docNo, []);
+ }
+ filesByDoc.get(docNo)!.push(file);
+ }
+
+ // 문서에 파일 통계 추가
+ const result = documents.map((doc) => {
+ const files = filesByDoc.get(doc.DOC_NO) || [];
+ const standbyFiles = files.filter((f) => f.STAT === "SCW01");
+
+ return {
+ ...doc,
+ fileCount: files.length,
+ standbyFileCount: standbyFiles.length,
+ latestFiles: files
+ .sort((a, b) => b.CRTE_DTM.localeCompare(a.CRTE_DTM))
+ .slice(0, 5), // 최신 5개만
+ };
+ });
+
+ debugSuccess("[getDocumentList] 완료", { count: result.length });
+ return result;
+ } catch (error) {
+ debugError("[getDocumentList] 실패", error);
+ throw new Error(
+ error instanceof Error ? error.message : "문서 목록 조회 실패"
+ );
+ }
+}
+
+// ============================================================================
+// 문서 상세 조회 (Rev-Activity-File 트리) (시나리오 1 상세)
+// ============================================================================
+
+/**
+ * 문서 상세 조회
+ * - GetActivityFileList + GetExternalInboxList 결합
+ * - Rev → Activity → File 트리 구성
+ */
+export async function getDocumentDetail(
+ projNo: string,
+ docNo: string
+): Promise<DocumentDetail> {
+ debugLog("[getDocumentDetail] 시작", { projNo, docNo });
+
+ try {
+ // 병렬 API 호출
+ const [activityFiles, inboxFiles] = await Promise.all([
+ fetchGetActivityFileList({ proj_no: projNo, doc_no: docNo }),
+ fetchGetExternalInboxList({ projNo: projNo, owndocno: docNo }),
+ ]);
+
+ debugLog("[getDocumentDetail] API 조회 완료", {
+ activityFiles: activityFiles.length,
+ inboxFiles: inboxFiles.length,
+ });
+
+ // Inbox 파일을 빠른 조회를 위해 Map으로 변환
+ const inboxFileMap = new Map<string, SwpFileApiResponse>();
+ for (const file of inboxFiles) {
+ const key = `${file.OWN_DOC_NO}|${file.FILE_NM}`;
+ inboxFileMap.set(key, file);
+ }
+
+ // 트리 구조 빌드
+ const tree = buildDocumentTree(activityFiles, inboxFileMap);
+
+ debugSuccess("[getDocumentDetail] 완료", {
+ docNo: tree.docNo,
+ revisions: tree.revisions.length,
+ });
+
+ return tree;
+ } catch (error) {
+ debugError("[getDocumentDetail] 실패", error);
+ throw new Error(
+ error instanceof Error ? error.message : "문서 상세 조회 실패"
+ );
+ }
+}
+
+/**
+ * Rev-Activity-File 트리 빌더
+ */
+function buildDocumentTree(
+ activityFiles: ActivityFileApiResponse[],
+ inboxFileMap: Map<string, SwpFileApiResponse>
+): DocumentDetail {
+ if (activityFiles.length === 0) {
+ return {
+ docNo: "",
+ docTitle: "",
+ projNo: "",
+ revisions: [],
+ };
+ }
+
+ const first = activityFiles[0];
+
+ // REV_NO로 그룹핑
+ const revisionMap = new Map<string, ActivityFileApiResponse[]>();
+ for (const item of activityFiles) {
+ const revKey = `${item.REV_NO}|${item.REV_SEQ}`;
+ if (!revisionMap.has(revKey)) {
+ revisionMap.set(revKey, []);
+ }
+ revisionMap.get(revKey)!.push(item);
+ }
+
+ // 각 리비전 처리
+ const revisions: Revision[] = [];
+ for (const [revKey, revFiles] of revisionMap) {
+ const [revNo, revSeq] = revKey.split("|");
+ const stage = revFiles[0].STAGE;
+
+ // ACTV_NO로 그룹핑
+ const activityMap = new Map<string, ActivityFileApiResponse[]>();
+ for (const item of revFiles) {
+ if (!activityMap.has(item.ACTV_NO)) {
+ activityMap.set(item.ACTV_NO, []);
+ }
+ activityMap.get(item.ACTV_NO)!.push(item);
+ }
+
+ // 각 액티비티 처리
+ const activities: Activity[] = [];
+ for (const [actvNo, actvFiles] of activityMap) {
+ const firstActvFile = actvFiles[0];
+
+ // 파일 정보에 Inbox 데이터 결합
+ const files: DocumentFile[] = actvFiles.map((af) => {
+ const inboxFile = inboxFileMap.get(`${af.OWN_DOC_NO}|${af.FILE_NM}`);
+
+ return {
+ fileNm: af.FILE_NM,
+ fileSeq: af.FILE_SEQ,
+ fileSz: af.FILE_SZ,
+ fileFmt: af.FILE_FMT,
+ fldPath: inboxFile?.FLD_PATH,
+ stat: inboxFile?.STAT,
+ statNm: inboxFile?.STAT_NM,
+ canCancel: inboxFile?.STAT === "SCW01", // Standby만 취소 가능
+ canDownload: !!inboxFile?.FLD_PATH,
+ boxSeq: inboxFile?.BOX_SEQ,
+ actvSeq: inboxFile?.ACTV_SEQ,
+ objId: af.OBJT_ID,
+ crteDate: af.CRTE_DTM,
+ };
+ });
+
+ activities.push({
+ actvNo: actvNo,
+ type: getActivityType(firstActvFile.STAT),
+ stat: firstActvFile.STAT,
+ toFrom: firstActvFile.TO_FROM,
+ createDate: firstActvFile.CRTE_DTM,
+ files: files,
+ });
+ }
+
+ revisions.push({
+ revNo: revNo,
+ revSeq: revSeq,
+ stage: stage,
+ activities: activities.sort((a, b) =>
+ b.createDate.localeCompare(a.createDate)
+ ),
+ totalFiles: revFiles.length,
+ });
+ }
+
+ return {
+ docNo: first.DOC_NO,
+ docTitle: first.DOC_TITLE,
+ projNo: first.OWN_DOC_NO.split("-")[0] || "", // 프로젝트 코드 추출
+ revisions: revisions.sort((a, b) => b.revNo.localeCompare(a.revNo)),
+ };
+}
+
+/**
+ * STAT 코드로 Activity 타입 판단
+ */
+function getActivityType(stat: string): "Receive" | "Send" | "Review" {
+ const firstChar = stat.charAt(0).toUpperCase();
+ if (firstChar === "R") return "Receive";
+ if (firstChar === "S") return "Send";
+ if (firstChar === "V") return "Review";
+ return "Send"; // 기본값
+}
+
+// ============================================================================
+// 파일 취소 (시나리오 1-1)
+// ============================================================================
+
+/**
+ * Standby 상태 파일 취소
+ */
+export async function cancelStandbyFile(
+ boxSeq: string,
+ actvSeq: string,
+ userId: string
+): Promise<void> {
+ debugLog("[cancelStandbyFile] 시작", { boxSeq, actvSeq, userId });
+
+ try {
+ // varchar(13) 제한
+ const chgr = `evcp${userId}`.substring(0, 13);
+
+ await callSaveInBoxListCancelStatus({
+ boxSeq: boxSeq,
+ actvSeq: actvSeq,
+ chgr: chgr,
+ });
+
+ debugSuccess("[cancelStandbyFile] 완료", { boxSeq, actvSeq });
+ } catch (error) {
+ debugError("[cancelStandbyFile] 실패", error);
+ throw new Error(
+ error instanceof Error ? error.message : "파일 취소 실패"
+ );
+ }
+}
+
+// ============================================================================
+// 파일 다운로드 (시나리오 1-2)
+// ============================================================================
+
+export interface DownloadFileResult {
+ success: boolean;
+ data?: Uint8Array;
+ fileName?: string;
+ mimeType?: string;
+ error?: string;
+}
+
+/**
+ * 문서 파일 다운로드
+ * - GetExternalInboxList에서 FLD_PATH 조회
+ * - 네트워크 드라이브에서 파일 읽기
+ */
+export async function downloadDocumentFile(
+ projNo: string,
+ ownDocNo: string,
+ fileName: string
+): Promise<DownloadFileResult> {
+ debugLog("[downloadDocumentFile] 시작", { projNo, ownDocNo, fileName });
+
+ try {
+ // 1. GetExternalInboxList에서 파일 정보 찾기
+ const files = await fetchGetExternalInboxList({
+ projNo: projNo,
+ owndocno: ownDocNo,
+ });
+
+ const targetFile = files.find((f) => f.FILE_NM === fileName);
+
+ if (!targetFile || !targetFile.FLD_PATH) {
+ debugWarn("[downloadDocumentFile] 파일 없음", { fileName });
+ return {
+ success: false,
+ error: "파일을 찾을 수 없습니다",
+ };
+ }
+
+ debugLog("[downloadDocumentFile] 파일 정보 조회 완료", {
+ fileName: targetFile.FILE_NM,
+ fldPath: targetFile.FLD_PATH,
+ });
+
+ // 2. NFS 마운트 경로 확인
+ const nfsBasePath = process.env.SWP_MOUNT_DIR;
+ if (!nfsBasePath) {
+ debugError("[downloadDocumentFile] SWP_MOUNT_DIR 미설정");
+ return {
+ success: false,
+ error: "서버 설정 오류: NFS 경로가 설정되지 않았습니다",
+ };
+ }
+
+ // 3. 전체 파일 경로 생성
+ const normalizedFldPath = targetFile.FLD_PATH.replace(/\\/g, "/");
+ const fullPath = path.join(nfsBasePath, normalizedFldPath, targetFile.FILE_NM);
+
+ debugLog("[downloadDocumentFile] 파일 경로", { fullPath });
+
+ // 4. 파일 존재 여부 확인
+ try {
+ await fs.access(fullPath, fs.constants.R_OK);
+ } catch (accessError) {
+ debugError("[downloadDocumentFile] 파일 접근 불가", accessError);
+ return {
+ success: false,
+ error: `파일을 찾을 수 없습니다: ${targetFile.FILE_NM}`,
+ };
+ }
+
+ // 5. 파일 읽기
+ const fileBuffer = await fs.readFile(fullPath);
+ const fileData = new Uint8Array(fileBuffer);
+
+ // 6. MIME 타입 결정
+ const mimeType = getMimeType(targetFile.FILE_NM);
+
+ debugSuccess("[downloadDocumentFile] 완료", {
+ fileName: targetFile.FILE_NM,
+ size: fileData.length,
+ mimeType,
+ });
+
+ return {
+ success: true,
+ data: fileData,
+ fileName: targetFile.FILE_NM,
+ mimeType,
+ };
+ } catch (error) {
+ debugError("[downloadDocumentFile] 실패", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "파일 다운로드 실패",
+ };
+ }
+}
+
+/**
+ * MIME 타입 결정
+ */
+function getMimeType(fileName: string): string {
+ const ext = path.extname(fileName).toLowerCase();
+
+ const mimeTypes: Record<string, string> = {
+ ".pdf": "application/pdf",
+ ".doc": "application/msword",
+ ".docx":
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ ".xls": "application/vnd.ms-excel",
+ ".xlsx":
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ ".ppt": "application/vnd.ms-powerpoint",
+ ".pptx":
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
+ ".txt": "text/plain",
+ ".csv": "text/csv",
+ ".jpg": "image/jpeg",
+ ".jpeg": "image/jpeg",
+ ".png": "image/png",
+ ".gif": "image/gif",
+ ".zip": "application/zip",
+ ".rar": "application/x-rar-compressed",
+ ".7z": "application/x-7z-compressed",
+ ".dwg": "application/acad",
+ ".dxf": "application/dxf",
+ };
+
+ return mimeTypes[ext] || "application/octet-stream";
+}
+
diff --git a/lib/swp/table/swp-document-detail-dialog.tsx b/lib/swp/table/swp-document-detail-dialog.tsx
new file mode 100644
index 00000000..418ddea9
--- /dev/null
+++ b/lib/swp/table/swp-document-detail-dialog.tsx
@@ -0,0 +1,412 @@
+"use client";
+
+import React, { useState, useEffect } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import {
+ Loader2,
+ ChevronDown,
+ ChevronRight,
+ Download,
+ FileIcon,
+ XCircle,
+ AlertCircle,
+} from "lucide-react";
+import {
+ fetchVendorDocumentDetail,
+ cancelVendorFile,
+ downloadVendorFile,
+} from "@/lib/swp/vendor-actions";
+import type { DocumentListItem, DocumentDetail } from "@/lib/swp/document-service";
+import { toast } from "sonner";
+
+interface SwpDocumentDetailDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ document: DocumentListItem | null;
+ projNo: string;
+ vendorCode: string;
+ userId: string;
+}
+
+export function SwpDocumentDetailDialog({
+ open,
+ onOpenChange,
+ document,
+ projNo,
+}: SwpDocumentDetailDialogProps) {
+ const [detail, setDetail] = useState<DocumentDetail | null>(null);
+ 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
+
+ // 문서 상세 로드
+ useEffect(() => {
+ if (open && document) {
+ loadDocumentDetail();
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [open, document?.DOC_NO]);
+
+ const loadDocumentDetail = async () => {
+ if (!document) return;
+
+ setIsLoading(true);
+ 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);
+ });
+ });
+
+ setExpandedRevisions(allRevKeys);
+ setExpandedActivities(allActKeys);
+ setIsAllExpanded(true);
+ } catch (error) {
+ console.error("문서 상세 조회 실패:", 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;
+ });
+ };
+
+ // 일괄 열기/닫기
+ const handleToggleAll = () => {
+ if (!detail) return;
+
+ 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);
+ });
+ });
+
+ 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();
+ } catch (error) {
+ console.error("파일 취소 실패:", error);
+ toast.error("파일 취소에 실패했습니다");
+ }
+ };
+
+ const handleDownloadFile = async (fileName: string, ownDocNo: string) => {
+ try {
+ toast.info("파일 다운로드 중...");
+ const result = await downloadVendorFile(projNo, ownDocNo, fileName);
+
+ if (!result.success || !result.data) {
+ toast.error(result.error || "파일 다운로드 실패");
+ return;
+ }
+
+ // Blob 생성 및 다운로드
+ const blob = new Blob([Buffer.from(result.data)], { type: result.mimeType });
+ const url = URL.createObjectURL(blob);
+ const link = window.document.createElement("a");
+ link.href = url;
+ link.download = result.fileName || fileName;
+ window.document.body.appendChild(link);
+ link.click();
+ window.document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+
+ toast.success(`파일 다운로드 완료: ${fileName}`);
+ } catch (error) {
+ console.error("파일 다운로드 실패:", error);
+ toast.error("파일 다운로드에 실패했습니다");
+ }
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>문서 상세</DialogTitle>
+ {document && (
+ <DialogDescription>
+ {document.DOC_NO} - {document.DOC_TITLE}
+ </DialogDescription>
+ )}
+ </DialogHeader>
+
+ {document && (
+ <div className="space-y-4">
+ {/* 문서 정보 */}
+ <div className="grid grid-cols-1 md:grid-cols-4 gap-4 p-4 bg-muted/30 rounded-lg">
+ <div>
+ <span className="text-sm font-semibold">프로젝트:</span>
+ <div className="text-sm">{document.PROJ_NO}</div>
+ {document.PROJ_NM && (
+ <div className="text-xs text-muted-foreground">{document.PROJ_NM}</div>
+ )}
+ </div>
+ <div>
+ <span className="text-sm font-semibold">패키지:</span>
+ <div className="text-sm">{document.PKG_NO || "-"}</div>
+ </div>
+ <div>
+ <span className="text-sm font-semibold">업체:</span>
+ <div className="text-sm">{document.CPY_NM || "-"}</div>
+ {document.VNDR_CD && (
+ <div className="text-xs text-muted-foreground">{document.VNDR_CD}</div>
+ )}
+ </div>
+ <div>
+ <span className="text-sm font-semibold">최신 리비전:</span>
+ <div className="text-sm">{document.LTST_REV_NO || "-"}</div>
+ </div>
+ </div>
+
+ {/* 리비전 및 액티비티 트리 */}
+ {isLoading ? (
+ <div className="flex items-center justify-center p-8">
+ <Loader2 className="h-6 w-6 animate-spin" />
+ <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 ? (
+ <>
+ <ChevronDown className="h-4 w-4 mr-2" />
+ 일괄 닫기
+ </>
+ ) : (
+ <>
+ <ChevronRight className="h-4 w-4 mr-2" />
+ 일괄 열기
+ </>
+ )}
+ </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>
+
+ {/* 액티비티 목록 */}
+ {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>
+ </div>
+ </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>
+ );
+ })}
+ </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>
+ </div>
+ )}
+ </div>
+ )}
+ </DialogContent>
+ </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-help-dialog.tsx b/lib/swp/table/swp-help-dialog.tsx
index 6880a8c7..c6c5296b 100644
--- a/lib/swp/table/swp-help-dialog.tsx
+++ b/lib/swp/table/swp-help-dialog.tsx
@@ -21,7 +21,7 @@ export function SwpUploadHelpDialog() {
업로드 가이드
</Button>
</DialogTrigger>
- <DialogContent className="max-w-2xl" opacityControl={false}>
+ <DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>파일 업로드 가이드</DialogTitle>
<DialogDescription>
@@ -34,10 +34,13 @@ export function SwpUploadHelpDialog() {
<div className="space-y-2">
<h3 className="text-sm font-semibold">파일명 형식</h3>
<div className="rounded-lg bg-muted p-4 font-mono text-sm">
- [OWN_DOC_NO]_[REV_NO]_[STAGE]_[YYYYMMDDhhmmss].[확장자]
+ [DOC_NO]_[REV_NO]_[STAGE].[확장자]
</div>
<p className="text-xs text-muted-foreground">
- ⚠️ 언더스코어(_)가 정확히 3개 있어야 합니다
+ ⚠️ 언더스코어(_)가 최소 2개 이상 있어야 합니다
+ </p>
+ <p className="text-xs text-muted-foreground">
+ ℹ️ 선택사항: 4번째 항목으로 파일명을 추가할 수 있습니다 (예: [DOC_NO]_[REV_NO]_[STAGE]_[파일명].[확장자])
</p>
</div>
@@ -47,7 +50,7 @@ export function SwpUploadHelpDialog() {
<div className="flex items-center gap-3 rounded-lg border p-3">
<Badge variant="secondary" className="font-mono shrink-0">
- OWN_DOC_NO
+ DOC_NO
</Badge>
<div className="text-sm">
<span className="font-medium">벤더의 문서번호</span>
@@ -77,11 +80,11 @@ export function SwpUploadHelpDialog() {
<div className="flex items-center gap-3 rounded-lg border p-3">
<Badge variant="secondary" className="font-mono shrink-0">
- YYYYMMDDhhmmss
+ 파일명
</Badge>
<div className="text-sm">
- <span className="font-medium">날짜 및 시간</span>
- <span className="text-muted-foreground"> - 업로드 날짜 정보를 기입합니다 (14자리 숫자)</span>
+ <span className="font-medium">자유 파일명 (선택사항)</span>
+ <span className="text-muted-foreground"> - 문서를 식별할 수 있는 이름 (언더스코어 포함 가능, 생략 가능)</span>
</div>
</div>
</div>
@@ -92,13 +95,35 @@ export function SwpUploadHelpDialog() {
<div className="space-y-2">
<div className="rounded-lg bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-800 p-3">
<code className="text-xs font-mono text-green-700 dark:text-green-300">
- VD-DOC-001_01_IFA_20250124143000.pdf
+ VD-DOC-001_01_IFA.pdf
+ </code>
+ <p className="text-xs text-green-600 dark:text-green-400 mt-1">
+ ✓ 기본 형식 (파일명 생략)
+ </p>
+ </div>
+ <div className="rounded-lg bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-800 p-3">
+ <code className="text-xs font-mono text-green-700 dark:text-green-300">
+ VD-DOC-001_01_IFA_drawing_final.pdf
+ </code>
+ <p className="text-xs text-green-600 dark:text-green-400 mt-1">
+ ✓ 파일명 추가 (파일명에 언더스코어 포함 가능)
+ </p>
+ </div>
+ <div className="rounded-lg bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-800 p-3">
+ <code className="text-xs font-mono text-green-700 dark:text-green-300">
+ TECH-SPEC-002_02_IFC.dwg
</code>
+ <p className="text-xs text-green-600 dark:text-green-400 mt-1">
+ ✓ 기본 형식 사용
+ </p>
</div>
<div className="rounded-lg bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-800 p-3">
<code className="text-xs font-mono text-green-700 dark:text-green-300">
- TECH-SPEC-002_02_IFC_20250124150000.dwg
+ DOC-003_03_IFA_test_result_data.xlsx
</code>
+ <p className="text-xs text-green-600 dark:text-green-400 mt-1">
+ ✓ 파일명 추가 (여러 단어 조합 가능)
+ </p>
</div>
</div>
</div>
@@ -109,7 +134,7 @@ export function SwpUploadHelpDialog() {
<div className="space-y-2">
<div className="rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 p-3">
<code className="text-xs font-mono text-red-700 dark:text-red-300">
- VD-DOC-001-01-IFA-20250124.pdf
+ VD-DOC-001-01-IFA.pdf
</code>
<p className="text-xs text-red-600 dark:text-red-400 mt-1">
✗ 언더스코어(_) 대신 하이픈(-) 사용
@@ -117,18 +142,26 @@ export function SwpUploadHelpDialog() {
</div>
<div className="rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 p-3">
<code className="text-xs font-mono text-red-700 dark:text-red-300">
- VD-DOC-001_01_IFA.pdf
+ VD-DOC-001_01.pdf
+ </code>
+ <p className="text-xs text-red-600 dark:text-red-400 mt-1">
+ ✗ STAGE 정보 누락 (최소 3개 항목 필요)
+ </p>
+ </div>
+ <div className="rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 p-3">
+ <code className="text-xs font-mono text-red-700 dark:text-red-300">
+ VD DOC 001_01_IFA.pdf
</code>
<p className="text-xs text-red-600 dark:text-red-400 mt-1">
- ✗ 날짜/시간 정보 누락
+ ✗ 공백 포함 (언더스코어 사용 필요)
</p>
</div>
<div className="rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 p-3">
<code className="text-xs font-mono text-red-700 dark:text-red-300">
- VD-DOC-001_01_IFA_20250124.pdf
+ VD-DOC-001__IFA.pdf
</code>
<p className="text-xs text-red-600 dark:text-red-400 mt-1">
- ✗ 시간 정보 누락 (14자리가 아님)
+ ✗ REV_NO 비어있음 (빈 항목 불가)
</p>
</div>
</div>
@@ -140,7 +173,10 @@ export function SwpUploadHelpDialog() {
⚠️ 주의사항
</h3>
<ul className="text-xs text-amber-800 dark:text-amber-200 space-y-1 list-disc list-inside">
- <li>파일명 형식이 올바르지 않으면 업로드가 실패합니다</li>
+ <li>파일명은 최소 [DOC_NO]_[REV_NO]_[STAGE].[확장자] 형식이어야 합니다</li>
+ <li>DOC_NO는 현재 프로젝트에 할당된 문서번호여야 합니다</li>
+ <li>4번째 항목(파일명)은 선택사항으로 생략 가능합니다</li>
+ <li>업로드 날짜/시간은 시스템에서 자동으로 생성됩니다</li>
<li>같은 파일명으로 이미 업로드된 파일이 있으면 덮어쓰지 않고 오류 처리됩니다</li>
<li>프로젝트와 업체 코드를 먼저 선택해야 업로드 버튼이 활성화됩니다</li>
</ul>
diff --git a/lib/swp/table/swp-table-columns.tsx b/lib/swp/table/swp-table-columns.tsx
index 9aecea96..e6abd2a0 100644
--- a/lib/swp/table/swp-table-columns.tsx
+++ b/lib/swp/table/swp-table-columns.tsx
@@ -2,43 +2,26 @@
import { ColumnDef } from "@tanstack/react-table";
import { Badge } from "@/components/ui/badge";
-import { Button } from "@/components/ui/button";
-import { ChevronDown, ChevronRight, FileIcon, Download, Loader2 } from "lucide-react";
-import { formatDistanceToNow } from "date-fns";
-import { ko } from "date-fns/locale";
-import type { SwpDocumentWithStats } from "../actions";
-import { useState } from "react";
-import { toast } from "sonner";
+import type { DocumentListItem } from "@/lib/swp/document-service";
-export const swpDocumentColumns: ColumnDef<SwpDocumentWithStats>[] = [
+export const swpDocumentColumns: ColumnDef<DocumentListItem>[] = [
{
id: "expander",
header: () => null,
- cell: () => {
- return (
- <Button
- variant="ghost"
- size="sm"
- className="h-8 w-8 p-0"
- >
- <ChevronRight className="h-4 w-4" />
- </Button>
- );
- },
+ cell: () => null,
size: 50,
},
{
accessorKey: "LTST_ACTV_STAT",
- header: "상태 (최신 액티비티)",
+ header: "상태",
cell: ({ row }) => {
const status = row.original.LTST_ACTV_STAT;
if (!status) return "-";
- // 상태에 따른 색상 설정 (필요에 따라 조정 가능)
const color =
- status === "Complete" ? "bg-green-100 text-green-800" :
- status === "In Progress" ? "bg-blue-100 text-blue-800" :
- status === "Pending" ? "bg-yellow-100 text-yellow-800" :
+ status.includes("Complete") ? "bg-green-100 text-green-800" :
+ status.includes("Progress") ? "bg-blue-100 text-blue-800" :
+ status.includes("Pending") || status.includes("Ready") ? "bg-yellow-100 text-yellow-800" :
"bg-gray-100 text-gray-800";
return (
@@ -47,7 +30,7 @@ export const swpDocumentColumns: ColumnDef<SwpDocumentWithStats>[] = [
</Badge>
);
},
- size: 100,
+ size: 120,
},
{
accessorKey: "DOC_NO",
@@ -61,7 +44,7 @@ export const swpDocumentColumns: ColumnDef<SwpDocumentWithStats>[] = [
accessorKey: "DOC_TITLE",
header: "문서제목",
cell: ({ row }) => (
- <div className="max-w-md" title={row.original.DOC_TITLE}>
+ <div className="max-w-md truncate" title={row.original.DOC_TITLE}>
{row.original.DOC_TITLE}
</div>
),
@@ -74,7 +57,7 @@ export const swpDocumentColumns: ColumnDef<SwpDocumentWithStats>[] = [
<div>
<div className="font-medium">{row.original.PROJ_NO}</div>
{row.original.PROJ_NM && (
- <div className="text-xs text-muted-foreground max-w-[150px]">
+ <div className="text-xs text-muted-foreground max-w-[150px] truncate">
{row.original.PROJ_NM}
</div>
)}
@@ -127,325 +110,25 @@ export const swpDocumentColumns: ColumnDef<SwpDocumentWithStats>[] = [
},
{
accessorKey: "LTST_REV_NO",
- header: "마지막 REV NO",
+ header: "최신 REV",
cell: ({ row }) => row.original.LTST_REV_NO || "-",
size: 80,
},
{
id: "stats",
- header: "REV/파일",
+ header: "파일",
cell: ({ row }) => (
<div className="text-center">
<div className="text-sm font-medium">
- {row.original.revision_count} / {row.original.file_count}
+ {row.original.fileCount}개
</div>
+ {row.original.standbyFileCount > 0 && (
+ <div className="text-xs text-yellow-600">
+ 대기중 {row.original.standbyFileCount}
+ </div>
+ )}
</div>
),
size: 100,
},
- {
- accessorKey: "last_synced_at",
- header: "동기화",
- cell: ({ row }) => (
- <div className="text-xs text-muted-foreground">
- {formatDistanceToNow(new Date(row.original.last_synced_at), {
- addSuffix: true,
- locale: ko,
- })}
- </div>
- ),
- size: 100,
- },
-];
-
-// ============================================================================
-// 리비전 컬럼 (서브 테이블용)
-// ============================================================================
-
-export interface RevisionRow {
- id: number;
- DOC_NO: string;
- REV_NO: string;
- STAGE: string;
- ACTV_NO: string | null;
- OFDC_NO: string | null;
- sync_status: "synced" | "pending" | "error";
- last_synced_at: Date;
- file_count: number;
-}
-
-export const swpRevisionColumns: ColumnDef<RevisionRow>[] = [
- {
- id: "expander",
- header: () => null,
- cell: ({ row }) => {
- return row.getCanExpand() ? (
- <Button
- variant="ghost"
- size="sm"
- className="h-8 w-8 p-0 ml-8"
- >
- {row.getIsExpanded() ? (
- <ChevronDown className="h-4 w-4" />
- ) : (
- <ChevronRight className="h-4 w-4" />
- )}
- </Button>
- ) : null;
- },
- size: 100,
- },
- {
- accessorKey: "REV_NO",
- header: "리비전",
- cell: ({ row }) => (
- <Badge variant="secondary" className="font-mono">
- REV {row.original.REV_NO}
- </Badge>
- ),
- size: 100,
- },
- {
- accessorKey: "STAGE",
- header: "스테이지",
- cell: ({ row }) => {
- const stage = row.original.STAGE;
- 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: 100,
- },
- {
- accessorKey: "OFDC_NO",
- header: "OFDC 번호",
- cell: ({ row }) => (
- <div className="font-mono text-sm">{row.original.OFDC_NO || "-"}</div>
- ),
- size: 200,
- },
- {
- accessorKey: "ACTV_NO",
- header: "Activity",
- cell: ({ row }) => (
- <div className="font-mono text-xs text-muted-foreground">
- {row.original.ACTV_NO || "-"}
- </div>
- ),
- size: 250,
- },
- {
- id: "file_count",
- header: "파일 수",
- cell: ({ row }) => (
- <div className="flex items-center gap-2">
- <FileIcon className="h-4 w-4 text-muted-foreground" />
- <span className="font-medium">{row.original.file_count}</span>
- </div>
- ),
- size: 100,
- },
- {
- accessorKey: "last_synced_at",
- header: "동기화",
- cell: ({ row }) => (
- <div className="text-xs text-muted-foreground">
- {formatDistanceToNow(new Date(row.original.last_synced_at), {
- addSuffix: true,
- locale: ko,
- })}
- </div>
- ),
- size: 100,
- },
-];
-
-// ============================================================================
-// 파일 컬럼 (서브 서브 테이블용)
-// ============================================================================
-
-export interface FileRow {
- id: number;
- FILE_NM: string;
- FILE_SEQ: string;
- FILE_SZ: string | null;
- FLD_PATH: string | null;
- STAT: string | null;
- STAT_NM: string | null;
- sync_status: "synced" | "pending" | "error";
- created_at: Date;
-}
-
-export const swpFileColumns: ColumnDef<FileRow>[] = [
- {
- id: "spacer",
- header: () => null,
- cell: () => <div className="w-16" />,
- size: 150,
- },
- {
- accessorKey: "FILE_SEQ",
- header: "순서",
- cell: ({ row }) => (
- <Badge variant="outline" className="font-mono">
- #{row.original.FILE_SEQ}
- </Badge>
- ),
- size: 80,
- },
- {
- accessorKey: "FILE_NM",
- header: "파일명",
- cell: ({ row }) => (
- <div className="flex items-center gap-2">
- <FileIcon className="h-4 w-4 text-blue-500" />
- <span className="font-mono text-sm">{row.original.FILE_NM}</span>
- </div>
- ),
- size: 400,
- },
- {
- accessorKey: "FILE_SZ",
- header: "크기",
- cell: ({ row }) => {
- const size = row.original.FILE_SZ;
- if (!size) return "-";
-
- const bytes = parseInt(size, 10);
- if (isNaN(bytes)) return size;
-
- const kb = bytes / 1024;
- const mb = kb / 1024;
-
- return mb >= 1
- ? `${mb.toFixed(2)} MB`
- : `${kb.toFixed(2)} KB`;
- },
- size: 100,
- },
- {
- accessorKey: "STAT_NM",
- header: "상태",
- cell: ({ row }) => {
- const status = row.original.STAT_NM;
- if (!status) return "-";
-
- const color = status === "Complete"
- ? "bg-green-100 text-green-800"
- : "bg-gray-100 text-gray-800";
-
- return (
- <Badge variant="outline" className={color}>
- {status}
- </Badge>
- );
- },
- size: 100,
- },
- {
- accessorKey: "FLD_PATH",
- header: "경로",
- cell: ({ row }) => (
- <div className="font-mono text-xs text-muted-foreground truncate max-w-[200px]" title={row.original.FLD_PATH || ""}>
- {row.original.FLD_PATH || "-"}
- </div>
- ),
- size: 200,
- },
- {
- accessorKey: "created_at",
- header: "생성일",
- cell: ({ row }) => (
- <div className="text-xs text-muted-foreground">
- {formatDistanceToNow(new Date(row.original.created_at), {
- addSuffix: true,
- locale: ko,
- })}
- </div>
- ),
- size: 100,
- },
- {
- id: "actions",
- header: "작업",
- cell: ({ row }) => (
- <DownloadButton fileId={row.original.id} fileName={row.original.FILE_NM} />
- ),
- size: 120,
- },
];
-
-// ============================================================================
-// 다운로드 버튼 컴포넌트: 임시 구성. Download.aspx 동작 안해서 일단 네트워크드라이브 사용하도록 처리
-// ============================================================================
-
-interface DownloadButtonProps {
- fileId: number;
- fileName: string;
-}
-
-function DownloadButton({ fileId, fileName }: DownloadButtonProps) {
- const [isDownloading, setIsDownloading] = useState(false);
-
- const handleDownload = async () => {
- try {
- setIsDownloading(true);
-
- // API Route 호출 (바이너리 직접 전송)
- const response = await fetch(`/api/swp/download/${fileId}`);
-
- if (!response.ok) {
- const errorData = await response.json().catch(() => ({ error: "다운로드 실패" }));
- toast.error(errorData.error || "파일 다운로드 실패");
- return;
- }
-
- // Blob 생성 및 다운로드
- const blob = await response.blob();
- const url = window.URL.createObjectURL(blob);
- const link = document.createElement("a");
- link.href = url;
- link.download = fileName;
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
- window.URL.revokeObjectURL(url);
-
- toast.success(`파일 다운로드 완료: ${fileName}`);
- } catch (error) {
- console.error("다운로드 오류:", error);
- toast.error("파일 다운로드 중 오류가 발생했습니다.");
- } finally {
- setIsDownloading(false);
- }
- };
-
- return (
- <Button
- variant="outline"
- size="sm"
- onClick={handleDownload}
- disabled={isDownloading}
- >
- {isDownloading ? (
- <>
- <Loader2 className="h-4 w-4 mr-1 animate-spin" />
- 다운로드 중...
- </>
- ) : (
- <>
- <Download className="h-4 w-4 mr-1" />
- 다운로드
- </>
- )}
- </Button>
- );
-}
-
diff --git a/lib/swp/table/swp-table-toolbar.tsx b/lib/swp/table/swp-table-toolbar.tsx
index fefff091..0fd29fd3 100644
--- a/lib/swp/table/swp-table-toolbar.tsx
+++ b/lib/swp/table/swp-table-toolbar.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState, useTransition, useMemo } from "react";
+import { useState, useTransition, useMemo, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
@@ -9,94 +9,137 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { Label } from "@/components/ui/label";
-import { RefreshCw, Search, X, Check, ChevronsUpDown, Upload } from "lucide-react";
-import { syncSwpProjectAction, type SwpTableFilters } from "../actions";
+import { Search, X, Check, ChevronsUpDown, Upload, RefreshCw } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
-import { useRouter } from "next/navigation";
import { cn } from "@/lib/utils";
-import { useRef } from "react";
import { SwpUploadHelpDialog } from "./swp-help-dialog";
import { SwpUploadResultDialog } from "./swp-upload-result-dialog";
+import {
+ SwpUploadValidationDialog,
+ validateFileName
+} from "./swp-upload-validation-dialog";
+import { SwpUploadedFilesDialog } from "./swp-uploaded-files-dialog";
+
+interface SwpTableFilters {
+ docNo?: string;
+ docTitle?: string;
+ pkgNo?: string;
+ stage?: string;
+}
interface SwpTableToolbarProps {
+ projNo: string;
filters: SwpTableFilters;
+ onProjNoChange: (projNo: string) => void;
onFiltersChange: (filters: SwpTableFilters) => void;
- projects?: Array<{ PROJ_NO: string; PROJ_NM: string }>;
- vendorCode?: string; // 벤더가 접속했을 때 고정할 벤더 코드
+ onRefresh: () => void;
+ isRefreshing: boolean;
+ projects?: Array<{ PROJ_NO: string; PROJ_NM: string | null }>;
+ vendorCode?: string;
+ droppedFiles?: File[];
+ onFilesProcessed?: () => void;
+ documents?: Array<{ DOC_NO: string }>; // 업로드 권한 검증용 문서 목록
+ userId?: string; // 파일 취소 시 필요
}
export function SwpTableToolbar({
+ projNo,
filters,
+ onProjNoChange,
onFiltersChange,
+ onRefresh,
+ isRefreshing,
projects = [],
vendorCode,
+ droppedFiles = [],
+ onFilesProcessed,
+ documents = [],
+ userId,
}: SwpTableToolbarProps) {
- const [isSyncing, startSync] = useTransition();
const [isUploading, startUpload] = useTransition();
- const [localFilters, setLocalFilters] = useState<SwpTableFilters>(filters);
+ const [localFilters, setLocalFilters] = useState(filters);
const { toast } = useToast();
- const router = useRouter();
const [projectSearchOpen, setProjectSearchOpen] = useState(false);
const [projectSearch, setProjectSearch] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploadResults, setUploadResults] = useState<Array<{ fileName: string; success: boolean; error?: string }>>([]);
const [showResultDialog, setShowResultDialog] = useState(false);
+
+ // 검증 다이얼로그 상태
+ const [validationResults, setValidationResults] = useState<Array<{
+ file: File;
+ valid: boolean;
+ parsed?: {
+ ownDocNo: string;
+ revNo: string;
+ stage: string;
+ fileName: string;
+ extension: string;
+ };
+ error?: string;
+ }>>([]);
+ const [showValidationDialog, setShowValidationDialog] = useState(false);
- // 동기화 핸들러
- const handleSync = () => {
- const projectNo = localFilters.projNo;
-
- if (!projectNo) {
- toast({
- variant: "destructive",
- title: "프로젝트 선택 필요",
- description: "동기화할 프로젝트를 먼저 선택해주세요.",
- });
- return;
- }
+ /**
+ * 업로드 가능한 문서번호 목록 추출
+ */
+ const availableDocNos = useMemo(() => {
+ return documents.map(doc => doc.DOC_NO);
+ }, [documents]);
- startSync(async () => {
- try {
+ /**
+ * 벤더 모드 여부 (벤더 코드가 있으면 벤더 모드)
+ */
+ const isVendorMode = !!vendorCode;
+
+ /**
+ * 드롭된 파일 처리 - useEffect로 감지하여 자동 검증
+ */
+ useEffect(() => {
+ if (droppedFiles.length > 0) {
+ // 프로젝트와 벤더 코드 검증
+ if (!projNo) {
toast({
- title: "동기화 시작",
- description: `프로젝트 ${projectNo} 동기화를 시작합니다...`,
+ variant: "destructive",
+ title: "프로젝트 선택 필요",
+ description: "파일을 업로드할 프로젝트를 먼저 선택해주세요.",
});
+ onFilesProcessed?.();
+ return;
+ }
- const result = await syncSwpProjectAction(projectNo, "V");
-
- if (result.success) {
- toast({
- title: "동기화 완료",
- description: `문서 ${result.stats.documents.total}개, 파일 ${result.stats.files.total}개 동기화 완료`,
- });
-
- // 페이지 새로고침
- router.refresh();
- } else {
- throw new Error(result.errors.join(", "));
- }
- } catch (error) {
- console.error("동기화 실패:", error);
+ if (!vendorCode) {
toast({
variant: "destructive",
- title: "동기화 실패",
- description: error instanceof Error ? error.message : "알 수 없는 오류",
+ title: "업체 코드 오류",
+ description: "벤더 정보를 가져올 수 없습니다.",
});
+ onFilesProcessed?.();
+ return;
}
- });
- };
+
+ // 파일명 검증 (문서번호 권한 포함)
+ const results = droppedFiles.map((file) => {
+ const validation = validateFileName(file.name, availableDocNos, isVendorMode);
+ return {
+ file,
+ valid: validation.valid,
+ parsed: validation.parsed,
+ error: validation.error,
+ };
+ });
+
+ setValidationResults(results);
+ setShowValidationDialog(true);
+ onFilesProcessed?.();
+ }
+ }, [droppedFiles, projNo, vendorCode, toast, onFilesProcessed, availableDocNos, isVendorMode]);
/**
* 파일 업로드 핸들러
- * 1) 네트워크 드라이브에 정해진 규칙대로, 파일이름 기반으로 파일 업로드하기
- * 2) 1~N개 파일 받아서, 파일 이름 기준으로 파싱해서 SaveInBoxList API를 통해 업로드 처리
- */
+ */
const handleUploadFiles = () => {
- // 프로젝트와 벤더 코드 체크
- const projectNo = localFilters.projNo;
- const vndrCd = vendorCode || localFilters.vndrCd;
-
- if (!projectNo) {
+ if (!projNo) {
toast({
variant: "destructive",
title: "프로젝트 선택 필요",
@@ -105,48 +148,66 @@ export function SwpTableToolbar({
return;
}
- if (!vndrCd) {
+ if (!vendorCode) {
toast({
variant: "destructive",
- title: "업체 코드 입력 필요",
- description: "파일을 업로드할 업체 코드를 입력해주세요.",
+ title: "업체 코드 오류",
+ description: "벤더 정보를 가져올 수 없습니다.",
});
return;
}
- // 파일 선택 다이얼로그 열기
fileInputRef.current?.click();
};
/**
- * 파일 선택 핸들러
+ * 파일 선택 핸들러 - 검증만 수행
*/
- const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
+ const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = event.target.files;
if (!selectedFiles || selectedFiles.length === 0) {
return;
}
- const projectNo = localFilters.projNo!;
- const vndrCd = vendorCode || localFilters.vndrCd!;
+ // 각 파일의 파일명 검증 (문서번호 권한 포함)
+ const results = Array.from(selectedFiles).map((file) => {
+ const validation = validateFileName(file.name, availableDocNos, isVendorMode);
+ return {
+ file,
+ valid: validation.valid,
+ parsed: validation.parsed,
+ error: validation.error,
+ };
+ });
+
+ setValidationResults(results);
+ setShowValidationDialog(true);
+
+ // input 초기화 (같은 파일 재선택 가능하도록)
+ if (fileInputRef.current) {
+ fileInputRef.current.value = "";
+ }
+ };
+ /**
+ * 검증 완료 후 실제 업로드 실행
+ */
+ const handleConfirmUpload = async (validFiles: File[]) => {
startUpload(async () => {
try {
toast({
title: "파일 업로드 시작",
- description: `${selectedFiles.length}개 파일을 업로드합니다...`,
+ description: `${validFiles.length}개 파일을 업로드합니다...`,
});
- // FormData 생성 (바이너리 직접 전송)
const formData = new FormData();
- formData.append("projNo", projectNo);
- formData.append("vndrCd", vndrCd);
-
- Array.from(selectedFiles).forEach((file) => {
+ formData.append("projNo", projNo);
+ formData.append("vndrCd", vendorCode!);
+
+ validFiles.forEach((file) => {
formData.append("files", file);
});
- // API Route 호출
const response = await fetch("/api/swp/upload", {
method: "POST",
body: formData,
@@ -158,31 +219,31 @@ export function SwpTableToolbar({
const result = await response.json();
- // 결과 저장 및 다이얼로그 표시
+ // 검증 다이얼로그 닫기
+ setShowValidationDialog(false);
+
+ // 결과 다이얼로그 표시
setUploadResults(result.details || []);
setShowResultDialog(true);
- // 성공한 파일이 있으면 페이지 새로고침
- if (result.successCount > 0) {
- router.refresh();
- }
+ toast({
+ title: result.success ? "업로드 완료" : "일부 업로드 실패",
+ description: result.message,
+ });
} catch (error) {
console.error("파일 업로드 실패:", error);
-
- // 예외 발생 시에도 결과 다이얼로그 표시
- const errorResults = Array.from(selectedFiles).map((file) => ({
+
+ // 검증 다이얼로그 닫기
+ setShowValidationDialog(false);
+
+ const errorResults = validFiles.map((file) => ({
fileName: file.name,
success: false,
error: error instanceof Error ? error.message : "알 수 없는 오류",
}));
-
+
setUploadResults(errorResults);
setShowResultDialog(true);
- } finally {
- // 파일 입력 초기화
- if (fileInputRef.current) {
- fileInputRef.current.value = "";
- }
}
});
};
@@ -194,7 +255,12 @@ export function SwpTableToolbar({
// 검색 초기화
const handleReset = () => {
- const resetFilters: SwpTableFilters = {};
+ const resetFilters: SwpTableFilters = {
+ docNo: "",
+ docTitle: "",
+ pkgNo: "",
+ stage: "",
+ };
setLocalFilters(resetFilters);
onFiltersChange(resetFilters);
};
@@ -202,17 +268,28 @@ export function SwpTableToolbar({
// 프로젝트 필터링
const filteredProjects = useMemo(() => {
if (!projectSearch) return projects;
-
+
const search = projectSearch.toLowerCase();
return projects.filter(
(proj) =>
proj.PROJ_NO.toLowerCase().includes(search) ||
- proj.PROJ_NM.toLowerCase().includes(search)
+ (proj.PROJ_NM?.toLowerCase().includes(search) ?? false)
);
}, [projects, projectSearch]);
return (
<>
+ {/* 업로드 검증 다이얼로그 */}
+ <SwpUploadValidationDialog
+ open={showValidationDialog}
+ onOpenChange={setShowValidationDialog}
+ validationResults={validationResults}
+ onConfirmUpload={handleConfirmUpload}
+ isUploading={isUploading}
+ availableDocNos={availableDocNos}
+ isVendorMode={isVendorMode}
+ />
+
{/* 업로드 결과 다이얼로그 */}
<SwpUploadResultDialog
open={showResultDialog}
@@ -220,240 +297,205 @@ export function SwpTableToolbar({
results={uploadResults}
/>
- <div className="space-y-4">
+ <div className="space-y-4 w-full">
{/* 상단 액션 바 */}
- <div className="flex items-center justify-between">
- <div className="flex items-center gap-2">
+ {vendorCode && (
+ <div className="flex items-center justify-end gap-2">
+ <input
+ ref={fileInputRef}
+ type="file"
+ multiple
+ className="hidden"
+ onChange={handleFileChange}
+ accept="*/*"
+ />
<Button
- onClick={handleSync}
- disabled={isSyncing || !localFilters.projNo}
+ variant="outline"
size="sm"
+ onClick={onRefresh}
+ disabled={isRefreshing || !projNo}
>
- <RefreshCw className={`h-4 w-4 mr-2 ${isSyncing ? "animate-spin" : ""}`} />
- {isSyncing ? "동기화 중..." : "SWP 동기화"}
+ <RefreshCw className={`h-4 w-4 mr-2 ${isRefreshing ? "animate-spin" : ""}`} />
+ 새로고침
</Button>
- </div>
-
- <div className="flex items-center gap-2">
- {/* 벤더만 파일 업로드 기능 사용 가능 */}
- {vendorCode && (
- <>
- <input
- ref={fileInputRef}
- type="file"
- multiple
- className="hidden"
- onChange={handleFileChange}
- accept="*/*"
- />
- <Button
- variant="outline"
- size="sm"
- onClick={handleUploadFiles}
- disabled={isUploading || !localFilters.projNo || (!vendorCode && !localFilters.vndrCd)}
- >
- <Upload className={`h-4 w-4 mr-2 ${isUploading ? "animate-pulse" : ""}`} />
- {isUploading ? "업로드 중..." : "파일 업로드"}
- </Button>
-
- <SwpUploadHelpDialog />
- </>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleUploadFiles}
+ disabled={isUploading || !projNo}
+ >
+ <Upload className={`h-4 w-4 mr-2 ${isUploading ? "animate-pulse" : ""}`} />
+ {isUploading ? "업로드 중..." : "파일 업로드"}
+ </Button>
+
+ {userId && (
+ <SwpUploadedFilesDialog
+ projNo={projNo}
+ vndrCd={vendorCode}
+ userId={userId}
+ />
)}
+
+ <SwpUploadHelpDialog />
</div>
- </div>
+ )}
- {/* 검색 필터 */}
- <div className="rounded-lg border p-4 space-y-4">
- <div className="flex items-center justify-between">
- <h3 className="text-sm font-semibold">검색 필터</h3>
- <Button
- variant="ghost"
- size="sm"
- onClick={handleReset}
- className="h-8"
- >
- <X className="h-4 w-4 mr-1" />
- 초기화
- </Button>
- </div>
+ {/* 검색 필터 */}
+ <div className="rounded-lg border p-4 space-y-4">
+ <div className="flex items-center justify-between">
+ <h3 className="text-sm font-semibold">검색 필터</h3>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={handleReset}
+ className="h-8"
+ >
+ <X className="h-4 w-4 mr-1" />
+ 초기화
+ </Button>
+ </div>
- <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
- {/* 프로젝트 번호 */}
- <div className="space-y-2">
- <Label htmlFor="projNo">프로젝트 번호</Label>
- {projects.length > 0 ? (
- <Popover open={projectSearchOpen} onOpenChange={setProjectSearchOpen}>
- <PopoverTrigger asChild>
- <Button
- variant="outline"
- role="combobox"
- aria-expanded={projectSearchOpen}
- className="w-full justify-between"
- >
- {localFilters.projNo ? (
- <span>
- {projects.find((p) => p.PROJ_NO === localFilters.projNo)?.PROJ_NO || localFilters.projNo}
- {" ["}
- {projects.find((p) => p.PROJ_NO === localFilters.projNo)?.PROJ_NM}
- {"]"}
- </span>
- ) : (
- <span className="text-muted-foreground">프로젝트 선택</span>
- )}
- <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
- </Button>
- </PopoverTrigger>
- <PopoverContent className="w-[400px] p-0" align="start">
- <div className="p-2">
- <div className="flex items-center border rounded-md px-3">
- <Search className="h-4 w-4 mr-2 opacity-50" />
- <Input
- placeholder="프로젝트 번호 또는 이름으로 검색..."
- value={projectSearch}
- onChange={(e) => setProjectSearch(e.target.value)}
- className="border-0 focus-visible:ring-0 focus-visible:ring-offset-0"
- />
- </div>
- </div>
- <div className="max-h-[300px] overflow-y-auto">
- <div className="p-1">
- <Button
- variant="ghost"
- className="w-full justify-start font-normal"
- onClick={() => {
- setLocalFilters({ ...localFilters, projNo: undefined });
- setProjectSearchOpen(false);
- setProjectSearch("");
- }}
- >
- <Check
- className={cn(
- "mr-2 h-4 w-4",
- !localFilters.projNo ? "opacity-100" : "opacity-0"
- )}
- />
- 전체
- </Button>
- {filteredProjects.map((proj) => (
- <Button
- key={proj.PROJ_NO}
- variant="ghost"
- className="w-full justify-start font-normal"
- onClick={() => {
- setLocalFilters({ ...localFilters, projNo: proj.PROJ_NO });
- setProjectSearchOpen(false);
- setProjectSearch("");
- }}
- >
- <Check
- className={cn(
- "mr-2 h-4 w-4",
- localFilters.projNo === proj.PROJ_NO ? "opacity-100" : "opacity-0"
- )}
- />
- <span className="font-mono text-sm">{proj.PROJ_NO} [{proj.PROJ_NM}]</span>
- </Button>
- ))}
- {filteredProjects.length === 0 && (
- <div className="py-6 text-center text-sm text-muted-foreground">
- 검색 결과가 없습니다.
- </div>
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
+ {/* 프로젝트 번호 */}
+ <div className="space-y-2">
+ <Label htmlFor="projNo">프로젝트 번호</Label>
+ {projects.length > 0 ? (
+ <Popover open={projectSearchOpen} onOpenChange={setProjectSearchOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={projectSearchOpen}
+ className="w-full justify-between"
+ >
+ {projNo ? (
+ <span>
+ {projects.find((p) => p.PROJ_NO === projNo)?.PROJ_NO || projNo}
+ {" ["}
+ {projects.find((p) => p.PROJ_NO === projNo)?.PROJ_NM}
+ {"]"}
+ </span>
+ ) : (
+ <span className="text-muted-foreground">프로젝트 선택</span>
)}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0" align="start">
+ <div className="p-2">
+ <div className="flex items-center border rounded-md px-3">
+ <Search className="h-4 w-4 mr-2 opacity-50" />
+ <Input
+ placeholder="프로젝트 번호 또는 이름으로 검색..."
+ value={projectSearch}
+ onChange={(e) => setProjectSearch(e.target.value)}
+ className="border-0 focus-visible:ring-0 focus-visible:ring-offset-0"
+ />
+ </div>
+ </div>
+ <div className="max-h-[300px] overflow-y-auto">
+ <div className="p-1">
+ {filteredProjects.map((proj) => (
+ <Button
+ key={proj.PROJ_NO}
+ variant="ghost"
+ className="w-full justify-start font-normal"
+ onClick={() => {
+ onProjNoChange(proj.PROJ_NO);
+ setProjectSearchOpen(false);
+ setProjectSearch("");
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ projNo === proj.PROJ_NO ? "opacity-100" : "opacity-0"
+ )}
+ />
+ <span className="font-mono text-sm">{proj.PROJ_NO} [{proj.PROJ_NM || ""}]</span>
+ </Button>
+ ))}
+ {filteredProjects.length === 0 && (
+ <div className="py-6 text-center text-sm text-muted-foreground">
+ 검색 결과가 없습니다.
+ </div>
+ )}
+ </div>
</div>
- </div>
- </PopoverContent>
- </Popover>
- ) : (
+ </PopoverContent>
+ </Popover>
+ ) : (
+ <Input
+ id="projNo"
+ placeholder="계약된 프로젝트가 없습니다"
+ value={projNo}
+ disabled
+ className="bg-muted"
+ />
+ )}
+ </div>
+
+ {/* 문서 번호 */}
+ <div className="space-y-2">
+ <Label htmlFor="docNo">문서 번호</Label>
<Input
- id="projNo"
- placeholder="계약된 프로젝트가 없습니다"
- value={localFilters.projNo || ""}
+ id="docNo"
+ placeholder="문서 번호 검색"
+ value={localFilters.docNo || ""}
onChange={(e) =>
- setLocalFilters({ ...localFilters, projNo: e.target.value })
+ setLocalFilters({ ...localFilters, docNo: e.target.value })
}
- disabled
- className="bg-muted"
/>
- )}
- </div>
-
- {/* 문서 번호 */}
- <div className="space-y-2">
- <Label htmlFor="docNo">문서 번호</Label>
- <Input
- id="docNo"
- placeholder="문서 번호 검색"
- value={localFilters.docNo || ""}
- onChange={(e) =>
- setLocalFilters({ ...localFilters, docNo: e.target.value })
- }
- />
- </div>
+ </div>
- {/* 문서 제목 */}
- <div className="space-y-2">
- <Label htmlFor="docTitle">문서 제목</Label>
- <Input
- id="docTitle"
- placeholder="제목 검색"
- value={localFilters.docTitle || ""}
- onChange={(e) =>
- setLocalFilters({ ...localFilters, docTitle: e.target.value })
- }
- />
- </div>
+ {/* 문서 제목 */}
+ <div className="space-y-2">
+ <Label htmlFor="docTitle">문서 제목</Label>
+ <Input
+ id="docTitle"
+ placeholder="제목 검색"
+ value={localFilters.docTitle || ""}
+ onChange={(e) =>
+ setLocalFilters({ ...localFilters, docTitle: e.target.value })
+ }
+ />
+ </div>
- {/* 패키지 번호 */}
- <div className="space-y-2">
- <Label htmlFor="pkgNo">패키지</Label>
- <Input
- id="pkgNo"
- placeholder="패키지 번호"
- value={localFilters.pkgNo || ""}
- onChange={(e) =>
- setLocalFilters({ ...localFilters, pkgNo: e.target.value })
- }
- />
- </div>
+ {/* 패키지 번호 */}
+ <div className="space-y-2">
+ <Label htmlFor="pkgNo">패키지</Label>
+ <Input
+ id="pkgNo"
+ placeholder="패키지 번호"
+ value={localFilters.pkgNo || ""}
+ onChange={(e) =>
+ setLocalFilters({ ...localFilters, pkgNo: e.target.value })
+ }
+ />
+ </div>
- {/* 업체 코드 */}
- <div className="space-y-2">
- <Label htmlFor="vndrCd">업체 코드</Label>
- <Input
- id="vndrCd"
- placeholder="업체 코드"
- value={vendorCode || localFilters.vndrCd || ""}
- onChange={(e) =>
- setLocalFilters({ ...localFilters, vndrCd: e.target.value })
- }
- disabled={!!vendorCode} // 벤더 코드가 제공되면 입력 비활성화
- className={vendorCode ? "bg-muted" : ""}
- />
+ {/* 스테이지 */}
+ <div className="space-y-2">
+ <Label htmlFor="stage">스테이지</Label>
+ <Input
+ id="stage"
+ placeholder="스테이지 입력 (예: IFC, IFA)"
+ value={localFilters.stage || ""}
+ onChange={(e) =>
+ setLocalFilters({ ...localFilters, stage: e.target.value })
+ }
+ />
+ </div>
</div>
- {/* 스테이지 */}
- <div className="space-y-2">
- <Label htmlFor="stage">스테이지</Label>
- <Input
- id="stage"
- placeholder="스테이지 입력"
- value={localFilters.stage || ""}
- onChange={(e) =>
- setLocalFilters({ ...localFilters, stage: e.target.value })
- }
- />
+ <div className="flex justify-end">
+ <Button onClick={handleSearch} size="sm">
+ <Search className="h-4 w-4 mr-2" />
+ 검색
+ </Button>
</div>
</div>
-
- <div className="flex justify-end">
- <Button onClick={handleSearch} size="sm">
- <Search className="h-4 w-4 mr-2" />
- 검색
- </Button>
- </div>
- </div>
</div>
</>
);
}
-
diff --git a/lib/swp/table/swp-table.tsx b/lib/swp/table/swp-table.tsx
index 47c9905a..7918c07e 100644
--- a/lib/swp/table/swp-table.tsx
+++ b/lib/swp/table/swp-table.tsx
@@ -4,9 +4,7 @@ import React, { useState } from "react";
import {
useReactTable,
getCoreRowModel,
- getExpandedRowModel,
flexRender,
- ExpandedState,
} from "@tanstack/react-table";
import {
Table,
@@ -17,116 +15,37 @@ import {
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
-import { swpDocumentColumns, type RevisionRow, type FileRow } from "./swp-table-columns";
-import { fetchDocumentRevisions, fetchRevisionFiles, type SwpDocumentWithStats } from "../actions";
-import { SwpRevisionListDialog } from "./swp-revision-list-dialog";
+import { ChevronRight } from "lucide-react";
+import { swpDocumentColumns } from "./swp-table-columns";
+import { SwpDocumentDetailDialog } from "./swp-document-detail-dialog";
+import type { DocumentListItem } from "@/lib/swp/document-service";
interface SwpTableProps {
- initialData: SwpDocumentWithStats[];
- total: number;
- page: number;
- pageSize: number;
- totalPages: number;
- onPageChange: (page: number) => void;
+ documents: DocumentListItem[];
+ projNo: string;
+ vendorCode: string;
+ userId: string;
}
export function SwpTable({
- initialData,
- total,
- page,
- pageSize,
- totalPages,
- onPageChange,
+ documents,
+ projNo,
+ vendorCode,
+ userId,
}: SwpTableProps) {
- const [expanded, setExpanded] = useState<ExpandedState>({});
- const [revisionData, setRevisionData] = useState<Record<string, RevisionRow[]>>({});
- const [fileData, setFileData] = useState<Record<number, FileRow[]>>({});
- const [loadingRevisions, setLoadingRevisions] = useState<Set<string>>(new Set());
- const [loadingFiles, setLoadingFiles] = useState<Set<number>>(new Set());
const [dialogOpen, setDialogOpen] = useState(false);
- const [selectedDocument, setSelectedDocument] = useState<SwpDocumentWithStats | null>(null);
+ const [selectedDocument, setSelectedDocument] = useState<DocumentListItem | null>(null);
const table = useReactTable({
- data: initialData,
+ data: documents,
columns: swpDocumentColumns,
- state: {
- expanded,
- },
- onExpandedChange: setExpanded,
getCoreRowModel: getCoreRowModel(),
- getExpandedRowModel: getExpandedRowModel(),
- getRowCanExpand: () => true, // 모든 문서는 확장 가능
});
- // 리비전 로드
- const loadRevisions = async (docNo: string) => {
- if (revisionData[docNo]) return; // 이미 로드됨
-
- setLoadingRevisions((prev) => {
- const newSet = new Set(prev);
- newSet.add(docNo);
- return newSet;
- });
-
- try {
- const revisions = await fetchDocumentRevisions(docNo);
- setRevisionData((prev) => ({ ...prev, [docNo]: revisions }));
- } catch (error) {
- console.error("리비전 로드 실패:", error);
- } finally {
- setLoadingRevisions((prev) => {
- const next = new Set(prev);
- next.delete(docNo);
- return next;
- });
- }
- };
-
- // 파일 로드
- const loadFiles = async (revisionId: number) => {
- if (fileData[revisionId]) return; // 이미 로드됨
-
- setLoadingFiles((prev) => {
- const newSet = new Set(prev);
- newSet.add(revisionId);
- return newSet;
- });
-
- try {
- const files = await fetchRevisionFiles(revisionId);
- setFileData((prev) => ({ ...prev, [revisionId]: files }));
- } catch (error) {
- console.error("파일 로드 실패:", error);
- } finally {
- setLoadingFiles((prev) => {
- const next = new Set(prev);
- next.delete(revisionId);
- return next;
- });
- }
- };
-
// 문서 클릭 핸들러 - Dialog 열기
- const handleDocumentClick = async (document: SwpDocumentWithStats) => {
+ const handleDocumentClick = (document: DocumentListItem) => {
setSelectedDocument(document);
setDialogOpen(true);
-
- // 리비전 데이터 로드
- if (!revisionData[document.DOC_NO]) {
- await loadRevisions(document.DOC_NO);
- }
- };
-
- // 모든 리비전의 파일을 로드
- const loadAllFiles = async (docNo: string) => {
- const revisions = revisionData[docNo];
- if (!revisions) return;
-
- for (const revision of revisions) {
- if (!fileData[revision.id]) {
- await loadFiles(revision.id);
- }
- }
};
return (
@@ -153,31 +72,28 @@ export function SwpTable({
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
- <React.Fragment key={row.id}>
- {/* 문서 행 */}
- <TableRow
- data-state={row.getIsSelected() && "selected"}
- className="hover:bg-muted/50"
- >
- {row.getVisibleCells().map((cell) => (
- <TableCell key={cell.id}>
- {cell.column.id === "expander" ? (
- <div
- onClick={() => handleDocumentClick(row.original)}
- className="cursor-pointer"
- >
- {flexRender(
- cell.column.columnDef.cell,
- cell.getContext()
- )}
- </div>
- ) : (
- flexRender(cell.column.columnDef.cell, cell.getContext())
- )}
- </TableCell>
- ))}
- </TableRow>
- </React.Fragment>
+ <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}>
+ {cell.column.id === "expander" ? (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-8 w-8 p-0"
+ >
+ <ChevronRight className="h-4 w-4" />
+ </Button>
+ ) : (
+ flexRender(cell.column.columnDef.cell, cell.getContext())
+ )}
+ </TableCell>
+ ))}
+ </TableRow>
))
) : (
<TableRow>
@@ -190,46 +106,14 @@ export function SwpTable({
</Table>
</div>
- {/* 페이지네이션 */}
- <div className="flex items-center justify-between">
- <div className="text-sm text-muted-foreground">
- 총 {total}개 중 {(page - 1) * pageSize + 1}-
- {Math.min(page * pageSize, total)}개 표시
- </div>
- <div className="flex items-center space-x-2">
- <Button
- variant="outline"
- size="sm"
- onClick={() => onPageChange(page - 1)}
- disabled={page === 1}
- >
- 이전
- </Button>
- <div className="text-sm">
- {page} / {totalPages}
- </div>
- <Button
- variant="outline"
- size="sm"
- onClick={() => onPageChange(page + 1)}
- disabled={page === totalPages}
- >
- 다음
- </Button>
- </div>
- </div>
-
{/* 문서 상세 Dialog */}
- <SwpRevisionListDialog
+ <SwpDocumentDetailDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
document={selectedDocument}
- revisions={selectedDocument ? revisionData[selectedDocument.DOC_NO] || [] : []}
- fileData={fileData}
- loadingRevisions={selectedDocument ? loadingRevisions.has(selectedDocument.DOC_NO) : false}
- loadingFiles={loadingFiles}
- onLoadFiles={loadFiles}
- onLoadAllFiles={() => selectedDocument && loadAllFiles(selectedDocument.DOC_NO)}
+ projNo={projNo}
+ vendorCode={vendorCode}
+ userId={userId}
/>
</div>
);
diff --git a/lib/swp/table/swp-upload-validation-dialog.tsx b/lib/swp/table/swp-upload-validation-dialog.tsx
new file mode 100644
index 00000000..2d17e041
--- /dev/null
+++ b/lib/swp/table/swp-upload-validation-dialog.tsx
@@ -0,0 +1,373 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Badge } from "@/components/ui/badge";
+import { CheckCircle2, XCircle, AlertCircle, Upload } from "lucide-react";
+import { ScrollArea } from "@/components/ui/scroll-area";
+
+interface FileValidationResult {
+ file: File;
+ valid: boolean;
+ parsed?: {
+ ownDocNo: string;
+ revNo: string;
+ stage: string;
+ fileName: string;
+ extension: string;
+ };
+ error?: string;
+}
+
+interface SwpUploadValidationDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ validationResults: FileValidationResult[];
+ onConfirmUpload: (validFiles: File[]) => void;
+ isUploading: boolean;
+ availableDocNos?: string[]; // 업로드 가능한 문서번호 목록
+ isVendorMode?: boolean; // 벤더 모드인지 여부 (문서번호 검증 필수)
+}
+
+/**
+ * 파일명 검증 함수 (클라이언트 사이드)
+ * 형식: [DOC_NO]_[REV_NO]_[STAGE].[확장자] 또는 [DOC_NO]_[REV_NO]_[STAGE]_[자유-파일명].[확장자]
+ * 자유 파일명은 선택사항이며, 포함될 경우 언더스코어를 포함할 수 있음
+ * @param fileName 검증할 파일명
+ * @param availableDocNos 업로드 가능한 문서번호 목록 (선택)
+ * @param isVendorMode 벤더 모드인지 여부 (true인 경우 문서번호 검증 필수)
+ */
+export function validateFileName(
+ fileName: string,
+ availableDocNos?: string[],
+ isVendorMode?: boolean
+): {
+ valid: boolean;
+ parsed?: {
+ ownDocNo: string;
+ revNo: string;
+ stage: string;
+ fileName: string;
+ extension: string;
+ };
+ error?: string;
+} {
+ try {
+ // 확장자 분리
+ const lastDotIndex = fileName.lastIndexOf(".");
+ if (lastDotIndex === -1) {
+ return {
+ valid: false,
+ error: "파일 확장자가 없습니다",
+ };
+ }
+
+ const extension = fileName.substring(lastDotIndex + 1);
+ const nameWithoutExt = fileName.substring(0, lastDotIndex);
+
+ // 언더스코어로 분리
+ const parts = nameWithoutExt.split("_");
+
+ // 최소 3개 파트 필요: docNo, revNo, stage (fileName은 선택사항)
+ if (parts.length < 3) {
+ return {
+ valid: false,
+ error: `언더스코어(_)가 최소 2개 있어야 합니다 (현재: ${parts.length - 1}개). 형식: [DOC_NO]_[REV_NO]_[STAGE].[확장자]`,
+ };
+ }
+
+ // 앞에서부터 3개는 고정: docNo, revNo, stage
+ const ownDocNo = parts[0];
+ const revNo = parts[1];
+ const stage = parts[2];
+
+ // 나머지는 자유 파일명 (선택사항, 언더스코어 포함 가능)
+ const customFileName = parts.length > 3 ? parts.slice(3).join("_") : "";
+
+ // 필수 항목이 비어있지 않은지 확인
+ if (!ownDocNo || ownDocNo.trim() === "") {
+ return {
+ valid: false,
+ error: "문서번호(DOC_NO)가 비어있습니다",
+ };
+ }
+
+ if (!revNo || revNo.trim() === "") {
+ return {
+ valid: false,
+ error: "리비전 번호(REV_NO)가 비어있습니다",
+ };
+ }
+
+ if (!stage || stage.trim() === "") {
+ return {
+ valid: false,
+ error: "스테이지(STAGE)가 비어있습니다",
+ };
+ }
+
+ // 문서번호 검증 (벤더 모드에서는 필수)
+ if (isVendorMode) {
+ const trimmedDocNo = ownDocNo.trim();
+
+ // 벤더 모드에서 문서 목록이 비어있으면 에러
+ if (!availableDocNos || availableDocNos.length === 0) {
+ return {
+ valid: false,
+ error: "할당된 문서가 없거나 문서 목록 로드에 실패했습니다. 페이지를 새로고침하거나 관리자에게 문의하세요.",
+ };
+ }
+
+ // 문서번호가 목록에 없으면 에러
+ if (!availableDocNos.includes(trimmedDocNo)) {
+ return {
+ valid: false,
+ error: `문서번호 '${trimmedDocNo}'는 업로드 권한이 없습니다. 할당된 문서번호를 확인해주세요.`,
+ };
+ }
+ }
+
+ return {
+ valid: true,
+ parsed: {
+ ownDocNo: ownDocNo.trim(),
+ revNo: revNo.trim(),
+ stage: stage.trim(),
+ fileName: customFileName.trim(),
+ extension,
+ },
+ };
+ } catch (error) {
+ return {
+ valid: false,
+ error: error instanceof Error ? error.message : "알 수 없는 오류",
+ };
+ }
+}
+
+/**
+ * 업로드 전 파일 검증 다이얼로그
+ */
+export function SwpUploadValidationDialog({
+ open,
+ onOpenChange,
+ validationResults,
+ onConfirmUpload,
+ isUploading,
+ availableDocNos = [],
+ isVendorMode = false,
+}: SwpUploadValidationDialogProps) {
+ const validFiles = validationResults.filter((r) => r.valid);
+ const invalidFiles = validationResults.filter((r) => !r.valid);
+
+ const handleUpload = () => {
+ if (validFiles.length > 0) {
+ onConfirmUpload(validFiles.map((r) => r.file));
+ }
+ };
+
+ const handleCancel = () => {
+ if (!isUploading) {
+ onOpenChange(false);
+ }
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-3xl max-h-[80vh]">
+ <DialogHeader>
+ <DialogTitle>파일 업로드 검증</DialogTitle>
+ <DialogDescription>
+ 선택한 파일의 파일명 형식을 검증합니다
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ {/* 요약 통계 */}
+ <div className="grid grid-cols-3 gap-4">
+ <div className="rounded-lg border p-3">
+ <div className="text-sm text-muted-foreground">전체 파일</div>
+ <div className="text-2xl font-bold">{validationResults.length}</div>
+ </div>
+ <div className="rounded-lg border p-3 bg-green-50 dark:bg-green-950/30">
+ <div className="text-sm text-green-600 dark:text-green-400">검증 성공</div>
+ <div className="text-2xl font-bold text-green-600 dark:text-green-400">
+ {validFiles.length}
+ </div>
+ </div>
+ <div className="rounded-lg border p-3 bg-red-50 dark:bg-red-950/30">
+ <div className="text-sm text-red-600 dark:text-red-400">검증 실패</div>
+ <div className="text-2xl font-bold text-red-600 dark:text-red-400">
+ {invalidFiles.length}
+ </div>
+ </div>
+ </div>
+
+ {/* 경고 메시지 */}
+ {invalidFiles.length > 0 && (
+ <Alert variant="destructive">
+ <AlertCircle className="h-4 w-4" />
+ <AlertDescription>
+ {invalidFiles.length}개 파일의 파일명 형식이 올바르지 않습니다.
+ 검증에 성공한 {validFiles.length}개 파일만 업로드됩니다.
+ </AlertDescription>
+ </Alert>
+ )}
+
+ {validFiles.length === 0 && (
+ <Alert variant="destructive">
+ <XCircle className="h-4 w-4" />
+ <AlertDescription>
+ 업로드 가능한 파일이 없습니다. 파일명 형식을 확인해주세요.
+ </AlertDescription>
+ </Alert>
+ )}
+
+ {/* 파일 목록 */}
+ <ScrollArea className="h-[300px] rounded-md border p-4">
+ <div className="space-y-3">
+ {/* 검증 성공 파일 */}
+ {validFiles.length > 0 && (
+ <div className="space-y-2">
+ <h4 className="text-sm font-semibold text-green-600 dark:text-green-400 flex items-center gap-2">
+ <CheckCircle2 className="h-4 w-4" />
+ 검증 성공 ({validFiles.length}개)
+ </h4>
+ {validFiles.map((result, index) => (
+ <div
+ key={index}
+ className="rounded-lg border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-950/30 p-3"
+ >
+ <div className="flex items-start justify-between gap-2">
+ <div className="flex-1 min-w-0">
+ <div className="font-mono text-sm break-all">
+ {result.file.name}
+ </div>
+ {result.parsed && (
+ <div className="flex flex-wrap gap-1 mt-2">
+ <Badge variant="outline" className="text-xs">
+ 문서: {result.parsed.ownDocNo}
+ </Badge>
+ <Badge variant="outline" className="text-xs">
+ Rev: {result.parsed.revNo}
+ </Badge>
+ <Badge variant="outline" className="text-xs">
+ Stage: {result.parsed.stage}
+ </Badge>
+ {result.parsed.fileName && (
+ <Badge variant="outline" className="text-xs">
+ 파일명: {result.parsed.fileName}
+ </Badge>
+ )}
+ <Badge variant="outline" className="text-xs">
+ 확장자: .{result.parsed.extension}
+ </Badge>
+ </div>
+ )}
+ </div>
+ <CheckCircle2 className="h-5 w-5 text-green-600 dark:text-green-400 shrink-0" />
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
+
+ {/* 검증 실패 파일 */}
+ {invalidFiles.length > 0 && (
+ <div className="space-y-2 mt-4">
+ <h4 className="text-sm font-semibold text-red-600 dark:text-red-400 flex items-center gap-2">
+ <XCircle className="h-4 w-4" />
+ 검증 실패 ({invalidFiles.length}개)
+ </h4>
+ {invalidFiles.map((result, index) => (
+ <div
+ key={index}
+ className="rounded-lg border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-950/30 p-3"
+ >
+ <div className="flex items-start justify-between gap-2">
+ <div className="flex-1 min-w-0">
+ <div className="font-mono text-sm break-all">
+ {result.file.name}
+ </div>
+ {result.error && (
+ <div className="text-xs text-red-600 dark:text-red-400 mt-1">
+ ✗ {result.error}
+ </div>
+ )}
+ </div>
+ <XCircle className="h-5 w-5 text-red-600 dark:text-red-400 shrink-0" />
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ </ScrollArea>
+
+ {/* 형식 안내 */}
+ <div className="rounded-lg bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800 p-3">
+ <div className="text-sm font-medium text-blue-900 dark:text-blue-100 mb-1">
+ 올바른 파일명 형식
+ </div>
+ <code className="text-xs text-blue-700 dark:text-blue-300">
+ [DOC_NO]_[REV_NO]_[STAGE].[확장자]
+ </code>
+ <div className="text-xs text-blue-600 dark:text-blue-400 mt-1">
+ 예: VD-DOC-001_01_IFA.pdf
+ </div>
+ <div className="text-xs text-blue-600 dark:text-blue-400 mt-1">
+ ※ 선택사항: [DOC_NO]_[REV_NO]_[STAGE]_[파일명].[확장자] (파일명 추가 가능)
+ </div>
+ <div className="text-xs text-blue-600 dark:text-blue-400 mt-1">
+ ※ 파일명에는 언더스코어(_)가 포함될 수 있습니다.
+ </div>
+ {isVendorMode && (
+ <div className="text-xs text-blue-600 dark:text-blue-400 mt-2 pt-2 border-t border-blue-200 dark:border-blue-800">
+ {availableDocNos.length > 0 ? (
+ <>ℹ️ 업로드 가능한 문서: {availableDocNos.length}개</>
+ ) : (
+ <>⚠️ 할당된 문서가 없습니다</>
+ )}
+ </div>
+ )}
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={handleCancel}
+ disabled={isUploading}
+ >
+ 취소
+ </Button>
+ <Button
+ onClick={handleUpload}
+ disabled={validFiles.length === 0 || isUploading}
+ >
+ {isUploading ? (
+ <>
+ <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2" />
+ 업로드 중...
+ </>
+ ) : (
+ <>
+ <Upload className="h-4 w-4 mr-2" />
+ 업로드 ({validFiles.length}개)
+ </>
+ )}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
+
diff --git a/lib/swp/table/swp-uploaded-files-dialog.tsx b/lib/swp/table/swp-uploaded-files-dialog.tsx
new file mode 100644
index 00000000..25a798b6
--- /dev/null
+++ b/lib/swp/table/swp-uploaded-files-dialog.tsx
@@ -0,0 +1,358 @@
+"use client";
+
+import { useState, useTransition, useMemo } from "react";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Badge } from "@/components/ui/badge";
+import { useToast } from "@/hooks/use-toast";
+import { FileText, ChevronRight, ChevronDown, X, Loader2, RefreshCw } from "lucide-react";
+import { fetchVendorUploadedFiles, cancelVendorUploadedFile } from "../actions";
+import type { SwpFileApiResponse } from "../api-client";
+
+interface SwpUploadedFilesDialogProps {
+ projNo: string;
+ vndrCd: string;
+ userId: string;
+}
+
+interface FileTreeNode {
+ files: SwpFileApiResponse[];
+}
+
+interface RevisionTreeNode {
+ revNo: string;
+ files: FileTreeNode;
+}
+
+interface DocumentTreeNode {
+ docNo: string;
+ revisions: Map<string, RevisionTreeNode>;
+}
+
+export function SwpUploadedFilesDialog({ projNo, vndrCd, userId }: SwpUploadedFilesDialogProps) {
+ const [open, setOpen] = useState(false);
+ const [files, setFiles] = useState<SwpFileApiResponse[]>([]);
+ const [isLoading, startLoading] = useTransition();
+ const [expandedDocs, setExpandedDocs] = useState<Set<string>>(new Set());
+ const [expandedRevs, setExpandedRevs] = useState<Set<string>>(new Set());
+ const [cancellingFiles, setCancellingFiles] = useState<Set<string>>(new Set());
+ const { toast } = useToast();
+
+ // 파일 목록을 트리 구조로 변환
+ const fileTree = useMemo(() => {
+ const tree = new Map<string, DocumentTreeNode>();
+
+ files.forEach((file) => {
+ const docNo = file.OWN_DOC_NO;
+ const revNo = file.REV_NO;
+
+ if (!tree.has(docNo)) {
+ tree.set(docNo, {
+ docNo,
+ revisions: new Map(),
+ });
+ }
+
+ const docNode = tree.get(docNo)!;
+
+ if (!docNode.revisions.has(revNo)) {
+ docNode.revisions.set(revNo, {
+ revNo,
+ files: { files: [] },
+ });
+ }
+
+ const revNode = docNode.revisions.get(revNo)!;
+ revNode.files.files.push(file);
+ });
+
+ return tree;
+ }, [files]);
+
+ // 다이얼로그 열릴 때 파일 목록 조회
+ const handleOpenChange = (newOpen: boolean) => {
+ setOpen(newOpen);
+ if (newOpen) {
+ loadFiles();
+ }
+ };
+
+ // 파일 목록 조회
+ const loadFiles = () => {
+ if (!projNo || !vndrCd) {
+ toast({
+ variant: "destructive",
+ title: "조회 불가",
+ description: "프로젝트와 업체 정보가 필요합니다.",
+ });
+ return;
+ }
+
+ startLoading(async () => {
+ try {
+ const result = await fetchVendorUploadedFiles(projNo, vndrCd);
+ setFiles(result);
+ toast({
+ title: "조회 완료",
+ description: `${result.length}개의 파일을 조회했습니다.`,
+ });
+ } catch (error) {
+ console.error("파일 목록 조회 실패:", error);
+ toast({
+ variant: "destructive",
+ title: "조회 실패",
+ description: error instanceof Error ? error.message : "알 수 없는 오류",
+ });
+ }
+ });
+ };
+
+ // 파일 취소
+ const handleCancelFile = async (file: SwpFileApiResponse) => {
+ if (!file.BOX_SEQ || !file.ACTV_SEQ) {
+ toast({
+ variant: "destructive",
+ title: "취소 불가",
+ description: "파일 정보가 올바르지 않습니다.",
+ });
+ return;
+ }
+
+ const fileKey = `${file.BOX_SEQ}_${file.ACTV_SEQ}`;
+ setCancellingFiles((prev) => new Set(prev).add(fileKey));
+
+ try {
+ await cancelVendorUploadedFile({
+ boxSeq: file.BOX_SEQ,
+ actvSeq: file.ACTV_SEQ,
+ userId,
+ });
+
+ toast({
+ title: "취소 완료",
+ description: `${file.FILE_NM} 파일이 취소되었습니다.`,
+ });
+
+ // 목록 새로고침
+ loadFiles();
+ } catch (error) {
+ console.error("파일 취소 실패:", error);
+ toast({
+ variant: "destructive",
+ title: "취소 실패",
+ description: error instanceof Error ? error.message : "알 수 없는 오류",
+ });
+ } finally {
+ setCancellingFiles((prev) => {
+ const newSet = new Set(prev);
+ newSet.delete(fileKey);
+ return newSet;
+ });
+ }
+ };
+
+ // 문서 토글
+ const toggleDoc = (docNo: string) => {
+ setExpandedDocs((prev) => {
+ const newSet = new Set(prev);
+ if (newSet.has(docNo)) {
+ newSet.delete(docNo);
+ } else {
+ newSet.add(docNo);
+ }
+ return newSet;
+ });
+ };
+
+ // 리비전 토글
+ const toggleRev = (docNo: string, revNo: string) => {
+ const key = `${docNo}_${revNo}`;
+ setExpandedRevs((prev) => {
+ const newSet = new Set(prev);
+ if (newSet.has(key)) {
+ newSet.delete(key);
+ } else {
+ newSet.add(key);
+ }
+ return newSet;
+ });
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={handleOpenChange}>
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm" disabled={!projNo || !vndrCd}>
+ <FileText className="h-4 w-4 mr-2" />
+ 업로드 파일 관리
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="max-w-4xl max-h-[80vh]">
+ <DialogHeader>
+ <DialogTitle>업로드한 파일 목록</DialogTitle>
+ <DialogDescription>
+ 프로젝트: {projNo} | 업체: {vndrCd}
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ {/* 액션 바 */}
+ <div className="flex items-center justify-between">
+ <div className="text-sm text-muted-foreground">
+ 총 {files.length}개 파일
+ </div>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={loadFiles}
+ disabled={isLoading}
+ >
+ {isLoading ? (
+ <>
+ <Loader2 className="h-4 w-4 mr-2 animate-spin" />
+ 조회 중...
+ </>
+ ) : (
+ <>
+ <RefreshCw className="h-4 w-4 mr-2" />
+ 새로고침
+ </>
+ )}
+ </Button>
+ </div>
+
+ {/* 파일 트리 */}
+ <ScrollArea className="h-[500px] rounded-md border p-4">
+ {isLoading && files.length === 0 ? (
+ <div className="flex items-center justify-center h-full text-muted-foreground">
+ <Loader2 className="h-6 w-6 animate-spin mr-2" />
+ 파일 목록을 조회하는 중...
+ </div>
+ ) : files.length === 0 ? (
+ <div className="flex items-center justify-center h-full text-muted-foreground">
+ 업로드한 파일이 없습니다.
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {Array.from(fileTree.entries()).map(([docNo, docNode]) => (
+ <div key={docNo} className="space-y-1">
+ {/* 문서번호 */}
+ <div
+ className="flex items-center gap-2 p-2 rounded-md hover:bg-muted cursor-pointer"
+ onClick={() => toggleDoc(docNo)}
+ >
+ {expandedDocs.has(docNo) ? (
+ <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
+ ) : (
+ <ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
+ )}
+ <FileText className="h-4 w-4 text-blue-600 shrink-0" />
+ <span className="font-semibold">{docNo}</span>
+ <Badge variant="outline" className="text-xs">
+ {docNode.revisions.size}개 리비전
+ </Badge>
+ </div>
+
+ {/* 리비전 목록 */}
+ {expandedDocs.has(docNo) && (
+ <div className="ml-6 space-y-1">
+ {Array.from(docNode.revisions.entries()).map(([revNo, revNode]) => {
+ const revKey = `${docNo}_${revNo}`;
+ return (
+ <div key={revKey} className="space-y-1">
+ {/* 리비전 번호 */}
+ <div
+ className="flex items-center gap-2 p-2 rounded-md hover:bg-muted cursor-pointer"
+ onClick={() => toggleRev(docNo, revNo)}
+ >
+ {expandedRevs.has(revKey) ? (
+ <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
+ ) : (
+ <ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
+ )}
+ <span className="font-medium text-sm">Rev: {revNo}</span>
+ <Badge variant="secondary" className="text-xs">
+ {revNode.files.files.length}개 파일
+ </Badge>
+ </div>
+
+ {/* 파일 목록 */}
+ {expandedRevs.has(revKey) && (
+ <div className="ml-6 space-y-1">
+ {revNode.files.files.map((file, idx) => {
+ const fileKey = `${file.BOX_SEQ}_${file.ACTV_SEQ}`;
+ const isCancellable = file.STAT === "SCW01";
+ const isCancelling = cancellingFiles.has(fileKey);
+
+ return (
+ <div
+ key={`${fileKey}_${idx}`}
+ className="flex items-center gap-2 p-2 rounded-md border bg-card"
+ >
+ <FileText className="h-4 w-4 text-muted-foreground shrink-0" />
+ <div className="flex-1 min-w-0">
+ <div className="text-sm truncate">{file.FILE_NM}</div>
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
+ <span>Stage: {file.STAGE}</span>
+ <span>•</span>
+ <span>상태: {file.STAT_NM || file.STAT || "알 수 없음"}</span>
+ </div>
+ </div>
+ <Button
+ variant="destructive"
+ size="sm"
+ onClick={() => handleCancelFile(file)}
+ disabled={!isCancellable || isCancelling}
+ title={
+ isCancellable
+ ? "파일 업로드 취소"
+ : `취소 불가 (상태: ${file.STAT_NM || file.STAT})`
+ }
+ >
+ {isCancelling ? (
+ <>
+ <Loader2 className="h-3 w-3 mr-1 animate-spin" />
+ 취소 중...
+ </>
+ ) : (
+ <>
+ <X className="h-3 w-3 mr-1" />
+ 취소
+ </>
+ )}
+ </Button>
+ </div>
+ );
+ })}
+ </div>
+ )}
+ </div>
+ );
+ })}
+ </div>
+ )}
+ </div>
+ ))}
+ </div>
+ )}
+ </ScrollArea>
+
+ {/* 안내 메시지 */}
+ <div className="rounded-lg bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800 p-3">
+ <div className="text-xs text-blue-600 dark:text-blue-400 space-y-1">
+ <p>ℹ️ 접수 전(SCW01) 상태의 파일만 취소할 수 있습니다.</p>
+ <p>ℹ️ 취소된 파일은 목록에서 제거됩니다.</p>
+ </div>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/lib/swp/vendor-actions.ts b/lib/swp/vendor-actions.ts
index c96cf055..f65ed007 100644
--- a/lib/swp/vendor-actions.ts
+++ b/lib/swp/vendor-actions.ts
@@ -6,13 +6,16 @@ import db from "@/db/db";
import { vendors } from "@/db/schema/vendors";
import { contracts } from "@/db/schema/contract";
import { projects } from "@/db/schema/projects";
-import { swpDocumentRevisions, swpDocumentFiles } from "@/db/schema/SWP/swp-documents";
-import { eq, sql, and } from "drizzle-orm";
-import { fetchSwpDocuments, type SwpTableParams } from "./actions";
-import { fetchGetExternalInboxList } from "./api-client";
-import type { SwpFileApiResponse } from "./sync-service";
-import fs from "fs/promises";
-import path from "path";
+import { eq } from "drizzle-orm";
+import {
+ getDocumentList,
+ getDocumentDetail,
+ cancelStandbyFile,
+ downloadDocumentFile,
+ type DocumentListItem,
+ type DocumentDetail,
+ type DownloadFileResult
+} from "./document-service";
import { debugLog, debugError, debugSuccess, debugProcess, debugWarn } from "@/lib/debug-utils";
// ============================================================================
@@ -110,11 +113,11 @@ export async function fetchVendorProjects() {
}
// ============================================================================
-// 벤더 필터링된 문서 목록 조회
+// 벤더 필터링된 문서 목록 조회 (Full API 기반)
// ============================================================================
-export async function fetchVendorDocuments(params: SwpTableParams) {
- debugProcess("벤더 문서 목록 조회 시작", { page: params.page, pageSize: params.pageSize });
+export async function fetchVendorDocuments(projNo?: string): Promise<DocumentListItem[]> {
+ debugProcess("벤더 문서 목록 조회 시작", { projNo });
try {
const vendorInfo = await getVendorSessionInfo();
@@ -124,22 +127,21 @@ export async function fetchVendorDocuments(params: SwpTableParams) {
throw new Error("벤더 정보를 찾을 수 없습니다.");
}
- // 벤더 코드를 필터에 자동 추가
- const vendorParams: SwpTableParams = {
- ...params,
- filters: {
- ...params.filters,
- vndrCd: vendorInfo.vendorCode, // 벤더 코드 필터 강제 적용
- },
- };
+ if (!projNo) {
+ debugWarn("프로젝트 번호 없음");
+ return [];
+ }
- debugLog("SWP 문서 조회 호출", { vendorCode: vendorInfo.vendorCode, filters: vendorParams.filters });
+ debugLog("문서 목록 조회 시작", {
+ projNo,
+ vendorCode: vendorInfo.vendorCode
+ });
- // 기존 fetchSwpDocuments 재사용
- const result = await fetchSwpDocuments(vendorParams);
+ // document-service의 getDocumentList 사용
+ const documents = await getDocumentList(projNo, vendorInfo.vendorCode);
- debugSuccess("문서 목록 조회 성공", { total: result.total, dataCount: result.data.length });
- return result;
+ debugSuccess("문서 목록 조회 성공", { count: documents.length });
+ return documents;
} catch (error) {
debugError("문서 목록 조회 실패", error);
console.error("[fetchVendorDocuments] 오류:", error);
@@ -148,104 +150,114 @@ export async function fetchVendorDocuments(params: SwpTableParams) {
}
// ============================================================================
-// 파일 업로드
+// 문서 상세 조회 (Rev-Activity-File 트리)
// ============================================================================
-export interface FileUploadParams {
- revisionId: number;
- file: {
- FILE_NM: string;
- FILE_SEQ: string;
- FILE_SZ: string;
- FLD_PATH: string;
- STAT?: string;
- STAT_NM?: string;
- };
- fileBuffer?: Buffer; // 실제 파일 데이터 추가
-}
-
-export async function uploadFileToRevision(params: FileUploadParams) {
- debugProcess("파일 업로드 시작", { revisionId: params.revisionId, fileName: params.file.FILE_NM });
+export async function fetchVendorDocumentDetail(
+ projNo: string,
+ docNo: string
+): Promise<DocumentDetail> {
+ debugProcess("벤더 문서 상세 조회 시작", { projNo, docNo });
try {
const vendorInfo = await getVendorSessionInfo();
if (!vendorInfo) {
- debugError("벤더 정보 없음 - 파일 업로드 실패");
+ debugError("벤더 정보 없음");
throw new Error("벤더 정보를 찾을 수 없습니다.");
}
- const { revisionId } = params;
- debugLog("리비전 권한 확인 시작", { revisionId });
-
- // 1. 해당 리비전이 벤더에게 제공된 문서인지 확인
- const revisionCheck = await db
- .select({
- DOC_NO: swpDocumentRevisions.DOC_NO,
- VNDR_CD: sql<string>`(
- SELECT d."VNDR_CD"
- FROM swp.swp_documents d
- WHERE d."DOC_NO" = ${swpDocumentRevisions.DOC_NO}
- )`,
- })
- .from(swpDocumentRevisions)
- .where(eq(swpDocumentRevisions.id, revisionId))
- .limit(1);
+ debugLog("문서 상세 조회 시작", { projNo, docNo });
- debugLog("리비전 조회 결과", { found: !!revisionCheck[0], docNo: revisionCheck[0]?.DOC_NO });
+ // document-service의 getDocumentDetail 사용
+ const detail = await getDocumentDetail(projNo, docNo);
- if (!revisionCheck[0]) {
- debugError("리비전 없음", { revisionId });
- throw new Error("리비전을 찾을 수 없습니다.");
- }
+ debugSuccess("문서 상세 조회 성공", {
+ docNo: detail.docNo,
+ revisions: detail.revisions.length,
+ });
- // 벤더 코드가 일치하는지 확인
- if (revisionCheck[0].VNDR_CD !== vendorInfo.vendorCode) {
- debugError("권한 없음", {
- expected: vendorInfo.vendorCode,
- actual: revisionCheck[0].VNDR_CD,
- docNo: revisionCheck[0].DOC_NO
- });
- throw new Error("이 문서에 대한 권한이 없습니다.");
+ return detail;
+ } catch (error) {
+ debugError("문서 상세 조회 실패", error);
+ console.error("[fetchVendorDocumentDetail] 오류:", error);
+ throw new Error("문서 상세 조회 실패");
+ }
+}
+
+// ============================================================================
+// 파일 취소
+// ============================================================================
+
+export async function cancelVendorFile(
+ boxSeq: string,
+ actvSeq: string
+): Promise<void> {
+ debugProcess("벤더 파일 취소 시작", { boxSeq, actvSeq });
+
+ try {
+ const vendorInfo = await getVendorSessionInfo();
+
+ if (!vendorInfo) {
+ debugError("벤더 정보 없음");
+ throw new Error("벤더 정보를 찾을 수 없습니다.");
}
- debugSuccess("리비전 권한 확인 성공");
+ // vendorId를 문자열로 변환하여 사용
+ await cancelStandbyFile(boxSeq, actvSeq, String(vendorInfo.vendorId));
- const { revisionId: revId, file, fileBuffer } = params;
+ debugSuccess("파일 취소 완료", { boxSeq, actvSeq });
+ } catch (error) {
+ debugError("파일 취소 실패", error);
+ console.error("[cancelVendorFile] 오류:", error);
+ throw new Error("파일 취소 실패");
+ }
+}
- // 1. SWP 마운트 경로에 파일 저장
- debugProcess("파일 저장 단계 시작");
- await saveFileToSwpNetwork(revId, {
- FILE_NM: file.FILE_NM,
- fileBuffer: fileBuffer,
- });
+// ============================================================================
+// 파일 다운로드
+// ============================================================================
+
+export async function downloadVendorFile(
+ projNo: string,
+ ownDocNo: string,
+ fileName: string
+): Promise<DownloadFileResult> {
+ debugProcess("벤더 파일 다운로드 시작", { projNo, ownDocNo, fileName });
- // 2. 파일 저장 API 호출 (메타데이터 전송)
- debugProcess("API 호출 단계 시작");
- await callSwpFileSaveApi(revId, file);
+ try {
+ const vendorInfo = await getVendorSessionInfo();
+
+ if (!vendorInfo) {
+ debugError("벤더 정보 없음");
+ return {
+ success: false,
+ error: "벤더 정보를 찾을 수 없습니다.",
+ };
+ }
- // 3. 파일 리스트 API 호출 (업데이트된 파일 목록 조회)
- debugProcess("파일 목록 조회 단계 시작");
- const updatedFiles = await fetchUpdatedFileList(revId);
- debugLog("업데이트된 파일 목록", { count: updatedFiles.length });
+ // document-service의 downloadDocumentFile 사용
+ const result = await downloadDocumentFile(projNo, ownDocNo, fileName);
- // 4. 파일 목록 DB 동기화 (새 파일들 추가)
- debugProcess("DB 동기화 단계 시작");
- await syncSwpDocumentFiles(revId, updatedFiles);
+ if (result.success) {
+ debugSuccess("파일 다운로드 완료", { fileName });
+ } else {
+ debugWarn("파일 다운로드 실패", { fileName, error: result.error });
+ }
- debugSuccess("파일 업로드 완료", { fileName: file.FILE_NM, revisionId });
- return { success: true, fileId: 0, action: "uploaded" };
+ return result;
} catch (error) {
- debugError("파일 업로드 실패", error);
- console.error("[uploadFileToRevision] 오류:", error);
- throw new Error(
- error instanceof Error ? error.message : "파일 업로드 실패"
- );
+ debugError("파일 다운로드 실패", error);
+ console.error("[downloadVendorFile] 오류:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "파일 다운로드 실패",
+ };
}
}
// ============================================================================
-// 벤더 통계 조회
+// 벤더 통계 조회 (Full API 기반)
// ============================================================================
export async function fetchVendorSwpStats(projNo?: string) {
@@ -259,48 +271,52 @@ export async function fetchVendorSwpStats(projNo?: string) {
throw new Error("벤더 정보를 찾을 수 없습니다.");
}
- const whereConditions = [
- sql`d."VNDR_CD" = ${vendorInfo.vendorCode}`,
- ];
+ if (!projNo) {
+ debugWarn("프로젝트 번호 없음");
+ return {
+ total_documents: 0,
+ total_revisions: 0,
+ total_files: 0,
+ uploaded_files: 0,
+ last_sync: null,
+ };
+ }
- if (projNo) {
- whereConditions.push(sql`d."PROJ_NO" = ${projNo}`);
+ // API에서 문서 목록 조회
+ const documents = await getDocumentList(projNo, vendorInfo.vendorCode);
+
+ // 통계 계산
+ let totalRevisions = 0;
+ let totalFiles = 0;
+ let uploadedFiles = 0;
+
+ for (const doc of documents) {
+ totalFiles += doc.fileCount;
+ // standbyFileCount가 0이 아니면 업로드된 것으로 간주
+ uploadedFiles += doc.fileCount - doc.standbyFileCount;
+
+ // 리비전 수 추정 (LTST_REV_NO 기반)
+ if (doc.LTST_REV_NO) {
+ const revNum = parseInt(doc.LTST_REV_NO, 10);
+ if (!isNaN(revNum)) {
+ totalRevisions += revNum + 1; // Rev 00부터 시작이므로 +1
+ }
+ }
}
- debugLog("통계 SQL 실행", { vendorCode: vendorInfo.vendorCode, projNo });
-
- const stats = await db.execute<{
- total_documents: number;
- total_revisions: number;
- total_files: number;
- uploaded_files: number;
- last_sync: Date | null;
- }>(sql`
- SELECT
- COUNT(DISTINCT d."DOC_NO")::int as total_documents,
- COUNT(DISTINCT r.id)::int as total_revisions,
- COUNT(f.id)::int as total_files,
- COUNT(CASE WHEN f."FLD_PATH" IS NOT NULL AND f."FLD_PATH" != '' THEN 1 END)::int as uploaded_files,
- MAX(d.last_synced_at) as last_sync
- FROM swp.swp_documents d
- LEFT JOIN swp.swp_document_revisions r ON d."DOC_NO" = r."DOC_NO"
- LEFT JOIN swp.swp_document_files f ON r.id = f.revision_id
- WHERE ${sql.join(whereConditions, sql` AND `)}
- `);
-
- const result = stats.rows[0] || {
- total_documents: 0,
- total_revisions: 0,
- total_files: 0,
- uploaded_files: 0,
- last_sync: null,
+ const result = {
+ total_documents: documents.length,
+ total_revisions: totalRevisions,
+ total_files: totalFiles,
+ uploaded_files: uploadedFiles,
+ last_sync: new Date(), // API 기반이므로 항상 최신
};
debugSuccess("통계 조회 성공", {
documents: result.total_documents,
revisions: result.total_revisions,
files: result.total_files,
- uploaded: result.uploaded_files
+ uploaded: result.uploaded_files,
});
return result;
@@ -318,245 +334,6 @@ export async function fetchVendorSwpStats(projNo?: string) {
}
// ============================================================================
-// SWP 파일 업로드 헬퍼 함수들
+// 주의: 파일 업로드는 /api/swp/upload 라우트에서 처리됩니다
// ============================================================================
-/**
- * 1. SWP 마운트 경로에 파일 저장
- */
-async function saveFileToSwpNetwork(
- revisionId: number,
- fileInfo: { FILE_NM: string; fileBuffer?: Buffer }
-): Promise<string> {
- debugProcess("네트워크 파일 저장 시작", { revisionId, fileName: fileInfo.FILE_NM });
-
- // 리비전 정보 조회
- const revisionInfo = await db
- .select({
- DOC_NO: swpDocumentRevisions.DOC_NO,
- REV_NO: swpDocumentRevisions.REV_NO,
- PROJ_NO: sql<string>`(
- SELECT d."PROJ_NO" FROM swp.swp_documents d
- WHERE d."DOC_NO" = ${swpDocumentRevisions.DOC_NO}
- )`,
- VNDR_CD: sql<string>`(
- SELECT d."VNDR_CD" FROM swp.swp_documents d
- WHERE d."DOC_NO" = ${swpDocumentRevisions.DOC_NO}
- )`,
- })
- .from(swpDocumentRevisions)
- .where(eq(swpDocumentRevisions.id, revisionId))
- .limit(1);
-
- debugLog("리비전 정보 조회", { found: !!revisionInfo[0], docNo: revisionInfo[0]?.DOC_NO });
-
- if (!revisionInfo[0]) {
- debugError("리비전 정보 없음");
- throw new Error("리비전 정보를 찾을 수 없습니다");
- }
-
- const { PROJ_NO, VNDR_CD, DOC_NO, REV_NO } = revisionInfo[0];
-
- // SWP 마운트 경로 생성
- const mountDir = process.env.SWP_MOUNT_DIR || "/mnt/swp-smb-dir";
- const targetDir = path.join(mountDir, PROJ_NO, VNDR_CD, DOC_NO, REV_NO);
-
- debugLog("파일 저장 경로 생성", { mountDir, targetDir });
-
- // 디렉토리 생성
- await fs.mkdir(targetDir, { recursive: true });
- debugLog("디렉토리 생성 완료");
-
- // 파일 저장
- const targetPath = path.join(targetDir, fileInfo.FILE_NM);
-
- if (fileInfo.fileBuffer) {
- await fs.writeFile(targetPath, fileInfo.fileBuffer);
- debugSuccess("파일 저장 완료", { fileName: fileInfo.FILE_NM, targetPath, size: fileInfo.fileBuffer.length });
- } else {
- debugWarn("파일 버퍼 없음", { fileName: fileInfo.FILE_NM });
- }
-
- return targetPath;
-}
-
-/**
- * 2. 파일 저장 API 호출 (메타데이터 전송)
- */
-async function callSwpFileSaveApi(
- revisionId: number,
- fileInfo: FileUploadParams['file']
-): Promise<void> {
- debugProcess("SWP 파일 저장 API 호출 시작", { revisionId, fileName: fileInfo.FILE_NM });
-
- // TODO: SWP 파일 저장 API 구현
- // buyer-system의 sendToInBox 패턴 참고
- debugLog("메타데이터 전송", {
- fileName: fileInfo.FILE_NM,
- fileSeq: fileInfo.FILE_SEQ,
- filePath: fileInfo.FLD_PATH
- });
-
- // 임시 구현: 실제로는 SWP SaveFile API 등을 호출해야 함
- // 예: SaveFile, UploadFile API 등
- debugWarn("SWP 파일 저장 API가 아직 구현되지 않음 - 임시 스킵");
-}
-
-/**
- * 3. 파일 리스트 API 호출 (업데이트된 파일 목록 조회)
- */
-async function fetchUpdatedFileList(revisionId: number): Promise<SwpFileApiResponse[]> {
- debugProcess("업데이트된 파일 목록 조회 시작", { revisionId });
-
- // 리비전 정보 조회
- const revisionInfo = await db
- .select({
- DOC_NO: swpDocumentRevisions.DOC_NO,
- PROJ_NO: sql<string>`(
- SELECT d."PROJ_NO" FROM swp.swp_documents d
- WHERE d."DOC_NO" = ${swpDocumentRevisions.DOC_NO}
- )`,
- VNDR_CD: sql<string>`(
- SELECT d."VNDR_CD" FROM swp.swp_documents d
- WHERE d."DOC_NO" = ${swpDocumentRevisions.DOC_NO}
- )`,
- })
- .from(swpDocumentRevisions)
- .where(eq(swpDocumentRevisions.id, revisionId))
- .limit(1);
-
- debugLog("리비전 정보 조회", { found: !!revisionInfo[0], docNo: revisionInfo[0]?.DOC_NO });
-
- if (!revisionInfo[0]) {
- debugError("리비전 정보 없음");
- throw new Error("리비전 정보를 찾을 수 없습니다");
- }
-
- const { PROJ_NO, VNDR_CD } = revisionInfo[0];
-
- debugLog("SWP 파일 목록 API 호출", { projNo: PROJ_NO, vndrCd: VNDR_CD });
-
- // SWP API에서 업데이트된 파일 목록 조회
- const files = await fetchGetExternalInboxList({
- projNo: PROJ_NO,
- vndrCd: VNDR_CD,
- });
-
- debugSuccess("파일 목록 조회 완료", { count: files.length });
- return files;
-}
-
-/**
- * 4. 파일 목록 DB 동기화 (새 파일들 추가)
- */
-async function syncSwpDocumentFiles(
- revisionId: number,
- apiFiles: SwpFileApiResponse[]
-): Promise<void> {
- debugProcess("DB 동기화 시작", { revisionId, fileCount: apiFiles.length });
-
- // 리비전 정보에서 DOC_NO 가져오기
- const revisionInfo = await db
- .select({
- DOC_NO: swpDocumentRevisions.DOC_NO,
- })
- .from(swpDocumentRevisions)
- .where(eq(swpDocumentRevisions.id, revisionId))
- .limit(1);
-
- debugLog("리비전 DOC_NO 조회", { found: !!revisionInfo[0], docNo: revisionInfo[0]?.DOC_NO });
-
- if (!revisionInfo[0]) {
- debugError("리비전 정보 없음");
- throw new Error("리비전 정보를 찾을 수 없습니다");
- }
-
- const { DOC_NO } = revisionInfo[0];
- let processedCount = 0;
- let updatedCount = 0;
- let insertedCount = 0;
-
- for (const apiFile of apiFiles) {
- try {
- processedCount++;
-
- // 기존 파일 확인
- const existingFile = await db
- .select({ id: swpDocumentFiles.id })
- .from(swpDocumentFiles)
- .where(
- and(
- eq(swpDocumentFiles.revision_id, revisionId),
- eq(swpDocumentFiles.FILE_SEQ, apiFile.FILE_SEQ || "1")
- )
- )
- .limit(1);
-
- const fileData = {
- DOC_NO: DOC_NO,
- FILE_NM: apiFile.FILE_NM,
- FILE_SEQ: apiFile.FILE_SEQ || "1",
- FILE_SZ: apiFile.FILE_SZ || "0",
- FLD_PATH: apiFile.FLD_PATH,
- STAT: apiFile.STAT || null,
- STAT_NM: apiFile.STAT_NM || null,
- ACTV_NO: apiFile.ACTV_NO || null,
- IDX: apiFile.IDX || null,
- CRTER: apiFile.CRTER || null,
- CRTE_DTM: apiFile.CRTE_DTM || null,
- CHGR: apiFile.CHGR || null,
- CHG_DTM: apiFile.CHG_DTM || null,
- sync_status: 'synced' as const,
- last_synced_at: new Date(),
- updated_at: new Date(),
- };
-
- if (existingFile[0]) {
- // 기존 파일 업데이트
- await db
- .update(swpDocumentFiles)
- .set({
- ...fileData,
- updated_at: new Date(),
- })
- .where(eq(swpDocumentFiles.id, existingFile[0].id));
- updatedCount++;
- debugLog("파일 업데이트", { fileName: apiFile.FILE_NM, fileSeq: apiFile.FILE_SEQ });
- } else {
- // 새 파일 추가
- await db.insert(swpDocumentFiles).values({
- revision_id: revisionId,
- DOC_NO: DOC_NO,
- FILE_NM: apiFile.FILE_NM,
- FILE_SEQ: apiFile.FILE_SEQ || "1",
- FILE_SZ: apiFile.FILE_SZ || "0",
- FLD_PATH: apiFile.FLD_PATH,
- STAT: apiFile.STAT || null,
- STAT_NM: apiFile.STAT_NM || null,
- ACTV_NO: apiFile.ACTV_NO || null,
- IDX: apiFile.IDX || null,
- CRTER: apiFile.CRTER || null,
- CRTE_DTM: apiFile.CRTE_DTM || null,
- CHGR: apiFile.CHGR || null,
- CHG_DTM: apiFile.CHG_DTM || null,
- sync_status: 'synced' as const,
- last_synced_at: new Date(),
- updated_at: new Date(),
- });
- insertedCount++;
- debugLog("파일 추가", { fileName: apiFile.FILE_NM, fileSeq: apiFile.FILE_SEQ });
- }
- } catch (error) {
- debugError("파일 동기화 실패", { fileName: apiFile.FILE_NM, error });
- console.error(`파일 동기화 실패: ${apiFile.FILE_NM}`, error);
- // 개별 파일 실패는 전체 프로세스를 중단하지 않음
- }
- }
-
- debugSuccess("DB 동기화 완료", {
- processed: processedCount,
- updated: updatedCount,
- inserted: insertedCount
- });
-}
-