summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/[lng]/evcp/(evcp)/(eng)/cover/page.tsx77
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/po/page.tsx2
-rw-r--r--app/[lng]/evcp/data-room/[projectId]/files/page.tsx (renamed from app/[lng]/evcp/(evcp)/data-room/[projectId]/files/page.tsx)0
-rw-r--r--app/[lng]/evcp/data-room/[projectId]/layout.tsx (renamed from app/[lng]/evcp/(evcp)/data-room/[projectId]/layout.tsx)0
-rw-r--r--app/[lng]/evcp/data-room/[projectId]/members/page.tsx (renamed from app/[lng]/evcp/(evcp)/data-room/[projectId]/members/page.tsx)0
-rw-r--r--app/[lng]/evcp/data-room/[projectId]/page.tsx (renamed from app/[lng]/evcp/(evcp)/data-room/[projectId]/page.tsx)0
-rw-r--r--app/[lng]/evcp/data-room/[projectId]/settings/page.tsx (renamed from app/[lng]/evcp/(evcp)/data-room/[projectId]/settings/page.tsx)0
-rw-r--r--app/[lng]/evcp/data-room/[projectId]/stats/page.tsx (renamed from app/[lng]/evcp/(evcp)/data-room/[projectId]/stats/page.tsx)0
-rw-r--r--app/[lng]/evcp/data-room/layout.tsx17
-rw-r--r--app/[lng]/evcp/data-room/owner-companies/[id]/page.tsx43
-rw-r--r--app/[lng]/evcp/data-room/owner-companies/[id]/users/new/page.tsx41
-rw-r--r--app/[lng]/evcp/data-room/owner-companies/[id]/users/page.tsx63
-rw-r--r--app/[lng]/evcp/data-room/owner-companies/new/page.tsx18
-rw-r--r--app/[lng]/evcp/data-room/owner-companies/page.tsx32
-rw-r--r--app/[lng]/evcp/data-room/page.tsx (renamed from app/[lng]/evcp/(evcp)/data-room/page.tsx)0
-rw-r--r--app/[lng]/partners/(partners)/po/page.tsx2
-rw-r--r--app/[lng]/partners/data-room/[projectId]/files/page.tsx (renamed from app/[lng]/partners/(partners)/data-room/[projectId]/files/page.tsx)6
-rw-r--r--app/[lng]/partners/data-room/[projectId]/layout.tsx (renamed from app/[lng]/partners/(partners)/data-room/[projectId]/layout.tsx)0
-rw-r--r--app/[lng]/partners/data-room/[projectId]/members/page.tsx (renamed from app/[lng]/partners/(partners)/data-room/[projectId]/members/page.tsx)0
-rw-r--r--app/[lng]/partners/data-room/[projectId]/page.tsx (renamed from app/[lng]/partners/(partners)/data-room/[projectId]/page.tsx)0
-rw-r--r--app/[lng]/partners/data-room/[projectId]/settings/page.tsx (renamed from app/[lng]/partners/(partners)/data-room/[projectId]/settings/page.tsx)0
-rw-r--r--app/[lng]/partners/data-room/[projectId]/stats/page.tsx (renamed from app/[lng]/partners/(partners)/data-room/[projectId]/stats/page.tsx)0
-rw-r--r--app/[lng]/partners/data-room/layout.tsx17
-rw-r--r--app/[lng]/partners/data-room/page.tsx (renamed from app/[lng]/partners/(partners)/data-room/page.tsx)0
-rw-r--r--app/api/projects/[projectId]/cover/route.ts73
-rw-r--r--app/api/projects/cover-template/save/route.ts125
-rw-r--r--app/api/projects/cover-template/upload/route.ts127
-rw-r--r--components/file-manager/FileManager copy.tsx1852
-rw-r--r--components/file-manager/FileManager.tsx729
-rw-r--r--components/file-manager/SecurePDFViewer.tsx9
-rw-r--r--components/file-manager/creaetWaterMarks.tsx71
-rw-r--r--components/form-data/form-data-table.tsx190
-rw-r--r--components/layout/HeaderDataroom.tsx202
-rw-r--r--components/layout/HeaderSimple.tsx2
-rw-r--r--components/project/ProjectList.tsx2
-rw-r--r--config/menuConfig.ts6
-rw-r--r--db/schema/companies.ts10
-rw-r--r--db/schema/users.ts15
-rw-r--r--db/schema/vendorDocu.ts52
-rw-r--r--lib/cover/repository.ts44
-rw-r--r--lib/cover/service.ts123
-rw-r--r--lib/cover/table/cover-template-dialog.tsx455
-rw-r--r--lib/cover/table/projects-table-columns.tsx187
-rw-r--r--lib/cover/table/projects-table-toolbar-actions.tsx50
-rw-r--r--lib/cover/table/projects-table.tsx114
-rw-r--r--lib/cover/validation.ts36
-rw-r--r--lib/owner-companies/owner-company-form.tsx99
-rw-r--r--lib/owner-companies/owner-company-list.tsx85
-rw-r--r--lib/owner-companies/owner-company-user-form.tsx125
-rw-r--r--lib/owner-companies/owner-company-user-list.tsx93
-rw-r--r--lib/owner-companies/service.ts77
-rw-r--r--lib/pcr/table/detail-table/pcr-detail-toolbar-action.tsx5
-rw-r--r--lib/pcr/table/pcr-table-toolbar-actions.tsx4
-rw-r--r--lib/po/service.ts2
-rw-r--r--lib/procurement-items/service.ts18
-rw-r--r--lib/procurement-items/validations.ts6
-rw-r--r--lib/rfq-last/service.ts1
-rw-r--r--lib/rfq-last/table/rfq-assign-pic-dialog.tsx36
-rw-r--r--lib/rfq-last/table/rfq-table-toolbar-actions.tsx28
-rw-r--r--lib/rfq-last/vendor/rfq-vendor-table.tsx32
-rw-r--r--lib/techsales-rfq/service.ts4
-rw-r--r--lib/vendor-document-list/plant/upload/columns.tsx198
-rw-r--r--lib/vendor-document-list/plant/upload/table.tsx37
-rw-r--r--lib/vendor-document-list/ship-all/enhanced-documents-table.tsx20
64 files changed, 4946 insertions, 716 deletions
diff --git a/app/[lng]/evcp/(evcp)/(eng)/cover/page.tsx b/app/[lng]/evcp/(evcp)/(eng)/cover/page.tsx
new file mode 100644
index 00000000..9f2b2e61
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/(eng)/cover/page.tsx
@@ -0,0 +1,77 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+import { searchParamsProjectsCache } from "@/lib/projects/validation"
+import { InformationButton } from "@/components/information/information-button"
+import { getProjectListsForCover } from "@/lib/cover/service"
+import { ProjectsTableForCover } from "@/lib/cover/table/projects-table"
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsProjectsCache.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getProjectListsForCover({
+ ...search,
+ filters: validFilters,
+ }),
+
+ ])
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <div className="flex items-center gap-2">
+ <h2 className="text-2xl font-bold tracking-tight">
+ 프로젝트 리스트
+ </h2>
+ <InformationButton pagePath="evcp/projects" />
+ </div>
+ {/* <p className="text-muted-foreground">
+ S-EDP로부터 수신하는 프로젝트 리스트입니다. 향후 MDG로 전환됩니다.{" "}
+ <span className="inline-flex items-center whitespace-nowrap">
+ <Ellipsis className="size-3" />
+ <span className="ml-1">버튼</span>
+ </span>
+ 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다.
+ </p> */}
+ </div>
+ </div>
+ </div>
+
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ {/* <DateRangePicker
+ triggerSize="sm"
+ triggerClassName="ml-auto w-56 sm:w-60"
+ align="end"
+ shallow={false}
+ /> */}
+ </React.Suspense>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <ProjectsTableForCover promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+}
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/po/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/po/page.tsx
index 7479df8c..292ef1cb 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/po/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/po/page.tsx
@@ -34,7 +34,7 @@ export default async function VendorPONew(props: VendorPOPageProps) {
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">
- PO 관리
+ PO/계약 관리
</h2>
<InformationButton pagePath="evcp/po-new" />
</div>
diff --git a/app/[lng]/evcp/(evcp)/data-room/[projectId]/files/page.tsx b/app/[lng]/evcp/data-room/[projectId]/files/page.tsx
index baac96ad..baac96ad 100644
--- a/app/[lng]/evcp/(evcp)/data-room/[projectId]/files/page.tsx
+++ b/app/[lng]/evcp/data-room/[projectId]/files/page.tsx
diff --git a/app/[lng]/evcp/(evcp)/data-room/[projectId]/layout.tsx b/app/[lng]/evcp/data-room/[projectId]/layout.tsx
index d2e74f8e..d2e74f8e 100644
--- a/app/[lng]/evcp/(evcp)/data-room/[projectId]/layout.tsx
+++ b/app/[lng]/evcp/data-room/[projectId]/layout.tsx
diff --git a/app/[lng]/evcp/(evcp)/data-room/[projectId]/members/page.tsx b/app/[lng]/evcp/data-room/[projectId]/members/page.tsx
index 18442c0e..18442c0e 100644
--- a/app/[lng]/evcp/(evcp)/data-room/[projectId]/members/page.tsx
+++ b/app/[lng]/evcp/data-room/[projectId]/members/page.tsx
diff --git a/app/[lng]/evcp/(evcp)/data-room/[projectId]/page.tsx b/app/[lng]/evcp/data-room/[projectId]/page.tsx
index d54a8cab..d54a8cab 100644
--- a/app/[lng]/evcp/(evcp)/data-room/[projectId]/page.tsx
+++ b/app/[lng]/evcp/data-room/[projectId]/page.tsx
diff --git a/app/[lng]/evcp/(evcp)/data-room/[projectId]/settings/page.tsx b/app/[lng]/evcp/data-room/[projectId]/settings/page.tsx
index aa0f3b52..aa0f3b52 100644
--- a/app/[lng]/evcp/(evcp)/data-room/[projectId]/settings/page.tsx
+++ b/app/[lng]/evcp/data-room/[projectId]/settings/page.tsx
diff --git a/app/[lng]/evcp/(evcp)/data-room/[projectId]/stats/page.tsx b/app/[lng]/evcp/data-room/[projectId]/stats/page.tsx
index 7f652a99..7f652a99 100644
--- a/app/[lng]/evcp/(evcp)/data-room/[projectId]/stats/page.tsx
+++ b/app/[lng]/evcp/data-room/[projectId]/stats/page.tsx
diff --git a/app/[lng]/evcp/data-room/layout.tsx b/app/[lng]/evcp/data-room/layout.tsx
new file mode 100644
index 00000000..9bef6027
--- /dev/null
+++ b/app/[lng]/evcp/data-room/layout.tsx
@@ -0,0 +1,17 @@
+import { ReactNode } from 'react';
+import { SiteFooter } from '@/components/layout/Footer';
+import { HeaderDataRoom } from '@/components/layout/HeaderDataroom';
+
+export default function EvcpLayout({ children }: { children: ReactNode }) {
+ return (
+ <div className="flex flex-col h-screen bg-background">
+ <HeaderDataRoom />
+ <main className="flex-1 overflow-hidden">
+ <div className='container-wrapper h-full'>
+ {children}
+ </div>
+ </main>
+ <SiteFooter/>
+ </div>
+ );
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/data-room/owner-companies/[id]/page.tsx b/app/[lng]/evcp/data-room/owner-companies/[id]/page.tsx
new file mode 100644
index 00000000..cc1901e4
--- /dev/null
+++ b/app/[lng]/evcp/data-room/owner-companies/[id]/page.tsx
@@ -0,0 +1,43 @@
+// app/evcp/data-room/owner-companies/[id]/page.tsx
+import db from "@/db/db";
+import { ownerCompanies } from "@/db/schema";
+import { eq } from "drizzle-orm";
+import { notFound } from "next/navigation";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { OwnerCompanyForm } from "@/lib/owner-companies/owner-company-form";
+
+export default async function EditOwnerCompanyPage({
+ params,
+}: {
+ params: { id: string };
+}) {
+ const companyId = parseInt(params.id);
+
+ const [company] = await db
+ .select()
+ .from(ownerCompanies)
+ .where(eq(ownerCompanies.id, companyId))
+ .limit(1);
+
+ if (!company) {
+ notFound();
+ }
+
+ return (
+ <div className="container mx-auto py-8 max-w-2xl">
+ <Card>
+ <CardHeader>
+ <CardTitle>발주처 회사 정보 수정</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <OwnerCompanyForm
+ initialData={{
+ id: company.id,
+ name: company.name,
+ }}
+ />
+ </CardContent>
+ </Card>
+ </div>
+ );
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/data-room/owner-companies/[id]/users/new/page.tsx b/app/[lng]/evcp/data-room/owner-companies/[id]/users/new/page.tsx
new file mode 100644
index 00000000..f78794c1
--- /dev/null
+++ b/app/[lng]/evcp/data-room/owner-companies/[id]/users/new/page.tsx
@@ -0,0 +1,41 @@
+// app/evcp/data-room/owner-companies/[id]/users/new/page.tsx
+import db from "@/db/db";
+import { ownerCompanies } from "@/db/schema";
+import { eq } from "drizzle-orm";
+import { notFound } from "next/navigation";
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
+import { OwnerCompanyUserForm } from "@/lib/owner-companies/owner-company-user-form";
+
+export default async function NewOwnerCompanyUserPage({
+ params,
+}: {
+ params: { id: string };
+}) {
+ const companyId = parseInt(params.id);
+
+ const [company] = await db
+ .select()
+ .from(ownerCompanies)
+ .where(eq(ownerCompanies.id, companyId))
+ .limit(1);
+
+ if (!company) {
+ notFound();
+ }
+
+ return (
+ <div className="container mx-auto py-8 max-w-2xl">
+ <Card>
+ <CardHeader>
+ <CardTitle>{company.name} - 사용자 추가</CardTitle>
+ <CardDescription>
+ 발주처 사용자를 등록합니다.
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <OwnerCompanyUserForm companyId={companyId} />
+ </CardContent>
+ </Card>
+ </div>
+ );
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/data-room/owner-companies/[id]/users/page.tsx b/app/[lng]/evcp/data-room/owner-companies/[id]/users/page.tsx
new file mode 100644
index 00000000..87ebb364
--- /dev/null
+++ b/app/[lng]/evcp/data-room/owner-companies/[id]/users/page.tsx
@@ -0,0 +1,63 @@
+// app/(admin)/owner-companies/[id]/users/page.tsx
+import db from "@/db/db";
+import { users, ownerCompanies } from "@/db/schema";
+import { eq } from "drizzle-orm";
+import { Button } from "@/components/ui/button";
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { ArrowLeft, Plus } from "lucide-react";
+import { OwnerCompanyUserList } from "@/lib/owner-companies/owner-company-user-list";
+
+export default async function OwnerCompanyUsersPage({
+ params,
+ }: {
+ params: { id: string };
+ }) {
+ const companyId = parseInt(params.id);
+
+ const [company] = await db
+ .select()
+ .from(ownerCompanies)
+ .where(eq(ownerCompanies.id, companyId))
+ .limit(1);
+
+ if (!company) {
+ notFound();
+ }
+
+ const companyUsers = await db
+ .select()
+ .from(users)
+ .where(eq(users.ownerCompanyId, companyId))
+ .orderBy(users.createdAt);
+
+ return (
+ <div className="container mx-auto py-8">
+ <div className="mb-4">
+ <Button variant="ghost" size="sm" asChild>
+ <Link href="/evcp/data-room/owner-companies">
+ <ArrowLeft className="h-4 w-4 mr-2" />
+ 발주처 목록으로
+ </Link>
+ </Button>
+ </div>
+
+ <div className="flex justify-between items-center mb-6">
+ <div>
+ <h1 className="text-3xl font-bold">{company.name}</h1>
+ <p className="text-muted-foreground mt-1">
+ 사용자 관리 · 총 {companyUsers.length}명
+ </p>
+ </div>
+ <Button asChild>
+ <Link href={`/evcp/data-room/owner-companies/${companyId}/users/new`}>
+ <Plus className="h-4 w-4 mr-2" />
+ 사용자 추가
+ </Link>
+ </Button>
+ </div>
+
+ <OwnerCompanyUserList users={companyUsers} companyId={companyId} />
+ </div>
+ );
+ } \ No newline at end of file
diff --git a/app/[lng]/evcp/data-room/owner-companies/new/page.tsx b/app/[lng]/evcp/data-room/owner-companies/new/page.tsx
new file mode 100644
index 00000000..166b8d41
--- /dev/null
+++ b/app/[lng]/evcp/data-room/owner-companies/new/page.tsx
@@ -0,0 +1,18 @@
+// app/(admin)/owner-companies/new/page.tsx
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { OwnerCompanyForm } from "@/lib/owner-companies/owner-company-form";
+
+export default function NewOwnerCompanyPage() {
+ return (
+ <div className="container mx-auto py-8 max-w-2xl">
+ <Card>
+ <CardHeader>
+ <CardTitle>발주처 회사 등록</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <OwnerCompanyForm />
+ </CardContent>
+ </Card>
+ </div>
+ );
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/data-room/owner-companies/page.tsx b/app/[lng]/evcp/data-room/owner-companies/page.tsx
new file mode 100644
index 00000000..483d58bf
--- /dev/null
+++ b/app/[lng]/evcp/data-room/owner-companies/page.tsx
@@ -0,0 +1,32 @@
+// app/evcp/data-room/owner-companies/page.tsx
+import db from "@/db/db";
+import { ownerCompanies } from "@/db/schema";
+import { Button } from "@/components/ui/button";
+import { OwnerCompanyList } from "@/lib/owner-companies/owner-company-list";
+import Link from "next/link";
+import { Plus } from "lucide-react";
+
+export default async function OwnerCompaniesPage() {
+ const companies = await db.select().from(ownerCompanies).orderBy(ownerCompanies.createdAt);
+
+ return (
+ <div className="container mx-auto py-8">
+ <div className="flex justify-between items-center mb-6">
+ <div>
+ <h1 className="text-3xl font-bold">발주처 관리</h1>
+ <p className="text-muted-foreground mt-1">
+ 발주처 회사 및 사용자를 관리합니다
+ </p>
+ </div>
+ <Button asChild>
+ <Link href="/evcp/data-room/owner-companies/new">
+ <Plus className="h-4 w-4 mr-2" />
+ 회사 등록
+ </Link>
+ </Button>
+ </div>
+
+ <OwnerCompanyList companies={companies} />
+ </div>
+ );
+ } \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/data-room/page.tsx b/app/[lng]/evcp/data-room/page.tsx
index 4ff56abc..4ff56abc 100644
--- a/app/[lng]/evcp/(evcp)/data-room/page.tsx
+++ b/app/[lng]/evcp/data-room/page.tsx
diff --git a/app/[lng]/partners/(partners)/po/page.tsx b/app/[lng]/partners/(partners)/po/page.tsx
index ebe7601e..c21d5e35 100644
--- a/app/[lng]/partners/(partners)/po/page.tsx
+++ b/app/[lng]/partners/(partners)/po/page.tsx
@@ -48,7 +48,7 @@ export default async function VendorPO(props: VendorPOPageProps) {
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">
- 벤더 PO 관리
+ PO/계약 목록
</h2>
<InformationButton pagePath="partners/po" />
</div>
diff --git a/app/[lng]/partners/(partners)/data-room/[projectId]/files/page.tsx b/app/[lng]/partners/data-room/[projectId]/files/page.tsx
index 985e7fef..7197a2a7 100644
--- a/app/[lng]/partners/(partners)/data-room/[projectId]/files/page.tsx
+++ b/app/[lng]/partners/data-room/[projectId]/files/page.tsx
@@ -6,9 +6,5 @@ export default function ProjectFilesPage({
}: {
params: { projectId: string };
}) {
- return (
- <div className="h-full flex flex-col">
- <FileManager projectId={params.projectId} />
- </div>
- );
+ return <FileManager projectId={params.projectId} />;
} \ No newline at end of file
diff --git a/app/[lng]/partners/(partners)/data-room/[projectId]/layout.tsx b/app/[lng]/partners/data-room/[projectId]/layout.tsx
index d2e74f8e..d2e74f8e 100644
--- a/app/[lng]/partners/(partners)/data-room/[projectId]/layout.tsx
+++ b/app/[lng]/partners/data-room/[projectId]/layout.tsx
diff --git a/app/[lng]/partners/(partners)/data-room/[projectId]/members/page.tsx b/app/[lng]/partners/data-room/[projectId]/members/page.tsx
index 18442c0e..18442c0e 100644
--- a/app/[lng]/partners/(partners)/data-room/[projectId]/members/page.tsx
+++ b/app/[lng]/partners/data-room/[projectId]/members/page.tsx
diff --git a/app/[lng]/partners/(partners)/data-room/[projectId]/page.tsx b/app/[lng]/partners/data-room/[projectId]/page.tsx
index d54a8cab..d54a8cab 100644
--- a/app/[lng]/partners/(partners)/data-room/[projectId]/page.tsx
+++ b/app/[lng]/partners/data-room/[projectId]/page.tsx
diff --git a/app/[lng]/partners/(partners)/data-room/[projectId]/settings/page.tsx b/app/[lng]/partners/data-room/[projectId]/settings/page.tsx
index aa0f3b52..aa0f3b52 100644
--- a/app/[lng]/partners/(partners)/data-room/[projectId]/settings/page.tsx
+++ b/app/[lng]/partners/data-room/[projectId]/settings/page.tsx
diff --git a/app/[lng]/partners/(partners)/data-room/[projectId]/stats/page.tsx b/app/[lng]/partners/data-room/[projectId]/stats/page.tsx
index 7f652a99..7f652a99 100644
--- a/app/[lng]/partners/(partners)/data-room/[projectId]/stats/page.tsx
+++ b/app/[lng]/partners/data-room/[projectId]/stats/page.tsx
diff --git a/app/[lng]/partners/data-room/layout.tsx b/app/[lng]/partners/data-room/layout.tsx
new file mode 100644
index 00000000..48913bd9
--- /dev/null
+++ b/app/[lng]/partners/data-room/layout.tsx
@@ -0,0 +1,17 @@
+import { ReactNode } from 'react';
+import { SiteFooter } from '@/components/layout/Footer';
+import { HeaderSimple } from '@/components/layout/HeaderSimple';
+
+export default function EvcpLayout({ children }: { children: ReactNode }) {
+ return (
+ <div className="flex flex-col h-screen bg-background">
+ <HeaderSimple />
+ <main className="flex-1 overflow-hidden">
+ <div className='container-wrapper h-full'>
+ {children}
+ </div>
+ </main>
+ <SiteFooter/>
+ </div>
+ );
+} \ No newline at end of file
diff --git a/app/[lng]/partners/(partners)/data-room/page.tsx b/app/[lng]/partners/data-room/page.tsx
index 4ff56abc..4ff56abc 100644
--- a/app/[lng]/partners/(partners)/data-room/page.tsx
+++ b/app/[lng]/partners/data-room/page.tsx
diff --git a/app/api/projects/[projectId]/cover/route.ts b/app/api/projects/[projectId]/cover/route.ts
new file mode 100644
index 00000000..b88f06ee
--- /dev/null
+++ b/app/api/projects/[projectId]/cover/route.ts
@@ -0,0 +1,73 @@
+// app/api/projects/[projectId]/cover/route.ts
+import { NextRequest, NextResponse } from "next/server"
+import db from "@/db/db"
+import { projectCoverTemplates, generatedCoverPages } from "@/db/schema"
+import { eq, and, desc } from "drizzle-orm"
+
+export async function GET(
+ request: NextRequest,
+ { params }: { params: { projectId: string } }
+) {
+ try {
+ const projectId = parseInt(params.projectId)
+
+ if (isNaN(projectId)) {
+ return NextResponse.json(
+ { success: false, message: "유효하지 않은 프로젝트 ID입니다" },
+ { status: 400 }
+ )
+ }
+
+ // 1. 해당 프로젝트의 활성 템플릿 찾기
+ const [activeTemplate] = await db
+ .select()
+ .from(projectCoverTemplates)
+ .where(
+ and(
+ eq(projectCoverTemplates.projectId, projectId),
+ eq(projectCoverTemplates.isActive, true)
+ )
+ )
+ .limit(1)
+
+ if (!activeTemplate) {
+ return NextResponse.json(
+ { success: false, message: "활성 템플릿을 찾을 수 없습니다" },
+ { status: 404 }
+ )
+ }
+
+ // 2. 해당 템플릿의 최신 생성된 커버 페이지 찾기
+ const [latestCover] = await db
+ .select()
+ .from(generatedCoverPages)
+ .where(eq(generatedCoverPages.templateId, activeTemplate.id))
+ .orderBy(desc(generatedCoverPages.generatedAt))
+ .limit(1)
+
+ if (!latestCover) {
+ return NextResponse.json(
+ { success: false, message: "생성된 커버 페이지를 찾을 수 없습니다" },
+ { status: 404 }
+ )
+ }
+
+ // 3. 파일 경로와 정보 반환
+ return NextResponse.json({
+ success: true,
+ fileUrl: latestCover.filePath,
+ fileName: latestCover.fileName,
+ generatedAt: latestCover.generatedAt,
+ })
+
+ } catch (error) {
+ console.error("❌ 커버 페이지 조회 오류:", error)
+ return NextResponse.json(
+ {
+ success: false,
+ message: error instanceof Error ? error.message : "조회 중 오류 발생"
+ },
+ { status: 500 }
+ )
+ }
+} \ No newline at end of file
diff --git a/app/api/projects/cover-template/save/route.ts b/app/api/projects/cover-template/save/route.ts
new file mode 100644
index 00000000..e681512d
--- /dev/null
+++ b/app/api/projects/cover-template/save/route.ts
@@ -0,0 +1,125 @@
+// app/api/projects/cover-template/save/route.ts
+import { saveFile } from "@/lib/file-stroage"
+import db from "@/db/db"
+import { projectCoverTemplates, generatedCoverPages } from "@/db/schema"
+import { eq, and, desc } from "drizzle-orm"
+import { NextRequest, NextResponse } from "next/server"
+import { revalidateTag } from "next/cache"
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/app/api/auth/[...nextauth]/route';
+
+export async function POST(request: NextRequest) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ return NextResponse.json(
+ { error: '인증이 필요합니다' },
+ { status: 401 }
+ );
+ }
+
+ const formData = await request.formData()
+ const file = formData.get("file") as File
+ const projectId = formData.get("projectId") as string
+ const templateName = formData.get("templateName") as string | null
+
+ if (!file) {
+ return NextResponse.json(
+ { success: false, message: "파일이 없습니다" },
+ { status: 400 }
+ )
+ }
+
+ if (!projectId) {
+ return NextResponse.json(
+ { success: false, message: "프로젝트 ID가 없습니다" },
+ { status: 400 }
+ )
+ }
+
+ // 해당 프로젝트의 활성 템플릿 찾기
+ const [activeTemplate] = await db
+ .select()
+ .from(projectCoverTemplates)
+ .where(
+ and(
+ eq(projectCoverTemplates.projectId, parseInt(projectId)),
+ eq(projectCoverTemplates.isActive, true)
+ )
+ )
+ .limit(1)
+
+ if (!activeTemplate) {
+ return NextResponse.json(
+ { success: false, message: "활성 템플릿을 찾을 수 없습니다" },
+ { status: 404 }
+ )
+ }
+
+ // 생성된 커버 페이지 저장 디렉토리
+ const coverPagesDirectory = `projects/${projectId}/generated-covers`
+
+ // 파일명 생성 (타임스탬프 포함)
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5)
+ const fileName = templateName
+ ? `${templateName}_${timestamp}.docx`
+ : `cover_${timestamp}.docx`
+
+ // 파일 저장
+ const saveResult = await saveFile({
+ file,
+ directory: coverPagesDirectory,
+ originalName: fileName,
+ })
+
+ if (!saveResult.success) {
+ return NextResponse.json(
+ { success: false, message: saveResult.error || "파일 저장 실패" },
+ { status: 500 }
+ )
+ }
+
+ // TODO: 실제로는 문서에서 변수 값을 추출하거나 별도로 전달받아야 함
+ // 현재는 빈 객체로 저장 (추후 확장 가능)
+ const variableValues = {}
+
+ // generatedCoverPages 테이블에 저장
+ const [generatedCover] = await db
+ .insert(generatedCoverPages)
+ .values({
+ templateId: activeTemplate.id,
+ variableValues: variableValues,
+ fileName: saveResult.fileName,
+ filePath: saveResult.publicPath,
+ fileSize: saveResult.fileSize,
+ generatedBy: session.user.name,
+ })
+ .returning()
+
+ console.log(`✅ 커버 페이지 생성 완료: ${saveResult.fileName}`)
+ console.log(`✅ DB 저장 완료 - Generated Cover ID: ${generatedCover.id}`)
+
+ // 캐시 무효화
+ revalidateTag("project-cover-lists")
+
+ return NextResponse.json({
+ success: true,
+ generatedCoverId: generatedCover.id,
+ templateId: activeTemplate.id,
+ filePath: saveResult.publicPath,
+ fileName: saveResult.fileName,
+ fileSize: saveResult.fileSize,
+ message: "커버 페이지가 저장되었습니다"
+ })
+
+ } catch (error) {
+ console.error("❌ 커버 페이지 저장 오류:", error)
+ return NextResponse.json(
+ {
+ success: false,
+ message: error instanceof Error ? error.message : "저장 중 오류 발생"
+ },
+ { status: 500 }
+ )
+ }
+} \ No newline at end of file
diff --git a/app/api/projects/cover-template/upload/route.ts b/app/api/projects/cover-template/upload/route.ts
new file mode 100644
index 00000000..9c8df7ca
--- /dev/null
+++ b/app/api/projects/cover-template/upload/route.ts
@@ -0,0 +1,127 @@
+// app/api/projects/cover-template/upload/route.ts
+import db from "@/db/db"
+import { projectCoverTemplates } from "@/db/schema"
+import { saveFile } from "@/lib/file-stroage"
+import { eq, and } from "drizzle-orm"
+import { NextRequest, NextResponse } from "next/server"
+import { revalidateTag } from "next/cache"
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/app/api/auth/[...nextauth]/route';
+
+export async function POST(request: NextRequest) {
+ try {
+
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ return NextResponse.json(
+ { error: '인증이 필요합니다' },
+ { status: 401 }
+ );
+ }
+
+ const formData = await request.formData()
+ const file = formData.get("file") as File
+ const projectId = formData.get("projectId") as string
+
+ if (!file) {
+ return NextResponse.json(
+ { success: false, message: "파일이 없습니다" },
+ { status: 400 }
+ )
+ }
+
+ if (!projectId) {
+ return NextResponse.json(
+ { success: false, message: "프로젝트 ID가 없습니다" },
+ { status: 400 }
+ )
+ }
+
+ // 파일 확장자 확인
+ if (!file.name.endsWith('.docx')) {
+ return NextResponse.json(
+ { success: false, message: "DOCX 파일만 업로드 가능합니다" },
+ { status: 400 }
+ )
+ }
+
+ // 템플릿 디렉토리
+ const templateDirectory = `projects/${projectId}/cover-templates`
+
+ // 파일 저장
+ const saveResult = await saveFile({
+ file,
+ directory: templateDirectory,
+ originalName: file.name,
+ })
+
+ if (!saveResult.success) {
+ return NextResponse.json(
+ { success: false, message: saveResult.error || "파일 저장 실패" },
+ { status: 500 }
+ )
+ }
+
+ // 기존 활성 템플릿 비활성화
+ await db
+ .update(projectCoverTemplates)
+ .set({
+ isActive: false,
+ updatedAt: new Date()
+ })
+ .where(
+ and(
+ eq(projectCoverTemplates.projectId, parseInt(projectId)),
+ eq(projectCoverTemplates.isActive, true)
+ )
+ )
+
+ // 기본 템플릿 변수 설정
+ const defaultVariables = {
+ docNumber: "{{docNumber}}",
+ projectNumber: "{{projectNumber}}",
+ projectName: "{{projectName}}"
+ }
+
+ // 새 템플릿을 DB에 저장
+ const [newTemplate] = await db
+ .insert(projectCoverTemplates)
+ .values({
+ projectId: parseInt(projectId),
+ templateName: file.name.replace('.docx', ''),
+ originalFileName: file.name,
+ filePath: saveResult.publicPath,
+ fileSize: saveResult.fileSize,
+ variables: defaultVariables,
+ isActive: true,
+ createdBy: session.user.name, // TODO: 실제 사용자 정보로 변경
+ updatedBy: session.user.name,
+ })
+ .returning()
+
+ console.log(`✅ 커버 템플릿 업로드 완료: ${saveResult.fileName}`)
+ console.log(`✅ DB 저장 완료 - Template ID: ${newTemplate.id}`)
+
+ // 캐시 무효화
+ revalidateTag("project-cover-lists")
+
+ return NextResponse.json({
+ success: true,
+ templateId: newTemplate.id,
+ filePath: saveResult.publicPath,
+ fileName: saveResult.fileName,
+ fileSize: saveResult.fileSize,
+ variables: defaultVariables,
+ })
+
+ } catch (error) {
+ console.error("❌ 템플릿 업로드 오류:", error)
+ return NextResponse.json(
+ {
+ success: false,
+ message: error instanceof Error ? error.message : "업로드 중 오류 발생"
+ },
+ { status: 500 }
+ )
+ }
+} \ No newline at end of file
diff --git a/components/file-manager/FileManager copy.tsx b/components/file-manager/FileManager copy.tsx
new file mode 100644
index 00000000..3f3d73a4
--- /dev/null
+++ b/components/file-manager/FileManager copy.tsx
@@ -0,0 +1,1852 @@
+'use client';
+
+import React, { useState, useEffect, useCallback } from 'react';
+import {
+ Folder,
+ File,
+ FolderPlus,
+ Upload,
+ Trash2,
+ Edit2,
+ Download,
+ Share2,
+ Eye,
+ EyeOff,
+ Lock,
+ Unlock,
+ Globe,
+ Shield,
+ AlertCircle,
+ MoreVertical,
+ ChevronRight,
+ ChevronDown,
+ Search,
+ Grid,
+ List,
+ Copy,
+ X
+} from 'lucide-react';
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuTrigger,
+ ContextMenuSeparator,
+ ContextMenuSub,
+ ContextMenuSubContent,
+ ContextMenuSubTrigger,
+} from '@/components/ui/context-menu';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+ DropdownMenuSeparator,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+} from '@/components/ui/dropdown-menu';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogFooter,
+ DialogDescription,
+} from '@/components/ui/dialog';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { Badge } from '@/components/ui/badge';
+import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList } from '@/components/ui/breadcrumb';
+import { useToast } from '@/hooks/use-toast';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { Label } from '@/components/ui/label';
+import { Switch } from '@/components/ui/switch';
+import { cn } from '@/lib/utils';
+import { useSession } from 'next-auth/react';
+import {
+ Dropzone,
+ DropzoneDescription,
+ DropzoneInput,
+ DropzoneTitle,
+ DropzoneUploadIcon,
+ DropzoneZone,
+} from "@/components/ui/dropzone";
+import {
+ FileList,
+ FileListAction,
+ FileListDescription,
+ FileListHeader,
+ FileListIcon,
+ FileListInfo,
+ FileListItem,
+ FileListName,
+ FileListSize,
+} from "@/components/ui/file-list";
+import { decryptWithServerAction } from '@/components/drm/drmUtils';
+import { Progress } from '@/components/ui/progress';
+// Import the secure viewer component
+import { SecurePDFViewer } from './SecurePDFViewer';
+
+interface FileItem {
+ id: string;
+ name: string;
+ type: 'file' | 'folder';
+ size?: number;
+ mimeType?: string;
+ category: 'public' | 'restricted' | 'confidential' | 'internal';
+ externalAccessLevel?: 'view_only' | 'view_download' | 'full_access';
+ updatedAt: Date;
+ permissions?: {
+ canView: boolean;
+ canDownload: boolean;
+ canEdit: boolean;
+ canDelete: boolean;
+ };
+ downloadCount?: number;
+ viewCount?: number;
+ parentId?: string | null;
+ children?: FileItem[];
+}
+
+interface UploadingFile {
+ file: File;
+ progress: number;
+ status: 'pending' | 'uploading' | 'processing' | 'completed' | 'error';
+ error?: string;
+}
+
+interface FileManagerProps {
+ projectId: string;
+}
+
+// Category configuration with icons and colors
+const categoryConfig = {
+ public: { icon: Globe, color: 'text-green-500', label: 'Public' },
+ restricted: { icon: Eye, color: 'text-yellow-500', label: 'Restricted' },
+ confidential: { icon: Lock, color: 'text-red-500', label: 'Confidential' },
+ internal: { icon: Shield, color: 'text-blue-500', label: 'Internal' },
+};
+
+// Tree Item Component
+const TreeItem: React.FC<{
+ item: FileItem;
+ level: number;
+ expandedFolders: Set<string>;
+ selectedItems: Set<string>;
+ onToggleExpand: (id: string) => void;
+ onSelectItem: (id: string) => void;
+ onDoubleClick: (item: FileItem) => void;
+ onView: (item: FileItem) => void;
+ onDownload: (item: FileItem) => void;
+ onDownloadFolder: (item: FileItem) => void;
+ onDelete: (ids: string[]) => void;
+ onShare: (item: FileItem) => void;
+ onRename: (item: FileItem) => void;
+ isInternalUser: boolean;
+}> = ({
+ item,
+ level,
+ expandedFolders,
+ selectedItems,
+ onToggleExpand,
+ onSelectItem,
+ onDoubleClick,
+ onView,
+ onDownload,
+ onDownloadFolder,
+ onDelete,
+ onShare,
+ onRename,
+ isInternalUser
+}) => {
+ const hasChildren = item.type === 'folder' && item.children && item.children.length > 0;
+ const isExpanded = expandedFolders.has(item.id);
+ const isSelected = selectedItems.has(item.id);
+ const CategoryIcon = categoryConfig[item.category].icon;
+ const categoryColor = categoryConfig[item.category].color;
+ const categoryLabel = categoryConfig[item.category].label;
+
+ const formatFileSize = (bytes?: number) => {
+ if (!bytes) return '-';
+ const sizes = ['B', 'KB', 'MB', 'GB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
+ return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`;
+ };
+
+ return (
+ <>
+ <div
+ className={cn(
+ "flex items-center p-2 rounded-lg cursor-pointer transition-colors",
+ "hover:bg-accent",
+ isSelected && "bg-accent"
+ )}
+ style={{ paddingLeft: `${level * 24 + 8}px` }}
+ onClick={() => onSelectItem(item.id)}
+ onDoubleClick={() => onDoubleClick(item)}
+ >
+ <div className="flex items-center mr-2">
+ {item.type === 'folder' && (
+ <button
+ onClick={(e) => {
+ e.stopPropagation();
+ onToggleExpand(item.id);
+ }}
+ className="p-0.5 hover:bg-gray-200 rounded"
+ >
+ {isExpanded ? (
+ <ChevronDown className="h-4 w-4" />
+ ) : (
+ <ChevronRight className="h-4 w-4" />
+ )}
+ </button>
+ )}
+ {item.type === 'file' && (
+ <div className="w-5" />
+ )}
+ </div>
+
+ {item.type === 'folder' ? (
+ <Folder className="h-5 w-5 text-blue-500 mr-2" />
+ ) : (
+ <File className="h-5 w-5 text-gray-500 mr-2" />
+ )}
+
+ <span className="flex-1">{item.name}</span>
+
+ <Badge variant="outline" className="mr-2">
+ <CategoryIcon className={cn("h-3 w-3 mr-1", categoryColor)} />
+ {categoryLabel}
+ </Badge>
+
+ <span className="text-sm text-muted-foreground mr-4">
+ {formatFileSize(item.size)}
+ </span>
+ <span className="text-sm text-muted-foreground mr-2">
+ {new Date(item.updatedAt).toLocaleDateString()}
+ </span>
+
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="ghost" size="sm">
+ <MoreVertical className="h-4 w-4" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent>
+ {item.type === 'file' && (
+ <>
+ <DropdownMenuItem onClick={() => onView(item)}>
+ <Eye className="h-4 w-4 mr-2" />
+ View
+ </DropdownMenuItem>
+ {item.permissions?.canDownload && (
+ <DropdownMenuItem onClick={() => onDownload(item)}>
+ <Download className="h-4 w-4 mr-2" />
+ Download
+ </DropdownMenuItem>
+ )}
+ </>
+ )}
+
+ {item.type === 'folder' && (
+ <DropdownMenuItem onClick={() => onDownloadFolder(item)}>
+ <Download className="h-4 w-4 mr-2" />
+ Download Folder
+ </DropdownMenuItem>
+ )}
+
+ {isInternalUser && (
+ <>
+ <DropdownMenuItem onClick={() => onShare(item)}>
+ <Share2 className="h-4 w-4 mr-2" />
+ Share
+ </DropdownMenuItem>
+
+ {item.permissions?.canEdit && (
+ <DropdownMenuItem onClick={() => onRename(item)}>
+ <Edit2 className="h-4 w-4 mr-2" />
+ Rename
+ </DropdownMenuItem>
+ )}
+ </>
+ )}
+
+ {item.permissions?.canDelete && (
+ <>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ className="text-destructive"
+ onClick={() => onDelete([item.id])}
+ >
+ <Trash2 className="h-4 w-4 mr-2" />
+ Delete
+ </DropdownMenuItem>
+ </>
+ )}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+
+ {item.type === 'folder' && isExpanded && item.children && (
+ <div>
+ {item.children.map((child) => (
+ <TreeItem
+ key={child.id}
+ item={child}
+ level={level + 1}
+ expandedFolders={expandedFolders}
+ selectedItems={selectedItems}
+ onToggleExpand={onToggleExpand}
+ onSelectItem={onSelectItem}
+ onDoubleClick={onDoubleClick}
+ onView={onView}
+ onDownload={onDownload}
+ onDownloadFolder={onDownloadFolder}
+ onDelete={onDelete}
+ onShare={onShare}
+ onRename={onRename}
+ isInternalUser={isInternalUser}
+ />
+ ))}
+ </div>
+ )}
+ </>
+ );
+ };
+
+export function FileManager({ projectId }: FileManagerProps) {
+ const { data: session } = useSession();
+ const [items, setItems] = useState<FileItem[]>([]);
+ const [treeItems, setTreeItems] = useState<FileItem[]>([]);
+ const [currentPath, setCurrentPath] = useState<string[]>([]);
+ const [currentParentId, setCurrentParentId] = useState<string | null>(null);
+ const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
+ const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
+ const [viewMode, setViewMode] = useState<'grid' | 'list'>('list');
+ const [searchQuery, setSearchQuery] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ console.log(items, "items")
+
+ // Upload states
+ const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
+ const [uploadingFiles, setUploadingFiles] = useState<UploadingFile[]>([]);
+ const [uploadCategory, setUploadCategory] = useState<string>('confidential');
+
+ // Dialog states
+ const [folderDialogOpen, setFolderDialogOpen] = useState(false);
+ const [shareDialogOpen, setShareDialogOpen] = useState(false);
+ const [permissionDialogOpen, setPermissionDialogOpen] = useState(false);
+ const [renameDialogOpen, setRenameDialogOpen] = useState(false);
+ const [viewerDialogOpen, setViewerDialogOpen] = useState(false);
+ const [viewerFileUrl, setViewerFileUrl] = useState<string | null>(null);
+
+ // Dialog data
+ const [dialogValue, setDialogValue] = useState('');
+ const [selectedCategory, setSelectedCategory] = useState<string>('confidential');
+ const [selectedFile, setSelectedFile] = useState<FileItem | null>(null);
+ const [shareSettings, setShareSettings] = useState({
+ accessLevel: 'view_only',
+ password: '',
+ expiresAt: '',
+ maxDownloads: '',
+ });
+
+ const { toast } = useToast();
+
+ // Check if user is internal
+ const isInternalUser = session?.user?.domain !== 'partners';
+
+ // Build tree structure function
+ const buildTree = (flatItems: FileItem[]): FileItem[] => {
+ const itemMap = new Map<string, FileItem>();
+ const rootItems: FileItem[] = [];
+
+ // Store all items in map (initialize children)
+ flatItems.forEach(item => {
+ itemMap.set(item.id, { ...item, children: [] });
+ });
+
+ // Set parent-child relationships
+ flatItems.forEach(item => {
+ const mappedItem = itemMap.get(item.id)!;
+
+ if (!item.parentId) {
+ // No parentId means root item
+ rootItems.push(mappedItem);
+ } else {
+ // Has parentId, add to parent's children
+ const parent = itemMap.get(item.parentId);
+ if (parent) {
+ if (!parent.children) parent.children = [];
+ parent.children.push(mappedItem);
+ } else {
+ // Can't find parent, treat as root
+ rootItems.push(mappedItem);
+ }
+ }
+ });
+
+ return rootItems;
+ };
+
+ // Fetch file list
+ const fetchItems = useCallback(async () => {
+ setLoading(true);
+ try {
+ const params = new URLSearchParams();
+
+ // For tree view, get entire list
+ if (viewMode === 'list') {
+ params.append('viewMode', 'tree');
+ // Keep current path info for tree view (used for highlighting, etc.)
+ if (currentParentId) params.append('currentParentId', currentParentId);
+ } else {
+ // For grid view, only get current folder contents
+ if (currentParentId) params.append('parentId', currentParentId);
+ }
+
+ const response = await fetch(`/api/data-room/${projectId}?${params}`);
+ if (!response.ok) throw new Error('Failed to fetch files');
+
+ const data = await response.json();
+ setItems(data);
+
+ // Build tree structure
+ if (viewMode === 'list') {
+ const tree = buildTree(data);
+ setTreeItems(tree);
+ }
+ } catch (error) {
+ toast({
+ title: 'Error',
+ description: 'Failed to load files.',
+ variant: 'destructive',
+ });
+ } finally {
+ setLoading(false);
+ }
+ }, [projectId, currentParentId, viewMode, toast]);
+
+ useEffect(() => {
+ fetchItems();
+ }, [fetchItems]);
+
+ // Create folder
+ const createFolder = async () => {
+ try {
+ const response = await fetch(`/api/data-room/${projectId}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ name: dialogValue,
+ type: 'folder',
+ category: selectedCategory,
+ parentId: currentParentId,
+ }),
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.error || 'Failed to create folder');
+ }
+
+ await fetchItems();
+ setFolderDialogOpen(false);
+ setDialogValue('');
+
+ toast({
+ title: 'Success',
+ description: 'Folder created successfully.',
+ });
+ } catch (error: any) {
+ toast({
+ title: 'Error',
+ description: error.message || 'Failed to create folder.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ // Handle file upload
+ const handleFileUpload = async (files: FileList | File[]) => {
+ const fileArray = Array.from(files);
+
+ // Initialize uploading file list
+ const newUploadingFiles: UploadingFile[] = fileArray.map(file => ({
+ file,
+ progress: 0,
+ status: 'pending' as const
+ }));
+
+ setUploadingFiles(newUploadingFiles);
+
+ // Process each file upload
+ for (let i = 0; i < fileArray.length; i++) {
+ const file = fileArray[i];
+
+ try {
+ // Update status: uploading
+ setUploadingFiles(prev => prev.map((f, idx) =>
+ idx === i ? { ...f, status: 'uploading', progress: 20 } : f
+ ));
+
+ // DRM decryption
+ setUploadingFiles(prev => prev.map((f, idx) =>
+ idx === i ? { ...f, status: 'processing', progress: 40 } : f
+ ));
+
+ const decryptedData = await decryptWithServerAction(file);
+
+ // Create FormData
+ const formData = new FormData();
+ const blob = new Blob([decryptedData], { type: file.type });
+ formData.append('file', blob, file.name);
+ formData.append('category', uploadCategory);
+ formData.append('fileSize', file.size.toString()); // Pass file size
+ if (currentParentId) {
+ formData.append('parentId', currentParentId);
+ }
+
+ // Update upload progress
+ setUploadingFiles(prev => prev.map((f, idx) =>
+ idx === i ? { ...f, progress: 60 } : f
+ ));
+
+ // API call
+ const response = await fetch(`/api/data-room/${projectId}/upload`, {
+ method: 'POST',
+ body: formData,
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.error || 'Upload failed');
+ }
+
+ // Success
+ setUploadingFiles(prev => prev.map((f, idx) =>
+ idx === i ? { ...f, status: 'completed', progress: 100 } : f
+ ));
+
+ } catch (error: any) {
+ // Failure
+ setUploadingFiles(prev => prev.map((f, idx) =>
+ idx === i ? {
+ ...f,
+ status: 'error',
+ error: error.message || 'Upload failed'
+ } : f
+ ));
+ }
+ }
+
+ // Refresh list after all uploads complete
+ await fetchItems();
+
+ // Show toast if any files succeeded
+ const successCount = newUploadingFiles.filter(f => f.status === 'completed').length;
+ if (successCount > 0) {
+ toast({
+ title: 'Upload Complete',
+ description: `${successCount} file(s) uploaded successfully.`,
+ });
+ }
+ };
+
+ // Download folder
+ const downloadFolder = async (folder: FileItem) => {
+ if (folder.type !== 'folder') return;
+
+ try {
+ toast({
+ title: 'Checking Permissions',
+ description: 'Verifying download permissions for folder contents...',
+ });
+
+ // Call folder download API
+ const response = await fetch(`/api/data-room/${projectId}/download-folder/${folder.id}`, {
+ method: 'GET',
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+
+ // If there are files without permission, provide details
+ if (error.unauthorizedFiles) {
+ toast({
+ title: 'Insufficient Permissions',
+ description: `No permission for ${error.unauthorizedFiles.length} file(s): ${error.unauthorizedFiles.join(', ')}`,
+ variant: 'destructive',
+ });
+ return;
+ }
+
+ throw new Error(error.error || 'Folder download failed');
+ }
+
+ const blob = await response.blob();
+ const url = window.URL.createObjectURL(blob);
+
+ // Include folder name in filename
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
+ const fileName = `${folder.name}_${timestamp}.zip`;
+
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = fileName;
+ document.body.appendChild(a);
+ a.click();
+
+ window.URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+
+ toast({
+ title: 'Download Complete',
+ description: `${folder.name} folder downloaded successfully.`,
+ });
+
+ } catch (error: any) {
+ toast({
+ title: 'Error',
+ description: error.message || 'Failed to download folder.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ // Share file
+ const shareFile = async () => {
+ if (!selectedFile) return;
+
+ try {
+ const response = await fetch(`/api/data-room/${projectId}/share`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ fileId: selectedFile.id,
+ ...shareSettings,
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to create share link');
+ }
+
+ const data = await response.json();
+
+ // Copy share link to clipboard
+ await navigator.clipboard.writeText(data.shareUrl);
+
+ toast({
+ title: 'Share Link Created',
+ description: 'Link copied to clipboard.',
+ });
+
+ setShareDialogOpen(false);
+ setSelectedFile(null);
+ } catch (error) {
+ toast({
+ title: 'Error',
+ description: 'Failed to create share link.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ // Download multiple files
+ const downloadMultipleFiles = async (itemIds: string[]) => {
+ // Filter only actual files (exclude folders) that can be downloaded
+ const filesToDownload = items.filter(item =>
+ itemIds.includes(item.id) &&
+ item.type === 'file' &&
+ item.permissions?.canDownload === 'true'
+ );
+
+ if (filesToDownload.length === 0) {
+ toast({
+ title: 'Notice',
+ description: 'No downloadable files selected.',
+ variant: 'default',
+ });
+ return;
+ }
+
+ // Use regular download for single file
+ if (filesToDownload.length === 1) {
+ await downloadFile(filesToDownload[0]);
+ return;
+ }
+
+ try {
+ toast({
+ title: 'Preparing Download',
+ description: `Compressing ${filesToDownload.length} files...`,
+ });
+
+ // Call multiple files download API
+ const response = await fetch(`/api/data-room/${projectId}/download-multiple`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ fileIds: filesToDownload.map(f => f.id) })
+ });
+
+ if (!response.ok) {
+ throw new Error('Download failed');
+ }
+
+ const blob = await response.blob();
+ const url = window.URL.createObjectURL(blob);
+
+ // Include timestamp in filename
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
+ const fileName = `files_${timestamp}.zip`;
+
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = fileName;
+ document.body.appendChild(a);
+ a.click();
+
+ window.URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+
+ toast({
+ title: 'Download Complete',
+ description: `${filesToDownload.length} files downloaded successfully.`,
+ });
+
+ } catch (error) {
+ console.error('Multiple download error:', error);
+
+ // Offer individual downloads on failure
+ toast({
+ title: 'Batch Download Failed',
+ description: 'Would you like to try individual downloads?',
+ action: (
+ <Button
+ size="sm"
+ variant="outline"
+ onClick={() => {
+ // Execute individual downloads
+ filesToDownload.forEach(async (file, index) => {
+ // Add delay between downloads to reduce browser load
+ setTimeout(() => downloadFile(file), index * 500);
+ });
+ }}
+ >
+ Download Individually
+ </Button>
+ ),
+ });
+ }
+ };
+
+ // View file with PDFTron
+ const viewFile = async (file: FileItem) => {
+ try {
+
+
+
+ setViewerFileUrl(file.filePath);
+ setSelectedFile(file);
+ setViewerDialogOpen(true);
+
+
+
+ } catch (error) {
+ toast({
+ title: 'Error',
+ description: 'Failed to open file for viewing.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ // Download file
+ const downloadFile = async (file: FileItem) => {
+ try {
+ const response = await fetch(`/api/data-room/${projectId}/${file.id}/download`);
+
+ if (!response.ok) {
+ throw new Error('Download failed');
+ }
+
+ const blob = await response.blob();
+ const url = window.URL.createObjectURL(blob);
+
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = file.name;
+ document.body.appendChild(a);
+ a.click();
+
+ window.URL.revokeObjectURL(url);
+ document.body.removeChild(a);
+ } catch (error) {
+ toast({
+ title: 'Error',
+ description: 'Download failed.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ // Delete files
+ const deleteItems = async (itemIds: string[]) => {
+ try {
+ await Promise.all(
+ itemIds.map(id =>
+ fetch(`/api/data-room/${projectId}/${id}`, { method: 'DELETE' })
+ )
+ );
+
+ await fetchItems();
+ setSelectedItems(new Set());
+
+ toast({
+ title: 'Success',
+ description: 'Selected items deleted successfully.',
+ });
+ } catch (error) {
+ toast({
+ title: 'Error',
+ description: 'Failed to delete items.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ // Rename item
+ const renameItem = async () => {
+ if (!selectedFile) return;
+
+ try {
+ const response = await fetch(
+ `/api/data-room/${projectId}/${selectedFile.id}`,
+ {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name: dialogValue }),
+ }
+ );
+
+ if (!response.ok) {
+ throw new Error('Failed to rename');
+ }
+
+ await fetchItems();
+ setRenameDialogOpen(false);
+ setSelectedFile(null);
+ setDialogValue('');
+
+ toast({
+ title: 'Success',
+ description: 'Item renamed successfully.',
+ });
+ } catch (error) {
+ toast({
+ title: 'Error',
+ description: 'Failed to rename item.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ // Change category
+ const changeCategory = async (
+ itemId: string,
+ newCategory: string,
+ applyToChildren: boolean = false
+ ) => {
+ try {
+ const response = await fetch(
+ `/api/data-room/${projectId}/${itemId}`,
+ {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ category: newCategory,
+ applyToChildren
+ }),
+ }
+ );
+
+ if (!response.ok) {
+ throw new Error('Failed to change category');
+ }
+
+ await fetchItems();
+
+ toast({
+ title: 'Success',
+ description: 'Category updated successfully.',
+ });
+ } catch (error) {
+ toast({
+ title: 'Error',
+ description: 'Failed to change category.',
+ variant: 'destructive',
+ });
+ }
+ };
+
+ // Category change dialog states
+ const [categoryDialogOpen, setCategoryDialogOpen] = useState(false);
+ const [applyToChildren, setApplyToChildren] = useState(false);
+ const [newCategory, setNewCategory] = useState('confidential');
+
+ // Handle folder double click
+ const handleFolderOpen = (folder: FileItem) => {
+ if (viewMode === 'grid') {
+ setCurrentPath([...currentPath, folder.name]);
+ setCurrentParentId(folder.id);
+ } else {
+ // In tree view, expand/collapse
+ const newExpanded = new Set(expandedFolders);
+ if (newExpanded.has(folder.id)) {
+ newExpanded.delete(folder.id);
+ } else {
+ newExpanded.add(folder.id);
+ }
+ setExpandedFolders(newExpanded);
+ }
+ setSelectedItems(new Set());
+ };
+
+ // Toggle folder expansion
+ const toggleFolderExpand = (folderId: string) => {
+ const newExpanded = new Set(expandedFolders);
+ if (newExpanded.has(folderId)) {
+ newExpanded.delete(folderId);
+ } else {
+ newExpanded.add(folderId);
+ }
+ setExpandedFolders(newExpanded);
+ };
+
+ // Toggle item selection
+ const toggleItemSelection = (itemId: string) => {
+ const newSelected = new Set(selectedItems);
+ if (newSelected.has(itemId)) {
+ newSelected.delete(itemId);
+ } else {
+ newSelected.add(itemId);
+ }
+ setSelectedItems(newSelected);
+ };
+
+ // Navigate to path
+ const navigateToPath = (index: number) => {
+ if (index === -1) {
+ setCurrentPath([]);
+ setCurrentParentId(null);
+ } else {
+ setCurrentPath(currentPath.slice(0, index + 1));
+ // Need to update parentId logic
+ }
+ };
+
+ // Filtered items
+ const filteredItems = items.filter(item =>
+ item.name.toLowerCase().includes(searchQuery.toLowerCase())
+ );
+
+ const filteredTreeItems = treeItems.filter(item =>
+ item.name.toLowerCase().includes(searchQuery.toLowerCase())
+ );
+
+ // Format file size
+ const formatFileSize = (bytes?: number) => {
+ if (!bytes) return '-';
+ const sizes = ['B', 'KB', 'MB', 'GB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
+ return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`;
+ };
+
+ return (
+ <div className="flex flex-col h-full">
+ {/* Toolbar */}
+ <div className="border-b p-4">
+ <div className="flex items-center justify-between mb-3">
+ <div className="flex items-center gap-2">
+ {isInternalUser && (
+ <>
+ <Button
+ size="sm"
+ onClick={() => setFolderDialogOpen(true)}
+ >
+ <FolderPlus className="h-4 w-4 mr-1" />
+ New Folder
+ </Button>
+ <Button
+ size="sm"
+ variant="outline"
+ onClick={() => {
+ // 현재 폴더의 카테고리를 기본값으로 설정
+ if (currentParentId) {
+ const currentFolder = items.find(item => item.parentId === currentParentId);
+ if (currentFolder) {
+ setUploadCategory(currentFolder.category);
+ }
+ }
+ setUploadDialogOpen(true);
+ }}
+ >
+ <Upload className="h-4 w-4 mr-1" />
+ Upload
+ </Button>
+ </>
+ )}
+
+ {selectedItems.size > 0 && (
+ <>
+ {/* Multiple download button */}
+ {items.filter(item =>
+ selectedItems.has(item.id) &&
+ item.type === 'file' &&
+ item.permissions?.canDownload === 'true'
+ ).length > 0 && (
+ <Button
+ size="sm"
+ variant="outline"
+ onClick={() => downloadMultipleFiles(Array.from(selectedItems))}
+ >
+ <Download className="h-4 w-4 mr-1" />
+ Download ({items.filter(item =>
+ selectedItems.has(item.id) && item.type === 'file'
+ ).length})
+ </Button>
+ )}
+
+ {/* Delete button */}
+ {items.find(item => selectedItems.has(item.id))?.permissions?.canDelete && (
+ <Button
+ size="sm"
+ variant="destructive"
+ onClick={() => deleteItems(Array.from(selectedItems))}
+ >
+ <Trash2 className="h-4 w-4 mr-1" />
+ Delete ({selectedItems.size})
+ </Button>
+ )}
+ </>
+ )}
+
+ {!isInternalUser && (
+ <Badge variant="secondary" className="ml-2">
+ <Shield className="h-3 w-3 mr-1" />
+ External User
+ </Badge>
+ )}
+ </div>
+
+ <div className="flex items-center gap-2">
+ <div className="relative">
+ <Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
+ <Input
+ placeholder="Search..."
+ className="pl-8 w-64"
+ value={searchQuery}
+ onChange={(e) => setSearchQuery(e.target.value)}
+ />
+ </div>
+
+ <Button
+ size="sm"
+ variant="ghost"
+ onClick={() => setViewMode(viewMode === 'grid' ? 'list' : 'grid')}
+ >
+ {viewMode === 'grid' ? <List className="h-4 w-4" /> : <Grid className="h-4 w-4" />}
+ </Button>
+ </div>
+ </div>
+
+ {/* Breadcrumb */}
+ <Breadcrumb>
+ <BreadcrumbList>
+ <BreadcrumbItem>
+ <BreadcrumbLink onClick={() => navigateToPath(-1)}>
+ Home
+ </BreadcrumbLink>
+ </BreadcrumbItem>
+ {currentPath.map((path, index) => (
+ <BreadcrumbItem key={index}>
+ <ChevronRight className="h-4 w-4" />
+ <BreadcrumbLink onClick={() => navigateToPath(index)}>
+ {path}
+ </BreadcrumbLink>
+ </BreadcrumbItem>
+ ))}
+ </BreadcrumbList>
+ </Breadcrumb>
+ </div>
+
+ {/* File List */}
+ <ScrollArea className="flex-1 p-4">
+ {loading ? (
+ <div className="flex justify-center items-center h-64">
+ <div className="text-muted-foreground">Loading...</div>
+ </div>
+ ) : filteredItems.length === 0 ? (
+ <div className="flex flex-col items-center justify-center h-64">
+ <Folder className="h-12 w-12 text-muted-foreground mb-2" />
+ <p className="text-muted-foreground">Empty</p>
+ </div>
+ ) : viewMode === 'grid' ? (
+ <div className="grid grid-cols-6 gap-4">
+ {filteredItems.map((item) => {
+ const CategoryIcon = categoryConfig[item.category].icon;
+ const categoryColor = categoryConfig[item.category].color;
+
+ return (
+ <ContextMenu key={item.id}>
+ <ContextMenuTrigger>
+ <div
+ className={cn(
+ "flex flex-col items-center p-3 rounded-lg cursor-pointer transition-colors",
+ "hover:bg-accent",
+ selectedItems.has(item.id) && "bg-accent"
+ )}
+ onClick={() => toggleItemSelection(item.id)}
+ onDoubleClick={() => {
+ if (item.type === 'folder') {
+ handleFolderOpen(item);
+ }
+ }}
+ >
+ <div className="relative">
+ {item.type === 'folder' ? (
+ <Folder className="h-12 w-12 text-blue-500" />
+ ) : (
+ <File className="h-12 w-12 text-gray-500" />
+ )}
+ <CategoryIcon className={cn("h-4 w-4 absolute -bottom-1 -right-1", categoryColor)} />
+ </div>
+
+ <span className="mt-2 text-sm text-center truncate w-full">
+ {item.name}
+ </span>
+
+ {item.viewCount !== undefined && (
+ <div className="flex items-center gap-2 mt-1">
+ <span className="text-xs text-muted-foreground flex items-center">
+ <Eye className="h-3 w-3 mr-1" />
+ {item.viewCount}
+ </span>
+ {item.downloadCount !== undefined && (
+ <span className="text-xs text-muted-foreground flex items-center">
+ <Download className="h-3 w-3 mr-1" />
+ {item.downloadCount}
+ </span>
+ )}
+ </div>
+ )}
+ </div>
+ </ContextMenuTrigger>
+
+ <ContextMenuContent>
+ {item.type === 'folder' && (
+ <>
+ <ContextMenuItem onClick={() => handleFolderOpen(item)}>
+ Open
+ </ContextMenuItem>
+ <ContextMenuItem onClick={() => downloadFolder(item)}>
+ <Download className="h-4 w-4 mr-2" />
+ Download Folder
+ </ContextMenuItem>
+ </>
+ )}
+
+ {item.type === 'file' && (
+ <>
+ <ContextMenuItem onClick={() => viewFile(item)}>
+ <Eye className="h-4 w-4 mr-2" />
+ View
+ </ContextMenuItem>
+ {item.permissions?.canDownload === 'true' && (
+ <ContextMenuItem onClick={() => downloadFile(item)}>
+ <Download className="h-4 w-4 mr-2" />
+ Download
+ </ContextMenuItem>
+ )}
+ </>
+ )}
+
+ {isInternalUser && (
+ <>
+ <ContextMenuSeparator />
+ <ContextMenuSub>
+ <ContextMenuSubTrigger>
+ <Shield className="h-4 w-4 mr-2" />
+ Change Category
+ </ContextMenuSubTrigger>
+ <ContextMenuSubContent>
+ {Object.entries(categoryConfig).map(([key, config]) => (
+ <ContextMenuItem
+ key={key}
+ onClick={() => {
+ if (item.type === 'folder') {
+ // Show dialog for folders
+ setSelectedFile(item);
+ setNewCategory(key);
+ setCategoryDialogOpen(true);
+ } else {
+ // Change immediately for files
+ changeCategory(item.id, key, false);
+ }
+ }}
+ >
+ <config.icon className={cn("h-4 w-4 mr-2", config.color)} />
+ {config.label}
+ </ContextMenuItem>
+ ))}
+ </ContextMenuSubContent>
+ </ContextMenuSub>
+
+ <ContextMenuItem
+ onClick={() => {
+ setSelectedFile(item);
+ setShareDialogOpen(true);
+ }}
+ >
+ <Share2 className="h-4 w-4 mr-2" />
+ Share
+ </ContextMenuItem>
+
+ {item.permissions?.canEdit && (
+ <ContextMenuItem onClick={() => {
+ setSelectedFile(item);
+ setDialogValue(item.name);
+ setRenameDialogOpen(true);
+ }}>
+ <Edit2 className="h-4 w-4 mr-2" />
+ Rename
+ </ContextMenuItem>
+ )}
+ </>
+ )}
+
+ {item.permissions?.canDelete && (
+ <>
+ <ContextMenuSeparator />
+ <ContextMenuItem
+ className="text-destructive"
+ onClick={() => deleteItems([item.id])}
+ >
+ <Trash2 className="h-4 w-4 mr-2" />
+ Delete
+ </ContextMenuItem>
+ </>
+ )}
+ </ContextMenuContent>
+ </ContextMenu>
+ );
+ })}
+ </div>
+ ) : (
+ // Tree View
+ <div className="space-y-1">
+ {filteredTreeItems.map((item) => (
+ <TreeItem
+ key={item.id}
+ item={item}
+ level={0}
+ expandedFolders={expandedFolders}
+ selectedItems={selectedItems}
+ onToggleExpand={toggleFolderExpand}
+ onSelectItem={toggleItemSelection}
+ onDoubleClick={handleFolderOpen}
+ onView={viewFile}
+ onDownload={downloadFile}
+ onDownloadFolder={downloadFolder}
+ onDelete={deleteItems}
+ onShare={(item) => {
+ setSelectedFile(item);
+ setShareDialogOpen(true);
+ }}
+ onRename={(item) => {
+ setSelectedFile(item);
+ setDialogValue(item.name);
+ setRenameDialogOpen(true);
+ }}
+ isInternalUser={isInternalUser}
+ />
+ ))}
+ </div>
+ )}
+ </ScrollArea>
+
+{/* Upload Dialog */}
+<Dialog open={uploadDialogOpen} onOpenChange={setUploadDialogOpen}>
+ <DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
+ <DialogHeader>
+ <DialogTitle>Upload Files</DialogTitle>
+ <DialogDescription>
+ Drag and drop files or click to select.
+ </DialogDescription>
+ </DialogHeader>
+
+ <ScrollArea className="flex-1 pr-4">
+ <div className="space-y-4">
+ {/* Category Selection */}
+ <div>
+ <Label htmlFor="upload-category">Category</Label>
+ <Select value={uploadCategory} onValueChange={setUploadCategory}>
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {Object.entries(categoryConfig)
+ .filter(([key]) => {
+ // 현재 폴더가 있는 경우
+ if (currentParentId) {
+ const currentFolder = items.find(item => item.parentId === currentParentId);
+ // 현재 폴더가 public이 아니면 public 옵션 제외
+ if (currentFolder && currentFolder.category !== 'public') {
+ return key !== 'public';
+ }
+ }
+ // 루트 폴더이거나 현재 폴더가 public인 경우 모든 옵션 표시
+ return true;
+ })
+ .map(([key, config]) => (
+ <SelectItem key={key} value={key}>
+ <div className="flex items-center">
+ <config.icon className={cn("h-4 w-4 mr-2", config.color)} />
+ <span>{config.label}</span>
+ </div>
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ {/* 현재 폴더 정보 표시 (선택사항) */}
+ {currentParentId && (() => {
+ const currentFolder = items.find(item => item.parentId === currentParentId);
+ if (currentFolder && currentFolder.category !== 'public') {
+ return (
+ <p className="text-xs text-muted-foreground mt-1 flex items-center">
+ <AlertCircle className="h-3 w-3 mr-1" />
+ Current folder is {categoryConfig[currentFolder.category].label}.
+ Public uploads are not allowed.
+ </p>
+ );
+ }
+ })()}
+ </div>
+
+ {/* Dropzone */}
+ <Dropzone
+ onDrop={(acceptedFiles: File[]) => {
+ handleFileUpload(acceptedFiles);
+ }}
+ accept={{
+ 'application/pdf': ['.pdf'],
+ 'application/msword': ['.doc'],
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
+ 'application/vnd.ms-excel': ['.xls'],
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
+ 'application/vnd.ms-powerpoint': ['.ppt'],
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['.pptx'],
+ 'text/plain': ['.txt'],
+ 'text/csv': ['.csv'],
+ 'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'],
+ 'application/zip': ['.zip'],
+ 'application/x-rar-compressed': ['.rar'],
+ 'application/x-7z-compressed': ['.7z'],
+ 'application/x-dwg': ['.dwg'],
+ 'application/x-dxf': ['.dxf'],
+ }}
+ multiple={true}
+ disabled={false}
+ >
+ <DropzoneZone className="h-48 border-2 border-dashed border-gray-300 rounded-lg">
+ <DropzoneInput />
+ <div className="flex flex-col items-center justify-center h-full">
+ <DropzoneUploadIcon className="h-12 w-12 text-muted-foreground mb-4" />
+ <DropzoneTitle>Drag files or click to upload</DropzoneTitle>
+ <DropzoneDescription>Multiple files can be uploaded simultaneously</DropzoneDescription>
+ </div>
+ </DropzoneZone>
+ </Dropzone>
+
+ {/* Uploading File List */}
+ {uploadingFiles.length > 0 && (
+ <div className="border rounded-lg p-4 bg-muted/50">
+ <div className="flex items-center justify-between mb-3">
+ <h4 className="font-medium text-sm">
+ Uploading Files ({uploadingFiles.filter(f => f.status === 'completed').length}/{uploadingFiles.length})
+ </h4>
+ {uploadingFiles.every(f => f.status === 'completed' || f.status === 'error') && (
+ <Button
+ size="sm"
+ variant="ghost"
+ onClick={() => setUploadingFiles([])}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ <div className="space-y-2 max-h-[300px] overflow-y-auto">
+ {uploadingFiles.map((uploadFile, index) => (
+ <div key={index} className="flex items-start gap-3 p-3 bg-background rounded-md">
+ <File className="h-5 w-5 mt-0.5 flex-shrink-0 text-muted-foreground" />
+ <div className="flex-1 min-w-0">
+ <p className="text-sm font-medium truncate">{uploadFile.file.name}</p>
+ <div className="flex items-center gap-2 mt-1">
+ <span className="text-xs text-muted-foreground">
+ {formatFileSize(uploadFile.file.size)}
+ </span>
+ <span className="text-xs">
+ {uploadFile.status === 'pending' && 'Waiting...'}
+ {uploadFile.status === 'uploading' && 'Uploading...'}
+ {uploadFile.status === 'processing' && 'Processing...'}
+ {uploadFile.status === 'completed' && (
+ <span className="text-green-600 font-medium">✓ Complete</span>
+ )}
+ {uploadFile.status === 'error' && (
+ <span className="text-red-600 font-medium">✗ {uploadFile.error}</span>
+ )}
+ </span>
+ </div>
+ {(uploadFile.status === 'uploading' || uploadFile.status === 'processing') && (
+ <Progress value={uploadFile.progress} className="h-1.5 mt-2" />
+ )}
+ </div>
+ {uploadFile.status === 'error' && (
+ <Button
+ size="sm"
+ variant="ghost"
+ onClick={() => {
+ setUploadingFiles(prev =>
+ prev.filter((_, i) => i !== index)
+ );
+ }}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+ </div>
+ </ScrollArea>
+
+ <DialogFooter className="mt-4">
+ <Button
+ variant="outline"
+ onClick={() => {
+ setUploadDialogOpen(false);
+ setUploadingFiles([]);
+ }}
+ >
+ Close
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+</Dialog>
+
+ {/* Create Folder Dialog */}
+ <Dialog open={folderDialogOpen} onOpenChange={setFolderDialogOpen}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Create New Folder</DialogTitle>
+ <DialogDescription>
+ Set the folder name and access permission category.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ <div>
+ <Label htmlFor="folder-name">Folder Name</Label>
+ <Input
+ id="folder-name"
+ value={dialogValue}
+ onChange={(e) => setDialogValue(e.target.value)}
+ placeholder="Enter folder name"
+ />
+ </div>
+
+ <div>
+ <Label htmlFor="folder-category">Category</Label>
+ <Select value={selectedCategory} onValueChange={setSelectedCategory}>
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {Object.entries(categoryConfig).map(([key, config]) => (
+ <SelectItem key={key} value={key}>
+ <div className="flex items-center">
+ <config.icon className={cn("h-4 w-4 mr-2", config.color)} />
+ <span>{config.label}</span>
+ </div>
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button variant="outline" onClick={() => setFolderDialogOpen(false)}>
+ Cancel
+ </Button>
+ <Button onClick={createFolder}>Create</Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
+ {/* File Share Dialog */}
+ <Dialog open={shareDialogOpen} onOpenChange={setShareDialogOpen}>
+ <DialogContent className="max-w-md">
+ <DialogHeader>
+ <DialogTitle>Share File</DialogTitle>
+ <DialogDescription>
+ Sharing {selectedFile?.name}.
+ </DialogDescription>
+ </DialogHeader>
+
+ <Tabs defaultValue="link" className="w-full">
+ <TabsList className="grid w-full grid-cols-2">
+ <TabsTrigger value="link">Link Sharing</TabsTrigger>
+ <TabsTrigger value="permission">Permission Settings</TabsTrigger>
+ </TabsList>
+
+ <TabsContent value="link" className="space-y-4">
+ <div>
+ <Label htmlFor="access-level">Access Level</Label>
+ <Select
+ value={shareSettings.accessLevel}
+ onValueChange={(value) => setShareSettings({ ...shareSettings, accessLevel: value })}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="view_only">
+ <div className="flex items-center">
+ <Eye className="h-4 w-4 mr-2" />
+ View Only
+ </div>
+ </SelectItem>
+ <SelectItem value="view_download">
+ <div className="flex items-center">
+ <Download className="h-4 w-4 mr-2" />
+ View + Download
+ </div>
+ </SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div>
+ <Label htmlFor="password">Password (Optional)</Label>
+ <Input
+ id="password"
+ type="password"
+ value={shareSettings.password}
+ onChange={(e) => setShareSettings({ ...shareSettings, password: e.target.value })}
+ placeholder="Enter password"
+ />
+ </div>
+
+ <div>
+ <Label htmlFor="expires">Expiry Date (Optional)</Label>
+ <Input
+ id="expires"
+ type="datetime-local"
+ value={shareSettings.expiresAt}
+ onChange={(e) => setShareSettings({ ...shareSettings, expiresAt: e.target.value })}
+ />
+ </div>
+
+ <div>
+ <Label htmlFor="max-downloads">Max Downloads (Optional)</Label>
+ <Input
+ id="max-downloads"
+ type="number"
+ value={shareSettings.maxDownloads}
+ onChange={(e) => setShareSettings({ ...shareSettings, maxDownloads: e.target.value })}
+ placeholder="Unlimited"
+ />
+ </div>
+ </TabsContent>
+
+ <TabsContent value="permission" className="space-y-4">
+ <div>
+ <Label htmlFor="target-domain">Target Domain</Label>
+ <Select>
+ <SelectTrigger>
+ <SelectValue placeholder="Select domain" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="partners">Partners</SelectItem>
+ <SelectItem value="internal">Internal</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div className="space-y-2">
+ <Label>Permissions</Label>
+ <div className="space-y-2">
+ <div className="flex items-center justify-between">
+ <Label htmlFor="can-view" className="text-sm font-normal">View</Label>
+ <Switch id="can-view" defaultChecked />
+ </div>
+ <div className="flex items-center justify-between">
+ <Label htmlFor="can-download" className="text-sm font-normal">Download</Label>
+ <Switch id="can-download" />
+ </div>
+ <div className="flex items-center justify-between">
+ <Label htmlFor="can-edit" className="text-sm font-normal">Edit</Label>
+ <Switch id="can-edit" />
+ </div>
+ <div className="flex items-center justify-between">
+ <Label htmlFor="can-share" className="text-sm font-normal">Share</Label>
+ <Switch id="can-share" />
+ </div>
+ </div>
+ </div>
+ </TabsContent>
+ </Tabs>
+
+ <DialogFooter>
+ <Button variant="outline" onClick={() => setShareDialogOpen(false)}>
+ Cancel
+ </Button>
+ <Button onClick={shareFile}>
+ <Share2 className="h-4 w-4 mr-2" />
+ Create Share Link
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
+ {/* Rename Dialog */}
+ <Dialog open={renameDialogOpen} onOpenChange={setRenameDialogOpen}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Rename</DialogTitle>
+ <DialogDescription>
+ {selectedFile?.type === 'file'
+ ? 'Enter the file name. (Extension will be preserved automatically)'
+ : 'Enter the folder name.'
+ }
+ </DialogDescription>
+ </DialogHeader>
+
+ <div>
+ <Label htmlFor="item-name">New Name</Label>
+ <Input
+ id="item-name"
+ value={dialogValue}
+ onChange={(e) => setDialogValue(e.target.value)}
+ placeholder={
+ selectedFile?.type === 'file'
+ ? selectedFile.name.substring(0, selectedFile.name.lastIndexOf('.'))
+ : selectedFile?.name
+ }
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ renameItem();
+ }
+ }}
+ />
+ {selectedFile?.type === 'file' && (
+ <p className="text-sm text-muted-foreground mt-1">
+ Extension: {selectedFile.name.substring(selectedFile.name.lastIndexOf('.'))}
+ </p>
+ )}
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => {
+ setRenameDialogOpen(false);
+ setSelectedFile(null);
+ setDialogValue('');
+ }}
+ >
+ Cancel
+ </Button>
+ <Button onClick={renameItem}>Rename</Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
+ {/* Category Change Dialog (for folders) */}
+ <Dialog open={categoryDialogOpen} onOpenChange={setCategoryDialogOpen}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Change Category</DialogTitle>
+ <DialogDescription>
+ Changing category for {selectedFile?.name} folder.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ <div>
+ <Label>New Category</Label>
+ <div className="mt-2 space-y-2">
+ {Object.entries(categoryConfig).map(([key, config]) => (
+ <div
+ key={key}
+ className={cn(
+ "flex items-center p-3 rounded-lg border cursor-pointer transition-colors",
+ newCategory === key && "bg-accent border-primary"
+ )}
+ onClick={() => setNewCategory(key)}
+ >
+ <config.icon className={cn("h-5 w-5 mr-3", config.color)} />
+ <div className="flex-1">
+ <div className="font-medium">{config.label}</div>
+ <div className="text-sm text-muted-foreground">
+ {key === 'public' && 'External users can access freely'}
+ {key === 'restricted' && 'External users can only view'}
+ {key === 'confidential' && 'External users cannot access'}
+ {key === 'internal' && 'Internal use only'}
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ {selectedFile?.type === 'folder' && (
+ <div className="flex items-center space-x-2">
+ <Switch
+ id="apply-to-children"
+ checked={newCategory !== 'public' ? true : applyToChildren}
+ onCheckedChange={(checked) => {
+ if (newCategory === 'public') {
+ setApplyToChildren(checked);
+ }
+ }}
+ disabled={newCategory !== 'public'}
+ />
+ <Label htmlFor="apply-to-children" className={cn(
+ newCategory !== 'public' && "text-muted-foreground"
+ )}>
+ Apply to all files and subfolders
+ {newCategory !== 'public' && (
+ <span className="text-xs block mt-1">
+ (Required for security categories)
+ </span>
+ )}
+ </Label>
+ </div>
+ )}
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => {
+ setCategoryDialogOpen(false);
+ setSelectedFile(null);
+ setApplyToChildren(false);
+ }}
+ >
+ Cancel
+ </Button>
+ <Button
+ onClick={() => {
+ if (selectedFile) {
+ changeCategory(selectedFile.id, newCategory, applyToChildren);
+ setCategoryDialogOpen(false);
+ setSelectedFile(null);
+ setApplyToChildren(false);
+ }
+ }}
+ >
+ Change
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
+ {/* Secure Document Viewer Dialog */}
+ <Dialog
+ open={viewerDialogOpen}
+ onOpenChange={(open) => {
+ if (!open) {
+ setViewerDialogOpen(false);
+ setViewerFileUrl(null);
+ setSelectedFile(null);
+ }
+ }}
+ >
+ <DialogContent className="max-w-[90vw] max-h-[90vh] w-full h-full p-0">
+ <DialogHeader className="px-6 py-4 border-b">
+ <DialogTitle className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <Eye className="h-5 w-5" />
+ Secure Document Viewer
+ </div>
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <Lock className="h-4 w-4" />
+ View Only Mode
+ </div>
+ </DialogTitle>
+ <DialogDescription>
+ <div className="flex items-center justify-between mt-2">
+ <span>Viewing: {selectedFile?.name}</span>
+ <Badge variant="destructive" className="text-xs">
+ <AlertCircle className="h-3 w-3 mr-1" />
+ Protected Document - No Download/Copy/Print
+ </Badge>
+ </div>
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="relative flex-1 h-[calc(90vh-120px)]">
+ {viewerFileUrl && selectedFile && (
+ <SecurePDFViewer
+ documentUrl={viewerFileUrl}
+ fileName={selectedFile.name}
+ onClose={() => {
+ setViewerDialogOpen(false);
+ setViewerFileUrl(null);
+ setSelectedFile(null);
+ }}
+ />
+ )}
+ </div>
+
+ <div className="px-6 py-3 border-t bg-muted/50">
+ <div className="flex items-center justify-between text-xs text-muted-foreground">
+ <div className="flex items-center gap-4">
+ <span>Viewer: {session?.user?.email}</span>
+ <span>Time: {new Date().toLocaleString()}</span>
+ <span>IP logged for security</span>
+ </div>
+ <Button
+ size="sm"
+ variant="outline"
+ onClick={() => {
+ setViewerDialogOpen(false);
+ setViewerFileUrl(null);
+ setSelectedFile(null);
+ }}
+ >
+ <X className="h-4 w-4 mr-1" />
+ Close Viewer
+ </Button>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ </div>
+ );
+} \ No newline at end of file
diff --git a/components/file-manager/FileManager.tsx b/components/file-manager/FileManager.tsx
index fa2d8c38..f92f6b04 100644
--- a/components/file-manager/FileManager.tsx
+++ b/components/file-manager/FileManager.tsx
@@ -266,10 +266,6 @@ const TreeItem: React.FC<{
{isInternalUser && (
<>
- <DropdownMenuItem onClick={() => onShare(item)}>
- <Share2 className="h-4 w-4 mr-2" />
- Share
- </DropdownMenuItem>
{item.permissions?.canEdit && (
<DropdownMenuItem onClick={() => onRename(item)}>
@@ -624,44 +620,6 @@ export function FileManager({ projectId }: FileManagerProps) {
}
};
- // Share file
- const shareFile = async () => {
- if (!selectedFile) return;
-
- try {
- const response = await fetch(`/api/data-room/${projectId}/share`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- fileId: selectedFile.id,
- ...shareSettings,
- }),
- });
-
- if (!response.ok) {
- throw new Error('Failed to create share link');
- }
-
- const data = await response.json();
-
- // Copy share link to clipboard
- await navigator.clipboard.writeText(data.shareUrl);
-
- toast({
- title: 'Share Link Created',
- description: 'Link copied to clipboard.',
- });
-
- setShareDialogOpen(false);
- setSelectedFile(null);
- } catch (error) {
- toast({
- title: 'Error',
- description: 'Failed to create share link.',
- variant: 'destructive',
- });
- }
- };
// Download multiple files
const downloadMultipleFiles = async (itemIds: string[]) => {
@@ -974,9 +932,9 @@ export function FileManager({ projectId }: FileManagerProps) {
};
return (
- <div className="flex flex-col h-full">
- {/* Toolbar */}
- <div className="border-b p-4">
+ <div className="h-full flex flex-col min-h-0">
+ {/* Toolbar - 고정 */}
+ <div className="border-b p-4 bg-background shrink-0">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
{isInternalUser && (
@@ -1091,204 +1049,114 @@ export function FileManager({ projectId }: FileManagerProps) {
</Breadcrumb>
</div>
- {/* File List */}
- <ScrollArea className="flex-1 p-4">
- {loading ? (
- <div className="flex justify-center items-center h-64">
- <div className="text-muted-foreground">Loading...</div>
- </div>
- ) : filteredItems.length === 0 ? (
- <div className="flex flex-col items-center justify-center h-64">
- <Folder className="h-12 w-12 text-muted-foreground mb-2" />
- <p className="text-muted-foreground">Empty</p>
- </div>
- ) : viewMode === 'grid' ? (
- <div className="grid grid-cols-6 gap-4">
- {filteredItems.map((item) => {
- const CategoryIcon = categoryConfig[item.category].icon;
- const categoryColor = categoryConfig[item.category].color;
-
- return (
- <ContextMenu key={item.id}>
- <ContextMenuTrigger>
- <div
- className={cn(
- "flex flex-col items-center p-3 rounded-lg cursor-pointer transition-colors",
- "hover:bg-accent",
- selectedItems.has(item.id) && "bg-accent"
- )}
- onClick={() => toggleItemSelection(item.id)}
- onDoubleClick={() => {
- if (item.type === 'folder') {
- handleFolderOpen(item);
- }
- }}
- >
- <div className="relative">
- {item.type === 'folder' ? (
- <Folder className="h-12 w-12 text-blue-500" />
- ) : (
- <File className="h-12 w-12 text-gray-500" />
- )}
- <CategoryIcon className={cn("h-4 w-4 absolute -bottom-1 -right-1", categoryColor)} />
- </div>
-
- <span className="mt-2 text-sm text-center truncate w-full">
- {item.name}
- </span>
+ {/* File List - 스크롤 가능 영역 */}
+ <div className="flex-1 min-h-0">
+ <ScrollArea className="h-full">
+ <div className="p-4">
+ {loading ? (
+ <div className="flex justify-center items-center h-64">
+ <div className="text-muted-foreground">Loading...</div>
+ </div>
+ ) : filteredItems.length === 0 ? (
+ <div className="flex flex-col items-center justify-center h-64">
+ <Folder className="h-12 w-12 text-muted-foreground mb-2" />
+ <p className="text-muted-foreground">Empty</p>
+ </div>
+ ) : viewMode === 'grid' ? (
+ <div className="grid grid-cols-6 gap-4">
+ {filteredItems.map((item) => {
+ const CategoryIcon = categoryConfig[item.category].icon;
+ const categoryColor = categoryConfig[item.category].color;
- {item.viewCount !== undefined && (
- <div className="flex items-center gap-2 mt-1">
- <span className="text-xs text-muted-foreground flex items-center">
- <Eye className="h-3 w-3 mr-1" />
- {item.viewCount}
- </span>
- {item.downloadCount !== undefined && (
- <span className="text-xs text-muted-foreground flex items-center">
- <Download className="h-3 w-3 mr-1" />
- {item.downloadCount}
- </span>
+ return (
+ <ContextMenu key={item.id}>
+ <ContextMenuTrigger>
+ <div
+ className={cn(
+ "flex flex-col items-center p-3 rounded-lg cursor-pointer transition-colors",
+ "hover:bg-accent",
+ selectedItems.has(item.id) && "bg-accent"
)}
- </div>
- )}
- </div>
- </ContextMenuTrigger>
-
- <ContextMenuContent>
- {item.type === 'folder' && (
- <>
- <ContextMenuItem onClick={() => handleFolderOpen(item)}>
- Open
- </ContextMenuItem>
- <ContextMenuItem onClick={() => downloadFolder(item)}>
- <Download className="h-4 w-4 mr-2" />
- Download Folder
- </ContextMenuItem>
- </>
- )}
-
- {item.type === 'file' && (
- <>
- <ContextMenuItem onClick={() => viewFile(item)}>
- <Eye className="h-4 w-4 mr-2" />
- View
- </ContextMenuItem>
- {item.permissions?.canDownload === 'true' && (
- <ContextMenuItem onClick={() => downloadFile(item)}>
- <Download className="h-4 w-4 mr-2" />
- Download
- </ContextMenuItem>
- )}
- </>
- )}
-
- {isInternalUser && (
- <>
- <ContextMenuSeparator />
- <ContextMenuSub>
- <ContextMenuSubTrigger>
- <Shield className="h-4 w-4 mr-2" />
- Change Category
- </ContextMenuSubTrigger>
- <ContextMenuSubContent>
- {Object.entries(categoryConfig).map(([key, config]) => (
- <ContextMenuItem
- key={key}
- onClick={() => {
- if (item.type === 'folder') {
- // Show dialog for folders
- setSelectedFile(item);
- setNewCategory(key);
- setCategoryDialogOpen(true);
- } else {
- // Change immediately for files
- changeCategory(item.id, key, false);
- }
- }}
- >
- <config.icon className={cn("h-4 w-4 mr-2", config.color)} />
- {config.label}
- </ContextMenuItem>
- ))}
- </ContextMenuSubContent>
- </ContextMenuSub>
-
- <ContextMenuItem
- onClick={() => {
- setSelectedFile(item);
- setShareDialogOpen(true);
+ onClick={() => toggleItemSelection(item.id)}
+ onDoubleClick={() => {
+ if (item.type === 'folder') {
+ handleFolderOpen(item);
+ }
}}
>
- <Share2 className="h-4 w-4 mr-2" />
- Share
- </ContextMenuItem>
-
- {item.permissions?.canEdit && (
- <ContextMenuItem onClick={() => {
- setSelectedFile(item);
- setDialogValue(item.name);
- setRenameDialogOpen(true);
- }}>
- <Edit2 className="h-4 w-4 mr-2" />
- Rename
- </ContextMenuItem>
- )}
- </>
- )}
+ <div className="relative">
+ {item.type === 'folder' ? (
+ <Folder className="h-12 w-12 text-blue-500" />
+ ) : (
+ <File className="h-12 w-12 text-gray-500" />
+ )}
+ <CategoryIcon className={cn("h-4 w-4 absolute -bottom-1 -right-1", categoryColor)} />
+ </div>
+
+ <span className="mt-2 text-sm text-center truncate w-full">
+ {item.name}
+ </span>
- {item.permissions?.canDelete && (
- <>
- <ContextMenuSeparator />
- <ContextMenuItem
- className="text-destructive"
- onClick={() => deleteItems([item.id])}
- >
- <Trash2 className="h-4 w-4 mr-2" />
- Delete
- </ContextMenuItem>
- </>
- )}
- </ContextMenuContent>
- </ContextMenu>
- );
- })}
- </div>
- ) : (
- // Tree View
- <div className="space-y-1">
- {filteredTreeItems.map((item) => (
- <TreeItem
- key={item.id}
- item={item}
- level={0}
- expandedFolders={expandedFolders}
- selectedItems={selectedItems}
- onToggleExpand={toggleFolderExpand}
- onSelectItem={toggleItemSelection}
- onDoubleClick={handleFolderOpen}
- onView={viewFile}
- onDownload={downloadFile}
- onDownloadFolder={downloadFolder}
- onDelete={deleteItems}
- onShare={(item) => {
- setSelectedFile(item);
- setShareDialogOpen(true);
- }}
- onRename={(item) => {
- setSelectedFile(item);
- setDialogValue(item.name);
- setRenameDialogOpen(true);
- }}
- isInternalUser={isInternalUser}
- />
- ))}
+ {item.viewCount !== undefined && (
+ <div className="flex items-center gap-2 mt-1">
+ <span className="text-xs text-muted-foreground flex items-center">
+ <Eye className="h-3 w-3 mr-1" />
+ {item.viewCount}
+ </span>
+ {item.downloadCount !== undefined && (
+ <span className="text-xs text-muted-foreground flex items-center">
+ <Download className="h-3 w-3 mr-1" />
+ {item.downloadCount}
+ </span>
+ )}
+ </div>
+ )}
+ </div>
+ </ContextMenuTrigger>
+
+ {/* ... ContextMenuContent는 동일 ... */}
+ </ContextMenu>
+ );
+ })}
+ </div>
+ ) : (
+ // Tree View
+ <div className="space-y-1">
+ {filteredTreeItems.map((item) => (
+ <TreeItem
+ key={item.id}
+ item={item}
+ level={0}
+ expandedFolders={expandedFolders}
+ selectedItems={selectedItems}
+ onToggleExpand={toggleFolderExpand}
+ onSelectItem={toggleItemSelection}
+ onDoubleClick={handleFolderOpen}
+ onView={viewFile}
+ onDownload={downloadFile}
+ onDownloadFolder={downloadFolder}
+ onDelete={deleteItems}
+ onShare={(item) => {
+ setSelectedFile(item);
+ setShareDialogOpen(true);
+ }}
+ onRename={(item) => {
+ setSelectedFile(item);
+ setDialogValue(item.name);
+ setRenameDialogOpen(true);
+ }}
+ isInternalUser={isInternalUser}
+ />
+ ))}
+ </div>
+ )}
</div>
- )}
- </ScrollArea>
+ </ScrollArea>
+ </div>
{/* Upload Dialog */}
<Dialog open={uploadDialogOpen} onOpenChange={setUploadDialogOpen}>
- <DialogContent className="max-w-2xl">
+ <DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>Upload Files</DialogTitle>
<DialogDescription>
@@ -1296,138 +1164,154 @@ export function FileManager({ projectId }: FileManagerProps) {
</DialogDescription>
</DialogHeader>
- <div className="space-y-4">
- {/* Category Selection */}
- <div>
- <Label htmlFor="upload-category">Category</Label>
- <Select value={uploadCategory} onValueChange={setUploadCategory}>
- <SelectTrigger>
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- {Object.entries(categoryConfig)
- .filter(([key]) => {
- // 현재 폴더가 있는 경우
- if (currentParentId) {
- const currentFolder = items.find(item => item.parentId === currentParentId);
- // 현재 폴더가 public이 아니면 public 옵션 제외
- if (currentFolder && currentFolder.category !== 'public') {
- return key !== 'public';
+ <ScrollArea className="flex-1 pr-4">
+ <div className="space-y-4">
+ {/* Category Selection */}
+ <div>
+ <Label htmlFor="upload-category">Category</Label>
+ <Select value={uploadCategory} onValueChange={setUploadCategory}>
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {Object.entries(categoryConfig)
+ .filter(([key]) => {
+ // 현재 폴더가 있는 경우
+ if (currentParentId) {
+ const currentFolder = items.find(item => item.parentId === currentParentId);
+ // 현재 폴더가 public이 아니면 public 옵션 제외
+ if (currentFolder && currentFolder.category !== 'public') {
+ return key !== 'public';
+ }
}
- }
- // 루트 폴더이거나 현재 폴더가 public인 경우 모든 옵션 표시
- return true;
- })
- .map(([key, config]) => (
- <SelectItem key={key} value={key}>
- <div className="flex items-center">
- <config.icon className={cn("h-4 w-4 mr-2", config.color)} />
- <span>{config.label}</span>
- </div>
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- {/* 현재 폴더 정보 표시 (선택사항) */}
- {currentParentId && (() => {
- const currentFolder = items.find(item => item.parentId === currentParentId);
- if (currentFolder && currentFolder.category !== 'public') {
- return (
- <p className="text-xs text-muted-foreground mt-1 flex items-center">
- <AlertCircle className="h-3 w-3 mr-1" />
- Current folder is {categoryConfig[currentFolder.category].label}.
- Public uploads are not allowed.
- </p>
- );
- }
- })()}
- </div>
+ // 루트 폴더이거나 현재 폴더가 public인 경우 모든 옵션 표시
+ return true;
+ })
+ .map(([key, config]) => (
+ <SelectItem key={key} value={key}>
+ <div className="flex items-center">
+ <config.icon className={cn("h-4 w-4 mr-2", config.color)} />
+ <span>{config.label}</span>
+ </div>
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ {/* 현재 폴더 정보 표시 (선택사항) */}
+ {currentParentId && (() => {
+ const currentFolder = items.find(item => item.parentId === currentParentId);
+ if (currentFolder && currentFolder.category !== 'public') {
+ return (
+ <p className="text-xs text-muted-foreground mt-1 flex items-center">
+ <AlertCircle className="h-3 w-3 mr-1" />
+ Current folder is {categoryConfig[currentFolder.category].label}.
+ Public uploads are not allowed.
+ </p>
+ );
+ }
+ })()}
+ </div>
- {/* Dropzone */}
- <Dropzone
- onDrop={(acceptedFiles: File[]) => {
- handleFileUpload(acceptedFiles);
- }}
- accept={{
- 'application/pdf': ['.pdf'],
- 'application/msword': ['.doc'],
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
- 'application/vnd.ms-excel': ['.xls'],
- 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
- 'application/vnd.ms-powerpoint': ['.ppt'],
- 'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['.pptx'],
- 'text/plain': ['.txt'],
- 'text/csv': ['.csv'],
- 'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'],
- 'application/zip': ['.zip'],
- 'application/x-rar-compressed': ['.rar'],
- 'application/x-7z-compressed': ['.7z'],
- 'application/x-dwg': ['.dwg'],
- 'application/x-dxf': ['.dxf'],
- }}
- multiple={true}
- disabled={false}
- >
- <DropzoneZone className="h-48 border-2 border-dashed border-gray-300 rounded-lg">
- <DropzoneInput />
- <div className="flex flex-col items-center justify-center h-full">
- <DropzoneUploadIcon className="h-12 w-12 text-muted-foreground mb-4" />
- <DropzoneTitle>Drag files or click to upload</DropzoneTitle>
- <DropzoneDescription>Multiple files can be uploaded simultaneously</DropzoneDescription>
- </div>
- </DropzoneZone>
- </Dropzone>
-
- {/* Uploading File List */}
- {uploadingFiles.length > 0 && (
- <FileList>
- <FileListHeader>Uploading Files</FileListHeader>
- {uploadingFiles.map((uploadFile, index) => (
- <FileListItem key={index}>
- <FileListIcon>
- <File className="h-4 w-4" />
- </FileListIcon>
- <FileListInfo>
- <FileListName>{uploadFile.file.name}</FileListName>
- <FileListDescription>
- <div className="flex items-center gap-2">
- <FileListSize>{uploadFile.file.size}</FileListSize>
- {uploadFile.status === 'uploading' && <span>Uploading...</span>}
- {uploadFile.status === 'processing' && <span>Processing...</span>}
- {uploadFile.status === 'completed' && (
- <span className="text-green-600">Complete</span>
- )}
- {uploadFile.status === 'error' && (
- <span className="text-red-600">{uploadFile.error}</span>
+ {/* Dropzone */}
+ <Dropzone
+ onDrop={(acceptedFiles: File[]) => {
+ handleFileUpload(acceptedFiles);
+ }}
+ accept={{
+ 'application/pdf': ['.pdf'],
+ 'application/msword': ['.doc'],
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
+ 'application/vnd.ms-excel': ['.xls'],
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
+ 'application/vnd.ms-powerpoint': ['.ppt'],
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['.pptx'],
+ 'text/plain': ['.txt'],
+ 'text/csv': ['.csv'],
+ 'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'],
+ 'application/zip': ['.zip'],
+ 'application/x-rar-compressed': ['.rar'],
+ 'application/x-7z-compressed': ['.7z'],
+ 'application/x-dwg': ['.dwg'],
+ 'application/x-dxf': ['.dxf'],
+ }}
+ multiple={true}
+ disabled={false}
+ >
+ <DropzoneZone className="h-48 border-2 border-dashed border-gray-300 rounded-lg">
+ <DropzoneInput />
+ <div className="flex flex-col items-center justify-center h-full">
+ <DropzoneUploadIcon className="h-12 w-12 text-muted-foreground mb-4" />
+ <DropzoneTitle>Drag files or click to upload</DropzoneTitle>
+ <DropzoneDescription>Multiple files can be uploaded simultaneously</DropzoneDescription>
+ </div>
+ </DropzoneZone>
+ </Dropzone>
+
+ {/* Uploading File List */}
+ {uploadingFiles.length > 0 && (
+ <div className="border rounded-lg p-4 bg-muted/50">
+ <div className="flex items-center justify-between mb-3">
+ <h4 className="font-medium text-sm">
+ Uploading Files ({uploadingFiles.filter(f => f.status === 'completed').length}/{uploadingFiles.length})
+ </h4>
+ {uploadingFiles.every(f => f.status === 'completed' || f.status === 'error') && (
+ <Button
+ size="sm"
+ variant="ghost"
+ onClick={() => setUploadingFiles([])}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ <div className="space-y-2 max-h-[300px] overflow-y-auto">
+ {uploadingFiles.map((uploadFile, index) => (
+ <div key={index} className="flex items-start gap-3 p-3 bg-background rounded-md">
+ <File className="h-5 w-5 mt-0.5 flex-shrink-0 text-muted-foreground" />
+ <div className="flex-1 min-w-0">
+ <p className="text-sm font-medium truncate">{uploadFile.file.name}</p>
+ <div className="flex items-center gap-2 mt-1">
+ <span className="text-xs text-muted-foreground">
+ {formatFileSize(uploadFile.file.size)}
+ </span>
+ <span className="text-xs">
+ {uploadFile.status === 'pending' && 'Waiting...'}
+ {uploadFile.status === 'uploading' && 'Uploading...'}
+ {uploadFile.status === 'processing' && 'Processing...'}
+ {uploadFile.status === 'completed' && (
+ <span className="text-green-600 font-medium">✓ Complete</span>
+ )}
+ {uploadFile.status === 'error' && (
+ <span className="text-red-600 font-medium">✗ {uploadFile.error}</span>
+ )}
+ </span>
+ </div>
+ {(uploadFile.status === 'uploading' || uploadFile.status === 'processing') && (
+ <Progress value={uploadFile.progress} className="h-1.5 mt-2" />
)}
</div>
- {(uploadFile.status === 'uploading' || uploadFile.status === 'processing') && (
- <Progress value={uploadFile.progress} className="h-1 mt-1" />
+ {uploadFile.status === 'error' && (
+ <Button
+ size="sm"
+ variant="ghost"
+ onClick={() => {
+ setUploadingFiles(prev =>
+ prev.filter((_, i) => i !== index)
+ );
+ }}
+ >
+ <X className="h-4 w-4" />
+ </Button>
)}
- </FileListDescription>
- </FileListInfo>
- <FileListAction>
- {uploadFile.status === 'error' && (
- <Button
- size="sm"
- variant="ghost"
- onClick={() => {
- setUploadingFiles(prev =>
- prev.filter((_, i) => i !== index)
- );
- }}
- >
- <X className="h-4 w-4" />
- </Button>
- )}
- </FileListAction>
- </FileListItem>
- ))}
- </FileList>
- )}
- </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+ </div>
+ </ScrollArea>
- <DialogFooter>
+ <DialogFooter className="mt-4">
<Button
variant="outline"
onClick={() => {
@@ -1491,131 +1375,6 @@ export function FileManager({ projectId }: FileManagerProps) {
</DialogContent>
</Dialog>
- {/* File Share Dialog */}
- <Dialog open={shareDialogOpen} onOpenChange={setShareDialogOpen}>
- <DialogContent className="max-w-md">
- <DialogHeader>
- <DialogTitle>Share File</DialogTitle>
- <DialogDescription>
- Sharing {selectedFile?.name}.
- </DialogDescription>
- </DialogHeader>
-
- <Tabs defaultValue="link" className="w-full">
- <TabsList className="grid w-full grid-cols-2">
- <TabsTrigger value="link">Link Sharing</TabsTrigger>
- <TabsTrigger value="permission">Permission Settings</TabsTrigger>
- </TabsList>
-
- <TabsContent value="link" className="space-y-4">
- <div>
- <Label htmlFor="access-level">Access Level</Label>
- <Select
- value={shareSettings.accessLevel}
- onValueChange={(value) => setShareSettings({ ...shareSettings, accessLevel: value })}
- >
- <SelectTrigger>
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="view_only">
- <div className="flex items-center">
- <Eye className="h-4 w-4 mr-2" />
- View Only
- </div>
- </SelectItem>
- <SelectItem value="view_download">
- <div className="flex items-center">
- <Download className="h-4 w-4 mr-2" />
- View + Download
- </div>
- </SelectItem>
- </SelectContent>
- </Select>
- </div>
-
- <div>
- <Label htmlFor="password">Password (Optional)</Label>
- <Input
- id="password"
- type="password"
- value={shareSettings.password}
- onChange={(e) => setShareSettings({ ...shareSettings, password: e.target.value })}
- placeholder="Enter password"
- />
- </div>
-
- <div>
- <Label htmlFor="expires">Expiry Date (Optional)</Label>
- <Input
- id="expires"
- type="datetime-local"
- value={shareSettings.expiresAt}
- onChange={(e) => setShareSettings({ ...shareSettings, expiresAt: e.target.value })}
- />
- </div>
-
- <div>
- <Label htmlFor="max-downloads">Max Downloads (Optional)</Label>
- <Input
- id="max-downloads"
- type="number"
- value={shareSettings.maxDownloads}
- onChange={(e) => setShareSettings({ ...shareSettings, maxDownloads: e.target.value })}
- placeholder="Unlimited"
- />
- </div>
- </TabsContent>
-
- <TabsContent value="permission" className="space-y-4">
- <div>
- <Label htmlFor="target-domain">Target Domain</Label>
- <Select>
- <SelectTrigger>
- <SelectValue placeholder="Select domain" />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="partners">Partners</SelectItem>
- <SelectItem value="internal">Internal</SelectItem>
- </SelectContent>
- </Select>
- </div>
-
- <div className="space-y-2">
- <Label>Permissions</Label>
- <div className="space-y-2">
- <div className="flex items-center justify-between">
- <Label htmlFor="can-view" className="text-sm font-normal">View</Label>
- <Switch id="can-view" defaultChecked />
- </div>
- <div className="flex items-center justify-between">
- <Label htmlFor="can-download" className="text-sm font-normal">Download</Label>
- <Switch id="can-download" />
- </div>
- <div className="flex items-center justify-between">
- <Label htmlFor="can-edit" className="text-sm font-normal">Edit</Label>
- <Switch id="can-edit" />
- </div>
- <div className="flex items-center justify-between">
- <Label htmlFor="can-share" className="text-sm font-normal">Share</Label>
- <Switch id="can-share" />
- </div>
- </div>
- </div>
- </TabsContent>
- </Tabs>
-
- <DialogFooter>
- <Button variant="outline" onClick={() => setShareDialogOpen(false)}>
- Cancel
- </Button>
- <Button onClick={shareFile}>
- <Share2 className="h-4 w-4 mr-2" />
- Create Share Link
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
{/* Rename Dialog */}
<Dialog open={renameDialogOpen} onOpenChange={setRenameDialogOpen}>
diff --git a/components/file-manager/SecurePDFViewer.tsx b/components/file-manager/SecurePDFViewer.tsx
index cd7c081a..707d95dc 100644
--- a/components/file-manager/SecurePDFViewer.tsx
+++ b/components/file-manager/SecurePDFViewer.tsx
@@ -5,6 +5,7 @@ import { useSession } from 'next-auth/react';
import { WebViewerInstance } from '@pdftron/webviewer';
import { Loader2 } from 'lucide-react';
import { toast } from 'sonner';
+import { createCustomWatermark } from './creaetWaterMarks';
interface SecurePDFViewerProps {
documentUrl: string;
@@ -194,14 +195,16 @@ export function SecurePDFViewer({ documentUrl, fileName, onClose }: SecurePDFVie
const watermarkText = `${session?.user?.email || 'CONFIDENTIAL'}\n${new Date().toLocaleString()}`;
// 대각선 워터마크
- documentViewer.setWatermark({
+ documentViewer.setWatermark(
+ {custom:createCustomWatermark({
text: watermarkText,
fontSize: 30,
fontFamily: 'Arial',
color: 'rgba(255, 0, 0, 0.3)',
opacity: 30,
- diagonal: true,
- });
+ // diagonal: true,
+ })}
+ );
// 각 페이지에 커스텀 워터마크 추가
const pageCount = documentViewer.getPageCount();
diff --git a/components/file-manager/creaetWaterMarks.tsx b/components/file-manager/creaetWaterMarks.tsx
new file mode 100644
index 00000000..524b18ee
--- /dev/null
+++ b/components/file-manager/creaetWaterMarks.tsx
@@ -0,0 +1,71 @@
+export const createCustomWatermark: CreateCustomWatermark = ({
+ text,
+ fontSize,
+ color,
+ opacity,
+ rotation = -45,
+ fontFamily = "Helvetica",
+ }) => {
+ return (ctx, pageNumber, pageWidth, pageHeight) => {
+ if (!text) return;
+
+ const lines = text.split("\n"); // 줄바꿈 기준 멀치 처리
+
+ ctx.save();
+ ctx.translate(pageWidth / 2, pageHeight / 2);
+ ctx.rotate((rotation * Math.PI) / 180);
+ ctx.fillStyle = color;
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+
+ const lineHeights = lines.map((s) => {
+ return fontSize;
+ });
+
+ const totalHeight =
+ lineHeights.reduce((sum, h) => sum + h, 0) - lineHeights[0]; // 첫 줄은 기준선 0
+
+ let yOffset = -totalHeight / 2;
+
+ lines.forEach((line, i) => {
+ ctx.font = `900 ${fontSize}px ${fontFamily}`;
+ ctx.fillText(line, 0, yOffset);
+ yOffset += lineHeights[i];
+ });
+
+ ctx.restore();
+ };
+ };
+
+
+ import { Core, WebViewerInstance } from "@pdftron/webviewer";
+
+export interface WaterMarkOption {
+ fontSize: number;
+ color: string;
+ opacity: number;
+ rotation: number;
+ fontFamily: string;
+ split: boolean;
+ shipNameCheck: boolean;
+ shipName: string;
+ ownerNameCheck: boolean;
+ ownerName: string;
+ classNameCheck: boolean;
+ className: string;
+ classList: string[];
+ customCheck: boolean;
+ text: string;
+}
+
+type CreateCustomWatermark = ({
+ text,
+ fontSize,
+ color,
+ opacity,
+ rotation,
+ fontFamily,
+}: Pick<
+ WaterMarkOption,
+ "text" | "fontSize" | "color" | "opacity" | "rotation" | "fontFamily"
+>) => Core.DocumentViewer.CustomWatermarkCallback; \ No newline at end of file
diff --git a/components/form-data/form-data-table.tsx b/components/form-data/form-data-table.tsx
index d5d79735..9dbcb627 100644
--- a/components/form-data/form-data-table.tsx
+++ b/components/form-data/form-data-table.tsx
@@ -19,7 +19,7 @@ import {
Upload,
Plus,
Tag,
- TagsIcon,
+ TagsIcon,
FileOutput,
Clipboard,
Send,
@@ -115,7 +115,97 @@ export default function DynamicTable({
const [formStats, setFormStats] = React.useState<FormStatusByVendor | null>(null);
const [isLoadingStats, setIsLoadingStats] = React.useState(true);
+ const [activeFilter, setActiveFilter] = React.useState<string | null>(null);
+ const [filteredTableData, setFilteredTableData] = React.useState<GenericData[]>(tableData);
+ // 필터링 로직
+ React.useEffect(() => {
+ if (!activeFilter) {
+ setFilteredTableData(tableData);
+ return;
+ }
+
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+ const sevenDaysLater = new Date(today);
+ sevenDaysLater.setDate(sevenDaysLater.getDate() + 7);
+
+ let filtered = [...tableData];
+
+ switch (activeFilter) {
+ case 'completed':
+ // 모든 필수 필드가 완료된 태그만 표시
+ filtered = tableData.filter(item => {
+ const tagEditableFields = editableFieldsMap.get(item.TAG_NO) || [];
+ return columnsJSON
+ .filter(col => (col.shi === 'IN' || col.shi === 'BOTH') && tagEditableFields.includes(col.key))
+ .every(col => {
+ const value = item[col.key];
+ return value !== undefined && value !== null && value !== '';
+ });
+ });
+ break;
+
+ case 'remaining':
+ // 미완료 필드가 있는 태그만 표시
+ filtered = tableData.filter(item => {
+ const tagEditableFields = editableFieldsMap.get(item.TAG_NO) || [];
+ return columnsJSON
+ .filter(col => (col.shi === 'IN' || col.shi === 'BOTH') && tagEditableFields.includes(col.key))
+ .some(col => {
+ const value = item[col.key];
+ return value === undefined || value === null || value === '';
+ });
+ });
+ break;
+
+ case 'upcoming':
+ // 7일 이내 임박한 태그만 표시
+ filtered = tableData.filter(item => {
+ const dueDate = item.DUE_DATE;
+ if (!dueDate) return false;
+
+ const target = new Date(dueDate);
+ target.setHours(0, 0, 0, 0);
+
+ // 미완료이면서 7일 이내인 경우
+ const hasIncompleteFields = columnsJSON
+ .filter(col => col.shi === 'IN' || col.shi === 'BOTH')
+ .some(col => !item[col.key]);
+
+ return hasIncompleteFields && target >= today && target <= sevenDaysLater;
+ });
+ break;
+
+ case 'overdue':
+ // 지연된 태그만 표시
+ filtered = tableData.filter(item => {
+ const dueDate = item.DUE_DATE;
+ if (!dueDate) return false;
+
+ const target = new Date(dueDate);
+ target.setHours(0, 0, 0, 0);
+
+ // 미완료이면서 지연된 경우
+ const hasIncompleteFields = columnsJSON
+ .filter(col => col.shi === 'IN' || col.shi === 'BOTH')
+ .some(col => !item[col.key]);
+
+ return hasIncompleteFields && target < today;
+ });
+ break;
+
+ default:
+ filtered = tableData;
+ }
+
+ setFilteredTableData(filtered);
+ }, [activeFilter, tableData, columnsJSON, editableFieldsMap]);
+
+ // 카드 클릭 핸들러
+ const handleCardClick = (filterType: string | null) => {
+ setActiveFilter(prev => prev === filterType ? null : filterType);
+ };
React.useEffect(() => {
const fetchFormStats = async () => {
@@ -310,7 +400,7 @@ export default function DynamicTable({
isArray: Array.isArray(templateResult),
data: templateResult
});
-
+
if (Array.isArray(templateResult)) {
templateResult.forEach((tmpl, idx) => {
console.log(` [${idx}] TMPL_ID: ${tmpl?.TMPL_ID || 'MISSING'}, NAME: ${tmpl?.NAME || 'N/A'}, TYPE: ${tmpl?.TMPL_TYPE || 'N/A'}`);
@@ -687,11 +777,15 @@ export default function DynamicTable({
return (
<>
-
+
<div className="mb-6">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-6">
- {/* Tag Count */}
- <Card>
+ {/* Total Tags Card - 클릭 시 전체 보기 */}
+ <Card
+ className={`cursor-pointer transition-all ${activeFilter === null ? 'ring-2 ring-primary' : 'hover:shadow-lg'
+ }`}
+ onClick={() => handleCardClick(null)}
+ >
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Total Tags
@@ -707,35 +801,17 @@ export default function DynamicTable({
)}
</div>
<p className="text-xs text-muted-foreground">
- Total Tag Count
+ {activeFilter === null ? 'Showing all' : 'Click to show all'}
</p>
</CardContent>
</Card>
- {/* Completion Rate */}
- <Card>
- <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
- <CardTitle className="text-sm font-medium">
- Completion
- </CardTitle>
- <Target className="h-4 w-4 text-muted-foreground" />
- </CardHeader>
- <CardContent>
- <div className="text-2xl font-bold">
- {isLoadingStats ? (
- <span className="animate-pulse">-</span>
- ) : (
- `${formStats?.completionRate || 0}%`
- )}
- </div>
- <p className="text-xs text-muted-foreground">
- {formStats ? `${formStats.completedFields} / ${formStats.totalFields}` : '-'}
- </p>
- </CardContent>
- </Card>
-
- {/* Completed Fields */}
- <Card>
+ {/* Completed Fields Card */}
+ <Card
+ className={`cursor-pointer transition-all ${activeFilter === 'completed' ? 'ring-2 ring-green-600' : 'hover:shadow-lg'
+ }`}
+ onClick={() => handleCardClick('completed')}
+ >
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Completed
@@ -751,13 +827,17 @@ export default function DynamicTable({
)}
</div>
<p className="text-xs text-muted-foreground">
- Completed Fields
+ {activeFilter === 'completed' ? 'Filtering active' : 'Click to filter'}
</p>
</CardContent>
</Card>
- {/* Remaining Fields */}
- <Card>
+ {/* Remaining Fields Card */}
+ <Card
+ className={`cursor-pointer transition-all ${activeFilter === 'remaining' ? 'ring-2 ring-blue-600' : 'hover:shadow-lg'
+ }`}
+ onClick={() => handleCardClick('remaining')}
+ >
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Remaining
@@ -773,13 +853,17 @@ export default function DynamicTable({
)}
</div>
<p className="text-xs text-muted-foreground">
- Remaining Fields
+ {activeFilter === 'remaining' ? 'Filtering active' : 'Click to filter'}
</p>
</CardContent>
</Card>
- {/* Upcoming (7 days) */}
- <Card>
+ {/* Upcoming Card */}
+ <Card
+ className={`cursor-pointer transition-all ${activeFilter === 'upcoming' ? 'ring-2 ring-yellow-600' : 'hover:shadow-lg'
+ }`}
+ onClick={() => handleCardClick('upcoming')}
+ >
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Upcoming
@@ -795,13 +879,17 @@ export default function DynamicTable({
)}
</div>
<p className="text-xs text-muted-foreground">
- Due in 7 Days
+ {activeFilter === 'upcoming' ? 'Filtering active' : 'Click to filter'}
</p>
</CardContent>
</Card>
- {/* Overdue */}
- <Card>
+ {/* Overdue Card */}
+ <Card
+ className={`cursor-pointer transition-all ${activeFilter === 'overdue' ? 'ring-2 ring-red-600' : 'hover:shadow-lg'
+ }`}
+ onClick={() => handleCardClick('overdue')}
+ >
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Overdue
@@ -817,22 +905,40 @@ export default function DynamicTable({
)}
</div>
<p className="text-xs text-muted-foreground">
- Overdue
+ {activeFilter === 'overdue' ? 'Filtering active' : 'Click to filter'}
</p>
</CardContent>
</Card>
</div>
</div>
-
+
<ClientDataTable
- data={tableData}
+ data={filteredTableData} // tableData 대신 filteredTableData 사용
columns={columns}
advancedFilterFields={advancedFilterFields}
autoSizeColumns
onSelectedRowsChange={setSelectedRowsData}
clearSelection={clearSelection}
>
+ {/* 필터 상태 표시 */}
+ {activeFilter && (
+ <div className="flex items-center gap-2 mr-auto">
+ <span className="text-sm text-muted-foreground">
+ Filter: {activeFilter === 'completed' ? 'Completed' :
+ activeFilter === 'remaining' ? 'Remaining' :
+ activeFilter === 'upcoming' ? 'Upcoming (7 days)' :
+ activeFilter === 'overdue' ? 'Overdue' : 'All'}
+ </span>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => setActiveFilter(null)}
+ >
+ Clear filter
+ </Button>
+ </div>
+ )}
{/* 선택된 항목 수 표시 (선택된 항목이 있을 때만) */}
{selectedRowCount > 0 && (
<Button
diff --git a/components/layout/HeaderDataroom.tsx b/components/layout/HeaderDataroom.tsx
new file mode 100644
index 00000000..333e3768
--- /dev/null
+++ b/components/layout/HeaderDataroom.tsx
@@ -0,0 +1,202 @@
+"use client";
+
+import * as React from "react";
+import Link from "next/link";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ NavigationMenu,
+ NavigationMenuContent,
+ NavigationMenuItem,
+ NavigationMenuLink,
+ NavigationMenuList,
+ NavigationMenuTrigger,
+ navigationMenuTriggerStyle,
+} from "@/components/ui/navigation-menu";
+import { SearchIcon, BellIcon, Menu } from "lucide-react";
+import { useParams, usePathname, useSearchParams } from "next/navigation";
+import { cn } from "@/lib/utils";
+import Image from "next/image";
+import { MobileMenu } from "./MobileMenu";
+import { CommandMenu } from "./command-menu";
+import { useSession, signOut } from "next-auth/react";
+import { NotificationDropdown } from "./NotificationDropdown";
+
+// 간단한 메뉴 배열
+const simpleMenus = [
+ { title: "발주처 목록", href: "/evcp/data-room/owner-companies" },
+ { title: "데이터룸", href: "/evcp/data-room" }
+];
+export function HeaderDataRoom() {
+ const params = useParams();
+ const lng = params?.lng as string;
+ const pathname = usePathname();
+ const { data: session } = useSession();
+
+ const userName = session?.user?.name || "";
+ const domain = session?.user?.domain || "";
+ const initials = userName
+ .split(" ")
+ .map((word) => word[0]?.toUpperCase())
+ .join("");
+
+ const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false);
+
+ const toggleMobileMenu = () => {
+ setIsMobileMenuOpen(!isMobileMenuOpen);
+ };
+
+ return (
+ <>
+ <header className="border-grid sticky top-0 z-40 w-full border-b bg-slate-100 backdrop-blur supports-[backdrop-filter]:bg-background/60">
+ <div className="container-wrapper">
+ <div className="container flex h-14 items-center">
+ {/* 햄버거 메뉴 버튼 (모바일) */}
+ <Button
+ onClick={toggleMobileMenu}
+ variant="ghost"
+ className="-ml-2 mr-2 h-8 w-8 px-0 text-base hover:bg-transparent focus-visible:bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 md:hidden"
+ >
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ strokeWidth="1.5"
+ stroke="currentColor"
+ className="!size-6"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ d="M3.75 9h16.5m-16.5 6.75h16.5"
+ />
+ </svg>
+ <span className="sr-only">메뉴 토글</span>
+ </Button>
+
+ {/* 로고 영역 */}
+ <div className="mr-4 flex-shrink-0 flex items-center gap-2 lg:mr-6">
+ <Link href={`/${lng}/evcp`} className="flex items-center gap-2">
+ <Image
+ className="dark:invert"
+ src="/images/vercel.svg"
+ alt="EVCP Logo"
+ width={20}
+ height={20}
+ />
+ <span className="hidden font-bold lg:inline-block">
+ EVCP
+ </span>
+ </Link>
+ </div>
+
+ {/* 네비게이션 메뉴 - 간단한 배열 */}
+ <div className="hidden md:block flex-1 min-w-0">
+ <nav className="flex items-center space-x-6">
+ {simpleMenus.map((menu) => (
+ <Link
+ key={menu.href}
+ href={`/${lng}${menu.href}`}
+ className="text-sm font-medium transition-colors hover:text-primary"
+ >
+ {menu.title}
+ </Link>
+ ))}
+ </nav>
+</div>
+
+ {/* 우측 영역 */}
+ <div className="ml-auto flex flex-shrink-0 items-center space-x-2">
+ {/* 데스크탑에서는 CommandMenu, 모바일에서는 검색 아이콘만 */}
+
+ {/* 알림 버튼 */}
+ <NotificationDropdown />
+
+ {/* 사용자 메뉴 */}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Avatar className="cursor-pointer h-8 w-8">
+ <AvatarImage src={`${session?.user?.image}` || "/user-avatar.jpg"} alt="User Avatar" />
+ <AvatarFallback>
+ {initials || "?"}
+ </AvatarFallback>
+ </Avatar>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent className="w-48" align="end">
+ <DropdownMenuLabel>내 계정</DropdownMenuLabel>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem asChild>
+ <Link href={`/${lng}/evcp/settings`}>설정</Link>
+ </DropdownMenuItem>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem onSelect={() => signOut({ callbackUrl: `/${lng}/${domain}` })}>
+ 로그아웃
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+ </div>
+ </div>
+
+ {/* 모바일 메뉴 */}
+ {isMobileMenuOpen && (
+ <MobileMenu
+ lng={lng}
+ onClose={toggleMobileMenu}
+ activeMenus={[]}
+ domainMain={simpleMenus}
+ domainAdditional={[]}
+ t={(key: string) => key}
+ />
+ )}
+ </header>
+ </>
+ );
+}
+
+const ListItem = React.forwardRef<
+ React.ElementRef<"a">,
+ React.ComponentPropsWithoutRef<"a">
+>(({ className, title, children, ...props }, ref) => {
+ return (
+ <li>
+ <NavigationMenuLink asChild>
+ <a
+ ref={ref}
+ className={cn(
+ "block select-none space-y-1 rounded-md p-3 leading-none no-underline outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground",
+ className
+ )}
+ {...props}
+ >
+ <div className="text-sm font-medium leading-none">{title}</div>
+ {children && (
+ <p className="line-clamp-2 text-sm leading-snug text-muted-foreground">
+ {children}
+ </p>
+ )}
+ </a>
+ </NavigationMenuLink>
+ </li>
+ );
+});
+ListItem.displayName = "ListItem";
+
+
+export function RouteLogger() {
+ const path = usePathname();
+ const qs = useSearchParams().toString();
+ React.useEffect(() => {
+ console.log("[URL]", path + (qs ? "?" + qs : ""));
+ }, [path, qs]);
+ return null;
+} \ No newline at end of file
diff --git a/components/layout/HeaderSimple.tsx b/components/layout/HeaderSimple.tsx
index f099d3ef..989929ae 100644
--- a/components/layout/HeaderSimple.tsx
+++ b/components/layout/HeaderSimple.tsx
@@ -100,7 +100,7 @@ export function HeaderSimple() {
/>
<span className="hidden font-bold lg:inline-block">
{isPartnerRoute
- ? "eVCP Partners"
+ ? "Data Room"
: pathname?.includes("/evcp")
? "eVCP 삼성중공업"
: "eVCP"}
diff --git a/components/project/ProjectList.tsx b/components/project/ProjectList.tsx
index e267b21c..5c01070e 100644
--- a/components/project/ProjectList.tsx
+++ b/components/project/ProjectList.tsx
@@ -237,7 +237,7 @@ const fetchProjects = async () => {
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
- <h1 className="text-3xl font-bold">Projects</h1>
+ <h1 className="text-2xl font-bold">Projects</h1>
<p className="text-muted-foreground mt-1">
Manage files and collaborate with your team
</p>
diff --git a/config/menuConfig.ts b/config/menuConfig.ts
index 5b5319e7..7175ed0d 100644
--- a/config/menuConfig.ts
+++ b/config/menuConfig.ts
@@ -413,6 +413,12 @@ export const mainNav: MenuSection[] = [
descriptionKey: 'menu.engineering_management.vendor_progress_desc',
groupKey: 'groups.engineering_management',
},
+ {
+ titleKey: "menu.engineering_management.cover",
+ href: "/evcp/cover",
+ descriptionKey: "menu.engineering_management.cover_desc",
+ groupKey: "groups.engineering_management"
+ },
],
},
{
diff --git a/db/schema/companies.ts b/db/schema/companies.ts
index 60f8a0ce..b24acef0 100644
--- a/db/schema/companies.ts
+++ b/db/schema/companies.ts
@@ -11,3 +11,13 @@ export const companies = pgTable("companies", {
});
export type Company = typeof companies.$inferSelect
+
+export const ownerCompanies = pgTable("owner_companies", {
+ id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
+ name: varchar("name", { length: 255 }).notNull(),
+ createdAt: timestamp("created_at", { withTimezone: true })
+ .defaultNow()
+ .notNull(),
+});
+
+export type OwnerCompany = typeof ownerCompanies.$inferSelect
diff --git a/db/schema/users.ts b/db/schema/users.ts
index 1d963228..278bc7ab 100644
--- a/db/schema/users.ts
+++ b/db/schema/users.ts
@@ -5,6 +5,7 @@ import {
import { eq, sql } from "drizzle-orm";
import { vendors } from "./vendors";
import { techVendors } from "./techVendors";
+import { ownerCompanies } from "./companies";
export const userDomainEnum = pgEnum("user_domain", ["pending", "evcp", "procurement", "sales", "engineering", "partners"]);
@@ -17,6 +18,8 @@ export const users = pgTable("users", {
deptCode: varchar("deptCode", { length: 50 }),
deptName: varchar("deptName", { length: 255 }),
+ ownerCompanyId: integer("owner_company_id")
+ .references(() => ownerCompanies.id, { onDelete: "set null" }),
companyId: integer("company_id")
.references(() => vendors.id, { onDelete: "set null" }),
techCompanyId: integer("tech_company_id")
@@ -49,12 +52,12 @@ export const users = pgTable("users", {
deactivatedAt: timestamp("deactivated_at", { withTimezone: true }),
deactivationReason: varchar("deactivation_reason", { length: 50 }), // 'INACTIVE', 'ADMIN', 'GDPR' 등
- // ✨ 새로 추가: 동의 관련 필드들
- lastConsentUpdate: timestamp("last_consent_update", { withTimezone: true }),
- consentVersion: varchar("consent_version", { length: 20 }), // 마지막 동의한 정책 버전
- requiresConsentUpdate: boolean("requires_consent_update").default(false).notNull(),
-
- // ✨ 새로 추가: 회원가입 관련
+ // ✨ 새로 추가: 동의 관련 필드들
+ lastConsentUpdate: timestamp("last_consent_update", { withTimezone: true }),
+ consentVersion: varchar("consent_version", { length: 20 }), // 마지막 동의한 정책 버전
+ requiresConsentUpdate: boolean("requires_consent_update").default(false).notNull(),
+
+ // ✨ 새로 추가: 회원가입 관련
// emailVerified: boolean("email_verified").default(false).notNull(),
// emailVerifiedAt: timestamp("email_verified_at", { withTimezone: true }),
// registrationCompleted: boolean("registration_completed").default(false).notNull(),
diff --git a/db/schema/vendorDocu.ts b/db/schema/vendorDocu.ts
index 812206c8..b7cef5e5 100644
--- a/db/schema/vendorDocu.ts
+++ b/db/schema/vendorDocu.ts
@@ -2086,4 +2086,54 @@ export const stageSubmissionView = pgView("stage_submission_view", {
ORDER BY ed.document_id, ist.stage_order
`);
-export type StageSubmissionView = typeof stageSubmissionView.$inferSelect \ No newline at end of file
+export type StageSubmissionView = typeof stageSubmissionView.$inferSelect
+
+// 프로젝트 커버 페이지 템플릿
+export const projectCoverTemplates = pgTable(
+ "project_cover_templates",
+ {
+ id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
+ projectId: integer("project_id")
+ .notNull()
+ .references(() => projects.id, { onDelete: "cascade" }),
+
+ // 템플릿 파일 정보
+ templateName: varchar("template_name", { length: 255 }).notNull(),
+ originalFileName: varchar("original_file_name", { length: 255 }).notNull(),
+ filePath: varchar("file_path", { length: 1024 }).notNull(), // S3 또는 로컬 경로
+ fileSize: integer("file_size"),
+
+ // 템플릿 변수 설정 (JSON으로 저장)
+ variables: jsonb("variables"), // {docNumber: "{{docNumber}}", customVar1: "{{customVar1}}"} 형태
+
+ // 메타데이터
+ isActive: boolean("is_active").default(true), // 여러 템플릿 중 활성화된 것
+ createdBy: varchar("created_by", { length: 255 }),
+ updatedBy: varchar("updated_by", { length: 255 }),
+
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
+ }
+)
+
+// 생성된 커버 페이지 (실제 사용 시 생성되는 문서)
+export const generatedCoverPages = pgTable(
+ "generated_cover_pages",
+ {
+ id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
+ templateId: integer("template_id")
+ .notNull()
+ .references(() => projectCoverTemplates.id, { onDelete: "cascade" }),
+
+ // 변수 값 (실제 입력된 값들)
+ variableValues: jsonb("variable_values"), // {docNumber: "DOC-2024-001", customVar1: "value"}
+
+ // 생성된 파일
+ fileName: varchar("file_name", { length: 255 }).notNull(),
+ filePath: varchar("file_path", { length: 1024 }).notNull(),
+ fileSize: integer("file_size"),
+
+ generatedBy: varchar("generated_by", { length: 255 }),
+ generatedAt: timestamp("generated_at").defaultNow().notNull(),
+ }
+)
diff --git a/lib/cover/repository.ts b/lib/cover/repository.ts
new file mode 100644
index 00000000..62b70778
--- /dev/null
+++ b/lib/cover/repository.ts
@@ -0,0 +1,44 @@
+import db from "@/db/db";
+import { projects } from "@/db/schema";
+import {
+ eq,
+ inArray,
+ not,
+ asc,
+ desc,
+ and,
+ ilike,
+ gte,
+ lte,
+ count,
+ gt,
+} from "drizzle-orm";
+import { PgTransaction } from "drizzle-orm/pg-core";
+
+export async function selectProjectLists(
+ tx: PgTransaction<any, any, any>,
+ params: {
+ where?: any; // drizzle-orm의 조건식 (and, eq...) 등
+ orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[];
+ offset?: number;
+ limit?: number;
+ }
+ ) {
+ const { where, orderBy, offset = 0, limit = 10 } = params;
+
+ return tx
+ .select()
+ .from(projects)
+ .where(where)
+ .orderBy(...(orderBy ?? []))
+ .offset(offset)
+ .limit(limit);
+ }
+/** 총 개수 count */
+export async function countProjectLists(
+ tx: PgTransaction<any, any, any>,
+ where?: any
+) {
+ const res = await tx.select({ count: count() }).from(projects).where(where);
+ return res[0]?.count ?? 0;
+}
diff --git a/lib/cover/service.ts b/lib/cover/service.ts
new file mode 100644
index 00000000..91ea3458
--- /dev/null
+++ b/lib/cover/service.ts
@@ -0,0 +1,123 @@
+"use server";
+
+import { revalidateTag, unstable_noStore } from "next/cache";
+import db from "@/db/db";
+import { unstable_cache } from "@/lib/unstable-cache";
+import { filterColumns } from "@/lib/filter-columns";
+import { tagTypeClassFormMappings } from "@/db/schema/vendorData";
+import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq } from "drizzle-orm";
+import { countProjectLists, selectProjectLists } from "./repository";
+import { projects, projectCoverTemplates, generatedCoverPages } from "@/db/schema";
+import { GetProjectListsSchema } from "./validation";
+
+export async function getProjectListsForCover(input: GetProjectListsSchema) {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+ const advancedTable = true;
+
+ const advancedWhere = filterColumns({
+ table: projects,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+ let globalWhere
+ if (input.search) {
+ const s = `%${input.search}%`
+ globalWhere = or(
+ ilike(projects.name, s),
+ ilike(projects.code, s),
+ ilike(projects.type, s),
+ )
+ }
+
+ const finalWhere = and(
+ eq(projects.type, "plant"),
+ advancedWhere,
+ globalWhere
+ )
+
+ const where = finalWhere
+
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(projects[item.id]) : asc(projects[item.id])
+ )
+ : [asc(projects.createdAt)];
+
+ // 트랜잭션 내부에서 Repository 호출
+ const { data, total } = await db.transaction(async (tx) => {
+ const projectData = await selectProjectLists(tx, {
+ where,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+
+ // 프로젝트 ID 목록 추출
+ const projectIds = projectData.map(p => p.id);
+
+ // 활성 템플릿 정보 조회
+ const templates = projectIds.length > 0
+ ? await tx
+ .select()
+ .from(projectCoverTemplates)
+ .where(
+ and(
+ inArray(projectCoverTemplates.projectId, projectIds),
+ eq(projectCoverTemplates.isActive, true)
+ )
+ )
+ : [];
+
+ // 템플릿 맵 생성
+ const templateMap = new Map(
+ templates.map(t => [t.projectId, t])
+ );
+
+ // 생성된 커버 페이지 조회 (각 템플릿의 최신 것만)
+ const templateIds = templates.map(t => t.id);
+ const generatedCovers = templateIds.length > 0
+ ? await tx
+ .select()
+ .from(generatedCoverPages)
+ .where(inArray(generatedCoverPages.templateId, templateIds))
+ .orderBy(desc(generatedCoverPages.generatedAt))
+ : [];
+
+ // 각 템플릿별 최신 생성 커버 맵 생성
+ const latestCoverMap = new Map();
+ for (const cover of generatedCovers) {
+ if (!latestCoverMap.has(cover.templateId)) {
+ latestCoverMap.set(cover.templateId, cover);
+ }
+ }
+
+ // 프로젝트에 템플릿 및 생성된 커버 정보 병합
+ const data = projectData.map(project => {
+ const template = templateMap.get(project.id);
+ const latestCover = template ? latestCoverMap.get(template.id) : null;
+
+ return {
+ ...project,
+ coverTemplatePath: template?.filePath || null,
+ templateVariables: template?.variables || null,
+ template: template || null,
+ generatedCover: latestCover || null,
+ generatedCoverPath: latestCover?.filePath || null,
+ };
+ });
+
+ const total = await countProjectLists(tx, where);
+ return { data, total };
+ });
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data, pageCount };
+ } catch (err) {
+ console.error("❌ getProjectListsForCover 오류:", err);
+ return { data: [], pageCount: 0 };
+ }
+} \ No newline at end of file
diff --git a/lib/cover/table/cover-template-dialog.tsx b/lib/cover/table/cover-template-dialog.tsx
new file mode 100644
index 00000000..f5ac3fae
--- /dev/null
+++ b/lib/cover/table/cover-template-dialog.tsx
@@ -0,0 +1,455 @@
+"use client"
+
+import * as React from "react"
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Upload, Save, Download, Copy, Check, Loader2 } from "lucide-react"
+import { WebViewerInstance } from "@pdftron/webviewer"
+import { Project } from "@/db/schema"
+import { toast } from "sonner"
+import { quickDownload } from "@/lib/file-download"
+import { BasicContractTemplateViewer } from "@/lib/basic-contract/template/basic-contract-template-viewer"
+import { useRouter, usePathname } from "next/navigation"
+
+interface CoverTemplateDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ project: Project | null
+}
+
+export function CoverTemplateDialog({ open, onOpenChange, project }: CoverTemplateDialogProps) {
+ const [instance, setInstance] = React.useState<WebViewerInstance | null>(null)
+ const [filePath, setFilePath] = React.useState<string>("")
+ const [uploadedFile, setUploadedFile] = React.useState<File | null>(null)
+ const [isSaving, setIsSaving] = React.useState(false)
+ const [isUploading, setIsUploading] = React.useState(false)
+ const [copiedVar, setCopiedVar] = React.useState<string | null>(null)
+ const router = useRouter()
+
+ // 필수 템플릿 변수
+ const templateVariables = [
+ { key: "docNumber", value: "docNumber", label: "문서 번호" },
+ { key: "projectNumber", value: "projectNumber", label: "프로젝트 번호" },
+ { key: "projectName", value: "projectName", label: "프로젝트명" }
+ ]
+
+ // instance 상태 모니터링
+ React.useEffect(() => {
+ console.log("🔍 Instance 상태:", instance ? "있음" : "없음");
+ }, [instance]);
+
+ // 다이얼로그가 열릴 때마다 상태 초기화 및 템플릿 로드
+ React.useEffect(() => {
+ if (open) {
+ // instance는 초기화하지 않음 - 뷰어가 알아서 설정함
+ setUploadedFile(null)
+ setIsSaving(false)
+ setIsUploading(false)
+ setCopiedVar(null)
+
+ // 프로젝트에 저장된 템플릿이 있으면 로드
+ if (project?.coverTemplatePath) {
+ setFilePath(project.coverTemplatePath)
+ } else {
+ setFilePath("")
+ }
+ } else {
+ // 다이얼로그가 닫힐 때만 완전히 초기화
+ setFilePath("")
+ setInstance(null)
+ setUploadedFile(null)
+ }
+ }, [open, project])
+
+ // 파일 업로드 핸들러
+ const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
+ const file = e.target.files?.[0]
+ if (!file) return
+
+ if (!file.name.endsWith('.docx')) {
+ toast.error("DOCX 파일만 업로드 가능합니다")
+ return
+ }
+
+ setIsUploading(true)
+ setUploadedFile(file)
+
+ const formData = new FormData()
+ formData.append("file", file)
+ formData.append("projectId", String(project?.id))
+
+ try {
+ const response = await fetch("/api/projects/cover-template/upload", {
+ method: "POST",
+ body: formData,
+ })
+
+ if (!response.ok) {
+ const error = await response.json()
+ throw new Error(error.message || "업로드 실패")
+ }
+
+ const data = await response.json()
+ setFilePath(data.filePath)
+ router.refresh()
+ toast.success("템플릿 파일이 업로드되었습니다")
+ } catch (error) {
+ console.error("파일 업로드 오류:", error)
+ toast.error(error instanceof Error ? error.message : "파일 업로드 중 오류가 발생했습니다")
+ } finally {
+ setIsUploading(false)
+ }
+ }
+
+ // 복사 함수 - 더 강력한 버전
+ const copyToClipboard = async (text: string, key: string) => {
+ let copySuccess = false;
+
+ // 방법 1: 최신 Clipboard API (가장 확실함)
+ try {
+ await navigator.clipboard.writeText(text);
+ copySuccess = true;
+ setCopiedVar(key);
+ setTimeout(() => setCopiedVar(null), 2000);
+ toast.success(`복사됨: ${text}`, {
+ description: "문서에 붙여넣으세요 (Ctrl+V)"
+ });
+ return;
+ } catch (err) {
+ console.error("Clipboard API 실패:", err);
+ }
+
+ // 방법 2: 이벤트 기반 복사 (사용자 상호작용 컨텍스트 유지)
+ try {
+ const listener = (e: ClipboardEvent) => {
+ e.clipboardData?.setData('text/plain', text);
+ e.preventDefault();
+ copySuccess = true;
+ };
+
+ document.addEventListener('copy', listener);
+ const result = document.execCommand('copy');
+ document.removeEventListener('copy', listener);
+
+ if (result && copySuccess) {
+ setCopiedVar(key);
+ setTimeout(() => setCopiedVar(null), 2000);
+ toast.success(`복사됨: ${text}`, {
+ description: "문서에 붙여넣으세요 (Ctrl+V)"
+ });
+ return;
+ }
+ } catch (err) {
+ console.error("이벤트 기반 복사 실패:", err);
+ }
+
+ // 방법 3: textarea 방식 (강화 버전)
+ try {
+ const textArea = document.createElement("textarea");
+ textArea.value = text;
+
+ // 스타일 설정으로 화면에 보이지 않게
+ textArea.style.position = "fixed";
+ textArea.style.top = "0";
+ textArea.style.left = "0";
+ textArea.style.width = "2em";
+ textArea.style.height = "2em";
+ textArea.style.padding = "0";
+ textArea.style.border = "none";
+ textArea.style.outline = "none";
+ textArea.style.boxShadow = "none";
+ textArea.style.background = "transparent";
+ textArea.style.opacity = "0";
+
+ document.body.appendChild(textArea);
+
+ // iOS 대응
+ if (navigator.userAgent.match(/ipad|ipod|iphone/i)) {
+ textArea.contentEditable = "true";
+ textArea.readOnly = false;
+ const range = document.createRange();
+ range.selectNodeContents(textArea);
+ const selection = window.getSelection();
+ selection?.removeAllRanges();
+ selection?.addRange(range);
+ textArea.setSelectionRange(0, 999999);
+ } else {
+ textArea.select();
+ textArea.setSelectionRange(0, 99999);
+ }
+
+ const successful = document.execCommand('copy');
+ document.body.removeChild(textArea);
+
+ if (successful) {
+ setCopiedVar(key);
+ setTimeout(() => setCopiedVar(null), 2000);
+ toast.success(`복사됨: ${text}`, {
+ description: "문서에 붙여넣으세요 (Ctrl+V)"
+ });
+ copySuccess = true;
+ return;
+ }
+ } catch (err) {
+ console.error("textarea 복사 실패:", err);
+ }
+
+ // 모든 방법 실패
+ if (!copySuccess) {
+ toast.error("자동 복사 실패", {
+ description: `수동으로 복사하세요: ${text}`,
+ duration: 5000,
+ });
+ }
+ };
+
+ // 템플릿 저장
+ const handleSaveTemplate = async () => {
+ console.log("💾 저장 시도 - instance:", instance);
+ console.log("💾 저장 시도 - project:", project);
+
+ if (!instance) {
+ toast.error("뷰어가 아직 준비되지 않았습니다", {
+ description: "문서가 완전히 로드될 때까지 기다려주세요"
+ })
+ return
+ }
+
+ if (!project) {
+ toast.error("프로젝트 정보가 없습니다")
+ return
+ }
+
+ setIsSaving(true)
+
+ try {
+ const { documentViewer } = instance.Core
+ const doc = documentViewer.getDocument()
+
+ if (!doc) {
+ throw new Error("문서가 로드되지 않았습니다")
+ }
+
+ console.log("📄 문서 export 시작...");
+
+ // DOCX로 export
+ const data = await doc.getFileData({
+ downloadType: 'office',
+ includeAnnotations: true
+ })
+
+ console.log("✅ 문서 export 완료, 크기:", data.byteLength);
+
+ const blob = new Blob([data], {
+ type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
+ })
+
+ // FormData 생성
+ const formData = new FormData()
+ formData.append("file", blob, `${project.code}_cover_template.docx`)
+ formData.append("projectId", String(project.id))
+ formData.append("templateName", `${project.name} 커버 템플릿`)
+
+ console.log("📤 서버 전송 시작...");
+
+ const response = await fetch("/api/projects/cover-template/save", {
+ method: "POST",
+ body: formData,
+ })
+
+ if (!response.ok) {
+ const error = await response.json()
+ throw new Error(error.message || "저장 실패")
+ }
+
+ const result = await response.json()
+
+ console.log("✅ 서버 저장 완료:", result);
+ router.refresh()
+ toast.success("커버 페이지가 생성되었습니다")
+
+ // 저장된 파일 경로 업데이트
+ if (result.filePath) {
+ setFilePath(result.filePath)
+ }
+
+ onOpenChange(false)
+ } catch (error) {
+ console.error("❌ 템플릿 저장 오류:", error)
+ toast.error(error instanceof Error ? error.message : "템플릿 저장 중 오류가 발생했습니다")
+ } finally {
+ setIsSaving(false)
+ }
+ }
+
+ const handleDownloadTemplate = () => {
+ if (!filePath || !project) return
+ quickDownload(filePath, `${project.code}_cover_template.docx`)
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-7xl w-[90vw] h-[85vh] p-0 gap-0 flex flex-col">
+ <DialogHeader className="px-6 py-3 border-b">
+ <DialogTitle className="text-base">
+ 커버 페이지 템플릿 관리 - {project?.name} ({project?.code})
+ </DialogTitle>
+ </DialogHeader>
+
+ <div className="flex flex-1 min-h-0 overflow-hidden">
+ <div className="w-80 h-full border-r p-4 overflow-y-auto flex flex-col gap-4">
+ <div className="space-y-2">
+ <Label>템플릿 파일 업로드</Label>
+ <div className="flex gap-2">
+ <Input
+ type="file"
+ accept=".docx"
+ onChange={handleFileUpload}
+ disabled={isUploading}
+ className="flex-1"
+ />
+ {filePath && (
+ <Button
+ size="icon"
+ variant="outline"
+ onClick={handleDownloadTemplate}
+ title="현재 템플릿 다운로드"
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ {isUploading && (
+ <p className="text-xs text-muted-foreground">업로드 중...</p>
+ )}
+ </div>
+
+ <div className="space-y-2">
+ <Label>필수 템플릿 변수</Label>
+ <div className="text-xs text-muted-foreground mb-2">
+ 복사 버튼을 클릭하여 변수를 복사한 후 문서에 붙여넣으세요
+ </div>
+
+ <div className="space-y-2">
+ {templateVariables.map(({ key, value, label }) => (
+ <div key={key} className="flex gap-2 items-center">
+ <div className="flex-1">
+ <div className="text-xs text-muted-foreground mb-1">{label}</div>
+ <div className="flex gap-2">
+ <Input
+ value={`{{${value}}}`}
+ readOnly
+ className="flex-1 text-xs font-mono bg-muted/50"
+ />
+ <Button
+ type="button"
+ size="sm"
+ variant="outline"
+ onClick={() => copyToClipboard(`{{${value}}}`, key)}
+ title="클립보드에 복사"
+ >
+ {copiedVar === key ? (
+ <Check className="h-3 w-3 text-green-600" />
+ ) : (
+ <Copy className="h-3 w-3" />
+ )}
+ </Button>
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+
+ <div className="text-xs text-muted-foreground p-3 bg-muted/50 rounded-md mt-3">
+ <div className="font-semibold mb-1">💡 사용 방법</div>
+ 1. 복사 버튼을 클릭하여 변수를 복사<br />
+ 2. 문서에서 원하는 위치에 Ctrl+V로 붙여넣기<br />
+ 3. 문서 생성 시 변수는 실제 값으로 자동 치환됩니다<br />
+ <br />
+ <div className="font-semibold">📌 커스텀 변수</div>
+ 필요한 경우 {`{{customField}}`} 형식으로 직접 입력 가능
+ </div>
+ </div>
+
+ <div className="mt-auto pt-4 space-y-2">
+ {/* 상태 표시 */}
+ <div className="text-xs text-muted-foreground space-y-1 p-2 bg-muted/30 rounded">
+ <div className="flex items-center gap-2">
+ <div className={`w-2 h-2 rounded-full ${filePath ? 'bg-green-500' : 'bg-gray-300'}`} />
+ 파일: {filePath ? '준비됨' : '없음'}
+ </div>
+ {filePath &&
+ <div className="flex items-center gap-2">
+ <div className={`w-2 h-2 rounded-full ${instance ? 'bg-green-500' : 'bg-yellow-500'}`} />
+ 뷰어: {instance ? '준비됨' : '로딩 중...'}
+ </div>
+ }
+ </div>
+
+ <Button
+ className="w-full"
+ onClick={handleSaveTemplate}
+ disabled={!filePath || isSaving || !instance}
+ >
+ {(() => {
+ if (isSaving) {
+ return (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ 저장 중...
+ </>
+ );
+ }
+
+ if (!filePath) {
+ return (
+ <>
+ <Upload className="mr-2 h-4 w-4" />
+ 파일을 먼저 업로드하세요
+ </>
+ );
+ }
+
+ if (!instance) {
+ return (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ 뷰어 로딩 중...
+ </>
+ );
+ }
+
+ return (
+ <>
+ <Save className="mr-2 h-4 w-4" />
+ 커버 페이지 생성
+ </>
+ );
+ })()}
+ </Button>
+ </div>
+ </div>
+
+ <div className="flex-1 relative overflow-hidden">
+ {filePath ? (
+ <div className="absolute inset-0">
+ <BasicContractTemplateViewer
+ key={filePath}
+ filePath={filePath}
+ instance={instance}
+ setInstance={setInstance}
+ />
+ </div>
+ ) : (
+ <div className="flex items-center justify-center h-full text-muted-foreground">
+ DOCX 파일을 업로드하세요
+ </div>
+ )}
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/cover/table/projects-table-columns.tsx b/lib/cover/table/projects-table-columns.tsx
new file mode 100644
index 00000000..9ed36436
--- /dev/null
+++ b/lib/cover/table/projects-table-columns.tsx
@@ -0,0 +1,187 @@
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Download, Eye, FileText, FilePlus } from "lucide-react"
+import { Checkbox } from "@/components/ui/checkbox";
+
+import { formatDate } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { quickDownload } from "@/lib/file-download"
+
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { Project } from "@/db/schema"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<Project> | null>>
+ onDetailClick: (project: Project) => void
+ onTemplateManage: (project: Project) => void
+}
+
+export function getColumns({ setRowAction, onDetailClick, onTemplateManage }: GetColumnsProps): ColumnDef<Project>[] {
+ return [
+ // Checkbox
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")}
+ onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
+ aria-label="select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(v) => row.toggleSelected(!!v)}
+ aria-label="select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ },
+ {
+ accessorKey: "code",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="프로젝트 코드" />
+ ),
+ meta: {
+ excelHeader: "Project Code",
+ },
+ },
+ {
+ accessorKey: "name",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="프로젝트명" />
+ ),
+ meta: {
+ excelHeader: "Project Name",
+ },
+ },
+ {
+ id: "coverTemplate",
+ enableResizing: true,
+ header: "커버 템플릿",
+ cell: ({ row }) => {
+ const project = row.original
+ const hasTemplate = !!project.coverTemplatePath
+
+ return (
+ <div className="flex items-center gap-2">
+ {hasTemplate ? (
+ <>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => quickDownload(project.coverTemplatePath!, `${project.code}_template.docx`)}
+ title="템플릿 다운로드"
+ >
+ <FileText className="h-4 w-4 text-blue-600" />
+ </Button>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => onTemplateManage(project)}
+ title="템플릿 관리"
+ >
+ <Eye className="h-4 w-4" />
+ </Button>
+ </>
+ ) : (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => onTemplateManage(project)}
+ >
+ <FilePlus className="h-4 w-4 mr-1" />
+ 생성
+ </Button>
+ )}
+ </div>
+ )
+ },
+ },
+ {
+ id: "generatedCover",
+ enableResizing: true,
+ header: "생성된 커버",
+ cell: ({ row }) => {
+ const project = row.original
+ const hasGenerated = !!project.generatedCoverPath
+ const generatedAt = project.generatedCover?.generatedAt
+
+ return (
+ <div className="flex flex-col gap-1">
+ {hasGenerated ? (
+ <>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => quickDownload(
+ project.generatedCoverPath!,
+ project.generatedCover?.fileName || `${project.code}_cover.docx`
+ )}
+ className="justify-start"
+ >
+ <Download className="h-4 w-4 mr-2 text-green-600" />
+ <span className="text-xs">다운로드</span>
+ </Button>
+ {generatedAt && (
+ <span className="text-xs text-muted-foreground pl-2">
+ {formatDate(new Date(generatedAt), "KR")}
+ </span>
+ )}
+ </>
+ ) : (
+ <span className="text-xs text-muted-foreground px-2">-</span>
+ )}
+ </div>
+ )
+ },
+ },
+ {
+ accessorKey: "OWN_NM",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="선주명" />
+ ),
+ meta: {
+ excelHeader: "Owner Name",
+ },
+ },
+ {
+ accessorKey: "createdAt",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="생성일" />
+ ),
+ meta: {
+ excelHeader: "Created At",
+ },
+ cell: ({ cell }) => {
+ const dateVal = cell.getValue() as Date
+ return formatDate(dateVal, "KR")
+ },
+ },
+ {
+ accessorKey: "updatedAt",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="수정일" />
+ ),
+ meta: {
+ excelHeader: "Updated At",
+ },
+ cell: ({ cell }) => {
+ const dateVal = cell.getValue() as Date
+ return formatDate(dateVal, "KR")
+ },
+ },
+ ]
+} \ No newline at end of file
diff --git a/lib/cover/table/projects-table-toolbar-actions.tsx b/lib/cover/table/projects-table-toolbar-actions.tsx
new file mode 100644
index 00000000..5d2d1fc6
--- /dev/null
+++ b/lib/cover/table/projects-table-toolbar-actions.tsx
@@ -0,0 +1,50 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Download, RefreshCcw, FileText } from "lucide-react"
+import { toast } from "sonner"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import { Project } from "@/db/schema"
+import { CoverTemplateDialog } from "./cover-template-dialog"
+
+interface ItemsTableToolbarActionsProps {
+ table: Table<Project>
+}
+
+export function ProjectTableToolbarActions({ table }: ItemsTableToolbarActionsProps) {
+ const [templateDialogOpen, setTemplateDialogOpen] = React.useState(false)
+ const [selectedProject, setSelectedProject] = React.useState<Project | null>(null)
+
+ const handleTemplateClick = () => {
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ if (selectedRows.length !== 1) {
+ toast.error("프로젝트를 하나만 선택해주세요")
+ return
+ }
+ setSelectedProject(selectedRows[0].original)
+ setTemplateDialogOpen(true)
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleTemplateClick}
+ disabled={table.getFilteredSelectedRowModel().rows.length !== 1}
+ >
+ <FileText className="mr-2 h-4 w-4" />
+ 커버 페이지 템플릿
+ </Button>
+
+ <CoverTemplateDialog
+ open={templateDialogOpen}
+ onOpenChange={setTemplateDialogOpen}
+ project={selectedProject}
+ />
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/cover/table/projects-table.tsx b/lib/cover/table/projects-table.tsx
new file mode 100644
index 00000000..944013ef
--- /dev/null
+++ b/lib/cover/table/projects-table.tsx
@@ -0,0 +1,114 @@
+"use client"
+
+import * as React from "react"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+
+import { getColumns } from "./projects-table-columns"
+import { getProjectListsForCover } from "../service"
+import { Project } from "@/db/schema"
+import { ProjectTableToolbarActions } from "./projects-table-toolbar-actions"
+import { CoverTemplateDialog } from "./cover-template-dialog"
+
+interface ItemsTableProps {
+promises: Promise<
+[
+ Awaited<ReturnType<typeof getProjectListsForCover>>,
+]
+>
+}
+
+export function ProjectsTableForCover({ promises }: ItemsTableProps) {
+ const [{ data, pageCount }] = React.use(promises)
+
+ console.log(data, 'data')
+
+ const [rowAction, setRowAction] =
+ React.useState<DataTableRowAction<Project> | null>(null)
+
+ // 템플릿 다이얼로그 상태
+ const [templateDialogOpen, setTemplateDialogOpen] = React.useState(false)
+ const [selectedProject, setSelectedProject] = React.useState<Project | null>(null)
+
+ const handleTemplateManage = React.useCallback((project: Project) => {
+ setSelectedProject(project)
+ setTemplateDialogOpen(true)
+ }, [])
+
+ const columns = React.useMemo(
+ () => getColumns({
+ setRowAction,
+ onDetailClick: () => {},
+ onTemplateManage: handleTemplateManage
+ }),
+ [setRowAction, handleTemplateManage]
+ )
+
+ const filterFields: DataTableFilterField<Project>[] = []
+
+ const advancedFilterFields: DataTableAdvancedFilterField<Project>[] = [
+ {
+ id: "code",
+ label: "Project Code",
+ type: "text",
+ },
+ {
+ id: "name",
+ label: "Project Name",
+ type: "text",
+ },
+ {
+ id: "createdAt",
+ label: "Created At",
+ type: "date",
+ },
+ {
+ id: "updatedAt",
+ label: "Updated At",
+ type: "date",
+ },
+ ]
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "createdAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <ProjectTableToolbarActions table={table} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ <CoverTemplateDialog
+ open={templateDialogOpen}
+ onOpenChange={setTemplateDialogOpen}
+ project={selectedProject}
+ />
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/cover/validation.ts b/lib/cover/validation.ts
new file mode 100644
index 00000000..ed1cc9a1
--- /dev/null
+++ b/lib/cover/validation.ts
@@ -0,0 +1,36 @@
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server"
+import * as z from "zod"
+
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+import { Project } from "@/db/schema";
+
+export const searchParamsProjectsCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<Project>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+
+ code: parseAsString.withDefault(""),
+ name: parseAsString.withDefault(""),
+ type: parseAsString.withDefault(""),
+
+ // advanced filter
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+
+})
+
+
+
+export type GetProjectListsSchema = Awaited<ReturnType<typeof searchParamsProjectsCache.parse>>
diff --git a/lib/owner-companies/owner-company-form.tsx b/lib/owner-companies/owner-company-form.tsx
new file mode 100644
index 00000000..a385eccc
--- /dev/null
+++ b/lib/owner-companies/owner-company-form.tsx
@@ -0,0 +1,99 @@
+// app/(admin)/owner-companies/_components/owner-company-form.tsx
+"use client";
+
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import * as z from "zod";
+import { Button } from "@/components/ui/button";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { useRouter } from "next/navigation";
+import { toast } from "sonner";
+import { createOwnerCompany, updateOwnerCompany } from "./service";
+
+const formSchema = z.object({
+ name: z.string().min(1, "회사명을 입력해주세요"),
+});
+
+type FormValues = z.infer<typeof formSchema>;
+
+interface OwnerCompanyFormProps {
+ initialData?: {
+ id: number;
+ name: string;
+ };
+}
+
+export function OwnerCompanyForm({ initialData }: OwnerCompanyFormProps) {
+ const router = useRouter();
+ const isEdit = !!initialData;
+
+ const form = useForm<FormValues>({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ name: initialData?.name || "",
+ },
+ });
+
+ async function onSubmit(values: FormValues) {
+ try {
+ const result = isEdit
+ ? await updateOwnerCompany(initialData.id, values)
+ : await createOwnerCompany(values);
+
+ if (result.success) {
+ toast.success(
+ isEdit ? "회사 정보가 수정되었습니다" : "회사가 등록되었습니다"
+ );
+ router.push("/evcp/data-room/owner-companies");
+ router.refresh();
+ }
+ } catch (error) {
+ toast.error("오류가 발생했습니다");
+ }
+ }
+
+ return (
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
+ <FormField
+ control={form.control}
+ name="name"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>회사명 *</FormLabel>
+ <FormControl>
+ <Input placeholder="삼성전자" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <div className="flex gap-2">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => router.back()}
+ >
+ 취소
+ </Button>
+ <Button type="submit" disabled={form.formState.isSubmitting}>
+ {form.formState.isSubmitting
+ ? "처리 중..."
+ : isEdit
+ ? "수정"
+ : "등록"}
+ </Button>
+ </div>
+ </form>
+ </Form>
+ );
+} \ No newline at end of file
diff --git a/lib/owner-companies/owner-company-list.tsx b/lib/owner-companies/owner-company-list.tsx
new file mode 100644
index 00000000..b78b193b
--- /dev/null
+++ b/lib/owner-companies/owner-company-list.tsx
@@ -0,0 +1,85 @@
+// app/(admin)/owner-companies/_components/owner-company-list.tsx
+"use client";
+
+import { Button } from "@/components/ui/button";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import Link from "next/link";
+import { Building2, Users } from "lucide-react";
+
+interface OwnerCompany {
+ id: number;
+ name: string;
+ createdAt: Date;
+}
+
+interface OwnerCompanyListProps {
+ companies: OwnerCompany[];
+}
+
+export function OwnerCompanyList({ companies }: OwnerCompanyListProps) {
+ if (companies.length === 0) {
+ return (
+ <div className="text-center py-12">
+ <Building2 className="mx-auto h-12 w-12 text-muted-foreground" />
+ <h3 className="mt-4 text-lg font-semibold">등록된 회사가 없습니다</h3>
+ <p className="mt-2 text-sm text-muted-foreground">
+ 첫 번째 발주처 회사를 등록해보세요.
+ </p>
+ <Button asChild className="mt-4">
+ <Link href="/evcp/data-room/owner-companies/new">회사 등록</Link>
+ </Button>
+ </div>
+ );
+ }
+
+ return (
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead>회사명</TableHead>
+ <TableHead>등록일</TableHead>
+ <TableHead className="text-right">작업</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {companies.map((company) => (
+ <TableRow key={company.id}>
+ <TableCell className="font-medium">
+ <div className="flex items-center gap-2">
+ <Building2 className="h-4 w-4 text-muted-foreground" />
+ {company.name}
+ </div>
+ </TableCell>
+ <TableCell>
+ {new Date(company.createdAt).toLocaleDateString("ko-KR", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ })}
+ </TableCell>
+ <TableCell className="text-right">
+ <div className="flex gap-2 justify-end">
+ <Button variant="outline" size="sm" asChild>
+ <Link href={`/owner-companies/${company.id}`}>수정</Link>
+ </Button>
+ <Button variant="outline" size="sm" asChild>
+ <Link href={`/evcp/data-room/owner-companies/${company.id}/users`}>
+ <Users className="h-4 w-4 mr-1" />
+ 사용자 관리
+ </Link>
+ </Button>
+ </div>
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ );
+} \ No newline at end of file
diff --git a/lib/owner-companies/owner-company-user-form.tsx b/lib/owner-companies/owner-company-user-form.tsx
new file mode 100644
index 00000000..52253607
--- /dev/null
+++ b/lib/owner-companies/owner-company-user-form.tsx
@@ -0,0 +1,125 @@
+// app/(admin)/owner-companies/_components/owner-company-user-form.tsx
+"use client";
+
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import * as z from "zod";
+import { Button } from "@/components/ui/button";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { useRouter } from "next/navigation";
+import { toast } from "sonner";
+import { createOwnerCompanyUser } from "./service";
+
+const formSchema = z.object({
+ name: z.string().min(1, "이름을 입력해주세요"),
+ email: z.string().email("올바른 이메일을 입력해주세요"),
+ phone: z.string().optional(),
+});
+
+type FormValues = z.infer<typeof formSchema>;
+
+interface OwnerCompanyUserFormProps {
+ companyId: number;
+}
+
+export function OwnerCompanyUserForm({ companyId }: OwnerCompanyUserFormProps) {
+ const router = useRouter();
+
+ const form = useForm<FormValues>({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ name: "",
+ email: "",
+ phone: "",
+ },
+ });
+
+ async function onSubmit(values: FormValues) {
+ try {
+ const result = await createOwnerCompanyUser(companyId, values);
+
+ if (result.success) {
+ toast.success("사용자가 등록되었습니다");
+ router.push(`/evcp/data-room/owner-companies/${companyId}/users`);
+ router.refresh();
+ } else {
+ toast.error(result.error || "오류가 발생했습니다");
+ }
+ } catch (error) {
+ toast.error("오류가 발생했습니다");
+ }
+ }
+
+ return (
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
+ <FormField
+ control={form.control}
+ name="name"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>이름 *</FormLabel>
+ <FormControl>
+ <Input placeholder="홍길동" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="email"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>이메일 *</FormLabel>
+ <FormControl>
+ <Input
+ type="email"
+ placeholder="user@company.com"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="phone"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>전화번호</FormLabel>
+ <FormControl>
+ <Input placeholder="+82-10-1234-5678" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <div className="flex gap-2">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => router.back()}
+ >
+ 취소
+ </Button>
+ <Button type="submit" disabled={form.formState.isSubmitting}>
+ {form.formState.isSubmitting ? "처리 중..." : "등록"}
+ </Button>
+ </div>
+ </form>
+ </Form>
+ );
+} \ No newline at end of file
diff --git a/lib/owner-companies/owner-company-user-list.tsx b/lib/owner-companies/owner-company-user-list.tsx
new file mode 100644
index 00000000..1f0963fe
--- /dev/null
+++ b/lib/owner-companies/owner-company-user-list.tsx
@@ -0,0 +1,93 @@
+// app/(admin)/owner-companies/_components/owner-company-user-list.tsx
+"use client";
+
+import { Button } from "@/components/ui/button";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { Badge } from "@/components/ui/badge";
+import { UserPlus } from "lucide-react";
+import Link from "next/link";
+
+interface User {
+ id: number;
+ name: string;
+ email: string;
+ phone: string | null;
+ isActive: boolean;
+ createdAt: Date;
+ employeeNumber: string | null;
+}
+
+interface OwnerCompanyUserListProps {
+ users: User[];
+ companyId: number;
+}
+
+export function OwnerCompanyUserList({
+ users,
+ companyId,
+}: OwnerCompanyUserListProps) {
+ if (users.length === 0) {
+ return (
+ <div className="text-center py-12 border rounded-lg">
+ <UserPlus className="mx-auto h-12 w-12 text-muted-foreground" />
+ <h3 className="mt-4 text-lg font-semibold">등록된 사용자가 없습니다</h3>
+ <p className="mt-2 text-sm text-muted-foreground">
+ 첫 번째 사용자를 추가해보세요.
+ </p>
+ <Button asChild className="mt-4">
+ <Link href={`/evcp/data-room/owner-companies/${companyId}/users/new`}>
+ 사용자 추가
+ </Link>
+ </Button>
+ </div>
+ );
+ }
+
+ return (
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead>이름</TableHead>
+ <TableHead>이메일</TableHead>
+ <TableHead>전화번호</TableHead>
+ <TableHead>사번</TableHead>
+ <TableHead>상태</TableHead>
+ <TableHead>등록일</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {users.map((user) => (
+ <TableRow key={user.id}>
+ <TableCell className="font-medium">{user.name}</TableCell>
+ <TableCell>{user.email}</TableCell>
+ <TableCell>{user.phone || "-"}</TableCell>
+ <TableCell>{user.employeeNumber || "-"}</TableCell>
+ <TableCell>
+ {user.isActive ? (
+ <Badge variant="default" className="bg-green-500">
+ 활성
+ </Badge>
+ ) : (
+ <Badge variant="destructive">비활성</Badge>
+ )}
+ </TableCell>
+ <TableCell>
+ {new Date(user.createdAt).toLocaleDateString("ko-KR", {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ })}
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ );
+} \ No newline at end of file
diff --git a/lib/owner-companies/service.ts b/lib/owner-companies/service.ts
new file mode 100644
index 00000000..3692abd4
--- /dev/null
+++ b/lib/owner-companies/service.ts
@@ -0,0 +1,77 @@
+// lib/owner-companies/service.ts
+"use server";
+
+import db from "@/db/db";
+import { ownerCompanies, users } from "@/db/schema";
+import { revalidatePath } from "next/cache";
+import { eq } from "drizzle-orm";
+
+export async function createOwnerCompany(data: { name: string }) {
+ const [company] = await db
+ .insert(ownerCompanies)
+ .values({
+ name: data.name,
+ })
+ .returning();
+
+ revalidatePath("/owner-companies");
+ return { success: true, data: company };
+}
+
+export async function updateOwnerCompany(
+ id: number,
+ data: { name: string }
+) {
+ const [company] = await db
+ .update(ownerCompanies)
+ .set({
+ name: data.name,
+ })
+ .where(eq(ownerCompanies.id, id))
+ .returning();
+
+ revalidatePath("/owner-companies");
+ revalidatePath(`/owner-companies/${id}`);
+ return { success: true, data: company };
+}
+
+export async function createOwnerCompanyUser(
+ companyId: number,
+ data: {
+ name: string;
+ email: string;
+ phone?: string;
+ employeeNumber?: string;
+ }
+) {
+ // 이메일 중복 체크
+ const existing = await db
+ .select()
+ .from(users)
+ .where(eq(users.email, data.email))
+ .limit(1);
+
+ if (existing.length > 0) {
+ return { success: false, error: "이미 사용 중인 이메일입니다." };
+ }
+
+ const [user] = await db
+ .insert(users)
+ .values({
+ ...data,
+ ownerCompanyId: companyId,
+ domain: "owner", // 발주처 도메인
+ isActive: true,
+ })
+ .returning();
+
+ revalidatePath(`/owner-companies/${companyId}/users`);
+ return { success: true, data: user };
+}
+
+export async function getOwnerCompanyUsers(companyId: number) {
+ return await db
+ .select()
+ .from(users)
+ .where(eq(users.ownerCompanyId, companyId));
+} \ No newline at end of file
diff --git a/lib/pcr/table/detail-table/pcr-detail-toolbar-action.tsx b/lib/pcr/table/detail-table/pcr-detail-toolbar-action.tsx
index 92829055..8e012e57 100644
--- a/lib/pcr/table/detail-table/pcr-detail-toolbar-action.tsx
+++ b/lib/pcr/table/detail-table/pcr-detail-toolbar-action.tsx
@@ -52,7 +52,7 @@ export function PcrDetailToolbarAction({
</Button>
{/* PCR_PR 생성 버튼 - Partners 페이지에서는 표시하지 않음 */}
- {!isPartnersPage && (
+ {/* {!isPartnersPage && (
<>
<Button
variant="default"
@@ -65,7 +65,6 @@ export function PcrDetailToolbarAction({
PCR_PR 생성
</Button>
- {/* PCR_PR 생성 다이얼로그 */}
<CreatePcrPrDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
@@ -73,7 +72,7 @@ export function PcrDetailToolbarAction({
onSuccess={handleCreateSuccess}
/>
</>
- )}
+ )} */}
</div>
)
}
diff --git a/lib/pcr/table/pcr-table-toolbar-actions.tsx b/lib/pcr/table/pcr-table-toolbar-actions.tsx
index 08a0ad72..2102d1d3 100644
--- a/lib/pcr/table/pcr-table-toolbar-actions.tsx
+++ b/lib/pcr/table/pcr-table-toolbar-actions.tsx
@@ -93,13 +93,13 @@ export function PcrTableToolbarActions<TData>({
)}
{/* PCR 생성 다이얼로그 - Partners 페이지에서는 표시하지 않음 */}
- {!isPartnersPage && (
+ {/* {!isPartnersPage && (
<CreatePcrDialog
isEvcpPage={isEvcpPage}
currentVendorId={currentVendorId}
onSuccess={onRefresh}
/>
- )}
+ )} */}
{/* 승인 다이얼로그 */}
<ApproveRejectPcrDialog
diff --git a/lib/po/service.ts b/lib/po/service.ts
index 99033854..31cbda5e 100644
--- a/lib/po/service.ts
+++ b/lib/po/service.ts
@@ -71,7 +71,7 @@ export async function getPOs(input: GetPOSchema) {
const s = `%${input.search}%`;
globalWhere = or(
ilike(contractsDetailView.contractNo, s),
- ilike(contractsDetailView.contractName, s)
+ ilike(contractsDetailView.contractName, s),
);
console.log("Global where clause built successfully");
} catch (searchErr) {
diff --git a/lib/procurement-items/service.ts b/lib/procurement-items/service.ts
index ee6df959..b62eb8df 100644
--- a/lib/procurement-items/service.ts
+++ b/lib/procurement-items/service.ts
@@ -31,7 +31,7 @@ import {
* Next.js의 unstable_cache를 사용해 일정 시간 캐시.
*/
export async function getProcurementItems(input: GetProcurementItemsSchema) {
- const safePerPage = Math.min(input.perPage, 100)
+ const safePerPage = Math.min(input.perPage, 1000)
return unstable_cache(
async () => {
@@ -328,15 +328,23 @@ export async function importProcurementItemsFromExcel(excelData: any[]): Promise
for (const itemData of batch) {
try {
// 데이터 검증
- const validatedData = createProcurementItemSchema.parse(itemData)
+ const cleanedData = {
+ itemCode: itemData.itemCode?.toString().trim() || "",
+ itemName: itemData.itemName?.toString().trim() || "",
+ material: itemData.material?.toString().trim() || "",
+ specification: itemData.specification?.toString().trim() || "",
+ unit: itemData.unit?.toString().trim() || "",
+ isActive: itemData.isActive?.toString().trim() || 'Y',
+ }
+ const validatedData = createProcurementItemSchema.parse(cleanedData)
// 품목 생성 또는 업데이트
const result = await createProcurementItem({
itemCode: validatedData.itemCode,
itemName: validatedData.itemName,
- material: validatedData.material || null,
- specification: validatedData.specification || null,
- unit: validatedData.unit || null,
+ material: validatedData.material || "",
+ specification: validatedData.specification || "",
+ unit: validatedData.unit || "",
isActive: validatedData.isActive || 'Y',
})
diff --git a/lib/procurement-items/validations.ts b/lib/procurement-items/validations.ts
index 0a2b2105..1d753e9d 100644
--- a/lib/procurement-items/validations.ts
+++ b/lib/procurement-items/validations.ts
@@ -37,9 +37,9 @@ export type GetProcurementItemsSchema = Awaited<ReturnType<typeof searchParamsCa
export const createProcurementItemSchema = z.object({
itemCode: z.string(),
itemName: z.string().min(1, "품목명은 필수입니다"),
- material: z.string().max(100).optional(),
- specification: z.string().max(255).optional(),
- unit: z.string().max(50).optional(),
+ material: z.string().max(100).optional().or(z.literal("")),
+ specification: z.string().max(255).optional().or(z.literal("")),
+ unit: z.string().max(50).optional().or(z.literal("")),
isActive: z.string().max(1).default('Y').optional(),
})
diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts
index d4efb81d..8475aac0 100644
--- a/lib/rfq-last/service.ts
+++ b/lib/rfq-last/service.ts
@@ -100,6 +100,7 @@ export async function getRfqs(input: GetRfqsSchema) {
ilike(rfqsLastView.packageNo, s),
ilike(rfqsLastView.packageName, s),
ilike(rfqsLastView.picName, s),
+ ilike(rfqsLastView.picCode, s),
ilike(rfqsLastView.engPicName, s),
ilike(rfqsLastView.projectCode, s),
ilike(rfqsLastView.projectName, s),
diff --git a/lib/rfq-last/table/rfq-assign-pic-dialog.tsx b/lib/rfq-last/table/rfq-assign-pic-dialog.tsx
index 9ca34ccd..70d2ed7e 100644
--- a/lib/rfq-last/table/rfq-assign-pic-dialog.tsx
+++ b/lib/rfq-last/table/rfq-assign-pic-dialog.tsx
@@ -39,15 +39,6 @@ export function RfqAssignPicDialog({
const [selectedCode, setSelectedCode] = React.useState<PurchaseGroupCodeWithUser | undefined>(undefined);
const [selectorOpen, setSelectorOpen] = React.useState(false);
- // ITB만 필터링 (rfqCode가 "I"로 시작하는 것)
- const itbCodes = React.useMemo(() => {
- return selectedRfqCodes.filter(code => code.startsWith("I"));
- }, [selectedRfqCodes]);
-
- const itbIds = React.useMemo(() => {
- return selectedRfqIds.filter((id, index) => selectedRfqCodes[index]?.startsWith("I"));
- }, [selectedRfqIds, selectedRfqCodes]);
-
// 다이얼로그 열릴 때 초기화
React.useEffect(() => {
if (open) {
@@ -81,15 +72,15 @@ export function RfqAssignPicDialog({
return;
}
- if (itbIds.length === 0) {
- toast.error("선택한 항목 중 ITB가 없습니다");
+ if (selectedRfqIds.length === 0) {
+ toast.error("담당자 지정이 가능한 ITB가 없습니다");
return;
}
setIsAssigning(true);
try {
const result = await assignPicToRfqs({
- rfqIds: itbIds,
+ rfqIds: selectedRfqIds,
picUserId: selectedCode.user.id,
});
@@ -127,32 +118,27 @@ export function RfqAssignPicDialog({
<label className="text-sm font-medium">선택된 ITB</label>
<div className="p-3 bg-muted rounded-md">
<div className="flex items-center gap-2 mb-2">
- <Badge variant="secondary">{itbCodes.length}건</Badge>
- {itbCodes.length !== selectedRfqCodes.length && (
- <span className="text-xs text-muted-foreground">
- (전체 {selectedRfqCodes.length}건 중)
- </span>
- )}
+ <Badge variant="secondary">{selectedRfqCodes.length}건</Badge>
</div>
<div className="max-h-[100px] overflow-y-auto">
<div className="flex flex-wrap gap-1">
- {itbCodes.slice(0, 10).map((code, index) => (
+ {selectedRfqCodes.slice(0, 10).map((code, index) => (
<Badge key={index} variant="outline" className="text-xs">
{code}
</Badge>
))}
- {itbCodes.length > 10 && (
+ {selectedRfqCodes.length > 10 && (
<Badge variant="outline" className="text-xs">
- +{itbCodes.length - 10}개
+ +{selectedRfqCodes.length - 10}개
</Badge>
)}
</div>
</div>
</div>
- {itbCodes.length === 0 && (
+ {selectedRfqCodes.length === 0 && (
<Alert className="border-orange-200 bg-orange-50">
<AlertDescription className="text-orange-800">
- 선택한 항목 중 ITB (I로 시작하는 코드)가 없습니다.
+ 담당자 지정이 가능한 ITB가 없습니다. (상태가 "RFQ 생성" 또는 "구매담당지정"인 ITB만 가능)
</AlertDescription>
</Alert>
)}
@@ -165,7 +151,7 @@ export function RfqAssignPicDialog({
type="button"
variant="outline"
className="w-full justify-start h-auto min-h-[40px]"
- disabled={itbCodes.length === 0}
+ disabled={selectedRfqCodes.length === 0}
onClick={() => setSelectorOpen(true)}
>
{selectedCode ? (
@@ -227,7 +213,7 @@ export function RfqAssignPicDialog({
<Button
type="submit"
onClick={handleAssign}
- disabled={!selectedCode || !selectedCode.user || itbCodes.length === 0 || isAssigning}
+ disabled={!selectedCode || !selectedCode.user || selectedRfqCodes.length === 0 || isAssigning}
>
{isAssigning ? (
<>
diff --git a/lib/rfq-last/table/rfq-table-toolbar-actions.tsx b/lib/rfq-last/table/rfq-table-toolbar-actions.tsx
index 7d48f5a4..00c41402 100644
--- a/lib/rfq-last/table/rfq-table-toolbar-actions.tsx
+++ b/lib/rfq-last/table/rfq-table-toolbar-actions.tsx
@@ -36,17 +36,27 @@ export function RfqTableToolbarActions<TData>({
// 선택된 RFQ의 ID와 코드 추출
const selectedRfqData = React.useMemo(() => {
const rows = selectedRows.map(row => row.original as RfqsLastView);
+ const assignableRows = rows.filter(row =>
+ row.rfqCode?.startsWith("I") &&
+ (row.status === "RFQ 생성" || row.status === "구매담당지정")
+ );
+
return {
ids: rows.map(row => row.id),
codes: rows.map(row => row.rfqCode || ""),
+ statuses: rows.map(row => row.status || ""),
// "I"로 시작하는 ITB만 필터링
itbCount: rows.filter(row => row.rfqCode?.startsWith("I")).length,
- totalCount: rows.length
+ totalCount: rows.length,
+ // 담당자 지정 가능한 ITB (상태가 "RFQ 생성" 또는 "구매담당지정"인 ITB)
+ assignableItbCount: assignableRows.length,
+ assignableIds: assignableRows.map(row => row.id),
+ assignableCodes: assignableRows.map(row => row.rfqCode || "")
};
}, [selectedRows]);
- // 담당자 지정 가능 여부 체크 ("I"로 시작하는 항목이 있는지)
- const canAssignPic = selectedRfqData.itbCount > 0;
+ // 담당자 지정 가능 여부 체크 (상태가 "RFQ 생성" 또는 "구매담당지정"인 ITB가 있는지)
+ const canAssignPic = selectedRfqData.assignableItbCount > 0;
const handleAssignSuccess = () => {
// 테이블 선택 초기화
@@ -76,15 +86,15 @@ export function RfqTableToolbarActions<TData>({
<Users className="h-4 w-4" />
담당자 지정
<Badge variant="secondary" className="ml-1">
- {selectedRfqData.itbCount}건
+ {selectedRfqData.assignableItbCount}건
</Badge>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>선택한 ITB에 구매 담당자를 지정합니다</p>
- {selectedRfqData.itbCount !== selectedRfqData.totalCount && (
+ {selectedRfqData.assignableItbCount !== selectedRfqData.itbCount && (
<p className="text-xs text-muted-foreground mt-1">
- 전체 {selectedRfqData.totalCount}건 중 ITB {selectedRfqData.itbCount}건만 지정됩니다
+ 전체 ITB {selectedRfqData.itbCount}건 중 {selectedRfqData.assignableItbCount}건만 지정 가능합니다
</p>
)}
</TooltipContent>
@@ -103,7 +113,7 @@ export function RfqTableToolbarActions<TData>({
</Badge>
{selectedRfqData.totalCount !== selectedRfqData.itbCount && (
<Badge variant="outline" className="text-xs">
- ITB {selectedRfqData.itbCount}건
+ ITB {selectedRfqData.itbCount}건 (지정가능 {selectedRfqData.assignableItbCount}건)
</Badge>
)}
</div>
@@ -139,8 +149,8 @@ export function RfqTableToolbarActions<TData>({
<RfqAssignPicDialog
open={showAssignDialog}
onOpenChange={setShowAssignDialog}
- selectedRfqIds={selectedRfqData.ids}
- selectedRfqCodes={selectedRfqData.codes}
+ selectedRfqIds={selectedRfqData.assignableIds}
+ selectedRfqCodes={selectedRfqData.assignableCodes}
onSuccess={handleAssignSuccess}
/>
</>
diff --git a/lib/rfq-last/vendor/rfq-vendor-table.tsx b/lib/rfq-last/vendor/rfq-vendor-table.tsx
index dc5564e2..428160d5 100644
--- a/lib/rfq-last/vendor/rfq-vendor-table.tsx
+++ b/lib/rfq-last/vendor/rfq-vendor-table.tsx
@@ -753,8 +753,10 @@ export function RfqVendorTable({
filterFn: createFilterFn("text"),
cell: ({ row }) => {
- const status = row.original.tbeStatus;
+ const status = row.original.tbeStatus?.trim();
+ const rfqCode = row.original.rfqCode?.trim();
+ // 생성중/준비중은 대기 표시(비클릭)
if (!status || status === "준비중") {
return (
<Badge variant="outline" className="text-gray-500">
@@ -772,8 +774,28 @@ export function RfqVendorTable({
"취소": { variant: "destructive", icon: <XCircle className="h-3 w-3 mr-1" /> },
}[status] || { variant: "outline", icon: null, color: "text-gray-600" };
+ const isClickable = !!rfqCode;
+
return (
- <Badge variant={statusConfig.variant as any} className={statusConfig.color}>
+ <Badge
+ role={isClickable ? "button" : undefined}
+ tabIndex={isClickable ? 0 : -1}
+ variant={statusConfig.variant as any}
+ className={cn(statusConfig.color, isClickable && "cursor-pointer hover:underline")}
+ onClick={(e) => {
+ if (!isClickable) return;
+ e.stopPropagation();
+ e.preventDefault();
+ router.push(`/evcp/tbe-last?search=${encodeURIComponent(rfqCode!)}`);
+ // window.open(
+ // `/evcp/tbe-last?search=${encodeURIComponent(rfqCode!)}`,
+ // "_blank",
+ // "noopener,noreferrer"
+ // );
+ // 새 창으로 이동
+ }}
+ title={isClickable ? `TBE로 이동: ${rfqCode}` : undefined}
+ >
{statusConfig.icon}
{status}
</Badge>
@@ -802,19 +824,19 @@ export function RfqVendorTable({
"Acceptable": {
variant: "success",
icon: <CheckCircle className="h-3 w-3" />,
- text: "적합",
+ text: "Acceptable",
color: "bg-green-50 text-green-700 border-green-200"
},
"Acceptable with Comment": {
variant: "warning",
icon: <AlertCircle className="h-3 w-3" />,
- text: "조건부 적합",
+ text: "Acceptable with Comment",
color: "bg-yellow-50 text-yellow-700 border-yellow-200"
},
"Not Acceptable": {
variant: "destructive",
icon: <XCircle className="h-3 w-3" />,
- text: "부적합",
+ text: "Not Acceptable",
color: "bg-red-50 text-red-700 border-red-200"
},
}[result];
diff --git a/lib/techsales-rfq/service.ts b/lib/techsales-rfq/service.ts
index deb2981a..b4fb28df 100644
--- a/lib/techsales-rfq/service.ts
+++ b/lib/techsales-rfq/service.ts
@@ -744,9 +744,7 @@ export async function sendTechSalesRfqToVendors(input: {
// 이메일 전송
await sendEmail({
to: vendorEmailsString,
- subject: isResend
- ? `[기술영업 RFQ 재전송] ${rfq.rfqCode} - ${rfqItems.length > 0 ? rfqItems.map(item => item.itemList).join(', ') : '견적 요청'} ${emailContext.versionInfo}`
- : `[기술영업 RFQ] ${rfq.rfqCode} - ${rfqItems.length > 0 ? rfqItems.map(item => item.itemList).join(', ') : '견적 요청'}`,
+ subject: '견적 요청',
template: 'tech-sales-rfq-invite-ko', // 기술영업용 템플릿
context: emailContext,
cc: sender.email, // 발신자를 CC에 추가
diff --git a/lib/vendor-document-list/plant/upload/columns.tsx b/lib/vendor-document-list/plant/upload/columns.tsx
index 01fc61df..9c2fe228 100644
--- a/lib/vendor-document-list/plant/upload/columns.tsx
+++ b/lib/vendor-document-list/plant/upload/columns.tsx
@@ -17,16 +17,16 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
-import {
- Ellipsis,
- Upload,
- Eye,
- RefreshCw,
- CheckCircle2,
- XCircle,
+import {
+ Ellipsis,
+ Upload,
+ Eye,
+ RefreshCw,
+ CheckCircle2,
+ XCircle,
AlertCircle,
Clock,
- Download
+ Download
} from "lucide-react"
interface GetColumnsProps {
@@ -109,7 +109,7 @@ export function getColumns({
const stageName = row.getValue("stageName") as string
const stageStatus = row.original.stageStatus
const stageOrder = row.original.stageOrder
-
+
return (
<div className="space-y-1">
<div className="flex items-center gap-2">
@@ -119,12 +119,12 @@ export function getColumns({
<span className="text-sm">{stageName}</span>
</div>
{stageStatus && (
- <Badge
+ <Badge
variant={
stageStatus === "COMPLETED" ? "success" :
- stageStatus === "IN_PROGRESS" ? "default" :
- stageStatus === "REJECTED" ? "destructive" :
- "secondary"
+ stageStatus === "IN_PROGRESS" ? "default" :
+ stageStatus === "REJECTED" ? "destructive" :
+ "secondary"
}
className="text-xs"
>
@@ -145,9 +145,9 @@ export function getColumns({
const planDate = row.getValue("stagePlanDate") as Date | null
const isOverdue = row.original.isOverdue
const daysUntilDue = row.original.daysUntilDue
-
+
if (!planDate) return <span className="text-muted-foreground">-</span>
-
+
return (
<div className="space-y-1">
<div className={isOverdue ? "text-destructive font-medium" : ""}>
@@ -187,7 +187,7 @@ export function getColumns({
const reviewStatus = row.original.latestReviewStatus
const revisionNumber = row.original.latestRevisionNumber
const revisionCode = row.original.latestRevisionCode
-
+
if (!status) {
return (
<Badge variant="outline" className="gap-1">
@@ -196,20 +196,20 @@ export function getColumns({
</Badge>
)
}
-
+
return (
<div className="space-y-1">
- <Badge
+ <Badge
variant={
reviewStatus === "APPROVED" ? "success" :
- reviewStatus === "REJECTED" ? "destructive" :
- status === "SUBMITTED" ? "default" :
- "secondary"
+ reviewStatus === "REJECTED" ? "destructive" :
+ status === "SUBMITTED" ? "default" :
+ "secondary"
}
>
{reviewStatus || status}
</Badge>
- {revisionCode !== null &&(
+ {revisionCode !== null && (
<div className="text-xs text-muted-foreground">
{revisionCode}
</div>
@@ -229,7 +229,7 @@ export function getColumns({
const syncStatus = row.getValue("latestSyncStatus") as string | null
const syncProgress = row.original.syncProgress
const requiresSync = row.original.requiresSync
-
+
if (!syncStatus || syncStatus === "pending") {
if (requiresSync) {
return (
@@ -241,15 +241,15 @@ export function getColumns({
}
return <span className="text-muted-foreground">-</span>
}
-
+
return (
<div className="space-y-2">
- <Badge
+ <Badge
variant={
syncStatus === "synced" ? "success" :
- syncStatus === "failed" ? "destructive" :
- syncStatus === "syncing" ? "default" :
- "secondary"
+ syncStatus === "failed" ? "destructive" :
+ syncStatus === "syncing" ? "default" :
+ "secondary"
}
className="gap-1"
>
@@ -274,9 +274,9 @@ export function getColumns({
cell: ({ row }) => {
const totalFiles = row.getValue("totalFiles") as number
const syncedFiles = row.original.syncedFilesCount
-
+
if (!totalFiles) return <span className="text-muted-foreground">0</span>
-
+
return (
<div className="text-sm">
{syncedFiles !== null && syncedFiles !== undefined ? (
@@ -297,7 +297,7 @@ export function getColumns({
// cell: ({ row }) => {
// const vendorName = row.getValue("vendorName") as string
// const vendorCode = row.original.vendorCode
-
+
// return (
// <div className="space-y-1">
// <div className="text-sm">{vendorName}</div>
@@ -309,82 +309,88 @@ export function getColumns({
// },
// size: 150,
// },
- {
- id: "actions",
- enableHiding: false,
- cell: function Cell({ row }) {
- const requiresSubmission = row.original.requiresSubmission
- const requiresSync = row.original.requiresSync
- const latestSubmissionId = row.original.latestSubmissionId
-
- return (
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- aria-label="Open menu"
- variant="ghost"
- className="flex size-7 p-0"
+// columns.tsx
+{
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ const requiresSubmission = row.original.requiresSubmission
+ const requiresSync = row.original.requiresSync
+ const latestSubmissionId = row.original.latestSubmissionId
+ const projectCode = row.original.projectCode // 프로젝트 코드 가져오기
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-7 p-0"
+ >
+ <Ellipsis className="size-4" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-48">
+ {requiresSubmission && (
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "upload" })}
+ className="gap-2"
+ >
+ <Upload className="h-4 w-4" />
+ Upload Documents
+ </DropdownMenuItem>
+ )}
+
+ {latestSubmissionId && (
+ <>
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "view" })}
+ className="gap-2"
>
- <Ellipsis className="size-4" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end" className="w-48">
- {requiresSubmission && (
+ <Eye className="h-4 w-4" />
+ View Submission
+ </DropdownMenuItem>
+
+ {requiresSync && (
<DropdownMenuItem
- onSelect={() => setRowAction({ row, type: "upload" })}
+ onSelect={() => setRowAction({ row, type: "sync" })}
className="gap-2"
>
- <Upload className="h-4 w-4" />
- Upload Documents
+ <RefreshCw className="h-4 w-4" />
+ Retry Sync
</DropdownMenuItem>
)}
-
- {latestSubmissionId && (
- <>
- <DropdownMenuItem
- onSelect={() => setRowAction({ row, type: "view" })}
- className="gap-2"
- >
- <Eye className="h-4 w-4" />
- View Submission
- </DropdownMenuItem>
-
- {requiresSync && (
- <DropdownMenuItem
- onSelect={() => setRowAction({ row, type: "sync" })}
- className="gap-2"
- >
- <RefreshCw className="h-4 w-4" />
- Retry Sync
- </DropdownMenuItem>
- )}
- </>
- )}
-
-
- {/* ✅ 커버 페이지 다운로드 */}
- <DropdownMenuItem
- onSelect={() => setRowAction({ row, type: "downloadCover" })}
- className="gap-2"
- >
- <Download className="h-4 w-4" />
- Download Cover Page
- </DropdownMenuItem>
+ </>
+ )}
+ {/* ✅ 커버 페이지 다운로드 - projectCode가 있을 때만 표시 */}
+ {projectCode && (
+ <>
<DropdownMenuSeparator />
-
<DropdownMenuItem
- onSelect={() => setRowAction({ row, type: "history" })}
+ onSelect={() => setRowAction({ row, type: "downloadCover" })}
className="gap-2"
>
- <Clock className="h-4 w-4" />
- View History
+ <Download className="h-4 w-4" />
+ Download Cover Page
</DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
- )
- },
- size: 40,
- }
+ </>
+ )}
+
+ <DropdownMenuSeparator />
+
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "history" })}
+ className="gap-2"
+ >
+ <Clock className="h-4 w-4" />
+ View History
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 40,
+}
]
} \ No newline at end of file
diff --git a/lib/vendor-document-list/plant/upload/table.tsx b/lib/vendor-document-list/plant/upload/table.tsx
index 84b04092..2247fc57 100644
--- a/lib/vendor-document-list/plant/upload/table.tsx
+++ b/lib/vendor-document-list/plant/upload/table.tsx
@@ -21,6 +21,7 @@ import { SingleUploadDialog } from "./components/single-upload-dialog"
import { HistoryDialog } from "./components/history-dialog"
import { ViewSubmissionDialog } from "./components/view-submission-dialog"
import { toast } from "sonner"
+import { quickDownload } from "@/lib/file-download"
interface StageSubmissionsTableProps {
promises: Promise<[
@@ -167,23 +168,43 @@ export function StageSubmissionsTable({ promises, selectedProjectId }: StageSubm
const { type, row } = rowAction;
if (type === "downloadCover") {
- // 2) 서버에서 생성 후 다운로드 (예: API 호출)
+ const projectCode = row.original.projectCode;
+ const project = projects.find(p => p.code === projectCode);
+
+ if (!project) {
+ toast.error("프로젝트 정보를 찾을 수 없습니다.");
+ setRowAction(null);
+ return;
+ }
+
(async () => {
try {
- const res = await fetch(`/api/stages/${row.original.stageId}/cover`, { method: "POST" });
- if (!res.ok) throw new Error("failed");
- const { fileUrl } = await res.json(); // 서버 응답: { fileUrl: string }
- window.open(fileUrl, "_blank", "noopener,noreferrer");
+ const res = await fetch(`/api/projects/${project.id}/cover`, {
+ method: "GET"
+ });
+
+ if (!res.ok) {
+ const error = await res.json();
+ throw new Error(error.message || "커버 페이지를 가져올 수 없습니다");
+ }
+
+ const { fileUrl, fileName } = await res.json();
+
+ // quickDownload 사용
+ quickDownload(fileUrl, fileName || `${projectCode}_cover.docx`);
+
+ toast.success("커버 페이지 다운로드를 시작했습니다.");
+
} catch (e) {
- toast.error("커버 페이지 생성에 실패했습니다.");
+ toast.error(e instanceof Error ? e.message : "커버 페이지 다운로드에 실패했습니다.");
console.error(e);
} finally {
setRowAction(null);
}
})();
}
- }, [rowAction, setRowAction]);
-
+ }, [rowAction, setRowAction, projects]);
+
return (
<>
<DataTable table={table}>
diff --git a/lib/vendor-document-list/ship-all/enhanced-documents-table.tsx b/lib/vendor-document-list/ship-all/enhanced-documents-table.tsx
index 255aa56c..b8b7542a 100644
--- a/lib/vendor-document-list/ship-all/enhanced-documents-table.tsx
+++ b/lib/vendor-document-list/ship-all/enhanced-documents-table.tsx
@@ -167,16 +167,16 @@ export function SimplifiedDocumentsTable({
label: "Second Actual Date",
type: "date",
},
- {
- id: "issuedDate",
- label: "Issue Date",
- type: "date",
- },
- {
- id: "createdAt",
- label: "Created Date",
- type: "date",
- },
+ // {
+ // id: "issuedDate",
+ // label: "Issue Date",
+ // type: "date",
+ // },
+ // {
+ // id: "createdAt",
+ // label: "Created Date",
+ // type: "date",
+ // },
{
id: "updatedAt",
label: "Updated Date",