diff options
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", |
