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/page.tsx57
-rw-r--r--app/[lng]/partners/(partners)/swp-document-upload/vendor-document-page.tsx230
-rw-r--r--components/login/login-form-shi.tsx41
-rw-r--r--db/schema/SWP/swp-documents.ts219
-rw-r--r--db/schema/index.ts5
-rw-r--r--lib/swp/actions.ts293
-rw-r--r--lib/swp/api-client.ts304
-rw-r--r--lib/swp/example-usage.ts347
-rw-r--r--lib/swp/sync-service.ts522
-rw-r--r--lib/swp/table/swp-table-columns.tsx394
-rw-r--r--lib/swp/table/swp-table-toolbar.tsx340
-rw-r--r--lib/swp/table/swp-table.tsx394
-rw-r--r--lib/swp/vendor-actions.ts273
15 files changed, 3683 insertions, 25 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
new file mode 100644
index 00000000..25a0bfe6
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/(eng)/swp-document-upload/page.tsx
@@ -0,0 +1,58 @@
+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
new file mode 100644
index 00000000..eedb68e2
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/(eng)/swp-document-upload/swp-document-page.tsx
@@ -0,0 +1,231 @@
+"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/page.tsx b/app/[lng]/partners/(partners)/swp-document-upload/page.tsx
new file mode 100644
index 00000000..25eb52aa
--- /dev/null
+++ b/app/[lng]/partners/(partners)/swp-document-upload/page.tsx
@@ -0,0 +1,57 @@
+import { Suspense } from "react";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import VendorDocumentPage from "./vendor-document-page";
+
+export const metadata = {
+ title: "문서 조회 및 업로드",
+ description: "협력업체 문서 조회 및 파일 업로드",
+};
+
+// ============================================================================
+// 로딩 스켈레톤
+// ============================================================================
+
+function VendorDocumentSkeleton() {
+ 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 DocumentUploadPage({
+ 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">문서 조회 및 업로드</CardTitle>
+ <CardDescription>
+ 프로젝트별 할당된 문서를 조회하고 파일을 업로드할 수 있습니다.
+ </CardDescription>
+ </CardHeader>
+ </Card>
+
+ {/* 메인 컨텐츠 */}
+ <Suspense fallback={<VendorDocumentSkeleton />}>
+ <VendorDocumentPage searchParams={params} />
+ </Suspense>
+ </div>
+ );
+} \ No newline at end of file
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
new file mode 100644
index 00000000..f2469c29
--- /dev/null
+++ b/app/[lng]/partners/(partners)/swp-document-upload/vendor-document-page.tsx
@@ -0,0 +1,230 @@
+"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 {
+ fetchVendorDocuments,
+ fetchVendorProjects,
+ fetchVendorSwpStats,
+ type SwpTableFilters,
+ type SwpDocumentWithStats,
+} from "@/lib/swp/vendor-actions";
+
+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);
+
+ // 상태 관리
+ 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,
+ uploaded_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([
+ fetchVendorProjects(),
+ fetchVendorSwpStats(),
+ fetchVendorDocuments({
+ 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 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 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-3xl text-green-600">
+ {stats.uploaded_files.toLocaleString()}
+ </CardTitle>
+ </CardHeader>
+ </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}
+ mode="vendor"
+ />
+ </CardHeader>
+ <CardContent>
+ <SwpTable
+ initialData={documents}
+ total={total}
+ page={page}
+ pageSize={pageSize}
+ totalPages={totalPages}
+ onPageChange={handlePageChange}
+ mode="vendor"
+ />
+ </CardContent>
+ </Card>
+ </div>
+ );
+}
+
diff --git a/components/login/login-form-shi.tsx b/components/login/login-form-shi.tsx
index 0be6709a..98d5e10a 100644
--- a/components/login/login-form-shi.tsx
+++ b/components/login/login-form-shi.tsx
@@ -23,6 +23,7 @@ import Link from "next/link"
import Image from 'next/image'; // 추가: Image 컴포넌트 import
import { KnoxSSOButton } from './saml-login-button'; // SAML 로그인 버튼 import
import Loading from "../common/loading/loading";
+import { VideoCarousel } from './video-carousel';
export function LoginFormSHI({
className,
@@ -212,9 +213,9 @@ export function LoginFormSHI({
}
return (
- <div className="container relative flex h-screen flex-col items-center justify-center md:grid lg:max-w-none lg:grid-cols-2 lg:px-0">
- {/* Left Content */}
- <div className="flex flex-col w-full h-screen lg:p-2">
+ <div className="container relative flex h-screen flex-col items-center justify-center md:grid lg:max-w-none lg:grid-cols-3 lg:px-0">
+ {/* Left Content - 축소된 로그인 영역 */}
+ <div className="flex flex-col w-full h-screen lg:p-2 lg:col-span-1">
{/* Top bar with Logo + eVCP (left) and "Request Vendor Repository" (right) */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
@@ -235,8 +236,8 @@ export function LoginFormSHI({
{/* Here's your existing login/OTP forms: */}
{/* {!otpSent ? ( */}
-
- <form onSubmit={handleOtpSubmit} className="p-6 md:p-8">
+
+ <form onSubmit={handleOtpSubmit} className="p-6 md:p-8">
{/* <form onSubmit={handleOtpSubmit} className="p-6 md:p-8"> */}
<div className="flex flex-col gap-6">
<div className="flex flex-col items-center text-center">
@@ -299,9 +300,11 @@ export function LoginFormSHI({
</div>
</div>
</form>
- {/* )
-
-
+ {/* )
+
+
+
+
: (
<form onSubmit={handleOtpSubmit} className="flex flex-col gap-4 p-6 md:p-8">
<div className="flex flex-col gap-6">
@@ -363,22 +366,12 @@ export function LoginFormSHI({
</div>
</div>
- {/* Right BG 이미지 영역 - Image 컴포넌트로 수정 */}
- <div className="relative hidden h-full flex-col p-10 text-white dark:border-r md:flex">
- {/* Image 컴포넌트로 대체 */}
- <div className="absolute inset-0">
- <Image
- src="/images/02.jpg"
- alt="Background image"
- fill
- priority
- sizes="(max-width: 1024px) 100vw, 50vw"
- className="object-cover"
- />
- </div>
- <div className="relative z-10 mt-auto">
- <blockquote className="space-y-2">
- <p className="text-sm">&ldquo;{t("blockquote")}&rdquo;</p>
+ {/* Right 영상 캐러셀 영역 - 확장된 영역 (2/3 비율) */}
+ <div className="relative hidden h-full flex-col p-6 text-white dark:border-r md:flex lg:col-span-2 shadow-2xl">
+ <VideoCarousel lng={lng} className="h-full" />
+ <div className="relative z-10 mt-4">
+ <blockquote className="space-y-2 text-center">
+ <p className="text-sm text-white/80">&ldquo;{t("blockquote")}&rdquo;</p>
{/* <footer className="text-sm">SHI</footer> */}
</blockquote>
</div>
diff --git a/db/schema/SWP/swp-documents.ts b/db/schema/SWP/swp-documents.ts
new file mode 100644
index 00000000..2c7d06b0
--- /dev/null
+++ b/db/schema/SWP/swp-documents.ts
@@ -0,0 +1,219 @@
+import {
+ varchar,
+ timestamp,
+ serial,
+ uniqueIndex,
+ index,
+ pgEnum,
+ pgSchema,
+} from "drizzle-orm/pg-core";
+
+// ============================================================================
+// 스키마
+// ============================================================================
+
+export const swpSchema = pgSchema("swp");
+
+// ============================================================================
+// ENUMS
+// ============================================================================
+
+export const syncStatusEnum = pgEnum("swp_sync_status", [
+ "synced",
+ "pending",
+ "error",
+]);
+
+// ============================================================================
+// 문서 마스터 (GetVDRDocumentList)
+// 컬럼명: API 필드명과 동일하게 유지 (UPPER_SNAKE_CASE)
+// ============================================================================
+
+export const swpDocuments = swpSchema.table(
+ "swp_documents",
+ {
+ // Primary Key
+ DOC_NO: varchar("DOC_NO", { length: 1000 }).primaryKey(),
+
+ // 문서 기본 정보
+ DOC_TITLE: varchar("DOC_TITLE", { length: 1000 }).notNull(),
+ DOC_GB: varchar("DOC_GB", { length: 1000 }),
+ DOC_TYPE: varchar("DOC_TYPE", { length: 1000 }),
+ OWN_DOC_NO: varchar("OWN_DOC_NO", { length: 1000 }),
+ SHI_DOC_NO: varchar("SHI_DOC_NO", { length: 1000 }),
+
+ // 프로젝트 정보
+ PROJ_NO: varchar("PROJ_NO", { length: 1000 }).notNull(),
+ PROJ_NM: varchar("PROJ_NM", { length: 1000 }),
+ PKG_NO: varchar("PKG_NO", { length: 1000 }),
+
+ // 자재/기술 정보
+ MAT_CD: varchar("MAT_CD", { length: 1000 }),
+ MAT_NM: varchar("MAT_NM", { length: 1000 }),
+ DISPLN: varchar("DISPLN", { length: 1000 }),
+ CTGRY: varchar("CTGRY", { length: 1000 }),
+
+ // 업체 정보
+ VNDR_CD: varchar("VNDR_CD", { length: 1000 }),
+ CPY_CD: varchar("CPY_CD", { length: 1000 }),
+ CPY_NM: varchar("CPY_NM", { length: 1000 }),
+
+ // 담당자 정보
+ PIC_NM: varchar("PIC_NM", { length: 1000 }),
+ PIC_DEPTCD: varchar("PIC_DEPTCD", { length: 1000 }),
+ PIC_DEPTNM: varchar("PIC_DEPTNM", { length: 1000 }),
+
+ // 최신 리비전 정보 (빠른 조회용)
+ LTST_REV_NO: varchar("LTST_REV_NO", { length: 1000 }),
+ LTST_REV_SEQ: varchar("LTST_REV_SEQ", { length: 1000 }),
+ LTST_ACTV_STAT: varchar("LTST_ACTV_STAT", { length: 1000 }),
+
+ // 기타
+ STAGE: varchar("STAGE", { length: 1000 }),
+ SKL_CD: varchar("SKL_CD", { length: 1000 }),
+ MOD_TYPE: varchar("MOD_TYPE", { length: 1000 }),
+ ACT_TYPE_NM: varchar("ACT_TYPE_NM", { length: 1000 }),
+ USE_YN: varchar("USE_YN", { length: 1000 }),
+
+ // 이력 정보 (SWP)
+ CRTER: varchar("CRTER", { length: 1000 }),
+ CRTE_DTM: varchar("CRTE_DTM", { length: 1000 }),
+ CHGR: varchar("CHGR", { length: 1000 }),
+ CHG_DTM: varchar("CHG_DTM", { length: 1000 }),
+ REV_DTM: varchar("REV_DTM", { length: 1000 }),
+
+ // 동기화 메타데이터
+ sync_status: syncStatusEnum("sync_status").default("synced").notNull(),
+ last_synced_at: timestamp("last_synced_at").defaultNow().notNull(),
+ created_at: timestamp("created_at").defaultNow().notNull(),
+ updated_at: timestamp("updated_at").defaultNow().notNull(),
+ },
+ (table) => ({
+ projNoIdx: index("swp_documents_proj_no_idx").on(table.PROJ_NO),
+ vndrCdIdx: index("swp_documents_vndr_cd_idx").on(table.VNDR_CD),
+ pkgNoIdx: index("swp_documents_pkg_no_idx").on(table.PKG_NO),
+ syncStatusIdx: index("swp_documents_sync_status_idx").on(table.sync_status),
+ })
+);
+
+// ============================================================================
+// 문서 리비전 (GetExternalInboxList에서 추출)
+// 컬럼명: API 필드명과 동일하게 유지 (UPPER_SNAKE_CASE)
+// ============================================================================
+
+export const swpDocumentRevisions = swpSchema.table(
+ "swp_document_revisions",
+ {
+ // Primary Key
+ id: serial("id").primaryKey(),
+
+ // Foreign Key
+ DOC_NO: varchar("DOC_NO", { length: 1000 })
+ .notNull()
+ .references(() => swpDocuments.DOC_NO, { onDelete: "cascade" }),
+
+ // 리비전 정보
+ REV_NO: varchar("REV_NO", { length: 1000 }).notNull(),
+ STAGE: varchar("STAGE", { length: 1000 }).notNull(),
+
+ // Activity 정보
+ ACTV_NO: varchar("ACTV_NO", { length: 1000 }),
+ ACTV_SEQ: varchar("ACTV_SEQ", { length: 1000 }),
+ BOX_SEQ: varchar("BOX_SEQ", { length: 1000 }),
+ OFDC_NO: varchar("OFDC_NO", { length: 1000 }),
+
+ // 프로젝트/패키지 정보 (파일 API에서만 제공)
+ PROJ_NO: varchar("PROJ_NO", { length: 1000 }),
+ PKG_NO: varchar("PKG_NO", { length: 1000 }),
+ VNDR_CD: varchar("VNDR_CD", { length: 1000 }),
+ CPY_CD: varchar("CPY_CD", { length: 1000 }),
+
+ // 동기화 메타데이터
+ sync_status: syncStatusEnum("sync_status").default("synced").notNull(),
+ last_synced_at: timestamp("last_synced_at").defaultNow().notNull(),
+ created_at: timestamp("created_at").defaultNow().notNull(),
+ updated_at: timestamp("updated_at").defaultNow().notNull(),
+ },
+ (table) => ({
+ // Unique constraint: 문서당 리비전은 유일
+ docRevUnique: uniqueIndex("swp_doc_rev_unique_idx").on(
+ table.DOC_NO,
+ table.REV_NO
+ ),
+ docNoIdx: index("swp_revisions_doc_no_idx").on(table.DOC_NO),
+ revNoIdx: index("swp_revisions_rev_no_idx").on(table.REV_NO),
+ stageIdx: index("swp_revisions_stage_idx").on(table.STAGE),
+ })
+);
+
+// ============================================================================
+// 첨부파일 (GetExternalInboxList)
+// 컬럼명: API 필드명과 동일하게 유지 (UPPER_SNAKE_CASE)
+// ============================================================================
+
+export const swpDocumentFiles = swpSchema.table(
+ "swp_document_files",
+ {
+ // Primary Key
+ id: serial("id").primaryKey(),
+
+ // Foreign Key
+ revision_id: serial("revision_id")
+ .notNull()
+ .references(() => swpDocumentRevisions.id, { onDelete: "cascade" }),
+
+ // 파일 정보
+ FILE_NM: varchar("FILE_NM", { length: 1000 }).notNull(),
+ FILE_SEQ: varchar("FILE_SEQ", { length: 1000 }).notNull(),
+ FILE_SZ: varchar("FILE_SZ", { length: 1000 }),
+ FLD_PATH: varchar("FLD_PATH", { length: 1000 }),
+
+ // 문서 참조 (조회 편의용, 비정규화)
+ DOC_NO: varchar("DOC_NO", { length: 1000 }).notNull(),
+
+ // 상태 정보
+ STAT: varchar("STAT", { length: 1000 }),
+ STAT_NM: varchar("STAT_NM", { length: 1000 }),
+ IDX: varchar("IDX", { length: 1000 }),
+
+ // Activity 정보
+ ACTV_NO: varchar("ACTV_NO", { length: 1000 }),
+
+ // 이력 정보 (SWP)
+ CRTER: varchar("CRTER", { length: 1000 }),
+ CRTE_DTM: varchar("CRTE_DTM", { length: 1000 }),
+ CHGR: varchar("CHGR", { length: 1000 }),
+ CHG_DTM: varchar("CHG_DTM", { length: 1000 }),
+
+ // 동기화 메타데이터
+ sync_status: syncStatusEnum("sync_status").default("synced").notNull(),
+ last_synced_at: timestamp("last_synced_at").defaultNow().notNull(),
+ created_at: timestamp("created_at").defaultNow().notNull(),
+ updated_at: timestamp("updated_at").defaultNow().notNull(),
+ },
+ (table) => ({
+ // Unique constraint: 리비전당 파일 시퀀스는 유일
+ revFileUnique: uniqueIndex("swp_rev_file_unique_idx").on(
+ table.revision_id,
+ table.FILE_SEQ
+ ),
+ revisionIdIdx: index("swp_files_revision_id_idx").on(table.revision_id),
+ docNoIdx: index("swp_files_doc_no_idx").on(table.DOC_NO),
+ fileNmIdx: index("swp_files_file_nm_idx").on(table.FILE_NM),
+ })
+);
+
+// ============================================================================
+// TYPES
+// ============================================================================
+
+export type SwpDocument = typeof swpDocuments.$inferSelect;
+export type SwpDocumentInsert = typeof swpDocuments.$inferInsert;
+
+export type SwpDocumentRevision = typeof swpDocumentRevisions.$inferSelect;
+export type SwpDocumentRevisionInsert =
+ typeof swpDocumentRevisions.$inferInsert;
+
+export type SwpDocumentFile = typeof swpDocumentFiles.$inferSelect;
+export type SwpDocumentFileInsert = typeof swpDocumentFiles.$inferInsert;
+
diff --git a/db/schema/index.ts b/db/schema/index.ts
index dbbb90a1..ea39ae8c 100644
--- a/db/schema/index.ts
+++ b/db/schema/index.ts
@@ -83,4 +83,7 @@ export * from './avl/avl';
export * from './avl/vendor-pool';
// === Email Logs 스키마 ===
export * from './emailLogs';
-export * from './emailWhitelist'; \ No newline at end of file
+export * from './emailWhitelist';
+
+// SWP 문서/첨부파일 테이블 및 뷰 스키마
+export * from './SWP/swp-documents'; \ No newline at end of file
diff --git a/lib/swp/actions.ts b/lib/swp/actions.ts
new file mode 100644
index 00000000..79c0bafe
--- /dev/null
+++ b/lib/swp/actions.ts
@@ -0,0 +1,293 @@
+"use server";
+
+import db from "@/db/db";
+import { swpDocuments, swpDocumentRevisions, swpDocumentFiles } from "@/db/schema/SWP/swp-documents";
+import { eq, and, sql, like, desc, asc, type SQL } from "drizzle-orm";
+import { fetchSwpProjectData } from "./api-client";
+import { syncSwpProject } from "./sync-service";
+
+// ============================================================================
+// 타입 정의
+// ============================================================================
+
+export interface SwpTableFilters {
+ projNo?: string;
+ docNo?: string;
+ docTitle?: string;
+ pkgNo?: string;
+ vndrCd?: string;
+ stage?: string;
+}
+
+export interface SwpTableParams {
+ page: number;
+ pageSize: number;
+ sortBy?: string;
+ sortOrder?: "asc" | "desc";
+ filters?: SwpTableFilters;
+}
+
+export interface SwpDocumentWithStats {
+ DOC_NO: string;
+ DOC_TITLE: string;
+ PROJ_NO: string;
+ PROJ_NM: string;
+ PKG_NO: string | null;
+ VNDR_CD: string | null;
+ CPY_NM: string | null;
+ LTST_REV_NO: string | null;
+ STAGE: string | null;
+ sync_status: "synced" | "pending" | "error";
+ last_synced_at: Date;
+ revision_count: number;
+ file_count: number;
+}
+
+// ============================================================================
+// 서버 액션: 문서 목록 조회 (페이지네이션 + 검색)
+// ============================================================================
+
+export async function fetchSwpDocuments(params: SwpTableParams) {
+ const { page, pageSize, sortBy = "last_synced_at", sortOrder = "desc", filters } = params;
+ const offset = (page - 1) * pageSize;
+
+ try {
+ // WHERE 조건 구성
+ const conditions: SQL<unknown>[] = [];
+
+ if (filters?.projNo) {
+ conditions.push(like(swpDocuments.PROJ_NO, `%${filters.projNo}%`));
+ }
+ if (filters?.docNo) {
+ conditions.push(like(swpDocuments.DOC_NO, `%${filters.docNo}%`));
+ }
+ if (filters?.docTitle) {
+ conditions.push(like(swpDocuments.DOC_TITLE, `%${filters.docTitle}%`));
+ }
+ if (filters?.pkgNo) {
+ conditions.push(like(swpDocuments.PKG_NO, `%${filters.pkgNo}%`));
+ }
+ if (filters?.vndrCd) {
+ conditions.push(like(swpDocuments.VNDR_CD, `%${filters.vndrCd}%`));
+ }
+ if (filters?.stage) {
+ conditions.push(eq(swpDocuments.STAGE, filters.stage));
+ }
+
+ const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
+
+ // 총 개수 조회
+ const totalResult = await db
+ .select({ count: sql<number>`count(*)::int` })
+ .from(swpDocuments)
+ .where(whereClause);
+
+ const total = totalResult[0]?.count || 0;
+
+ // 정렬 컬럼 결정
+ const orderByColumn =
+ sortBy === "DOC_NO" ? swpDocuments.DOC_NO :
+ sortBy === "DOC_TITLE" ? swpDocuments.DOC_TITLE :
+ sortBy === "PROJ_NO" ? swpDocuments.PROJ_NO :
+ sortBy === "PKG_NO" ? swpDocuments.PKG_NO :
+ sortBy === "STAGE" ? swpDocuments.STAGE :
+ swpDocuments.last_synced_at;
+
+ // 데이터 조회 (Drizzle query builder 사용)
+ const documents = await db
+ .select({
+ DOC_NO: swpDocuments.DOC_NO,
+ DOC_TITLE: swpDocuments.DOC_TITLE,
+ PROJ_NO: swpDocuments.PROJ_NO,
+ PROJ_NM: swpDocuments.PROJ_NM,
+ PKG_NO: swpDocuments.PKG_NO,
+ VNDR_CD: swpDocuments.VNDR_CD,
+ CPY_NM: swpDocuments.CPY_NM,
+ LTST_REV_NO: swpDocuments.LTST_REV_NO,
+ STAGE: swpDocuments.STAGE,
+ sync_status: swpDocuments.sync_status,
+ last_synced_at: swpDocuments.last_synced_at,
+ revision_count: sql<number>`COUNT(DISTINCT ${swpDocumentRevisions.id})::int`,
+ file_count: sql<number>`COUNT(${swpDocumentFiles.id})::int`,
+ })
+ .from(swpDocuments)
+ .leftJoin(swpDocumentRevisions, eq(swpDocuments.DOC_NO, swpDocumentRevisions.DOC_NO))
+ .leftJoin(swpDocumentFiles, eq(swpDocumentRevisions.id, swpDocumentFiles.revision_id))
+ .where(whereClause)
+ .groupBy(
+ swpDocuments.DOC_NO,
+ swpDocuments.DOC_TITLE,
+ swpDocuments.PROJ_NO,
+ swpDocuments.PROJ_NM,
+ swpDocuments.PKG_NO,
+ swpDocuments.VNDR_CD,
+ swpDocuments.CPY_NM,
+ swpDocuments.LTST_REV_NO,
+ swpDocuments.STAGE,
+ swpDocuments.sync_status,
+ swpDocuments.last_synced_at
+ )
+ .orderBy(sortOrder === "desc" ? desc(orderByColumn) : asc(orderByColumn))
+ .limit(pageSize)
+ .offset(offset);
+
+ return {
+ data: documents,
+ total,
+ page,
+ pageSize,
+ totalPages: Math.ceil(total / pageSize),
+ };
+ } catch (error) {
+ console.error("[fetchSwpDocuments] 오류:", error);
+ throw new Error("문서 목록 조회 실패");
+ }
+}
+
+// ============================================================================
+// 서버 액션: 문서의 리비전 목록 조회
+// ============================================================================
+
+export async function fetchDocumentRevisions(docNo: string) {
+ try {
+ const revisions = await db
+ .select({
+ id: swpDocumentRevisions.id,
+ DOC_NO: swpDocumentRevisions.DOC_NO,
+ REV_NO: swpDocumentRevisions.REV_NO,
+ STAGE: swpDocumentRevisions.STAGE,
+ ACTV_NO: swpDocumentRevisions.ACTV_NO,
+ OFDC_NO: swpDocumentRevisions.OFDC_NO,
+ sync_status: swpDocumentRevisions.sync_status,
+ last_synced_at: swpDocumentRevisions.last_synced_at,
+ file_count: sql<number>`(
+ SELECT COUNT(*)::int
+ FROM swp.swp_document_files f
+ WHERE f.revision_id = ${swpDocumentRevisions.id}
+ )`,
+ })
+ .from(swpDocumentRevisions)
+ .where(eq(swpDocumentRevisions.DOC_NO, docNo))
+ .orderBy(desc(swpDocumentRevisions.REV_NO));
+
+ return revisions;
+ } catch (error) {
+ console.error("[fetchDocumentRevisions] 오류:", error);
+ throw new Error("리비전 목록 조회 실패");
+ }
+}
+
+// ============================================================================
+// 서버 액션: 리비전의 파일 목록 조회
+// ============================================================================
+
+export async function fetchRevisionFiles(revisionId: number) {
+ try {
+ const files = await db
+ .select({
+ id: swpDocumentFiles.id,
+ FILE_NM: swpDocumentFiles.FILE_NM,
+ FILE_SEQ: swpDocumentFiles.FILE_SEQ,
+ FILE_SZ: swpDocumentFiles.FILE_SZ,
+ FLD_PATH: swpDocumentFiles.FLD_PATH,
+ STAT: swpDocumentFiles.STAT,
+ STAT_NM: swpDocumentFiles.STAT_NM,
+ sync_status: swpDocumentFiles.sync_status,
+ created_at: swpDocumentFiles.created_at,
+ })
+ .from(swpDocumentFiles)
+ .where(eq(swpDocumentFiles.revision_id, revisionId))
+ .orderBy(asc(swpDocumentFiles.FILE_SEQ));
+
+ return files;
+ } catch (error) {
+ console.error("[fetchRevisionFiles] 오류:", error);
+ throw new Error("파일 목록 조회 실패");
+ }
+}
+
+// ============================================================================
+// 서버 액션: 프로젝트 동기화
+// ============================================================================
+
+export async function syncSwpProjectAction(projectNo: string, docGb: "M" | "V" = "V") {
+ try {
+ console.log(`[syncSwpProjectAction] 시작: ${projectNo}`);
+
+ // 1. API에서 데이터 조회
+ const { documents, files } = await fetchSwpProjectData(projectNo, docGb);
+
+ // 2. 동기화 실행
+ const result = await syncSwpProject(projectNo, documents, files);
+
+ console.log(`[syncSwpProjectAction] 완료:`, result.stats);
+
+ return result;
+ } catch (error) {
+ console.error("[syncSwpProjectAction] 오류:", error);
+ throw new Error(
+ error instanceof Error ? error.message : "동기화 실패"
+ );
+ }
+}
+
+// ============================================================================
+// 서버 액션: 프로젝트 목록 조회 (필터용)
+// ============================================================================
+
+export async function fetchProjectList() {
+ try {
+ const projects = await db
+ .select({
+ PROJ_NO: swpDocuments.PROJ_NO,
+ PROJ_NM: swpDocuments.PROJ_NM,
+ doc_count: sql<number>`COUNT(DISTINCT ${swpDocuments.DOC_NO})::int`,
+ })
+ .from(swpDocuments)
+ .groupBy(swpDocuments.PROJ_NO, swpDocuments.PROJ_NM)
+ .orderBy(desc(sql`COUNT(DISTINCT ${swpDocuments.DOC_NO})`));
+
+ return projects;
+ } catch (error) {
+ console.error("[fetchProjectList] 오류:", error);
+ return [];
+ }
+}
+
+// ============================================================================
+// 서버 액션: 통계 조회
+// ============================================================================
+
+export async function fetchSwpStats(projNo?: string) {
+ try {
+ const whereClause = projNo ? eq(swpDocuments.PROJ_NO, projNo) : undefined;
+
+ const stats = await db
+ .select({
+ total_documents: sql<number>`COUNT(DISTINCT ${swpDocuments.DOC_NO})::int`,
+ total_revisions: sql<number>`COUNT(DISTINCT ${swpDocumentRevisions.id})::int`,
+ total_files: sql<number>`COUNT(${swpDocumentFiles.id})::int`,
+ last_sync: sql<Date>`MAX(${swpDocuments.last_synced_at})`,
+ })
+ .from(swpDocuments)
+ .leftJoin(swpDocumentRevisions, eq(swpDocuments.DOC_NO, swpDocumentRevisions.DOC_NO))
+ .leftJoin(swpDocumentFiles, eq(swpDocumentRevisions.id, swpDocumentFiles.revision_id))
+ .where(whereClause);
+
+ return stats[0] || {
+ total_documents: 0,
+ total_revisions: 0,
+ total_files: 0,
+ last_sync: null,
+ };
+ } catch (error) {
+ console.error("[fetchSwpStats] 오류:", error);
+ return {
+ total_documents: 0,
+ total_revisions: 0,
+ total_files: 0,
+ last_sync: null,
+ };
+ }
+}
+
diff --git a/lib/swp/api-client.ts b/lib/swp/api-client.ts
new file mode 100644
index 00000000..9ce8c5c1
--- /dev/null
+++ b/lib/swp/api-client.ts
@@ -0,0 +1,304 @@
+"use server";
+
+import type {
+ SwpDocumentApiResponse,
+ SwpFileApiResponse,
+} from "./sync-service";
+
+// ============================================================================
+// SWP API 클라이언트
+// ============================================================================
+
+const SWP_BASE_URL = process.env.SWP_API_URL || "http://60.100.99.217/DDC";
+
+// ============================================================================
+// API 요청 타입 정의
+// ============================================================================
+
+/**
+ * 문서 리스트 조회 필터
+ */
+export interface GetVDRDocumentListFilter {
+ proj_no: string; // 필수
+ doc_gb: "M" | "V"; // 필수 (M=MDR, V=VDR)
+ ctgry?: string; // HULL or TOP
+ pkgNo?: string;
+ vndrCd?: string;
+ pic_deptcd?: string;
+ doc_type?: string;
+ displn?: string;
+ mat_cd?: string;
+ proj_nm?: string;
+ stage?: string;
+ own_doc_no?: string;
+ doc_title?: string;
+ lang_gb?: string;
+}
+
+/**
+ * 첨부파일 리스트 조회 필터
+ */
+export interface GetExternalInboxListFilter {
+ projNo: string; // 필수
+ pkgNo?: string;
+ vndrCd?: string;
+ stage?: string;
+ owndocno?: string;
+ doctitle?: string;
+}
+
+// ============================================================================
+// 공통 API 호출 함수
+// ============================================================================
+
+async function callSwpApi<T>(
+ endpoint: string,
+ body: Record<string, unknown>,
+ resultKey: string
+): Promise<T[]> {
+ const url = `${SWP_BASE_URL}/Services/WebService.svc/${endpoint}`;
+
+ console.log(`[SWP API] 호출: ${endpoint}`, body);
+
+ try {
+ const response = await fetch(url, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(body),
+ signal: AbortSignal.timeout(30000), // 30초
+ });
+
+ if (!response.ok) {
+ throw new Error(
+ `SWP API 오류: ${response.status} ${response.statusText}`
+ );
+ }
+
+ const data = await response.json();
+
+ if (!data[resultKey]) {
+ throw new Error(`API 응답 형식 오류: ${resultKey} 없음`);
+ }
+
+ console.log(`[SWP API] 성공: ${data[resultKey].length}개 조회`);
+
+ return data[resultKey] as T[];
+ } catch (error) {
+ if (error instanceof Error) {
+ if (error.name === "AbortError") {
+ throw new Error(`API 타임아웃: 30초 초과`);
+ }
+ throw new Error(`${endpoint} 실패: ${error.message}`);
+ }
+ throw error;
+ }
+}
+
+// ============================================================================
+// 서버 액션: 문서 리스트 조회
+// ============================================================================
+
+/**
+ * 문서 리스트 조회 (GetVDRDocumentList)
+ * @param filter 조회 필터
+ */
+export async function fetchGetVDRDocumentList(
+ filter: GetVDRDocumentListFilter
+): Promise<SwpDocumentApiResponse[]> {
+ // doc_gb 기본값 설정
+ const body = {
+ proj_no: filter.proj_no,
+ doc_gb: filter.doc_gb || "V", // 기본값 V
+ ctgry: filter.ctgry || "",
+ pkgNo: filter.pkgNo || "",
+ vndrCd: filter.vndrCd || "",
+ pic_deptcd: filter.pic_deptcd || "",
+ doc_type: filter.doc_type || "",
+ displn: filter.displn || "",
+ mat_cd: filter.mat_cd || "",
+ proj_nm: filter.proj_nm || "",
+ stage: filter.stage || "",
+ own_doc_no: filter.own_doc_no || "",
+ doc_title: filter.doc_title || "",
+ lang_gb: filter.lang_gb || "",
+ };
+
+ return callSwpApi<SwpDocumentApiResponse>(
+ "GetVDRDocumentList",
+ body,
+ "GetVDRDocumentListResult"
+ );
+}
+
+// ============================================================================
+// 서버 액션: 첨부파일 리스트 조회
+// ============================================================================
+
+/**
+ * 첨부파일 리스트 조회 (GetExternalInboxList)
+ * @param filter 조회 필터
+ */
+export async function fetchGetExternalInboxList(
+ filter: GetExternalInboxListFilter
+): Promise<SwpFileApiResponse[]> {
+ const body = {
+ projNo: filter.projNo,
+ pkgNo: filter.pkgNo || "",
+ vndrCd: filter.vndrCd || "",
+ stage: filter.stage || "",
+ owndocno: filter.owndocno || "",
+ doctitle: filter.doctitle || "",
+ };
+
+ return callSwpApi<SwpFileApiResponse>(
+ "GetExternalInboxList",
+ body,
+ "GetExternalInboxListResult"
+ );
+}
+
+// ============================================================================
+// 서버 액션: 프로젝트 데이터 일괄 조회
+// ============================================================================
+
+/**
+ * 프로젝트의 문서 + 파일 리스트 동시 조회
+ * @param projectNo 프로젝트 번호 (예: "SN2190")
+ * @param docGb 문서 구분 (M=MDR, V=VDR, 기본값: V)
+ */
+export async function fetchSwpProjectData(
+ projectNo: string,
+ docGb: "M" | "V" = "V"
+): Promise<{
+ documents: SwpDocumentApiResponse[];
+ files: SwpFileApiResponse[];
+}> {
+ console.log(`[SWP API] 프로젝트 ${projectNo} 데이터 조회 시작`);
+ const startTime = Date.now();
+
+ try {
+ // 병렬 호출
+ const [documents, files] = await Promise.all([
+ fetchGetVDRDocumentList({
+ proj_no: projectNo,
+ doc_gb: docGb,
+ }),
+ fetchGetExternalInboxList({
+ projNo: projectNo,
+ }),
+ ]);
+
+ const duration = Date.now() - startTime;
+ console.log(
+ `[SWP API] 조회 완료: 문서 ${documents.length}개, 파일 ${files.length}개 (${duration}ms)`
+ );
+
+ return { documents, files };
+ } catch (error) {
+ console.error(`[SWP API] 조회 실패:`, error);
+ throw error;
+ }
+}
+
+// ============================================================================
+// 파일 다운로드 URL 생성
+// ============================================================================
+
+/**
+ * SWP 파일 다운로드 URL 생성
+ */
+export async function getSwpFileDownloadUrl(file: {
+ FLD_PATH: string;
+ FILE_NM: string;
+}): Promise<string> {
+ // FLD_PATH: "\SN2190\C00035\\20170217180135"
+ // FILE_NM: "C168-SH-SBN08-XG-20118-01_04_IFC_20170216.pdf"
+
+ const encodedPath = encodeURIComponent(file.FLD_PATH);
+ const encodedName = encodeURIComponent(file.FILE_NM);
+
+ return `${SWP_BASE_URL}/Files/${encodedPath}/${encodedName}`;
+}
+
+/**
+ * SWP 파일 직접 다운로드 (Blob)
+ */
+export async function downloadSwpFile(file: {
+ FLD_PATH: string;
+ FILE_NM: string;
+}): Promise<Blob> {
+ const url = await getSwpFileDownloadUrl(file);
+
+ const response = await fetch(url, {
+ method: "GET",
+ signal: AbortSignal.timeout(60000), // 1분
+ });
+
+ if (!response.ok) {
+ throw new Error(`파일 다운로드 실패: ${response.status}`);
+ }
+
+ return response.blob();
+}
+
+// ============================================================================
+// 통계 및 유틸리티
+// ============================================================================
+
+/**
+ * API 응답 통계
+ */
+export interface SwpDataStats {
+ projectNo: string;
+ documentCount: number;
+ fileCount: number;
+ revisionCount: number;
+ avgFilesPerDoc: number;
+ stages: Record<string, number>;
+ fileTypes: Record<string, number>;
+ totalFileSize: number;
+}
+
+export async function analyzeSwpData(
+ projectNo: string,
+ documents: SwpDocumentApiResponse[],
+ files: SwpFileApiResponse[]
+): Promise<SwpDataStats> {
+ // 리비전 카운트
+ const revisionSet = new Set<string>();
+ files.forEach((f) => revisionSet.add(`${f.OWN_DOC_NO}|${f.REV_NO}`));
+
+ // 스테이지별 카운트
+ const stages: Record<string, number> = {};
+ files.forEach((f) => {
+ stages[f.STAGE] = (stages[f.STAGE] || 0) + 1;
+ });
+
+ // 파일 타입별 카운트
+ const fileTypes: Record<string, number> = {};
+ files.forEach((f) => {
+ const ext = f.FILE_NM.split(".").pop()?.toLowerCase() || "unknown";
+ fileTypes[ext] = (fileTypes[ext] || 0) + 1;
+ });
+
+ // 총 파일 사이즈 (숫자로 변환 가능한 것만)
+ const totalFileSize = files.reduce((sum, f) => {
+ const size = parseInt(f.FILE_SZ, 10);
+ return sum + (isNaN(size) ? 0 : size);
+ }, 0);
+
+ return {
+ projectNo,
+ documentCount: documents.length,
+ fileCount: files.length,
+ revisionCount: revisionSet.size,
+ avgFilesPerDoc:
+ documents.length > 0 ? files.length / documents.length : 0,
+ stages,
+ fileTypes,
+ totalFileSize,
+ };
+}
+
diff --git a/lib/swp/example-usage.ts b/lib/swp/example-usage.ts
new file mode 100644
index 00000000..8e1791f7
--- /dev/null
+++ b/lib/swp/example-usage.ts
@@ -0,0 +1,347 @@
+"use server";
+
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+/**
+ * SWP 문서 관리 시스템 사용 예제
+ *
+ * 이 파일은 실제 사용 시나리오를 보여주는 예제입니다.
+ */
+
+import {
+ fetchSwpProjectData,
+ analyzeSwpData,
+ getSwpFileDownloadUrl,
+} from "./api-client";
+import {
+ syncSwpProject,
+ getProjectDocumentsHierarchy,
+ getDocumentRevisions,
+ getRevisionFiles,
+ getProjectSyncStatus,
+} from "./sync-service";
+import db from "@/db/db";
+import { sql } from "drizzle-orm";
+
+// ============================================================================
+// 예제 1: 프로젝트 전체 동기화
+// ============================================================================
+
+export async function example1_FullProjectSync(projectNo: string) {
+ console.log("=== 예제 1: 프로젝트 전체 동기화 ===\n");
+
+ // 1. API에서 데이터 조회
+ console.log(`📡 API 호출 중...`);
+ const { documents, files } = await fetchSwpProjectData(projectNo, "V");
+
+ // 2. 데이터 분석
+ const stats: Awaited<ReturnType<typeof analyzeSwpData>> = await analyzeSwpData(projectNo, documents, files);
+ console.log(`📊 데이터 분석:`);
+ console.log(` - 문서: ${stats.documentCount}개`);
+ console.log(` - 리비전: ${stats.revisionCount}개`);
+ console.log(` - 파일: ${stats.fileCount}개`);
+ console.log(` - 평균 파일/문서: ${stats.avgFilesPerDoc.toFixed(2)}개`);
+ console.log(
+ ` - 총 용량: ${(stats.totalFileSize / 1024 / 1024).toFixed(2)} MB`
+ );
+ console.log(` - 스테이지:`, stats.stages);
+ console.log(` - 파일 타입:`, stats.fileTypes);
+
+ // 3. 동기화 실행
+ console.log(`\n💾 동기화 시작...`);
+ const syncResult = await syncSwpProject(projectNo, documents, files);
+
+ if (syncResult.success) {
+ console.log(`✅ 동기화 완료 (${syncResult.duration}ms)`);
+ console.log(` - 문서: +${syncResult.stats.documents.inserted}개`);
+ console.log(` - 리비전: +${syncResult.stats.revisions.inserted}개`);
+ console.log(` - 파일: +${syncResult.stats.files.inserted}개`);
+ } else {
+ console.error(`❌ 동기화 실패:`);
+ syncResult.errors.forEach((err) => console.error(` - ${err}`));
+ }
+
+ return syncResult;
+}
+
+// ============================================================================
+// 예제 2: 계층 구조 조회 및 UI 렌더링
+// ============================================================================
+
+export async function example2_HierarchyView(projectNo: string) {
+ console.log("=== 예제 2: 계층 구조 조회 ===\n");
+
+ // 1. 계층 뷰 조회
+ const result = await getProjectDocumentsHierarchy(projectNo);
+ const documents = result.rows as any[];
+
+ console.log(`📁 문서 ${documents.length}개 조회됨\n`);
+
+ // 2. 첫 3개 문서만 출력 (예제)
+ documents.slice(0, 3).forEach((doc) => {
+ console.log(`📄 ${doc.doc_no}`);
+ console.log(` 제목: ${doc.doc_title}`);
+ console.log(` 최신 리비전: ${doc.ltst_rev_no}`);
+ console.log(` 리비전 수: ${doc.revision_count}개`);
+
+ const revisions = JSON.parse(doc.revisions || "[]");
+ revisions.slice(0, 2).forEach((rev: any) => {
+ console.log(` 📋 REV ${rev.revNo} (${rev.stage})`);
+ console.log(` 파일: ${rev.fileCount}개`);
+
+ const files = rev.files || [];
+ files.forEach((file: any) => {
+ console.log(` 📎 ${file.fileNm} (${file.fileSz} bytes)`);
+ });
+ });
+ console.log();
+ });
+
+ return documents;
+}
+
+// ============================================================================
+// 예제 3: 특정 문서의 리비전 조회
+// ============================================================================
+
+export async function example3_DocumentRevisions(docNo: string) {
+ console.log("=== 예제 3: 문서 리비전 조회 ===\n");
+
+ // 1. 리비전 목록 조회
+ const revisions = await getDocumentRevisions(docNo);
+
+ console.log(`📄 문서: ${docNo}`);
+ console.log(`📋 리비전: ${revisions.length}개\n`);
+
+ // 2. 각 리비전별 파일 조회
+ for (const rev of revisions) {
+ const files = await getRevisionFiles(rev.id);
+
+ console.log(`REV ${rev.REV_NO} (${rev.STAGE})`);
+ console.log(` 파일: ${files.length}개`);
+ console.log(` Activity: ${rev.ACTV_NO || "N/A"}`);
+ console.log(` OFDC: ${rev.OFDC_NO}`);
+ console.log(` 동기화: ${rev.sync_status} (${rev.last_synced_at})`);
+
+ files.forEach((file) => {
+ console.log(` 📎 ${file.FILE_NM}`);
+ console.log(` 크기: ${file.FILE_SZ} bytes`);
+ console.log(` 경로: ${file.FLD_PATH}`);
+ console.log(` 상태: ${file.STAT_NM}`);
+ });
+ console.log();
+ }
+
+ return revisions;
+}
+
+// ============================================================================
+// 예제 4: 파일 검색 (플랫 뷰 활용)
+// ============================================================================
+
+export async function example4_SearchFiles(
+ projectNo: string,
+ fileNamePattern: string
+) {
+ console.log("=== 예제 4: 파일 검색 ===\n");
+
+ // 1. 플랫 뷰에서 검색
+ const result = await db.execute(sql`
+ SELECT
+ "DOC_NO",
+ "DOC_TITLE",
+ "REV_NO",
+ "STAGE",
+ "FILE_NM",
+ "FILE_SZ",
+ "FLD_PATH",
+ "STAT_NM"
+ FROM swp.v_swp_documents_flat
+ WHERE "PROJ_NO" = ${projectNo}
+ AND "FILE_NM" ILIKE ${`%${fileNamePattern}%`}
+ ORDER BY "DOC_NO", "REV_NO" DESC
+ LIMIT 20
+ `);
+
+ console.log(`🔍 검색어: "${fileNamePattern}"`);
+ console.log(`📊 결과: ${result.rowCount}개\n`);
+
+ result.rows.forEach((row: any) => {
+ console.log(`📄 ${row.DOC_NO} (${row.DOC_TITLE})`);
+ console.log(` REV ${row.REV_NO} (${row.STAGE})`);
+ console.log(` 📎 ${row.FILE_NM} (${row.FILE_SZ} bytes)`);
+ console.log(` 상태: ${row.STAT_NM}`);
+ console.log();
+ });
+
+ return result.rows;
+}
+
+// ============================================================================
+// 예제 5: 파일 다운로드 URL 생성
+// ============================================================================
+
+export async function example5_FileDownload(revisionId: number) {
+ console.log("=== 예제 5: 파일 다운로드 ===\n");
+
+ // 1. 리비전의 파일 조회
+ const files = await getRevisionFiles(revisionId);
+
+ console.log(`📋 리비전 ID: ${revisionId}`);
+ console.log(`📎 파일: ${files.length}개\n`);
+
+ // 2. 다운로드 URL 생성
+ const fileUrls = await Promise.all(
+ files
+ .filter((file) => file.FLD_PATH && file.FILE_NM)
+ .map(async (file) => ({
+ fileName: file.FILE_NM,
+ downloadUrl: await getSwpFileDownloadUrl({
+ FLD_PATH: file.FLD_PATH!,
+ FILE_NM: file.FILE_NM,
+ }),
+ size: file.FILE_SZ,
+ }))
+ );
+
+ fileUrls.forEach((item) => {
+ console.log(`📎 ${item.fileName}`);
+ console.log(` URL: ${item.downloadUrl}`);
+ console.log(` 크기: ${item.size} bytes`);
+ console.log();
+ });
+
+ return fileUrls;
+}
+
+// ============================================================================
+// 예제 6: 동기화 상태 모니터링
+// ============================================================================
+
+export async function example6_SyncMonitoring(projectNo: string) {
+ console.log("=== 예제 6: 동기화 상태 모니터링 ===\n");
+
+ // 1. 프로젝트 동기화 상태 조회
+ const result = await getProjectSyncStatus(projectNo);
+ const status = result.rows[0] as any;
+
+ console.log(`📊 프로젝트: ${status.proj_no} (${status.proj_nm})`);
+ console.log(`\n📈 통계:`);
+ console.log(` - 문서: ${status.total_documents}개`);
+ console.log(` - 리비전: ${status.total_revisions}개`);
+ console.log(` - 파일: ${status.total_files}개`);
+
+ console.log(`\n✅ 동기화 상태:`);
+ console.log(` - 문서: ${status.docs_synced}개 완료`);
+ console.log(` - 대기: ${status.docs_pending}개`);
+ console.log(` - 오류: ${status.docs_error}개`);
+
+ console.log(`\n🕐 마지막 동기화: ${status.last_sync_time}`);
+
+ return status;
+}
+
+// ============================================================================
+// 예제 7: 스테이지별 문서 통계
+// ============================================================================
+
+export async function example7_StageStatistics(projectNo: string) {
+ console.log("=== 예제 7: 스테이지별 통계 ===\n");
+
+ const result = await db.execute(sql`
+ SELECT
+ "STAGE",
+ COUNT(DISTINCT "DOC_NO")::int as doc_count,
+ COUNT(DISTINCT "REV_NO")::int as rev_count,
+ COUNT(*)::int as file_count
+ FROM swp.v_swp_documents_flat
+ WHERE "PROJ_NO" = ${projectNo}
+ AND "STAGE" IS NOT NULL
+ GROUP BY "STAGE"
+ ORDER BY "STAGE"
+ `);
+
+ console.log(`📊 프로젝트: ${projectNo}\n`);
+
+ result.rows.forEach((row: any) => {
+ console.log(`📌 ${row.STAGE}`);
+ console.log(` 문서: ${row.doc_count}개`);
+ console.log(` 리비전: ${row.rev_count}개`);
+ console.log(` 파일: ${row.file_count}개`);
+ console.log();
+ });
+
+ return result.rows;
+}
+
+// ============================================================================
+// 예제 8: 증분 동기화 (변경된 항목만)
+// ============================================================================
+
+export async function example8_IncrementalSync(projectNo: string) {
+ console.log("=== 예제 8: 증분 동기화 ===\n");
+
+ // 1. 마지막 동기화 시간 확인
+ const lastSyncResult = await db.execute(sql`
+ SELECT MAX(last_synced_at) as last_sync
+ FROM swp.swp_documents
+ WHERE "PROJ_NO" = ${projectNo}
+ `);
+
+ const lastSync = lastSyncResult.rows[0] as any;
+ console.log(`🕐 마지막 동기화: ${lastSync.last_sync || "없음"}`);
+
+ // 2. 전체 동기화 (API는 증분 제공 안하므로)
+ console.log(`📡 전체 데이터 조회 중...`);
+ const { documents, files } = await fetchSwpProjectData(projectNo, "V");
+
+ // 3. 동기화 (upsert로 변경된 항목만 업데이트됨)
+ console.log(`💾 동기화 시작...`);
+ const syncResult = await syncSwpProject(projectNo, documents, files);
+
+ console.log(`\n📊 결과:`);
+ console.log(
+ ` - 신규 문서: ${syncResult.stats.documents.inserted}개 (기존: ${syncResult.stats.documents.updated}개)`
+ );
+ console.log(
+ ` - 신규 리비전: ${syncResult.stats.revisions.inserted}개 (기존: ${syncResult.stats.revisions.updated}개)`
+ );
+ console.log(
+ ` - 신규 파일: ${syncResult.stats.files.inserted}개 (기존: ${syncResult.stats.files.updated}개)`
+ );
+
+ return syncResult;
+}
+
+// ============================================================================
+// 전체 시나리오 실행
+// ============================================================================
+
+export async function runAllExamples(projectNo: string = "SN2190") {
+ console.log("╔═══════════════════════════════════════════╗");
+ console.log("║ SWP 문서 관리 시스템 사용 예제 ║");
+ console.log("╚═══════════════════════════════════════════╝\n");
+
+ try {
+ // 예제 1: 전체 동기화
+ await example1_FullProjectSync(projectNo);
+ console.log("\n" + "=".repeat(50) + "\n");
+
+ // 예제 2: 계층 구조 조회
+ await example2_HierarchyView(projectNo);
+ console.log("\n" + "=".repeat(50) + "\n");
+
+ // 예제 6: 동기화 상태
+ await example6_SyncMonitoring(projectNo);
+ console.log("\n" + "=".repeat(50) + "\n");
+
+ // 예제 7: 스테이지별 통계
+ await example7_StageStatistics(projectNo);
+
+ console.log("\n✅ 모든 예제 실행 완료!");
+ } catch (error) {
+ console.error("\n❌ 오류 발생:", error);
+ throw error;
+ }
+}
+
diff --git a/lib/swp/sync-service.ts b/lib/swp/sync-service.ts
new file mode 100644
index 00000000..0a801bd8
--- /dev/null
+++ b/lib/swp/sync-service.ts
@@ -0,0 +1,522 @@
+"use server";
+
+import db from "@/db/db";
+import { eq, and, sql } from "drizzle-orm";
+import {
+ swpDocuments,
+ swpDocumentRevisions,
+ swpDocumentFiles,
+ type SwpDocumentInsert,
+ type SwpDocumentRevisionInsert,
+ type SwpDocumentFileInsert,
+ swpSchema,
+} from "@/db/schema/SWP/swp-documents";
+
+// ============================================================================
+// API 응답 타입 정의
+// ============================================================================
+
+export interface SwpDocumentApiResponse {
+ DOC_NO: string;
+ DOC_TITLE: string;
+ DOC_GB: string;
+ DOC_TYPE: string;
+ OWN_DOC_NO: string;
+ SHI_DOC_NO: string;
+ PROJ_NO: string;
+ PROJ_NM: string;
+ PKG_NO: string;
+ MAT_CD: string;
+ MAT_NM: string;
+ DISPLN: string;
+ CTGRY: string;
+ VNDR_CD: string;
+ CPY_CD: string;
+ CPY_NM: string;
+ PIC_NM: string;
+ PIC_DEPTCD: string;
+ PIC_DEPTNM: string;
+ LTST_REV_NO: string;
+ LTST_REV_SEQ: string;
+ LTST_ACTV_STAT: string;
+ STAGE: string;
+ SKL_CD: string;
+ MOD_TYPE: string;
+ ACT_TYPE_NM: string;
+ USE_YN: string;
+ CRTER: string;
+ CRTE_DTM: string;
+ CHGR: string;
+ CHG_DTM: string;
+ REV_DTM: string | null;
+}
+
+export interface SwpFileApiResponse {
+ OWN_DOC_NO: string;
+ REV_NO: string;
+ STAGE: string;
+ FILE_NM: string;
+ FILE_SEQ: string;
+ FILE_SZ: string;
+ FLD_PATH: string;
+ ACTV_NO: string | null;
+ ACTV_SEQ: string;
+ BOX_SEQ: string;
+ OFDC_NO: string;
+ PROJ_NO: string;
+ PKG_NO: string;
+ VNDR_CD: string;
+ CPY_CD: string;
+ STAT: string;
+ STAT_NM: string;
+ IDX: string;
+ CRTER: string;
+ CRTE_DTM: string;
+ CHGR: string;
+ CHG_DTM: string;
+}
+
+// ============================================================================
+// 동기화 결과 타입
+// ============================================================================
+
+export interface SyncResult {
+ success: boolean;
+ projectNo: string;
+ stats: {
+ documents: {
+ total: number;
+ inserted: number;
+ updated: number;
+ };
+ revisions: {
+ total: number;
+ inserted: number;
+ updated: number;
+ };
+ files: {
+ total: number;
+ inserted: number;
+ updated: number;
+ };
+ };
+ errors: string[];
+ duration: number;
+}
+
+// ============================================================================
+// 동기화 메인 함수
+// ============================================================================
+
+export async function syncSwpProject(
+ projectNo: string,
+ documents: SwpDocumentApiResponse[],
+ files: SwpFileApiResponse[]
+): Promise<SyncResult> {
+ const startTime = Date.now();
+ const errors: string[] = [];
+ const stats = {
+ documents: { total: 0, inserted: 0, updated: 0 },
+ revisions: { total: 0, inserted: 0, updated: 0 },
+ files: { total: 0, inserted: 0, updated: 0 },
+ };
+
+ try {
+ // 트랜잭션으로 일괄 처리
+ await db.transaction(async (tx) => {
+ // 1. 문서 동기화
+ console.log(`[SYNC] 문서 동기화 시작: ${documents.length}개`);
+ for (const doc of documents) {
+ try {
+ const result = await upsertDocument(tx, doc);
+ stats.documents.total++;
+ if (result.inserted) stats.documents.inserted++;
+ if (result.updated) stats.documents.updated++;
+ } catch (error) {
+ errors.push(
+ `문서 ${doc.DOC_NO} 동기화 실패: ${error instanceof Error ? error.message : String(error)}`
+ );
+ }
+ }
+
+ // 2. 리비전별로 파일 그룹핑
+ const revisionMap = new Map<string, SwpFileApiResponse[]>();
+ for (const file of files) {
+ const key = `${file.OWN_DOC_NO}|${file.REV_NO}`;
+ if (!revisionMap.has(key)) {
+ revisionMap.set(key, []);
+ }
+ revisionMap.get(key)!.push(file);
+ }
+
+ // 3. 리비전 및 파일 동기화
+ console.log(`[SYNC] 리비전 동기화 시작: ${revisionMap.size}개`);
+ for (const [key, revFiles] of revisionMap) {
+ const [docNo, revNo] = key.split("|");
+ const firstFile = revFiles[0];
+
+ try {
+ // 리비전 생성/업데이트
+ const revisionResult = await upsertRevision(tx, docNo, firstFile);
+ stats.revisions.total++;
+ if (revisionResult.inserted) stats.revisions.inserted++;
+ if (revisionResult.updated) stats.revisions.updated++;
+
+ const revisionId = revisionResult.id;
+
+ // 파일들 생성/업데이트
+ for (const file of revFiles) {
+ try {
+ const fileResult = await upsertFile(tx, revisionId, docNo, file);
+ stats.files.total++;
+ if (fileResult.inserted) stats.files.inserted++;
+ if (fileResult.updated) stats.files.updated++;
+ } catch (error) {
+ errors.push(
+ `파일 ${file.FILE_NM} 동기화 실패: ${error instanceof Error ? error.message : String(error)}`
+ );
+ }
+ }
+ } catch (error) {
+ errors.push(
+ `리비전 ${docNo}-${revNo} 동기화 실패: ${error instanceof Error ? error.message : String(error)}`
+ );
+ }
+ }
+
+ console.log(
+ `[SYNC] 동기화 완료: 문서 ${stats.documents.total}, 리비전 ${stats.revisions.total}, 파일 ${stats.files.total}`
+ );
+ });
+
+ return {
+ success: errors.length === 0,
+ projectNo,
+ stats,
+ errors,
+ duration: Date.now() - startTime,
+ };
+ } catch (error) {
+ return {
+ success: false,
+ projectNo,
+ stats,
+ errors: [
+ ...errors,
+ `트랜잭션 실패: ${error instanceof Error ? error.message : String(error)}`,
+ ],
+ duration: Date.now() - startTime,
+ };
+ }
+}
+
+// ============================================================================
+// Upsert 헬퍼 함수들
+// ============================================================================
+
+async function upsertDocument(
+ tx: any,
+ doc: SwpDocumentApiResponse
+): Promise<{ id: string; inserted: boolean; updated: boolean }> {
+ const data: SwpDocumentInsert = {
+ DOC_NO: doc.DOC_NO,
+ DOC_TITLE: doc.DOC_TITLE,
+ DOC_GB: doc.DOC_GB || null,
+ DOC_TYPE: doc.DOC_TYPE || null,
+ OWN_DOC_NO: doc.OWN_DOC_NO,
+ SHI_DOC_NO: doc.SHI_DOC_NO,
+ PROJ_NO: doc.PROJ_NO,
+ PROJ_NM: doc.PROJ_NM,
+ PKG_NO: doc.PKG_NO || null,
+ MAT_CD: doc.MAT_CD || null,
+ MAT_NM: doc.MAT_NM || null,
+ DISPLN: doc.DISPLN || null,
+ CTGRY: doc.CTGRY || null,
+ VNDR_CD: doc.VNDR_CD || null,
+ CPY_CD: doc.CPY_CD,
+ CPY_NM: doc.CPY_NM,
+ PIC_NM: doc.PIC_NM,
+ PIC_DEPTCD: doc.PIC_DEPTCD || null,
+ PIC_DEPTNM: doc.PIC_DEPTNM,
+ LTST_REV_NO: doc.LTST_REV_NO || null,
+ LTST_REV_SEQ: doc.LTST_REV_SEQ || null,
+ LTST_ACTV_STAT: doc.LTST_ACTV_STAT || null,
+ STAGE: doc.STAGE || null,
+ SKL_CD: doc.SKL_CD,
+ MOD_TYPE: doc.MOD_TYPE || null,
+ ACT_TYPE_NM: doc.ACT_TYPE_NM || null,
+ USE_YN: doc.USE_YN || null,
+ CRTER: doc.CRTER,
+ CRTE_DTM: doc.CRTE_DTM,
+ CHGR: doc.CHGR,
+ CHG_DTM: doc.CHG_DTM,
+ REV_DTM: doc.REV_DTM || null,
+ sync_status: "synced",
+ last_synced_at: new Date(),
+ updated_at: new Date(),
+ };
+
+ // 기존 문서 확인
+ const existing = await tx
+ .select()
+ .from(swpDocuments)
+ .where(eq(swpDocuments.DOC_NO, doc.DOC_NO))
+ .limit(1);
+
+ if (existing.length > 0) {
+ // 업데이트
+ await tx
+ .update(swpDocuments)
+ .set(data)
+ .where(eq(swpDocuments.DOC_NO, doc.DOC_NO));
+ return { id: doc.DOC_NO, inserted: false, updated: true };
+ } else {
+ // 삽입
+ await tx.insert(swpDocuments).values(data);
+ return { id: doc.DOC_NO, inserted: true, updated: false };
+ }
+}
+
+async function upsertRevision(
+ tx: any,
+ docNo: string,
+ file: SwpFileApiResponse
+): Promise<{ id: number; inserted: boolean; updated: boolean }> {
+ const data: Omit<SwpDocumentRevisionInsert, "id"> = {
+ DOC_NO: docNo,
+ REV_NO: file.REV_NO,
+ STAGE: file.STAGE,
+ ACTV_NO: file.ACTV_NO || null,
+ ACTV_SEQ: file.ACTV_SEQ,
+ BOX_SEQ: file.BOX_SEQ,
+ OFDC_NO: file.OFDC_NO,
+ PROJ_NO: file.PROJ_NO,
+ PKG_NO: file.PKG_NO || null,
+ VNDR_CD: file.VNDR_CD || null,
+ CPY_CD: file.CPY_CD,
+ sync_status: "synced",
+ last_synced_at: new Date(),
+ updated_at: new Date(),
+ };
+
+ // 기존 리비전 확인
+ const existing = await tx
+ .select()
+ .from(swpDocumentRevisions)
+ .where(
+ and(
+ eq(swpDocumentRevisions.DOC_NO, docNo),
+ eq(swpDocumentRevisions.REV_NO, file.REV_NO)
+ )
+ )
+ .limit(1);
+
+ if (existing.length > 0) {
+ // 업데이트
+ await tx
+ .update(swpDocumentRevisions)
+ .set(data)
+ .where(eq(swpDocumentRevisions.id, existing[0].id));
+ return { id: existing[0].id, inserted: false, updated: true };
+ } else {
+ // 삽입
+ const result = await tx
+ .insert(swpDocumentRevisions)
+ .values(data)
+ .returning({ id: swpDocumentRevisions.id });
+ return { id: result[0].id, inserted: true, updated: false };
+ }
+}
+
+async function upsertFile(
+ tx: any,
+ revisionId: number,
+ docNo: string,
+ file: SwpFileApiResponse
+): Promise<{ id: number; inserted: boolean; updated: boolean }> {
+ const data: Omit<SwpDocumentFileInsert, "id"> = {
+ revision_id: revisionId,
+ DOC_NO: docNo,
+ FILE_NM: file.FILE_NM,
+ FILE_SEQ: file.FILE_SEQ,
+ FILE_SZ: file.FILE_SZ,
+ FLD_PATH: file.FLD_PATH,
+ STAT: file.STAT,
+ STAT_NM: file.STAT_NM,
+ IDX: file.IDX,
+ ACTV_NO: file.ACTV_NO || null,
+ CRTER: file.CRTER,
+ CRTE_DTM: file.CRTE_DTM,
+ CHGR: file.CHGR,
+ CHG_DTM: file.CHG_DTM,
+ sync_status: "synced",
+ last_synced_at: new Date(),
+ updated_at: new Date(),
+ };
+
+ // 기존 파일 확인 (revision + fileSeq로 unique)
+ const existing = await tx
+ .select()
+ .from(swpDocumentFiles)
+ .where(
+ and(
+ eq(swpDocumentFiles.revision_id, revisionId),
+ eq(swpDocumentFiles.FILE_SEQ, file.FILE_SEQ)
+ )
+ )
+ .limit(1);
+
+ if (existing.length > 0) {
+ // 업데이트
+ await tx
+ .update(swpDocumentFiles)
+ .set(data)
+ .where(eq(swpDocumentFiles.id, existing[0].id));
+ return { id: existing[0].id, inserted: false, updated: true };
+ } else {
+ // 삽입
+ const result = await tx
+ .insert(swpDocumentFiles)
+ .values(data)
+ .returning({ id: swpDocumentFiles.id });
+ return { id: result[0].id, inserted: true, updated: false };
+ }
+}
+
+// ============================================================================
+// 조회 헬퍼 함수들
+// ============================================================================
+
+/**
+ * 프로젝트의 문서 계층 구조 조회 (복잡한 JSON 집계는 SQL 직접 실행)
+ */
+export async function getProjectDocumentsHierarchy(projectNo: string) {
+ return db.execute(sql`
+ SELECT
+ d."DOC_NO",
+ d."DOC_TITLE",
+ d."PROJ_NO",
+ d."PROJ_NM",
+ d."PKG_NO",
+ d."VNDR_CD",
+ d."CPY_NM",
+ d."MAT_NM",
+ d."LTST_REV_NO",
+ d."LTST_ACTV_STAT",
+ d.sync_status,
+ d.last_synced_at,
+
+ COALESCE(
+ json_agg(
+ json_build_object(
+ 'id', r.id,
+ 'revNo', r."REV_NO",
+ 'stage', r."STAGE",
+ 'actvNo', r."ACTV_NO",
+ 'ofdcNo', r."OFDC_NO",
+ 'syncStatus', r.sync_status,
+ 'fileCount', (
+ SELECT COUNT(*)::int
+ FROM swp.swp_document_files f2
+ WHERE f2.revision_id = r.id
+ ),
+ 'files', (
+ SELECT COALESCE(json_agg(
+ json_build_object(
+ 'id', f.id,
+ 'fileNm', f."FILE_NM",
+ 'fileSeq', f."FILE_SEQ",
+ 'fileSz', f."FILE_SZ",
+ 'fldPath', f."FLD_PATH",
+ 'stat', f."STAT",
+ 'statNm', f."STAT_NM",
+ 'syncStatus', f.sync_status,
+ 'createdAt', f.created_at
+ )
+ ORDER BY f."FILE_SEQ"
+ ), '[]'::json)
+ FROM swp.swp_document_files f
+ WHERE f.revision_id = r.id
+ )
+ )
+ ORDER BY r."REV_NO" DESC
+ ) FILTER (WHERE r.id IS NOT NULL),
+ '[]'::json
+ ) as revisions,
+
+ COUNT(DISTINCT r.id)::int as revision_count,
+ COUNT(f.id)::int as total_file_count
+
+ 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 d."PROJ_NO" = ${projectNo}
+ GROUP BY
+ d."DOC_NO",
+ d."DOC_TITLE",
+ d."PROJ_NO",
+ d."PROJ_NM",
+ d."PKG_NO",
+ d."VNDR_CD",
+ d."CPY_NM",
+ d."MAT_NM",
+ d."LTST_REV_NO",
+ d."LTST_ACTV_STAT",
+ d.sync_status,
+ d.last_synced_at
+ ORDER BY d."DOC_NO"
+ `);
+}
+
+/**
+ * 특정 문서의 모든 리비전 조회
+ */
+export async function getDocumentRevisions(docNo: string) {
+ return db
+ .select()
+ .from(swpDocumentRevisions)
+ .where(eq(swpDocumentRevisions.DOC_NO, docNo))
+ .orderBy(sql`${swpDocumentRevisions.REV_NO} DESC`);
+}
+
+/**
+ * 특정 리비전의 모든 파일 조회
+ */
+export async function getRevisionFiles(revisionId: number) {
+ return db
+ .select()
+ .from(swpDocumentFiles)
+ .where(eq(swpDocumentFiles.revision_id, revisionId))
+ .orderBy(swpDocumentFiles.FILE_SEQ);
+}
+
+/**
+ * 프로젝트 동기화 상태 조회
+ */
+export async function getProjectSyncStatus(projectNo: string) {
+ return db.execute(sql`
+ SELECT
+ d."PROJ_NO",
+ d."PROJ_NM",
+
+ 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(DISTINCT d."DOC_NO") FILTER (WHERE d.sync_status = 'synced')::int as docs_synced,
+ COUNT(DISTINCT d."DOC_NO") FILTER (WHERE d.sync_status = 'pending')::int as docs_pending,
+ COUNT(DISTINCT d."DOC_NO") FILTER (WHERE d.sync_status = 'error')::int as docs_error,
+
+ COUNT(DISTINCT r.id) FILTER (WHERE r.sync_status = 'synced')::int as revs_synced,
+ COUNT(f.id) FILTER (WHERE f.sync_status = 'synced')::int as files_synced,
+
+ MAX(d.last_synced_at) as last_sync_time
+
+ 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 d."PROJ_NO" = ${projectNo}
+ GROUP BY d."PROJ_NO", d."PROJ_NM"
+ `);
+}
+
diff --git a/lib/swp/table/swp-table-columns.tsx b/lib/swp/table/swp-table-columns.tsx
new file mode 100644
index 00000000..dd605453
--- /dev/null
+++ b/lib/swp/table/swp-table-columns.tsx
@@ -0,0 +1,394 @@
+"use client";
+
+import { ColumnDef } from "@tanstack/react-table";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { ChevronDown, ChevronRight, FileIcon } from "lucide-react";
+import { formatDistanceToNow } from "date-fns";
+import { ko } from "date-fns/locale";
+import type { SwpDocumentWithStats } from "../actions";
+
+export const swpDocumentColumns: ColumnDef<SwpDocumentWithStats>[] = [
+ {
+ id: "expander",
+ header: () => null,
+ cell: ({ row }) => {
+ return row.getCanExpand() ? (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={row.getToggleExpandedHandler()}
+ className="h-8 w-8 p-0"
+ >
+ {row.getIsExpanded() ? (
+ <ChevronDown className="h-4 w-4" />
+ ) : (
+ <ChevronRight className="h-4 w-4" />
+ )}
+ </Button>
+ ) : null;
+ },
+ size: 50,
+ },
+ {
+ accessorKey: "DOC_NO",
+ header: "문서번호",
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">{row.original.DOC_NO}</div>
+ ),
+ size: 250,
+ },
+ {
+ accessorKey: "DOC_TITLE",
+ header: "문서제목",
+ cell: ({ row }) => (
+ <div className="max-w-md truncate" title={row.original.DOC_TITLE}>
+ {row.original.DOC_TITLE}
+ </div>
+ ),
+ size: 300,
+ },
+ {
+ accessorKey: "PROJ_NO",
+ header: "프로젝트",
+ cell: ({ row }) => (
+ <div>
+ <div className="font-medium">{row.original.PROJ_NO}</div>
+ {row.original.PROJ_NM && (
+ <div className="text-xs text-muted-foreground truncate max-w-[150px]">
+ {row.original.PROJ_NM}
+ </div>
+ )}
+ </div>
+ ),
+ size: 150,
+ },
+ {
+ accessorKey: "PKG_NO",
+ header: "패키지",
+ cell: ({ row }) => row.original.PKG_NO || "-",
+ size: 100,
+ },
+ {
+ accessorKey: "VNDR_CD",
+ header: "업체",
+ cell: ({ row }) => (
+ <div>
+ {row.original.VNDR_CD && (
+ <div className="text-xs text-muted-foreground">{row.original.VNDR_CD}</div>
+ )}
+ {row.original.CPY_NM && (
+ <div className="text-sm truncate max-w-[120px]" title={row.original.CPY_NM}>
+ {row.original.CPY_NM}
+ </div>
+ )}
+ </div>
+ ),
+ size: 120,
+ },
+ {
+ accessorKey: "STAGE",
+ header: "스테이지",
+ cell: ({ row }) => {
+ const stage = row.original.STAGE;
+ if (!stage) return "-";
+
+ const color =
+ stage === "IFC" ? "bg-green-100 text-green-800" :
+ stage === "IFA" ? "bg-blue-100 text-blue-800" :
+ "bg-gray-100 text-gray-800";
+
+ return (
+ <Badge variant="outline" className={color}>
+ {stage}
+ </Badge>
+ );
+ },
+ size: 80,
+ },
+ {
+ accessorKey: "LTST_REV_NO",
+ header: "최신 REV",
+ cell: ({ row }) => row.original.LTST_REV_NO || "-",
+ size: 80,
+ },
+ {
+ id: "stats",
+ header: "REV/파일",
+ cell: ({ row }) => (
+ <div className="text-center">
+ <div className="text-sm font-medium">
+ {row.original.revision_count} / {row.original.file_count}
+ </div>
+ </div>
+ ),
+ size: 100,
+ },
+ {
+ accessorKey: "sync_status",
+ header: "상태",
+ cell: ({ row }) => {
+ const status = row.original.sync_status;
+ const color =
+ status === "synced" ? "bg-green-100 text-green-800" :
+ status === "pending" ? "bg-yellow-100 text-yellow-800" :
+ "bg-red-100 text-red-800";
+
+ return (
+ <Badge variant="outline" className={color}>
+ {status}
+ </Badge>
+ );
+ },
+ size: 80,
+ },
+ {
+ 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"
+ onClick={row.getToggleExpandedHandler()}
+ 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: "sync_status",
+ header: "상태",
+ cell: ({ row }) => {
+ const status = row.original.sync_status;
+ const color =
+ status === "synced" ? "bg-green-100 text-green-800" :
+ status === "pending" ? "bg-yellow-100 text-yellow-800" :
+ "bg-red-100 text-red-800";
+
+ return (
+ <Badge variant="outline" className={color}>
+ {status}
+ </Badge>
+ );
+ },
+ size: 80,
+ },
+ {
+ 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,
+ },
+];
+
diff --git a/lib/swp/table/swp-table-toolbar.tsx b/lib/swp/table/swp-table-toolbar.tsx
new file mode 100644
index 00000000..656dfd4a
--- /dev/null
+++ b/lib/swp/table/swp-table-toolbar.tsx
@@ -0,0 +1,340 @@
+"use client";
+
+import { useState, useTransition, useMemo } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { Label } from "@/components/ui/label";
+import { RefreshCw, Download, Search, X, Check, ChevronsUpDown } from "lucide-react";
+import { syncSwpProjectAction, type SwpTableFilters } from "../actions";
+import { useToast } from "@/hooks/use-toast";
+import { useRouter } from "next/navigation";
+import { cn } from "@/lib/utils";
+
+interface SwpTableToolbarProps {
+ filters: SwpTableFilters;
+ onFiltersChange: (filters: SwpTableFilters) => void;
+ projects?: Array<{ PROJ_NO: string; PROJ_NM: string }>;
+ mode?: "admin" | "vendor"; // admin: SWP 동기화 가능, vendor: 읽기 전용
+}
+
+export function SwpTableToolbar({
+ filters,
+ onFiltersChange,
+ projects = [],
+ mode = "admin",
+}: SwpTableToolbarProps) {
+ const [isSyncing, startSync] = useTransition();
+ const [localFilters, setLocalFilters] = useState<SwpTableFilters>(filters);
+ const { toast } = useToast();
+ const router = useRouter();
+ const [projectSearchOpen, setProjectSearchOpen] = useState(false);
+ const [projectSearch, setProjectSearch] = useState("");
+
+ // 동기화 핸들러
+ const handleSync = () => {
+ const projectNo = localFilters.projNo;
+
+ if (!projectNo) {
+ toast({
+ variant: "destructive",
+ title: "프로젝트 선택 필요",
+ description: "동기화할 프로젝트를 먼저 선택해주세요.",
+ });
+ return;
+ }
+
+ startSync(async () => {
+ try {
+ toast({
+ title: "동기화 시작",
+ description: `프로젝트 ${projectNo} 동기화를 시작합니다...`,
+ });
+
+ 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);
+ toast({
+ variant: "destructive",
+ title: "동기화 실패",
+ description: error instanceof Error ? error.message : "알 수 없는 오류",
+ });
+ }
+ });
+ };
+
+ // 검색 적용
+ const handleSearch = () => {
+ onFiltersChange(localFilters);
+ };
+
+ // 검색 초기화
+ const handleReset = () => {
+ const resetFilters: SwpTableFilters = {};
+ setLocalFilters(resetFilters);
+ onFiltersChange(resetFilters);
+ };
+
+ // 프로젝트 필터링
+ 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)
+ );
+ }, [projects, projectSearch]);
+
+ return (
+ <div className="space-y-4">
+ {/* 상단 액션 바 */}
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ {mode === "admin" && (
+ <Button
+ onClick={handleSync}
+ disabled={isSyncing || !localFilters.projNo}
+ size="sm"
+ >
+ <RefreshCw className={`h-4 w-4 mr-2 ${isSyncing ? "animate-spin" : ""}`} />
+ {isSyncing ? "동기화 중..." : "SWP 동기화"}
+ </Button>
+ )}
+
+ <Button variant="outline" size="sm" disabled>
+ <Download className="h-4 w-4 mr-2" />
+ Excel 내보내기
+ </Button>
+ </div>
+
+ <div className="text-sm text-muted-foreground">
+ {mode === "vendor" ? "문서 조회 및 업로드" : "SWP 문서 관리 시스템"}
+ </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="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 className="truncate">
+ {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"
+ )}
+ />
+ <div className="flex flex-col items-start">
+ <span className="font-mono text-sm">{proj.PROJ_NO}</span>
+ <span className="text-xs text-muted-foreground">{proj.PROJ_NM}</span>
+ </div>
+ </Button>
+ ))}
+ {filteredProjects.length === 0 && (
+ <div className="py-6 text-center text-sm text-muted-foreground">
+ 검색 결과가 없습니다.
+ </div>
+ )}
+ </div>
+ </div>
+ </PopoverContent>
+ </Popover>
+ ) : (
+ <Input
+ id="projNo"
+ placeholder="예: SN2190"
+ value={localFilters.projNo || ""}
+ onChange={(e) =>
+ setLocalFilters({ ...localFilters, projNo: e.target.value })
+ }
+ />
+ )}
+ </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 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="vndrCd">업체 코드</Label>
+ <Input
+ id="vndrCd"
+ placeholder="업체 코드"
+ value={localFilters.vndrCd || ""}
+ onChange={(e) =>
+ setLocalFilters({ ...localFilters, vndrCd: e.target.value })
+ }
+ />
+ </div>
+
+ {/* 스테이지 */}
+ <div className="space-y-2">
+ <Label htmlFor="stage">스테이지</Label>
+ <Select
+ value={localFilters.stage || "__all__"}
+ onValueChange={(value) =>
+ setLocalFilters({ ...localFilters, stage: value === "__all__" ? undefined : value })
+ }
+ >
+ <SelectTrigger id="stage">
+ <SelectValue placeholder="전체" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="__all__">전체</SelectItem>
+ <SelectItem value="IFA">IFA</SelectItem>
+ <SelectItem value="IFC">IFC</SelectItem>
+ <SelectItem value="AFC">AFC</SelectItem>
+ <SelectItem value="BFC">BFC</SelectItem>
+ </SelectContent>
+ </Select>
+ </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
new file mode 100644
index 00000000..4024c711
--- /dev/null
+++ b/lib/swp/table/swp-table.tsx
@@ -0,0 +1,394 @@
+"use client";
+
+import { useState } from "react";
+import {
+ useReactTable,
+ getCoreRowModel,
+ getExpandedRowModel,
+ flexRender,
+ ExpandedState,
+} from "@tanstack/react-table";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { Button } from "@/components/ui/button";
+import { Loader2 } from "lucide-react";
+import { swpDocumentColumns, swpRevisionColumns, swpFileColumns, type RevisionRow, type FileRow } from "./swp-table-columns";
+import { fetchDocumentRevisions, fetchRevisionFiles, type SwpDocumentWithStats } from "../actions";
+
+interface SwpTableProps {
+ initialData: SwpDocumentWithStats[];
+ total: number;
+ page: number;
+ pageSize: number;
+ totalPages: number;
+ onPageChange: (page: number) => void;
+ mode?: "admin" | "vendor";
+}
+
+export function SwpTable({
+ initialData,
+ total,
+ page,
+ pageSize,
+ totalPages,
+ onPageChange,
+ mode = "admin",
+}: 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 table = useReactTable({
+ data: initialData,
+ columns: swpDocumentColumns,
+ state: {
+ expanded,
+ },
+ onExpandedChange: setExpanded,
+ getCoreRowModel: getCoreRowModel(),
+ getExpandedRowModel: getExpandedRowModel(),
+ getRowCanExpand: (row) => 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;
+ });
+ }
+ };
+
+ // 문서 행 확장 핸들러
+ const handleDocumentExpand = (docNo: string, isExpanded: boolean) => {
+ if (!isExpanded) {
+ loadRevisions(docNo);
+ }
+ };
+
+ return (
+ <div className="space-y-4">
+ {/* 테이블 */}
+ <div className="rounded-md border">
+ <Table>
+ <TableHeader>
+ {table.getHeaderGroups().map((headerGroup) => (
+ <TableRow key={headerGroup.id}>
+ {headerGroup.headers.map((header) => (
+ <TableHead key={header.id}>
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+ </TableHead>
+ ))}
+ </TableRow>
+ ))}
+ </TableHeader>
+ <TableBody>
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => (
+ <>
+ {/* 문서 행 */}
+ <TableRow
+ key={row.id}
+ data-state={row.getIsSelected() && "selected"}
+ className="hover:bg-muted/50"
+ >
+ {row.getVisibleCells().map((cell) => (
+ <TableCell key={cell.id}>
+ {cell.column.id === "expander" ? (
+ <div
+ onClick={() => {
+ row.toggleExpanded();
+ handleDocumentExpand(row.original.DOC_NO, row.getIsExpanded());
+ }}
+ >
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )}
+ </div>
+ ) : (
+ flexRender(cell.column.columnDef.cell, cell.getContext())
+ )}
+ </TableCell>
+ ))}
+ </TableRow>
+
+ {/* 리비전 행들 (확장 시) */}
+ {row.getIsExpanded() && (
+ <TableRow>
+ <TableCell colSpan={swpDocumentColumns.length} className="p-0 bg-muted/30">
+ {loadingRevisions.has(row.original.DOC_NO) ? (
+ <div className="flex items-center justify-center p-8">
+ <Loader2 className="h-6 w-6 animate-spin" />
+ <span className="ml-2">리비전 로딩 중...</span>
+ </div>
+ ) : revisionData[row.original.DOC_NO]?.length ? (
+ <RevisionSubTable
+ revisions={revisionData[row.original.DOC_NO]}
+ fileData={fileData}
+ loadingFiles={loadingFiles}
+ onLoadFiles={loadFiles}
+ />
+ ) : (
+ <div className="p-8 text-center text-muted-foreground">
+ 리비전 없음
+ </div>
+ )}
+ </TableCell>
+ </TableRow>
+ )}
+ </>
+ ))
+ ) : (
+ <TableRow>
+ <TableCell colSpan={swpDocumentColumns.length} className="h-24 text-center">
+ 데이터 없음
+ </TableCell>
+ </TableRow>
+ )}
+ </TableBody>
+ </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>
+ </div>
+ );
+}
+
+// ============================================================================
+// 리비전 서브 테이블
+// ============================================================================
+
+interface RevisionSubTableProps {
+ revisions: RevisionRow[];
+ fileData: Record<number, FileRow[]>;
+ loadingFiles: Set<number>;
+ onLoadFiles: (revisionId: number) => void;
+}
+
+function RevisionSubTable({
+ revisions,
+ fileData,
+ loadingFiles,
+ onLoadFiles,
+}: RevisionSubTableProps) {
+ const [expandedRevisions, setExpandedRevisions] = useState<ExpandedState>({});
+
+ const revisionTable = useReactTable({
+ data: revisions,
+ columns: swpRevisionColumns,
+ state: {
+ expanded: expandedRevisions,
+ },
+ onExpandedChange: setExpandedRevisions,
+ getCoreRowModel: getCoreRowModel(),
+ getExpandedRowModel: getExpandedRowModel(),
+ getRowCanExpand: () => true,
+ });
+
+ const handleRevisionExpand = (revisionId: number, isExpanded: boolean) => {
+ if (!isExpanded) {
+ onLoadFiles(revisionId);
+ }
+ };
+
+ return (
+ <div className="border-l-4 border-blue-200">
+ <Table>
+ <TableHeader>
+ {revisionTable.getHeaderGroups().map((headerGroup) => (
+ <TableRow key={headerGroup.id} className="bg-muted/50">
+ {headerGroup.headers.map((header) => (
+ <TableHead key={header.id} className="font-semibold">
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+ </TableHead>
+ ))}
+ </TableRow>
+ ))}
+ </TableHeader>
+ <TableBody>
+ {revisionTable.getRowModel().rows.map((row) => (
+ <>
+ {/* 리비전 행 */}
+ <TableRow key={row.id} className="bg-muted/20">
+ {row.getVisibleCells().map((cell) => (
+ <TableCell key={cell.id}>
+ {cell.column.id === "expander" ? (
+ <div
+ onClick={() => {
+ row.toggleExpanded();
+ handleRevisionExpand(row.original.id, row.getIsExpanded());
+ }}
+ >
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )}
+ </div>
+ ) : (
+ flexRender(cell.column.columnDef.cell, cell.getContext())
+ )}
+ </TableCell>
+ ))}
+ </TableRow>
+
+ {/* 파일 행들 (확장 시) */}
+ {row.getIsExpanded() && (
+ <TableRow>
+ <TableCell colSpan={swpRevisionColumns.length} className="p-0 bg-blue-50/30">
+ {loadingFiles.has(row.original.id) ? (
+ <div className="flex items-center justify-center p-4">
+ <Loader2 className="h-5 w-5 animate-spin" />
+ <span className="ml-2 text-sm">파일 로딩 중...</span>
+ </div>
+ ) : fileData[row.original.id]?.length ? (
+ <FileSubTable files={fileData[row.original.id]} />
+ ) : (
+ <div className="p-4 text-center text-sm text-muted-foreground">
+ 파일 없음
+ </div>
+ )}
+ </TableCell>
+ </TableRow>
+ )}
+ </>
+ ))}
+ </TableBody>
+ </Table>
+ </div>
+ );
+}
+
+// ============================================================================
+// 파일 서브 테이블
+// ============================================================================
+
+interface FileSubTableProps {
+ files: FileRow[];
+}
+
+function FileSubTable({ files }: FileSubTableProps) {
+ const fileTable = useReactTable({
+ data: files,
+ columns: swpFileColumns,
+ getCoreRowModel: getCoreRowModel(),
+ });
+
+ return (
+ <div className="border-l-4 border-green-200">
+ <Table>
+ <TableHeader>
+ {fileTable.getHeaderGroups().map((headerGroup) => (
+ <TableRow key={headerGroup.id} className="bg-blue-50/50">
+ {headerGroup.headers.map((header) => (
+ <TableHead key={header.id} className="font-semibold text-xs">
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+ </TableHead>
+ ))}
+ </TableRow>
+ ))}
+ </TableHeader>
+ <TableBody>
+ {fileTable.getRowModel().rows.map((row) => (
+ <TableRow key={row.id} className="bg-green-50/20 hover:bg-green-50/40">
+ {row.getVisibleCells().map((cell) => (
+ <TableCell key={cell.id} className="py-2">
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+ </TableCell>
+ ))}
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </div>
+ );
+}
diff --git a/lib/swp/vendor-actions.ts b/lib/swp/vendor-actions.ts
new file mode 100644
index 00000000..7d6dfa85
--- /dev/null
+++ b/lib/swp/vendor-actions.ts
@@ -0,0 +1,273 @@
+"use server";
+
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/app/api/auth/[...nextauth]/route";
+import db from "@/db/db";
+import { vendors } from "@/db/schema/vendors";
+import { contracts } from "@/db/schema/contract";
+import { projects } from "@/db/schema/projects";
+import { swpDocumentFiles, swpDocumentRevisions } from "@/db/schema/SWP/swp-documents";
+import { eq, and, sql } from "drizzle-orm";
+import { fetchSwpDocuments, type SwpTableParams } from "./actions";
+
+// ============================================================================
+// 벤더 세션 정보 조회
+// ============================================================================
+
+interface VendorSessionInfo {
+ vendorId: number;
+ vendorCode: string;
+ vendorName: string;
+ companyId: number;
+}
+
+export async function getVendorSessionInfo(): Promise<VendorSessionInfo | null> {
+ const session = await getServerSession(authOptions);
+
+ if (!session?.user?.companyId) {
+ return null;
+ }
+
+ const companyId = typeof session.user.companyId === 'string'
+ ? parseInt(session.user.companyId, 10)
+ : session.user.companyId as number;
+
+ // vendors 테이블에서 companyId로 벤더 정보 조회
+ const vendor = await db
+ .select({
+ id: vendors.id,
+ vendorCode: vendors.vendorCode,
+ vendorName: vendors.vendorName,
+ })
+ .from(vendors)
+ .where(eq(vendors.id, companyId))
+ .limit(1);
+
+ if (!vendor[0] || !vendor[0].vendorCode) {
+ return null;
+ }
+
+ return {
+ vendorId: vendor[0].id,
+ vendorCode: vendor[0].vendorCode,
+ vendorName: vendor[0].vendorName,
+ companyId,
+ };
+}
+
+// ============================================================================
+// 벤더의 프로젝트 목록 조회
+// ============================================================================
+
+export async function fetchVendorProjects() {
+ try {
+ const vendorInfo = await getVendorSessionInfo();
+
+ if (!vendorInfo) {
+ throw new Error("벤더 정보를 찾을 수 없습니다.");
+ }
+
+ // contracts 테이블에서 해당 벤더의 계약들의 프로젝트 조회
+ const vendorProjects = await db
+ .selectDistinct({
+ PROJ_NO: projects.code,
+ PROJ_NM: projects.name,
+ contract_count: sql<number>`COUNT(DISTINCT ${contracts.id})::int`,
+ })
+ .from(contracts)
+ .innerJoin(projects, eq(contracts.projectId, projects.id))
+ .where(eq(contracts.vendorId, vendorInfo.vendorId))
+ .groupBy(projects.id, projects.code, projects.name)
+ .orderBy(sql`COUNT(DISTINCT ${contracts.id}) DESC`);
+
+ return vendorProjects;
+ } catch (error) {
+ console.error("[fetchVendorProjects] 오류:", error);
+ return [];
+ }
+}
+
+// ============================================================================
+// 벤더 필터링된 문서 목록 조회
+// ============================================================================
+
+export async function fetchVendorDocuments(params: SwpTableParams) {
+ try {
+ const vendorInfo = await getVendorSessionInfo();
+
+ if (!vendorInfo) {
+ throw new Error("벤더 정보를 찾을 수 없습니다.");
+ }
+
+ // 벤더 코드를 필터에 자동 추가
+ const vendorParams: SwpTableParams = {
+ ...params,
+ filters: {
+ ...params.filters,
+ vndrCd: vendorInfo.vendorCode, // 벤더 코드 필터 강제 적용
+ },
+ };
+
+ // 기존 fetchSwpDocuments 재사용
+ return await fetchSwpDocuments(vendorParams);
+ } catch (error) {
+ console.error("[fetchVendorDocuments] 오류:", error);
+ throw new Error("문서 목록 조회 실패");
+ }
+}
+
+// ============================================================================
+// 파일 업로드
+// ============================================================================
+
+export interface FileUploadParams {
+ revisionId: number;
+ file: {
+ FILE_NM: string;
+ FILE_SEQ: string;
+ FILE_SZ: string;
+ FLD_PATH: string;
+ STAT?: string;
+ STAT_NM?: string;
+ };
+}
+
+export async function uploadFileToRevision(params: FileUploadParams) {
+ try {
+ const vendorInfo = await getVendorSessionInfo();
+
+ if (!vendorInfo) {
+ throw new Error("벤더 정보를 찾을 수 없습니다.");
+ }
+
+ const { revisionId, file } = params;
+
+ // 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);
+
+ if (!revisionCheck[0]) {
+ throw new Error("리비전을 찾을 수 없습니다.");
+ }
+
+ // 벤더 코드가 일치하는지 확인
+ if (revisionCheck[0].VNDR_CD !== vendorInfo.vendorCode) {
+ throw new Error("이 문서에 대한 권한이 없습니다.");
+ }
+
+ // 2. 파일 정보 저장 (upsert)
+ const existingFile = await db
+ .select({ id: swpDocumentFiles.id })
+ .from(swpDocumentFiles)
+ .where(
+ and(
+ eq(swpDocumentFiles.revision_id, revisionId),
+ eq(swpDocumentFiles.FILE_SEQ, file.FILE_SEQ)
+ )
+ )
+ .limit(1);
+
+ if (existingFile[0]) {
+ // 업데이트
+ await db.execute(sql`
+ UPDATE swp.swp_document_files
+ SET
+ "FILE_NM" = ${file.FILE_NM},
+ "FILE_SZ" = ${file.FILE_SZ},
+ "FLD_PATH" = ${file.FLD_PATH},
+ "STAT" = ${file.STAT || null},
+ "STAT_NM" = ${file.STAT_NM || null},
+ sync_status = 'synced',
+ updated_at = NOW()
+ WHERE id = ${existingFile[0].id}
+ `);
+
+ return { success: true, fileId: existingFile[0].id, action: "updated" };
+ } else {
+ // 삽입
+ const result = await db.execute<{ id: number }>(sql`
+ INSERT INTO swp.swp_document_files
+ (revision_id, "FILE_NM", "FILE_SEQ", "FILE_SZ", "FLD_PATH", "STAT", "STAT_NM", sync_status)
+ VALUES
+ (${revisionId}, ${file.FILE_NM}, ${file.FILE_SEQ}, ${file.FILE_SZ}, ${file.FLD_PATH}, ${file.STAT || null}, ${file.STAT_NM || null}, 'synced')
+ RETURNING id
+ `);
+
+ return { success: true, fileId: result.rows[0].id, action: "created" };
+ }
+ } catch (error) {
+ console.error("[uploadFileToRevision] 오류:", error);
+ throw new Error(
+ error instanceof Error ? error.message : "파일 업로드 실패"
+ );
+ }
+}
+
+// ============================================================================
+// 벤더 통계 조회
+// ============================================================================
+
+export async function fetchVendorSwpStats(projNo?: string) {
+ try {
+ const vendorInfo = await getVendorSessionInfo();
+
+ if (!vendorInfo) {
+ throw new Error("벤더 정보를 찾을 수 없습니다.");
+ }
+
+ const whereConditions = [
+ sql`d."VNDR_CD" = ${vendorInfo.vendorCode}`,
+ ];
+
+ if (projNo) {
+ whereConditions.push(sql`d."PROJ_NO" = ${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 `)}
+ `);
+
+ return stats.rows[0] || {
+ total_documents: 0,
+ total_revisions: 0,
+ total_files: 0,
+ uploaded_files: 0,
+ last_sync: null,
+ };
+ } catch (error) {
+ console.error("[fetchVendorSwpStats] 오류:", error);
+ return {
+ total_documents: 0,
+ total_revisions: 0,
+ total_files: 0,
+ uploaded_files: 0,
+ last_sync: null,
+ };
+ }
+}
+