summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/[lng]/evcp/(evcp)/(not-used)/b-rfq/[id]/final/page.tsx52
-rw-r--r--app/[lng]/evcp/(evcp)/(not-used)/b-rfq/[id]/initial/page.tsx52
-rw-r--r--app/[lng]/evcp/(evcp)/(not-used)/b-rfq/[id]/layout.tsx87
-rw-r--r--app/[lng]/evcp/(evcp)/(not-used)/b-rfq/[id]/page.tsx53
-rw-r--r--app/[lng]/evcp/(evcp)/(not-used)/b-rfq/page.tsx83
-rw-r--r--app/[lng]/evcp/(evcp)/(not-used)/bqcbe/page.tsx74
-rw-r--r--app/[lng]/evcp/(evcp)/(not-used)/bqtbe/page.tsx72
-rw-r--r--app/[lng]/evcp/(evcp)/(not-used)/budgetary-rfq/[id]/cbe/page.tsx56
-rw-r--r--app/[lng]/evcp/(evcp)/(not-used)/budgetary-rfq/[id]/layout.tsx90
-rw-r--r--app/[lng]/evcp/(evcp)/(not-used)/budgetary-rfq/[id]/page.tsx57
-rw-r--r--app/[lng]/evcp/(evcp)/(not-used)/budgetary-rfq/[id]/tbe/page.tsx55
-rw-r--r--app/[lng]/evcp/(evcp)/(not-used)/budgetary-rfq/page.tsx89
-rw-r--r--app/[lng]/evcp/(evcp)/(not-used)/budgetary/[id]/cbe/page.tsx56
-rw-r--r--app/[lng]/evcp/(evcp)/(not-used)/budgetary/[id]/layout.tsx90
-rw-r--r--app/[lng]/evcp/(evcp)/(not-used)/budgetary/[id]/page.tsx57
-rw-r--r--app/[lng]/evcp/(evcp)/(not-used)/budgetary/[id]/tbe/page.tsx55
-rw-r--r--app/[lng]/evcp/(evcp)/(not-used)/budgetary/page.tsx86
-rw-r--r--app/[lng]/evcp/(evcp)/(not-used)/materials/page.tsx76
-rw-r--r--app/[lng]/evcp/(evcp)/(not-used)/po-rfq/page.tsx64
-rw-r--r--app/[lng]/evcp/(evcp)/(not-used)/poa/page.tsx64
-rw-r--r--app/[lng]/evcp/(evcp)/(not-used)/project-gtc/page.tsx66
-rw-r--r--app/[lng]/evcp/(evcp)/(not-used)/project-vendors/page.tsx77
-rw-r--r--app/[lng]/evcp/(evcp)/(not-used)/rfq/[id]/cbe/page.tsx55
-rw-r--r--app/[lng]/evcp/(evcp)/(not-used)/rfq/[id]/layout.tsx89
-rw-r--r--app/[lng]/evcp/(evcp)/(not-used)/rfq/[id]/page.tsx55
-rw-r--r--app/[lng]/evcp/(evcp)/(not-used)/rfq/[id]/tbe/page.tsx55
-rw-r--r--app/[lng]/evcp/(evcp)/(not-used)/rfq/page.tsx80
-rw-r--r--app/[lng]/evcp/(evcp)/(not-used)/tasks/page.tsx63
-rw-r--r--app/[lng]/evcp/(evcp)/(not-used)/tbe/page.tsx113
-rw-r--r--app/[lng]/test/table/page.tsx168
-rw-r--r--components/client-table-v2/README.md159
-rw-r--r--components/client-table-v2/client-table-column-header.tsx235
-rw-r--r--components/client-table-v2/client-table-filter.tsx101
-rw-r--r--components/client-table-v2/client-table-preset.tsx185
-rw-r--r--components/client-table-v2/client-table-save-view.tsx185
-rw-r--r--components/client-table-v2/client-table-toolbar.tsx59
-rw-r--r--components/client-table-v2/client-table-view-options.tsx67
-rw-r--r--components/client-table-v2/client-virtual-table.tsx626
-rw-r--r--components/client-table-v2/export-utils.ts136
-rw-r--r--components/client-table-v2/import-utils.ts100
-rw-r--r--components/client-table-v2/index.ts7
-rw-r--r--components/client-table-v2/preset-actions.ts87
-rw-r--r--components/client-table-v2/preset-types.ts13
-rw-r--r--components/client-table-v2/types.ts11
-rw-r--r--components/client-table/README.md159
-rw-r--r--components/client-table/client-table-column-header.tsx235
-rw-r--r--components/client-table/client-table-filter.tsx101
-rw-r--r--components/client-table/client-table-preset.tsx185
-rw-r--r--components/client-table/client-table-save-view.tsx185
-rw-r--r--components/client-table/client-table-toolbar.tsx59
-rw-r--r--components/client-table/client-table-view-options.tsx67
-rw-r--r--components/client-table/client-virtual-table.tsx609
-rw-r--r--components/client-table/export-utils.ts136
-rw-r--r--components/client-table/import-utils.ts100
-rw-r--r--components/client-table/index.ts7
-rw-r--r--components/client-table/preset-actions.ts87
-rw-r--r--components/client-table/preset-types.ts13
-rw-r--r--components/client-table/types.ts11
-rw-r--r--db/schema/bRfq.ts1409
-rw-r--r--db/schema/index.ts13
-rw-r--r--db/schema/procurementRFQ.ts771
-rw-r--r--db/schema/rfqLastTBE.ts63
-rw-r--r--db/schema/user-custom-data/userCustomData.ts16
-rw-r--r--db/schema/vendors.ts3
-rw-r--r--lib/table/server-query-builder.ts129
-rw-r--r--package-lock.json44
-rw-r--r--package.json3
67 files changed, 4328 insertions, 4237 deletions
diff --git a/app/[lng]/evcp/(evcp)/(not-used)/b-rfq/[id]/final/page.tsx b/app/[lng]/evcp/(evcp)/(not-used)/b-rfq/[id]/final/page.tsx
deleted file mode 100644
index d50ec03d..00000000
--- a/app/[lng]/evcp/(evcp)/(not-used)/b-rfq/[id]/final/page.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { getFinalRfqDetail } from "@/lib/b-rfq/service"
-import { searchParamsFinalRfqDetailCache } from "@/lib/b-rfq/validations"
-import { FinalRfqDetailTable } from "@/lib/b-rfq/final/final-rfq-detail-table"
-
-interface IndexPageProps {
- // Next.js 13 App Router에서 기본으로 주어지는 객체들
- params: {
- lng: string
- id: string
- }
- searchParams: Promise<SearchParams>
-}
-
-export default async function RfqPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
- const id = resolvedParams.id
-
- const idAsNumber = Number(id)
-
- // 2) SearchParams 파싱 (Zod)
- // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
- const searchParams = await props.searchParams
- const search = searchParamsFinalRfqDetailCache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- const promises = getFinalRfqDetail({
- ...search,
- filters: validFilters,
- }, idAsNumber)
-
- // 4) 렌더링
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">
- Fianl RFQ List
- </h3>
- <p className="text-sm text-muted-foreground">
- 업체에게 최종 RFQ를 송부하는 화면입니다.
- </p>
- </div>
- <Separator />
- <div>
- <FinalRfqDetailTable promises={promises} rfqId={idAsNumber}/>
- </div>
- </div>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/(not-used)/b-rfq/[id]/initial/page.tsx b/app/[lng]/evcp/(evcp)/(not-used)/b-rfq/[id]/initial/page.tsx
deleted file mode 100644
index 1af65fbc..00000000
--- a/app/[lng]/evcp/(evcp)/(not-used)/b-rfq/[id]/initial/page.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { InitialRfqDetailTable } from "@/lib/b-rfq/initial/initial-rfq-detail-table"
-import { getInitialRfqDetail } from "@/lib/b-rfq/service"
-import { searchParamsInitialRfqDetailCache } from "@/lib/b-rfq/validations"
-
-interface IndexPageProps {
- // Next.js 13 App Router에서 기본으로 주어지는 객체들
- params: {
- lng: string
- id: string
- }
- searchParams: Promise<SearchParams>
-}
-
-export default async function RfqPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
- const id = resolvedParams.id
-
- const idAsNumber = Number(id)
-
- // 2) SearchParams 파싱 (Zod)
- // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
- const searchParams = await props.searchParams
- const search = searchParamsInitialRfqDetailCache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- const promises = getInitialRfqDetail({
- ...search,
- filters: validFilters,
- }, idAsNumber)
-
- // 4) 렌더링
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">
- Initial RFQ List
- </h3>
- <p className="text-sm text-muted-foreground">
- 설계로부터 받은 RFQ 문서와 구매 RFQ 문서 및 사전 계약자료를 Vendor에 발송하기 위한 RFQ 생성 및 관리하는 화면입니다.
- </p>
- </div>
- <Separator />
- <div>
- <InitialRfqDetailTable promises={promises} rfqId={idAsNumber}/>
- </div>
- </div>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/(not-used)/b-rfq/[id]/layout.tsx b/app/[lng]/evcp/(evcp)/(not-used)/b-rfq/[id]/layout.tsx
deleted file mode 100644
index d6836437..00000000
--- a/app/[lng]/evcp/(evcp)/(not-used)/b-rfq/[id]/layout.tsx
+++ /dev/null
@@ -1,87 +0,0 @@
-import { Metadata } from "next"
-import Link from "next/link"
-import { Separator } from "@/components/ui/separator"
-import { SidebarNav } from "@/components/layout/sidebar-nav"
-import { formatDate } from "@/lib/utils"
-import { Button } from "@/components/ui/button"
-import { ArrowLeft } from "lucide-react"
-import { RfqDashboardView } from "@/db/schema"
-import { findBRfqById } from "@/lib/b-rfq/service"
-
-export const metadata: Metadata = {
- title: "견적 RFQ 상세",
-}
-
-export default async function RfqLayout({
- children,
- params,
-}: {
- children: React.ReactNode
- params: { lng: string, id: string }
-}) {
-
- // 1) URL 파라미터에서 id 추출, Number로 변환
- const resolvedParams = await params
- const lng = resolvedParams.lng
- const id = resolvedParams.id
-
- const idAsNumber = Number(id)
- // 2) DB에서 해당 협력업체 정보 조회
- const rfq: RfqDashboardView | null = await findBRfqById(idAsNumber)
-
- // 3) 사이드바 메뉴
- const sidebarNavItems = [
- {
- title: "견적/입찰 문서관리",
- href: `/${lng}/evcp/b-rfq/${id}`,
- },
- {
- title: "Initial RFQ 발송",
- href: `/${lng}/evcp/b-rfq/${id}/initial`,
- },
- {
- title: "Final RFQ 발송",
- href: `/${lng}/evcp/b-rfq/${id}/final`,
- },
-
- ]
-
- return (
- <>
- <div className="container py-6">
- <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
- <div className="hidden space-y-6 p-10 pb-16 md:block">
- <div className="flex items-center justify-end mb-4">
- <Link href={`/${lng}/evcp/b-rfq`} passHref>
- <Button variant="ghost" className="flex items-center text-primary hover:text-primary/80 transition-colors p-0 h-auto">
- <ArrowLeft className="mr-1 h-4 w-4" />
- <span>RFQ 목록으로 돌아가기</span>
- </Button>
- </Link>
- </div>
- <div className="space-y-0.5">
- {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */}
- <h2 className="text-2xl font-bold tracking-tight">
- {rfq
- ? `${rfq.rfqCode ?? ""} | ${rfq.packageNo ?? ""} | ${rfq.packageName ?? ""}`
- : "Loading RFQ..."}
- </h2>
-
- <p className="text-muted-foreground">
- PR발행 전 RFQ를 생성하여 관리하는 화면입니다.
- </p>
- <h3>Due Date:{rfq && rfq?.dueDate && <strong>{formatDate(rfq?.dueDate, "KR")}</strong>}</h3>
- </div>
- <Separator className="my-6" />
- <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
- <aside className="lg:w-64 flex-shrink-0">
- <SidebarNav items={sidebarNavItems} />
- </aside>
- <div className="lg:w-[calc(100%-16rem)] overflow-auto">{children}</div>
- </div>
- </div>
- </section>
- </div>
- </>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/(not-used)/b-rfq/[id]/page.tsx b/app/[lng]/evcp/(evcp)/(not-used)/b-rfq/[id]/page.tsx
deleted file mode 100644
index 26dc45fb..00000000
--- a/app/[lng]/evcp/(evcp)/(not-used)/b-rfq/[id]/page.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { searchParamsRfqAttachmentsCache } from "@/lib/b-rfq/validations"
-import { getRfqAttachments } from "@/lib/b-rfq/service"
-import { RfqAttachmentsTable } from "@/lib/b-rfq/attachment/attachment-table"
-
-interface IndexPageProps {
- // Next.js 13 App Router에서 기본으로 주어지는 객체들
- params: {
- lng: string
- id: string
- }
- searchParams: Promise<SearchParams>
-}
-
-export default async function RfqPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
- const id = resolvedParams.id
-
- const idAsNumber = Number(id)
-
-
- // 2) SearchParams 파싱 (Zod)
- // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
- const searchParams = await props.searchParams
- const search = searchParamsRfqAttachmentsCache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- const promises = getRfqAttachments({
- ...search,
- filters: validFilters,
- }, idAsNumber)
-
- // 4) 렌더링
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">
- 견적 RFQ 문서관리
- </h3>
- <p className="text-sm text-muted-foreground">
- 설계로부터 받은 RFQ 문서와 구매 RFQ 문서를 관리하고 Vendor 회신을 점검/관리하는 화면입니다.
- </p>
- </div>
- <Separator />
- <div>
- <RfqAttachmentsTable promises={promises} rfqId={idAsNumber} />
- </div>
- </div>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/(not-used)/b-rfq/page.tsx b/app/[lng]/evcp/(evcp)/(not-used)/b-rfq/page.tsx
deleted file mode 100644
index 6dc0fb44..00000000
--- a/app/[lng]/evcp/(evcp)/(not-used)/b-rfq/page.tsx
+++ /dev/null
@@ -1,83 +0,0 @@
-import * as React from "react"
-import { Metadata } from "next"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { Shell } from "@/components/shell"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-import { searchParamsRFQDashboardCache } from "@/lib/b-rfq/validations"
-import { getRFQDashboard } from "@/lib/b-rfq/service"
-import { RFQDashboardTable } from "@/lib/b-rfq/summary-table/summary-rfq-table"
-import { InformationButton } from "@/components/information/information-button"
-
-export const metadata: Metadata = {
- title: "견적 RFQ",
- description: "",
-}
-
-interface PQReviewPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function PQReviewPage(props: PQReviewPageProps) {
- const searchParams = await props.searchParams
- const search = searchParamsRFQDashboardCache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- // 기본 필터 처리 (통일된 이름 사용)
- let basicFilters = []
- if (search.basicFilters && search.basicFilters.length > 0) {
- basicFilters = search.basicFilters
- console.log("Using search.basicFilters:", basicFilters);
- } else {
- console.log("No basic filters found");
- }
-
- // 모든 필터를 합쳐서 처리
- const allFilters = [...validFilters, ...basicFilters]
-
- // 조인 연산자도 통일된 이름 사용
- const joinOperator = search.basicJoinOperator || search.joinOperator || 'and';
-
- // Promise.all로 감싸서 전달
- const promises = Promise.all([
- getRFQDashboard({
- ...search,
- filters: allFilters,
- joinOperator,
- })
- ])
-
- console.log(search, "견적")
-
- return (
- <Shell className="gap-4">
- <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">
- 견적 RFQ
- </h2>
- <InformationButton pagePath="evcp/b-rfq" />
- </div>
- </div>
- </div>
- </div>
-
- {/* Items처럼 직접 테이블 렌더링 */}
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={8}
- searchableColumnCount={2}
- filterableColumnCount={3}
- cellWidths={["10rem", "15rem", "12rem", "12rem", "8rem", "8rem", "10rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <RFQDashboardTable promises={promises} />
- </React.Suspense>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/(not-used)/bqcbe/page.tsx b/app/[lng]/evcp/(evcp)/(not-used)/bqcbe/page.tsx
deleted file mode 100644
index 6f97efd3..00000000
--- a/app/[lng]/evcp/(evcp)/(not-used)/bqcbe/page.tsx
+++ /dev/null
@@ -1,74 +0,0 @@
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { getAllCBE } from "@/lib/rfqs/service"
-import { searchParamsCBECache } from "@/lib/rfqs/validations"
-
-import { AllCbeTable } from "@/lib/cbe/table/cbe-table"
-
-import { RfqType } from "@/lib/rfqs/validations"
-import * as React from "react"
-import { Shell } from "@/components/shell"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-
-interface IndexPageProps {
- // Next.js 13 App Router에서 기본으로 주어지는 객체들
- params: {
- lng: string
- }
- searchParams: Promise<SearchParams>
- rfqType: RfqType
-}
-
-export default async function RfqCBEPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
-
- const rfqType = props.rfqType ?? RfqType.BUDGETARY // rfqType이 없으면 BUDGETARY로 설정
-
- // 2) SearchParams 파싱 (Zod)
- // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
- const searchParams = await props.searchParams
- const search = searchParamsCBECache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getAllCBE({
- ...search,
- filters: validFilters,
- rfqType
- }
- )
- ])
-
- // 4) 렌더링
- 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>
- <h2 className="text-2xl font-bold tracking-tight">
- CBE 보내기
- </h2>
- {/* <p className="text-muted-foreground">
- 초대된 협력업체에게 CBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 CBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
- </p> */}
- </div>
- </div>
- </div>
-
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <AllCbeTable promises={promises}/>
- </React.Suspense>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/(not-used)/bqtbe/page.tsx b/app/[lng]/evcp/(evcp)/(not-used)/bqtbe/page.tsx
deleted file mode 100644
index a1fee1b6..00000000
--- a/app/[lng]/evcp/(evcp)/(not-used)/bqtbe/page.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { getAllTBE } from "@/lib/rfqs/service"
-import { searchParamsTBECache } from "@/lib/rfqs/validations"
-import { AllTbeTable } from "@/lib/tbe/table/tbe-table"
-import { RfqType } from "@/lib/rfqs/validations"
-import * as React from "react"
-import { Shell } from "@/components/shell"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-
-interface IndexPageProps {
- // Next.js 13 App Router에서 기본으로 주어지는 객체들
- params: {
- lng: string
- }
- searchParams: Promise<SearchParams>
- rfqType: RfqType
-}
-
-export default async function RfqTBEPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
-
- const rfqType = props.rfqType ?? RfqType.BUDGETARY // rfqType이 없으면 BUDGETARY로 설정
-
- // 2) SearchParams 파싱 (Zod)
- // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
- const searchParams = await props.searchParams
- const search = searchParamsTBECache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getAllTBE({
- ...search,
- filters: validFilters,
- rfqType
- }
- )
- ])
-
- // 4) 렌더링
- 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>
- <h2 className="text-2xl font-bold tracking-tight">
- TBE 보내기
- </h2>
- {/* <p className="text-muted-foreground">
- 초대된 협력업체에게 TBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
- </p> */}
- </div>
- </div>
- </div>
-
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <AllTbeTable promises={promises}/>
- </React.Suspense>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/(not-used)/budgetary-rfq/[id]/cbe/page.tsx b/app/[lng]/evcp/(evcp)/(not-used)/budgetary-rfq/[id]/cbe/page.tsx
deleted file mode 100644
index 956facd3..00000000
--- a/app/[lng]/evcp/(evcp)/(not-used)/budgetary-rfq/[id]/cbe/page.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { getCBE, getTBE } from "@/lib/rfqs/service"
-import { searchParamsCBECache, } from "@/lib/rfqs/validations"
-import { TbeTable } from "@/lib/rfqs/tbe-table/tbe-table"
-import { CbeTable } from "@/lib/rfqs/cbe-table/cbe-table"
-
-interface IndexPageProps {
- // Next.js 13 App Router에서 기본으로 주어지는 객체들
- params: {
- lng: string
- id: string
- }
- searchParams: Promise<SearchParams>
-}
-
-export default async function RfqTBEPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
- const id = resolvedParams.id
-
- const idAsNumber = Number(id)
-
- // 2) SearchParams 파싱 (Zod)
- // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
- const searchParams = await props.searchParams
- const search = searchParamsCBECache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getCBE({
- ...search,
- filters: validFilters,
- },
- idAsNumber)
- ])
-
- // 4) 렌더링
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">
- Commercial Bid Evaluation
- </h3>
- <p className="text-sm text-muted-foreground">
- 초대된 협력업체에게 CBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 CBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
- </p>
- </div>
- <Separator />
- <div>
- <CbeTable promises={promises} rfqId={idAsNumber}/>
- </div>
- </div>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/(not-used)/budgetary-rfq/[id]/layout.tsx b/app/[lng]/evcp/(evcp)/(not-used)/budgetary-rfq/[id]/layout.tsx
deleted file mode 100644
index 2b80e64f..00000000
--- a/app/[lng]/evcp/(evcp)/(not-used)/budgetary-rfq/[id]/layout.tsx
+++ /dev/null
@@ -1,90 +0,0 @@
-import { Metadata } from "next"
-import Link from "next/link"
-import { ArrowLeft } from "lucide-react"
-
-import { Separator } from "@/components/ui/separator"
-import { SidebarNav } from "@/components/layout/sidebar-nav"
-import { RfqViewWithItems } from "@/db/schema/rfq"
-import { findRfqById } from "@/lib/rfqs/service"
-import { formatDate } from "@/lib/utils"
-import { Button } from "@/components/ui/button"
-
-export const metadata: Metadata = {
- title: "Vendor Detail",
-}
-
-export default async function RfqLayout({
- children,
- params,
-}: {
- children: React.ReactNode
- params: { lng: string, id: string }
-}) {
-
- // 1) URL 파라미터에서 id 추출, Number로 변환
- const resolvedParams = await params
- const lng = resolvedParams.lng
- const id = resolvedParams.id
-
- const idAsNumber = Number(id)
- // 2) DB에서 해당 협력업체 정보 조회
- const rfq: RfqViewWithItems | null = await findRfqById(idAsNumber)
-
- // 3) 사이드바 메뉴
- const sidebarNavItems = [
- {
- title: "Matched Vendors",
- href: `/${lng}/evcp/budgetary/${id}`,
- },
- {
- title: "TBE",
- href: `/${lng}/evcp/budgetary/${id}/tbe`,
- },
- {
- title: "CBE",
- href: `/${lng}/evcp/budgetary/${id}/cbe`,
- },
-
- ]
-
- return (
- <>
- <div className="container py-6">
- <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
- <div className="hidden space-y-6 p-10 pb-16 md:block">
- <div className="flex items-center justify-end mb-4">
- <Link href={`/${lng}/evcp/budgetary-rfq`} passHref>
- <Button variant="ghost" className="flex items-center text-primary hover:text-primary/80 transition-colors p-0 h-auto">
- <ArrowLeft className="mr-1 h-4 w-4" />
- <span>Budgetary RFQ 목록으로 돌아가기</span>
- </Button>
- </Link>
- </div>
- <div className="space-y-0.5">
- {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */}
- <h2 className="text-2xl font-bold tracking-tight">
- {rfq
- ? `${rfq.projectCode ?? ""} ${rfq.rfqCode ?? ""} 관리`
- : "Loading RFQ..."}
- </h2>
-
- <p className="text-muted-foreground">
- {rfq
- ? `${rfq.description ?? ""} ${rfq.lines.map(line => line.itemCode).join(", ")}`
- : ""}
- </p>
- <h3>Due Date:{rfq && rfq?.dueDate && <strong>{formatDate(rfq?.dueDate, "KR")}</strong>}</h3>
- </div>
- <Separator className="my-6" />
- <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
- <aside className="lg:w-64 flex-shrink-0">
- <SidebarNav items={sidebarNavItems} />
- </aside>
- <div className="lg:w-[calc(100%-16rem)] overflow-auto">{children}</div>
- </div>
- </div>
- </section>
- </div>
- </>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/(not-used)/budgetary-rfq/[id]/page.tsx b/app/[lng]/evcp/(evcp)/(not-used)/budgetary-rfq/[id]/page.tsx
deleted file mode 100644
index dd9df563..00000000
--- a/app/[lng]/evcp/(evcp)/(not-used)/budgetary-rfq/[id]/page.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { getMatchedVendors } from "@/lib/rfqs/service"
-import { searchParamsMatchedVCache } from "@/lib/rfqs/validations"
-import { MatchedVendorsTable } from "@/lib/rfqs/vendor-table/vendors-table"
-import { RfqType } from "@/lib/rfqs/validations"
-
-interface IndexPageProps {
- // Next.js 13 App Router에서 기본으로 주어지는 객체들
- params: {
- lng: string
- id: string
- }
- searchParams: Promise<SearchParams>
- rfqType: RfqType
-}
-
-export default async function RfqPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
- const id = resolvedParams.id
- const rfqType = props.rfqType ?? RfqType.BUDGETARY // rfqType이 없으면 BUDGETARY로 설정
-
- const idAsNumber = Number(id)
-
- // 2) SearchParams 파싱 (Zod)
- const searchParams = await props.searchParams
- const search = searchParamsMatchedVCache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getMatchedVendors({
- ...search,
- filters: validFilters,
- },
- idAsNumber)
- ])
-
- // 4) 렌더링
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">
- Vendors
- </h3>
- <p className="text-sm text-muted-foreground">
- 등록된 협력업체 중에서 이 RFQ 아이템에 매칭되는 업체를 보여줍니다. <br/>"발행하기" 버튼을 통해 RFQ를 전송하면 첨부파일과 함께 RFQ 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
- </p>
- </div>
- <Separator />
- <div>
- <MatchedVendorsTable promises={promises} rfqId={idAsNumber} rfqType={rfqType}/>
- </div>
- </div>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/(not-used)/budgetary-rfq/[id]/tbe/page.tsx b/app/[lng]/evcp/(evcp)/(not-used)/budgetary-rfq/[id]/tbe/page.tsx
deleted file mode 100644
index ec894e1c..00000000
--- a/app/[lng]/evcp/(evcp)/(not-used)/budgetary-rfq/[id]/tbe/page.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { getTBE } from "@/lib/rfqs/service"
-import { searchParamsTBECache } from "@/lib/rfqs/validations"
-import { TbeTable } from "@/lib/rfqs/tbe-table/tbe-table"
-
-interface IndexPageProps {
- // Next.js 13 App Router에서 기본으로 주어지는 객체들
- params: {
- lng: string
- id: string
- }
- searchParams: Promise<SearchParams>
-}
-
-export default async function RfqTBEPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
- const id = resolvedParams.id
-
- const idAsNumber = Number(id)
-
- // 2) SearchParams 파싱 (Zod)
- // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
- const searchParams = await props.searchParams
- const search = searchParamsTBECache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getTBE({
- ...search,
- filters: validFilters,
- },
- idAsNumber)
- ])
-
- // 4) 렌더링
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">
- Technical Bid Evaluation
- </h3>
- <p className="text-sm text-muted-foreground">
- 초대된 협력업체에게 TBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
- </p>
- </div>
- <Separator />
- <div>
- <TbeTable promises={promises} rfqId={idAsNumber}/>
- </div>
- </div>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/(not-used)/budgetary-rfq/page.tsx b/app/[lng]/evcp/(evcp)/(not-used)/budgetary-rfq/page.tsx
deleted file mode 100644
index 290b9f27..00000000
--- a/app/[lng]/evcp/(evcp)/(not-used)/budgetary-rfq/page.tsx
+++ /dev/null
@@ -1,89 +0,0 @@
-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 { InformationButton } from "@/components/information/information-button"
-import { searchParamsCache } from "@/lib/rfqs/validations"
-import { getRfqs, getRfqStatusCounts } from "@/lib/rfqs/service"
-import { RfqsTable } from "@/lib/rfqs/table/rfqs-table"
-import { getAllItems } from "@/lib/items/service"
-import { RfqType } from "@/lib/rfqs/validations"
-import { Ellipsis } from "lucide-react"
-
-interface RfqPageProps {
- searchParams: Promise<SearchParams>;
- rfqType: RfqType;
- title: string;
- description: string;
-}
-
-export default async function RfqPage({
- searchParams,
- rfqType = RfqType.PURCHASE_BUDGETARY,
- title = "Budgetary Quote",
- description = "Budgetary Quote를 등록하여 요청 및 응답을 관리할 수 있습니다."
-}: RfqPageProps) {
- const search = searchParamsCache.parse(await searchParams)
-
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getRfqs({
- ...search,
- filters: validFilters,
- rfqType // 전달받은 rfqType 사용
- }),
- getRfqStatusCounts(rfqType), // rfqType 전달
- getAllItems()
- ])
-
- 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">
- {title}
- </h2>
- <InformationButton pagePath="evcp/budgetary-rfq" />
- </div>
- {/* <p className="text-muted-foreground">
- {description}
- 기본적인 정보와 RFQ를 위한 아이템 등록 및 첨부를 한 후,
- <span className="inline-flex items-center whitespace-nowrap">
- <Ellipsis className="size-3" />
- <span className="ml-1">버튼</span>
- </span> 을 클릭하면 "Proceed"를 통해 상세화면으로 이동하여 진행할 수 있습니다.
- </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
- />
- }
- >
- <RfqsTable promises={promises} rfqType={rfqType} />
- </React.Suspense>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/(not-used)/budgetary/[id]/cbe/page.tsx b/app/[lng]/evcp/(evcp)/(not-used)/budgetary/[id]/cbe/page.tsx
deleted file mode 100644
index 956facd3..00000000
--- a/app/[lng]/evcp/(evcp)/(not-used)/budgetary/[id]/cbe/page.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { getCBE, getTBE } from "@/lib/rfqs/service"
-import { searchParamsCBECache, } from "@/lib/rfqs/validations"
-import { TbeTable } from "@/lib/rfqs/tbe-table/tbe-table"
-import { CbeTable } from "@/lib/rfqs/cbe-table/cbe-table"
-
-interface IndexPageProps {
- // Next.js 13 App Router에서 기본으로 주어지는 객체들
- params: {
- lng: string
- id: string
- }
- searchParams: Promise<SearchParams>
-}
-
-export default async function RfqTBEPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
- const id = resolvedParams.id
-
- const idAsNumber = Number(id)
-
- // 2) SearchParams 파싱 (Zod)
- // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
- const searchParams = await props.searchParams
- const search = searchParamsCBECache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getCBE({
- ...search,
- filters: validFilters,
- },
- idAsNumber)
- ])
-
- // 4) 렌더링
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">
- Commercial Bid Evaluation
- </h3>
- <p className="text-sm text-muted-foreground">
- 초대된 협력업체에게 CBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 CBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
- </p>
- </div>
- <Separator />
- <div>
- <CbeTable promises={promises} rfqId={idAsNumber}/>
- </div>
- </div>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/(not-used)/budgetary/[id]/layout.tsx b/app/[lng]/evcp/(evcp)/(not-used)/budgetary/[id]/layout.tsx
deleted file mode 100644
index d58d8363..00000000
--- a/app/[lng]/evcp/(evcp)/(not-used)/budgetary/[id]/layout.tsx
+++ /dev/null
@@ -1,90 +0,0 @@
-import { Metadata } from "next"
-import Link from "next/link"
-import { ArrowLeft } from "lucide-react"
-import { Separator } from "@/components/ui/separator"
-import { SidebarNav } from "@/components/layout/sidebar-nav"
-import { RfqViewWithItems } from "@/db/schema/rfq"
-import { findRfqById } from "@/lib/rfqs/service"
-import { formatDate } from "@/lib/utils"
-import { Button } from "@/components/ui/button"
-
-export const metadata: Metadata = {
- title: "Vendor Detail",
-}
-
-export default async function RfqLayout({
- children,
- params,
-}: {
- children: React.ReactNode
- params: { lng: string, id: string }
-}) {
-
- // 1) URL 파라미터에서 id 추출, Number로 변환
- const resolvedParams = await params
- const lng = resolvedParams.lng
- const id = resolvedParams.id
-
- const idAsNumber = Number(id)
- // 2) DB에서 해당 협력업체 정보 조회
- const rfq: RfqViewWithItems | null = await findRfqById(idAsNumber)
-
- // 3) 사이드바 메뉴
- const sidebarNavItems = [
- {
- title: "Matched Vendors",
- href: `/${lng}/evcp/budgetary/${id}`,
- },
- {
- title: "TBE",
- href: `/${lng}/evcp/budgetary/${id}/tbe`,
- },
- {
- title: "CBE",
- href: `/${lng}/evcp/budgetary/${id}/cbe`,
- },
- ]
-
- return (
- <>
- <div className="container py-6">
- <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
- <div className="hidden space-y-6 p-10 pb-16 md:block">
- {/* RFQ 목록으로 돌아가는 링크 추가 */}
- <div className="flex items-center justify-end mb-4">
- <Link href={`/${lng}/evcp/budgetary`} passHref>
- <Button variant="ghost" className="flex items-center text-primary hover:text-primary/80 transition-colors p-0 h-auto">
- <ArrowLeft className="mr-1 h-4 w-4" />
- <span>Budgetary Quote 목록으로 돌아가기</span>
- </Button>
- </Link>
- </div>
-
- <div className="space-y-0.5">
- {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */}
- <h2 className="text-2xl font-bold tracking-tight">
- {rfq
- ? `${rfq.projectCode ?? ""} ${rfq.rfqCode ?? ""} 관리`
- : "Loading RFQ..."}
- </h2>
-
- <p className="text-muted-foreground">
- {rfq
- ? `${rfq.description ?? ""} ${rfq.lines.map(line => line.itemCode).join(", ")}`
- : ""}
- </p>
- <h3>Due Date:{rfq && rfq?.dueDate && <strong>{formatDate(rfq?.dueDate, "KR")}</strong>}</h3>
- </div>
- <Separator className="my-6" />
- <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
- <aside className="lg:w-64 flex-shrink-0">
- <SidebarNav items={sidebarNavItems} />
- </aside>
- <div className="lg:w-[calc(100%-16rem)] overflow-auto">{children}</div>
- </div>
- </div>
- </section>
- </div>
- </>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/(not-used)/budgetary/[id]/page.tsx b/app/[lng]/evcp/(evcp)/(not-used)/budgetary/[id]/page.tsx
deleted file mode 100644
index dd9df563..00000000
--- a/app/[lng]/evcp/(evcp)/(not-used)/budgetary/[id]/page.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { getMatchedVendors } from "@/lib/rfqs/service"
-import { searchParamsMatchedVCache } from "@/lib/rfqs/validations"
-import { MatchedVendorsTable } from "@/lib/rfqs/vendor-table/vendors-table"
-import { RfqType } from "@/lib/rfqs/validations"
-
-interface IndexPageProps {
- // Next.js 13 App Router에서 기본으로 주어지는 객체들
- params: {
- lng: string
- id: string
- }
- searchParams: Promise<SearchParams>
- rfqType: RfqType
-}
-
-export default async function RfqPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
- const id = resolvedParams.id
- const rfqType = props.rfqType ?? RfqType.BUDGETARY // rfqType이 없으면 BUDGETARY로 설정
-
- const idAsNumber = Number(id)
-
- // 2) SearchParams 파싱 (Zod)
- const searchParams = await props.searchParams
- const search = searchParamsMatchedVCache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getMatchedVendors({
- ...search,
- filters: validFilters,
- },
- idAsNumber)
- ])
-
- // 4) 렌더링
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">
- Vendors
- </h3>
- <p className="text-sm text-muted-foreground">
- 등록된 협력업체 중에서 이 RFQ 아이템에 매칭되는 업체를 보여줍니다. <br/>"발행하기" 버튼을 통해 RFQ를 전송하면 첨부파일과 함께 RFQ 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
- </p>
- </div>
- <Separator />
- <div>
- <MatchedVendorsTable promises={promises} rfqId={idAsNumber} rfqType={rfqType}/>
- </div>
- </div>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/(not-used)/budgetary/[id]/tbe/page.tsx b/app/[lng]/evcp/(evcp)/(not-used)/budgetary/[id]/tbe/page.tsx
deleted file mode 100644
index ec894e1c..00000000
--- a/app/[lng]/evcp/(evcp)/(not-used)/budgetary/[id]/tbe/page.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { getTBE } from "@/lib/rfqs/service"
-import { searchParamsTBECache } from "@/lib/rfqs/validations"
-import { TbeTable } from "@/lib/rfqs/tbe-table/tbe-table"
-
-interface IndexPageProps {
- // Next.js 13 App Router에서 기본으로 주어지는 객체들
- params: {
- lng: string
- id: string
- }
- searchParams: Promise<SearchParams>
-}
-
-export default async function RfqTBEPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
- const id = resolvedParams.id
-
- const idAsNumber = Number(id)
-
- // 2) SearchParams 파싱 (Zod)
- // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
- const searchParams = await props.searchParams
- const search = searchParamsTBECache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getTBE({
- ...search,
- filters: validFilters,
- },
- idAsNumber)
- ])
-
- // 4) 렌더링
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">
- Technical Bid Evaluation
- </h3>
- <p className="text-sm text-muted-foreground">
- 초대된 협력업체에게 TBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
- </p>
- </div>
- <Separator />
- <div>
- <TbeTable promises={promises} rfqId={idAsNumber}/>
- </div>
- </div>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/(not-used)/budgetary/page.tsx b/app/[lng]/evcp/(evcp)/(not-used)/budgetary/page.tsx
deleted file mode 100644
index 15b4cdd4..00000000
--- a/app/[lng]/evcp/(evcp)/(not-used)/budgetary/page.tsx
+++ /dev/null
@@ -1,86 +0,0 @@
-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 { searchParamsCache } from "@/lib/rfqs/validations"
-import { getRfqs, getRfqStatusCounts } from "@/lib/rfqs/service"
-import { RfqsTable } from "@/lib/rfqs/table/rfqs-table"
-import { getAllItems } from "@/lib/items/service"
-import { RfqType } from "@/lib/rfqs/validations"
-import { Ellipsis } from "lucide-react"
-
-interface RfqPageProps {
- searchParams: Promise<SearchParams>;
- rfqType: RfqType;
- title: string;
- description: string;
-}
-
-export default async function RfqPage({
- searchParams,
- rfqType = RfqType.BUDGETARY,
- title = "Budgetary Quote",
- description = "Budgetary Quote를 등록하여 요청 및 응답을 관리할 수 있습니다."
-}: RfqPageProps) {
- const search = searchParamsCache.parse(await searchParams)
-
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getRfqs({
- ...search,
- filters: validFilters,
- rfqType // 전달받은 rfqType 사용
- }),
- getRfqStatusCounts(rfqType), // rfqType 전달
- getAllItems()
- ])
-
- 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>
- <h2 className="text-2xl font-bold tracking-tight">
- {title}
- </h2>
- {/* <p className="text-muted-foreground">
- {description}
- 기본적인 정보와 RFQ를 위한 아이템 등록 및 첨부를 한 후,
- <span className="inline-flex items-center whitespace-nowrap">
- <Ellipsis className="size-3" />
- <span className="ml-1">버튼</span>
- </span> 을 클릭하면 "Proceed"를 통해 상세화면으로 이동하여 진행할 수 있습니다.
- </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
- />
- }
- >
- <RfqsTable promises={promises} rfqType={rfqType} />
- </React.Suspense>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/(not-used)/materials/page.tsx b/app/[lng]/evcp/(evcp)/(not-used)/materials/page.tsx
deleted file mode 100644
index 00983a3f..00000000
--- a/app/[lng]/evcp/(evcp)/(not-used)/materials/page.tsx
+++ /dev/null
@@ -1,76 +0,0 @@
-/**
- * 자재마스터 테이블
- * MDG 자재마스터를 그대로 보여줄 것임
- * 수정/추가 기능은 불필요
- */
-
-import * as React from "react"
-import { type SearchParams } from "@/types/table"
-
-import { Skeleton } from "@/components/ui/skeleton"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-import { Shell } from "@/components/shell"
-import { getMaterials } from "@/lib/material/services"
-import { MaterialTable } from "@/lib/material/table/material-table"
-import { InformationButton } from "@/components/information/information-button"
-import { searchParamsCache } from "@/lib/material/validations"
-
-interface MaterialPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function MaterialPage(props: MaterialPageProps) {
- const searchParams = await props.searchParams
-
- // searchParamsCache를 사용해서 파라미터 파싱
- const search = searchParamsCache.parse(searchParams)
-
- // pageSize 기반으로 모드 자동 결정
- const isInfiniteMode = search.perPage >= 1_000_000
-
- // 페이지네이션 모드일 때만 서버에서 데이터 가져오기
- // 무한 스크롤 모드에서는 클라이언트에서 SWR로 데이터 로드
- const promises = isInfiniteMode
- ? undefined
- : Promise.all([
- getMaterials(search as any), // 타입 캐스팅으로 임시 해결
- ])
-
- 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/material" />
- </div>
- <p className="text-muted-foreground">
- MDG로부터 수신된 자재마스터 정보입니다.
- </p>
- </div>
- </div>
- </div>
-
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- {/* 추가 컴포넌트가 필요한 경우 여기에 */}
- </React.Suspense>
-
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={5}
- searchableColumnCount={1}
- filterableColumnCount={3}
- cellWidths={["10rem", "15rem", "20rem", "12rem", "12rem", "12rem"]}
- shrinkZero
- />
- }
- >
- <MaterialTable promises={promises} />
- </React.Suspense>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/(not-used)/po-rfq/page.tsx b/app/[lng]/evcp/(evcp)/(not-used)/po-rfq/page.tsx
deleted file mode 100644
index 27e48384..00000000
--- a/app/[lng]/evcp/(evcp)/(not-used)/po-rfq/page.tsx
+++ /dev/null
@@ -1,64 +0,0 @@
-import { getPORfqs } from "@/lib/procurement-rfqs/services"
-import { searchParamsCache } from "@/lib/procurement-rfqs/validations"
-import { getValidFilters } from "@/lib/data-table"
-import { Shell } from "@/components/shell"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-import { RFQListTable } from "@/lib/procurement-rfqs/table/rfq-table"
-import { type SearchParams } from "@/types/table"
-import * as React from "react"
-import { InformationButton } from "@/components/information/information-button"
-interface RfqPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function RfqPage(props: RfqPageProps) {
- // searchParams를 await하여 resolve
- const searchParams = await props.searchParams
-
- // 파라미터 파싱
- const search = searchParamsCache.parse(searchParams);
- const validFilters = getValidFilters(search.filters);
-
- // RFQ 서버는 기본필터와 고급필터를 분리해서 받으므로 그대로 전달
- const promises = Promise.all([
- getPORfqs({
- ...search, // 모든 파라미터 전달 (page, perPage, sort, basicFilters, filters 등)
- filters: validFilters, // 고급 필터를 명시적으로 오버라이드 (파싱된 버전)
- })
- ])
-
- return (
- <Shell variant="fullscreen" className="h-full"> {/* fullscreen variant 사용 */}
- {/* 고정 헤더 영역 */}
- <div className="flex-shrink-0">
- <div className="flex items-center justify-between">
- <div>
- <div className="flex items-center gap-2">
- <h2 className="text-2xl font-bold tracking-tight">
- RFQ
- </h2>
- <InformationButton pagePath="evcp/po-rfq" />
- </div>
- </div>
- </div>
- </div>
-
- {/* 테이블 영역 - 남은 공간 모두 차지 */}
- <div className="flex-1 min-h-0">
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={8}
- searchableColumnCount={2}
- filterableColumnCount={3}
- cellWidths={["10rem", "15rem", "12rem", "12rem", "8rem", "8rem", "10rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <RFQListTable promises={promises} className="h-full" />
- </React.Suspense>
- </div>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/(not-used)/poa/page.tsx b/app/[lng]/evcp/(evcp)/(not-used)/poa/page.tsx
deleted file mode 100644
index 0ced4957..00000000
--- a/app/[lng]/evcp/(evcp)/(not-used)/poa/page.tsx
+++ /dev/null
@@ -1,64 +0,0 @@
-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 { getChangeOrders } from "@/lib/poa/service"
-import { searchParamsCache } from "@/lib/poa/validations"
-import { ChangeOrderListsTable } from "@/lib/poa/table/poa-table"
-import { InformationButton } from "@/components/information/information-button"
-interface IndexPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function IndexPage(props: IndexPageProps) {
- const searchParams = await props.searchParams
- const search = searchParamsCache.parse(searchParams)
-
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getChangeOrders({
- ...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">
- 변경 PO 확인 및 전자서명
- </h2>
- <InformationButton pagePath="evcp/poa" />
- </div>
- {/* <p className="text-muted-foreground">
- 발행된 PO의 변경 내역을 확인하고 관리할 수 있습니다.
- </p> */}
- </div>
- </div>
- </div>
-
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- </React.Suspense>
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <ChangeOrderListsTable promises={promises} />
- </React.Suspense>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/(not-used)/project-gtc/page.tsx b/app/[lng]/evcp/(evcp)/(not-used)/project-gtc/page.tsx
deleted file mode 100644
index ac9ce03c..00000000
--- a/app/[lng]/evcp/(evcp)/(not-used)/project-gtc/page.tsx
+++ /dev/null
@@ -1,66 +0,0 @@
-import * as React from "react"
-import { type SearchParams } from "@/types/table"
-
-import { Skeleton } from "@/components/ui/skeleton"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-import { Shell } from "@/components/shell"
-import { getProjectGtcList } from "@/lib/project-gtc/service"
-import { projectGtcSearchParamsSchema } from "@/lib/project-gtc/validations"
-import { ProjectGtcTable } from "@/lib/project-gtc/table/project-gtc-table"
-import { InformationButton } from "@/components/information/information-button"
-interface IndexPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function IndexPage(props: IndexPageProps) {
- const searchParams = await props.searchParams
- const search = projectGtcSearchParamsSchema.parse(searchParams)
-
- const promises = Promise.all([
- getProjectGtcList({
- page: search.page,
- perPage: search.perPage,
- search: search.search,
- sort: search.sort,
- }),
- ])
-
- 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">
- Project GTC 관리
- </h2>
- <InformationButton pagePath="evcp/project-gtc" />
- </div>
- {/* <p className="text-muted-foreground">
- 프로젝트별 GTC(General Terms and Conditions) 파일을 관리할 수 있습니다.
- 각 프로젝트마다 하나의 GTC 파일을 업로드할 수 있으며, 파일 업로드 시 기존 파일은 자동으로 교체됩니다.
- </p> */}
- </div>
- </div>
- </div>
-
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- {/* 추가 기능이 필요하면 여기에 추가 */}
- </React.Suspense>
-
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={8}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["3rem", "3rem", "12rem", "20rem", "10rem", "20rem", "15rem", "12rem", "3rem"]}
- shrinkZero
- />
- }
- >
- <ProjectGtcTable promises={promises} />
- </React.Suspense>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/(not-used)/project-vendors/page.tsx b/app/[lng]/evcp/(evcp)/(not-used)/project-vendors/page.tsx
deleted file mode 100644
index 07caddbb..00000000
--- a/app/[lng]/evcp/(evcp)/(not-used)/project-vendors/page.tsx
+++ /dev/null
@@ -1,77 +0,0 @@
-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 { ProjectAVLTable } from "@/lib/project-avl/table/proejctAVL-table"
-import { getProjecTAVL } from "@/lib/project-avl/service"
-import { searchProjectAVLParamsCache } from "@/lib/project-avl/validations"
-import { InformationButton } from "@/components/information/information-button"
-
-interface IndexPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function IndexPage(props: IndexPageProps) {
- const searchParams = await props.searchParams
- const search = searchProjectAVLParamsCache.parse(searchParams)
-
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getProjecTAVL({
- ...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">
- 프로젝트 AVL 리스트
- </h2>
- <InformationButton pagePath="evcp/project-vendors" />
- </div>
- {/* <p className="text-muted-foreground">
- 프로젝트 PQ를 통과한 벤더의 리스트를 보여줍니다.{" "}
- <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
- />
- }
- >
- <ProjectAVLTable promises={promises} />
- </React.Suspense>
- </Shell>
- )
-}
diff --git a/app/[lng]/evcp/(evcp)/(not-used)/rfq/[id]/cbe/page.tsx b/app/[lng]/evcp/(evcp)/(not-used)/rfq/[id]/cbe/page.tsx
deleted file mode 100644
index fb288a98..00000000
--- a/app/[lng]/evcp/(evcp)/(not-used)/rfq/[id]/cbe/page.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { searchParamsCBECache } from "@/lib/rfqs/validations"
-import { getCBE } from "@/lib/rfqs/service"
-import { CbeTable } from "@/lib/rfqs/cbe-table/cbe-table"
-
-interface IndexPageProps {
- // Next.js 13 App Router에서 기본으로 주어지는 객체들
- params: {
- lng: string
- id: string
- }
- searchParams: Promise<SearchParams>
-}
-
-export default async function RfqCBEPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
- const id = resolvedParams.id
-
- const idAsNumber = Number(id)
-
- // 2) SearchParams 파싱 (Zod)
- // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
- const searchParams = await props.searchParams
- const search = searchParamsCBECache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getCBE({
- ...search,
- filters: validFilters,
- },
- idAsNumber)
- ])
-
- // 4) 렌더링
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">
- Commercial Bid Evaluation
- </h3>
- <p className="text-sm text-muted-foreground">
- 초대된 협력업체에게 CBE를 보낼 수 있습니다. <br />"발행하기" 버튼을 통해 CBE를 전송하면 CBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
- </p>
- </div>
- <Separator />
- <div>
- <CbeTable promises={promises} rfqId={idAsNumber} />
- </div>
- </div>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/(not-used)/rfq/[id]/layout.tsx b/app/[lng]/evcp/(evcp)/(not-used)/rfq/[id]/layout.tsx
deleted file mode 100644
index 92817b4b..00000000
--- a/app/[lng]/evcp/(evcp)/(not-used)/rfq/[id]/layout.tsx
+++ /dev/null
@@ -1,89 +0,0 @@
-import { Metadata } from "next"
-import Link from "next/link"
-import { Separator } from "@/components/ui/separator"
-import { SidebarNav } from "@/components/layout/sidebar-nav"
-import { RfqViewWithItems } from "@/db/schema/rfq"
-import { findRfqById } from "@/lib/rfqs/service"
-import { formatDate } from "@/lib/utils"
-import { Button } from "@/components/ui/button"
-import { ArrowLeft } from "lucide-react"
-
-export const metadata: Metadata = {
- title: "Vendor Detail",
-}
-
-export default async function RfqLayout({
- children,
- params,
-}: {
- children: React.ReactNode
- params: { lng: string, id: string }
-}) {
-
- // 1) URL 파라미터에서 id 추출, Number로 변환
- const resolvedParams = await params
- const lng = resolvedParams.lng
- const id = resolvedParams.id
-
- const idAsNumber = Number(id)
- // 2) DB에서 해당 협력업체 정보 조회
- const rfq: RfqViewWithItems | null = await findRfqById(idAsNumber)
-
- // 3) 사이드바 메뉴
- const sidebarNavItems = [
- {
- title: "Matched Vendors",
- href: `/${lng}/evcp/rfq/${id}`,
- },
- {
- title: "TBE",
- href: `/${lng}/evcp/rfq/${id}/tbe`,
- },
- {
- title: "CBE",
- href: `/${lng}/evcp/rfq/${id}/cbe`,
- },
-
- ]
-
- return (
- <>
- <div className="container py-6">
- <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
- <div className="hidden space-y-6 p-10 pb-16 md:block">
- <div className="flex items-center justify-end mb-4">
- <Link href={`/${lng}/evcp/rfq`} passHref>
- <Button variant="ghost" className="flex items-center text-primary hover:text-primary/80 transition-colors p-0 h-auto">
- <ArrowLeft className="mr-1 h-4 w-4" />
- <span>RFQ 목록으로 돌아가기</span>
- </Button>
- </Link>
- </div>
- <div className="space-y-0.5">
- {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */}
- <h2 className="text-2xl font-bold tracking-tight">
- {rfq
- ? `${rfq.projectCode ?? ""} ${rfq.rfqCode ?? ""} 관리`
- : "Loading RFQ..."}
- </h2>
-
- <p className="text-muted-foreground">
- {rfq
- ? `${rfq.description ?? ""} ${rfq.lines.map(line => line.itemCode).join(", ")}`
- : ""}
- </p>
- <h3>Due Date:{rfq && rfq?.dueDate && <strong>{formatDate(rfq?.dueDate, "KR")}</strong>}</h3>
- </div>
- <Separator className="my-6" />
- <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
- <aside className="lg:w-64 flex-shrink-0">
- <SidebarNav items={sidebarNavItems} />
- </aside>
- <div className="lg:w-[calc(100%-16rem)] overflow-auto">{children}</div>
- </div>
- </div>
- </section>
- </div>
- </>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/(not-used)/rfq/[id]/page.tsx b/app/[lng]/evcp/(evcp)/(not-used)/rfq/[id]/page.tsx
deleted file mode 100644
index 1a9f4b18..00000000
--- a/app/[lng]/evcp/(evcp)/(not-used)/rfq/[id]/page.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { getMatchedVendors } from "@/lib/rfqs/service"
-import { searchParamsMatchedVCache } from "@/lib/rfqs/validations"
-import { MatchedVendorsTable } from "@/lib/rfqs/vendor-table/vendors-table"
-
-interface IndexPageProps {
- // Next.js 13 App Router에서 기본으로 주어지는 객체들
- params: {
- lng: string
- id: string
- }
- searchParams: Promise<SearchParams>
-}
-
-export default async function RfqPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
- const id = resolvedParams.id
-
- const idAsNumber = Number(id)
-
- // 2) SearchParams 파싱 (Zod)
- // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
- const searchParams = await props.searchParams
- const search = searchParamsMatchedVCache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getMatchedVendors({
- ...search,
- filters: validFilters,
- },
- idAsNumber)
- ])
-
- // 4) 렌더링
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">
- Vendors
- </h3>
- <p className="text-sm text-muted-foreground">
- 등록된 협력업체 중에서 이 RFQ 아이템에 매칭되는 업체를 보여줍니다. <br/>"발행하기" 버튼을 통해 RFQ를 전송하면 첨부파일과 함께 RFQ 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
- </p>
- </div>
- <Separator />
- <div>
- <MatchedVendorsTable promises={promises} rfqId={idAsNumber}/>
- </div>
- </div>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/(not-used)/rfq/[id]/tbe/page.tsx b/app/[lng]/evcp/(evcp)/(not-used)/rfq/[id]/tbe/page.tsx
deleted file mode 100644
index 76eea302..00000000
--- a/app/[lng]/evcp/(evcp)/(not-used)/rfq/[id]/tbe/page.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import { Separator } from "@/components/ui/separator"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { getTBE } from "@/lib/rfqs/service"
-import { searchParamsTBECache } from "@/lib/rfqs/validations"
-import { TbeTable } from "@/lib/rfqs/tbe-table/tbe-table"
-
-interface IndexPageProps {
- // Next.js 13 App Router에서 기본으로 주어지는 객체들
- params: {
- lng: string
- id: string
- }
- searchParams: Promise<SearchParams>
-}
-
-export default async function RfqTBEPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
- const id = resolvedParams.id
-
- const idAsNumber = Number(id)
-
- // 2) SearchParams 파싱 (Zod)
- // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
- const searchParams = await props.searchParams
- const search = searchParamsTBECache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getTBE({
- ...search,
- filters: validFilters,
- },
- idAsNumber)
- ])
-
- // 4) 렌더링
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">
- Technical Bid Evaluation
- </h3>
- <p className="text-sm text-muted-foreground">
- 초대된 협력업체에게 TBE를 보낼 수 있습니다. <br/>"발행하기" 버튼을 통해 TBE를 전송하면 첨부파일과 함께 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
- </p>
- </div>
- <Separator />
- <div>
- <TbeTable promises={promises} rfqId={idAsNumber}/>
- </div>
- </div>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/(not-used)/rfq/page.tsx b/app/[lng]/evcp/(evcp)/(not-used)/rfq/page.tsx
deleted file mode 100644
index 26f49cfb..00000000
--- a/app/[lng]/evcp/(evcp)/(not-used)/rfq/page.tsx
+++ /dev/null
@@ -1,80 +0,0 @@
-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 { searchParamsCache } from "@/lib/rfqs/validations"
-import { getRfqs, getRfqStatusCounts } from "@/lib/rfqs/service"
-import { RfqsTable } from "@/lib/rfqs/table/rfqs-table"
-import { getAllItems } from "@/lib/items/service"
-import { RfqType } from "@/lib/rfqs/validations"
-
-interface RfqPageProps {
- searchParams: Promise<SearchParams>;
- rfqType: RfqType;
- title: string;
- description: string;
-}
-
-export default async function RfqPage({
- searchParams,
- rfqType = RfqType.PURCHASE,
- title = "RFQ",
- description = "RFQ를 등록하고 관리할 수 있습니다."
-}: RfqPageProps) {
- const search = searchParamsCache.parse(await searchParams)
-
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getRfqs({
- ...search,
- filters: validFilters,
- rfqType // 전달받은 rfqType 사용
- }),
- getRfqStatusCounts(rfqType), // rfqType 전달
- getAllItems()
- ])
-
- 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>
- <h2 className="text-2xl font-bold tracking-tight">
- {title}
- </h2>
- {/* <p className="text-muted-foreground">
- {description}
- </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
- />
- }
- >
- <RfqsTable promises={promises} rfqType={rfqType} />
- </React.Suspense>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/(not-used)/tasks/page.tsx b/app/[lng]/evcp/(evcp)/(not-used)/tasks/page.tsx
deleted file mode 100644
index 91b946fb..00000000
--- a/app/[lng]/evcp/(evcp)/(not-used)/tasks/page.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-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 { DateRangePicker } from "@/components/date-range-picker"
-import { Shell } from "@/components/shell"
-
-import { FeatureFlagsProvider } from "@/lib/tasks/table/feature-flags-provider"
-import { TasksTable } from "@/lib/tasks/table/tasks-table"
-import {
- getTaskPriorityCounts,
- getTasks,
- getTaskStatusCounts,
-} from "@/lib/tasks/service"
-import { searchParamsCache } from "@/lib/tasks/validations"
-
-interface IndexPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function IndexPage(props: IndexPageProps) {
- const searchParams = await props.searchParams
- const search = searchParamsCache.parse(searchParams)
-
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getTasks({
- ...search,
- filters: validFilters,
- }),
- getTaskStatusCounts(),
- getTaskPriorityCounts(),
- ])
-
- return (
- <Shell className="gap-2">
- <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
- />
- }
- >
- <TasksTable promises={promises} />
- </React.Suspense>
- </Shell>
- )
-}
diff --git a/app/[lng]/evcp/(evcp)/(not-used)/tbe/page.tsx b/app/[lng]/evcp/(evcp)/(not-used)/tbe/page.tsx
deleted file mode 100644
index 211cf376..00000000
--- a/app/[lng]/evcp/(evcp)/(not-used)/tbe/page.tsx
+++ /dev/null
@@ -1,113 +0,0 @@
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { getAllTBE } from "@/lib/rfqs/service"
-import { searchParamsTBECache } from "@/lib/rfqs/validations"
-import { AllTbeTable } from "@/lib/tbe/table/tbe-table"
-import { RfqType } from "@/lib/rfqs/validations"
-import * as React from "react"
-import { Shell } from "@/components/shell"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
-
-interface IndexPageProps {
- params: {
- lng: string
- }
- searchParams: Promise<SearchParams>
-}
-
-// 타입별 페이지 설명 구성 (Budgetary 제외)
-const typeConfig: Record<string, { title: string; description: string; rfqType: RfqType }> = {
- "purchase": {
- title: "Purchase RFQ Technical Bid Evaluation",
- description: "실제 구매 발주 전 가격 요청을 위한 TBE입니다.",
- rfqType: RfqType.PURCHASE
- },
- "purchase-budgetary": {
- title: "Purchase Budgetary RFQ Technical Bid Evaluation",
- description: "프로젝트 수주 후, 공식 입찰 전 예산 책정을 위한 TBE입니다.",
- rfqType: RfqType.PURCHASE_BUDGETARY
- }
-}
-
-export default async function RfqTBEPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
-
- // URL 쿼리 파라미터에서 타입 추출
- const searchParams = await props.searchParams
- // 기본값으로 'purchase' 사용
- const typeParam = searchParams?.type as string || 'purchase'
-
- // 유효한 타입인지 확인하고 기본값 설정
- const validType = Object.keys(typeConfig).includes(typeParam) ? typeParam : 'purchase'
- const rfqType = typeConfig[validType].rfqType
-
- // SearchParams 파싱 (Zod)
- const search = searchParamsTBECache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- // 현재 선택된 타입의 데이터 로드
- const promises = Promise.all([
- getAllTBE({
- ...search,
- filters: validFilters,
- rfqType
- })
- ])
-
- // 페이지 경로 생성 함수 - 단순화
- const getTabUrl = (type: string) => {
- return `/${lng}/evcp/tbe?type=${type}`;
- }
-
- 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>
- <h2 className="text-2xl font-bold tracking-tight">
- TBE 관리
- </h2>
- {/* <p className="text-muted-foreground">
- 초대된 협력업체에게 TBE를 보낼 수 있습니다. <br/>
- 체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
- </p> */}
- </div>
- </div>
- </div>
-
- {/* 타입 선택 탭 (Budgetary 제외) */}
- <Tabs defaultValue={validType} value={validType} className="w-full">
- <TabsList className="grid grid-cols-2 w-full max-w-md">
- <TabsTrigger value="purchase" asChild>
- <a href={getTabUrl('purchase')}>Purchase</a>
- </TabsTrigger>
- <TabsTrigger value="purchase-budgetary" asChild>
- <a href={getTabUrl('purchase-budgetary')}>Purchase Budgetary</a>
- </TabsTrigger>
- </TabsList>
-
- <div className="mt-2">
- <p className="text-sm text-muted-foreground">
- {typeConfig[validType].description}
- </p>
- </div>
- </Tabs>
-
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <AllTbeTable promises={promises}/>
- </React.Suspense>
- </Shell>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/test/table/page.tsx b/app/[lng]/test/table/page.tsx
new file mode 100644
index 00000000..88d050fc
--- /dev/null
+++ b/app/[lng]/test/table/page.tsx
@@ -0,0 +1,168 @@
+"use client"
+
+import * as React from "react"
+import { ColumnDef } from "@tanstack/react-table"
+import { ClientVirtualTable } from "@/components/client-table/client-virtual-table"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+
+// 1. Define the data type
+type TestData = {
+ id: string
+ name: string
+ email: string
+ role: "Admin" | "User" | "Guest"
+ status: "Active" | "Inactive" | "Pending"
+ lastLogin: string
+ amount: number
+}
+
+// 2. Generate dummy data
+const generateData = (count: number): TestData[] => {
+ const roles: TestData["role"][] = ["Admin", "User", "Guest"]
+ const statuses: TestData["status"][] = ["Active", "Inactive", "Pending"]
+
+ return Array.from({ length: count }).map((_, i) => ({
+ id: `ID-${i + 1}`,
+ name: `User ${i + 1}`,
+ email: `user${i + 1}@example.com`,
+ role: roles[Math.floor(Math.random() * roles.length)],
+ status: statuses[Math.floor(Math.random() * statuses.length)],
+ lastLogin: new Date(Date.now() - Math.floor(Math.random() * 10000000000)).toISOString().split('T')[0],
+ amount: Math.floor(Math.random() * 10000),
+ }))
+}
+
+export default function TestTablePage() {
+ // State for data
+ const [data, setData] = React.useState<TestData[]>([])
+ const [isLoading, setIsLoading] = React.useState(true)
+
+ // Load data on mount
+ React.useEffect(() => {
+ const timer = setTimeout(() => {
+ setData(generateData(100000)) // Generate 1000 rows
+ setIsLoading(false)
+ }, 500)
+ return () => clearTimeout(timer)
+ }, [])
+
+ // 3. Define columns
+ const columns: ColumnDef<TestData>[] = [
+ {
+ accessorKey: "id",
+ header: "ID",
+ size: 80,
+ },
+ {
+ accessorKey: "name",
+ header: "Name",
+ size: 150,
+ },
+ {
+ accessorKey: "email",
+ header: "Email",
+ size: 200,
+ },
+ {
+ accessorKey: "role",
+ header: "Role",
+ size: 100,
+ cell: ({ getValue }) => {
+ const role = getValue() as string
+ return (
+ <Badge variant={role === "Admin" ? "default" : "secondary"}>
+ {role}
+ </Badge>
+ )
+ }
+ },
+ {
+ accessorKey: "status",
+ header: "Status",
+ size: 100,
+ cell: ({ getValue }) => {
+ const status = getValue() as string
+ let color = "bg-gray-500"
+ if (status === "Active") color = "bg-green-500"
+ if (status === "Inactive") color = "bg-red-500"
+ if (status === "Pending") color = "bg-yellow-500"
+
+ return (
+ <div className="flex items-center gap-2">
+ <div className={`w-2 h-2 rounded-full ${color}`} />
+ <span>{status}</span>
+ </div>
+ )
+ }
+ },
+ {
+ accessorKey: "amount",
+ header: "Amount",
+ size: 200,
+ cell: ({ getValue }) => {
+ const amount = getValue() as number
+ return new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: "USD",
+ }).format(amount)
+ },
+ meta: {
+ align: "right"
+ }
+ },
+ {
+ accessorKey: "lastLogin",
+ header: "Last Login",
+ size: 120,
+ },
+ {
+ id: "actions",
+ header: "Actions",
+ size: 100,
+ cell: () => (
+ <Button variant="ghost" size="sm">Edit</Button>
+ ),
+ enablePinning: true,
+ }
+ ]
+
+ return (
+ <div className="h-full flex flex-col p-6 space-y-4">
+ <div className="flex justify-between items-center">
+ <div>
+ <h1 className="text-2xl font-bold tracking-tight">Virtual Table Test</h1>
+ <p className="text-muted-foreground">
+ Testing the ClientVirtualTable component with 1000 generated rows.
+ </p>
+ </div>
+ <div className="flex gap-2">
+ <Button onClick={() => {
+ setIsLoading(true)
+ setTimeout(() => {
+ setData(generateData(5000))
+ setIsLoading(false)
+ }, 500)
+ }}>
+ Reload 5k Rows
+ </Button>
+ </div>
+ </div>
+
+ <div className="border rounded-lg overflow-auto h-[1000px]">
+ <ClientVirtualTable
+ data={data}
+ columns={columns}
+ height="100%"
+ isLoading={isLoading}
+ enablePagination={true}
+ enableRowSelection={true}
+ enableGrouping={true}
+ onRowClick={(row) => console.log("Row clicked:", row.original)}
+ enableUserPreset={true}
+ tableKey="test-table"
+ />
+ </div>
+ </div>
+ )
+}
diff --git a/components/client-table-v2/README.md b/components/client-table-v2/README.md
new file mode 100644
index 00000000..053175d4
--- /dev/null
+++ b/components/client-table-v2/README.md
@@ -0,0 +1,159 @@
+# Client Table Components
+
+A set of reusable, virtualized table components for client-side data rendering, built on top of `@tanstack/react-table` and `@tanstack/react-virtual`.
+
+## Features
+
+- **Virtualization**: Efficiently renders large datasets (50,000+ rows) by only rendering visible rows.
+- **Sorting**: Built-in column sorting (Ascending/Descending).
+- **Filtering**:
+ - Global search (all columns).
+ - Column-specific filters: Text (default), Select, Boolean.
+- **Pagination**: Supports both client-side and server-side (manual) pagination.
+- **Column Management**:
+ - **Reordering**: Drag and drop columns to change order.
+ - **Hiding**: Right-click header to hide columns.
+ - **Pinning**: Right-click header to pin columns (Left/Right).
+- **Excel Export**: Export current table view or custom datasets to `.xlsx`.
+- **Excel Import**: Utility to parse Excel files into JSON objects.
+- **Template Generation**: Create Excel templates for users to fill out and import.
+
+## Installation / Usage
+
+The components are located in `@/components/client-table`.
+
+### 1. Basic Usage
+
+```tsx
+import { ClientVirtualTable, ClientTableColumnDef } from "@/components/client-table"
+
+// 1. Define Data Type
+interface User {
+ id: string
+ name: string
+ role: string
+ active: boolean
+}
+
+// 2. Define Columns
+const columns: ClientTableColumnDef<User>[] = [
+ {
+ accessorKey: "name",
+ header: "Name",
+ // Default filter is text
+ },
+ {
+ accessorKey: "role",
+ header: "Role",
+ meta: {
+ filterType: "select",
+ filterOptions: [
+ { label: "Admin", value: "admin" },
+ { label: "User", value: "user" },
+ ]
+ }
+ },
+ {
+ accessorKey: "active",
+ header: "Active",
+ meta: {
+ filterType: "boolean"
+ }
+ }
+]
+
+// 3. Render Component
+export default function UserTable({ data }: { data: User[] }) {
+ return (
+ <ClientVirtualTable
+ data={data}
+ columns={columns}
+ height="600px" // Required for virtualization
+ enableExport={true} // Shows export button
+ enablePagination={true} // Shows pagination footer
+ />
+ )
+}
+```
+
+### 2. Server-Side Pagination
+
+For very large datasets where you don't want to fetch everything at once.
+
+```tsx
+<ClientVirtualTable
+ data={currentData} // Only the current page data
+ columns={columns}
+ manualPagination={true}
+ pageCount={totalPages}
+ rowCount={totalRows}
+ pagination={{ pageIndex, pageSize }}
+ onPaginationChange={setPaginationState}
+ enablePagination={true}
+/>
+```
+
+### 3. Excel Utilities
+
+#### Exporting Data
+
+Automatic export is available via the `enableExport` prop. For custom export logic:
+
+```tsx
+import { exportToExcel } from "@/components/client-table"
+
+await exportToExcel(data, columns, "my-data.xlsx")
+```
+
+#### Creating Import Templates
+
+Generate a blank Excel file with headers for users to fill in.
+
+```tsx
+import { createExcelTemplate } from "@/components/client-table"
+
+await createExcelTemplate({
+ columns,
+ filename: "user-import-template.xlsx",
+ excludeColumns: ["id", "createdAt"], // Columns to skip
+ includeColumns: [{ key: "notes", header: "Notes" }] // Extra columns
+})
+```
+
+#### Importing Data
+
+Parses an uploaded Excel file into a raw JSON array. Does **not** handle validation or DB insertion.
+
+```tsx
+import { importFromExcel } from "@/components/client-table"
+
+const handleFileUpload = async (file: File) => {
+ const { data, errors } = await importFromExcel({
+ file,
+ columnMapping: { "Name": "name", "Role": "role" } // Optional header mapping
+ })
+
+ if (errors.length > 0) {
+ console.error(errors)
+ return
+ }
+
+ // Send `data` to your API/Service for validation and insertion
+ await saveUsers(data)
+}
+```
+
+## Types
+
+We use a custom column definition type to support our extended `meta` properties.
+
+```typescript
+import { ClientTableColumnDef } from "@/components/client-table"
+
+const columns: ClientTableColumnDef<MyData>[] = [ ... ]
+```
+
+Supported `meta` properties:
+
+- `filterType`: `"text" | "select" | "boolean"`
+- `filterOptions`: `{ label: string; value: string }[]` (Required for `select`)
diff --git a/components/client-table-v2/client-table-column-header.tsx b/components/client-table-v2/client-table-column-header.tsx
new file mode 100644
index 00000000..2d8e5bce
--- /dev/null
+++ b/components/client-table-v2/client-table-column-header.tsx
@@ -0,0 +1,235 @@
+"use client"
+
+import * as React from "react"
+import { Header, Column } from "@tanstack/react-table"
+import { useSortable } from "@dnd-kit/sortable"
+import { CSS } from "@dnd-kit/utilities"
+import { flexRender } from "@tanstack/react-table"
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuSeparator,
+ ContextMenuTrigger,
+} from "@/components/ui/context-menu"
+import {
+ ArrowDown,
+ ArrowUp,
+ ChevronsUpDown,
+ EyeOff,
+ PinOff,
+ MoveLeft,
+ MoveRight,
+ Group,
+ Ungroup,
+} from "lucide-react"
+import { cn } from "@/lib/utils"
+import { ClientTableFilter } from "../client-table/client-table-filter"
+
+interface ClientTableColumnHeaderProps<TData, TValue>
+ extends React.HTMLAttributes<HTMLTableHeaderCellElement> {
+ header: Header<TData, TValue>
+ enableReordering?: boolean
+ renderHeaderVisualFeedback?: (props: {
+ column: Column<TData, TValue>
+ isPinned: boolean | string
+ isSorted: boolean | string
+ isFiltered: boolean
+ isGrouped: boolean
+ }) => React.ReactNode
+}
+
+export function ClientTableColumnHeader<TData, TValue>({
+ header,
+ enableReordering = true,
+ renderHeaderVisualFeedback,
+ className,
+ ...props
+}: ClientTableColumnHeaderProps<TData, TValue>) {
+ const column = header.column
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({
+ id: header.id,
+ disabled: !enableReordering || column.getIsResizing(),
+ })
+
+ // -- Styles --
+ const style: React.CSSProperties = {
+ // Apply transform only if reordering is enabled and active
+ transform: enableReordering ? CSS.Translate.toString(transform) : undefined,
+ transition: enableReordering ? transition : undefined,
+ width: header.getSize(),
+ zIndex: isDragging ? 100 : 0,
+ position: "relative",
+ ...props.style,
+ }
+
+ // Pinning Styles
+ const isPinned = column.getIsPinned()
+ const isSorted = column.getIsSorted()
+ const isFiltered = column.getFilterValue() !== undefined
+ const isGrouped = column.getIsGrouped()
+
+ if (isPinned === "left") {
+ style.left = `${column.getStart("left")}px`
+ style.position = "sticky"
+ style.zIndex = 30 // Pinned columns needs to be higher than normal headers
+ } else if (isPinned === "right") {
+ style.right = `${column.getAfter("right")}px`
+ style.position = "sticky"
+ style.zIndex = 30 // Pinned columns needs to be higher than normal headers
+ }
+
+ // -- Handlers --
+ const handleHide = () => column.toggleVisibility(false)
+ const handlePinLeft = () => column.pin("left")
+ const handlePinRight = () => column.pin("right")
+ const handleUnpin = () => column.pin(false)
+ const handleToggleGrouping = () => column.toggleGrouping()
+
+ // -- Content --
+ const content = (
+ <>
+ <div
+ className={cn(
+ "flex items-center gap-2",
+ column.getCanSort() ? "cursor-pointer select-none" : ""
+ )}
+ onClick={column.getToggleSortingHandler()}
+ >
+ {flexRender(column.columnDef.header, header.getContext())}
+ {column.getCanSort() && (
+ <span className="flex items-center">
+ {column.getIsSorted() === "desc" ? (
+ <ArrowDown className="h-4 w-4" />
+ ) : column.getIsSorted() === "asc" ? (
+ <ArrowUp className="h-4 w-4" />
+ ) : (
+ <ChevronsUpDown className="h-4 w-4 opacity-50" />
+ )}
+ </span>
+ )}
+ {isGrouped && <Group className="h-4 w-4 text-blue-500" />}
+ </div>
+
+ {/* Resize Handle */}
+ <div
+ onMouseDown={header.getResizeHandler()}
+ onTouchStart={header.getResizeHandler()}
+ onPointerDown={(e) => e.stopPropagation()}
+ onClick={(e) => e.stopPropagation()} // Prevent sort trigger
+ className={cn(
+ "absolute right-0 top-0 h-full w-2 cursor-col-resize select-none touch-none z-10",
+ "after:absolute after:right-0 after:top-0 after:h-full after:w-[1px] after:bg-border", // 시각적 구분선
+ "hover:bg-primary/20 hover:w-4 hover:-right-2", // 호버 시 클릭 영역 확장
+ header.column.getIsResizing() ? "bg-primary/50 w-1" : "bg-transparent"
+ )}
+ />
+
+ {/* Filter */}
+ {column.getCanFilter() && <ClientTableFilter column={column} />}
+
+ {/* Visual Feedback Indicators */}
+ {renderHeaderVisualFeedback ? (
+ renderHeaderVisualFeedback({
+ column,
+ isPinned,
+ isSorted,
+ isFiltered,
+ isGrouped,
+ })
+ ) : (
+ (isPinned || isFiltered || isGrouped) && (
+ <div className="absolute top-0.5 right-1 flex gap-1 z-10 pointer-events-none">
+ {isPinned && <div className="h-1.5 w-1.5 rounded-full bg-blue-500" />}
+ {isFiltered && <div className="h-1.5 w-1.5 rounded-full bg-yellow-500" />}
+ {isGrouped && <div className="h-1.5 w-1.5 rounded-full bg-green-500" />}
+ </div>
+ )
+ )}
+ </>
+ )
+
+ if (header.isPlaceholder) {
+ return (
+ <th
+ colSpan={header.colSpan}
+ style={style}
+ className={cn("border-b px-4 py-2 text-left text-sm font-medium bg-muted", className)}
+ {...props}
+ >
+ {null}
+ </th>
+ )
+ }
+
+ return (
+ <ContextMenu>
+ <ContextMenuTrigger asChild>
+ <th
+ ref={setNodeRef}
+ colSpan={header.colSpan}
+ style={style}
+ className={cn(
+ "border-b px-4 py-2 text-left text-sm font-medium bg-muted group transition-colors",
+ isDragging ? "opacity-50 bg-accent" : "",
+ isPinned ? "shadow-[0_0_10px_rgba(0,0,0,0.1)]" : "",
+ className
+ )}
+ {...attributes}
+ {...listeners}
+ {...props}
+ >
+ {content}
+ </th>
+ </ContextMenuTrigger>
+ <ContextMenuContent className="w-48">
+ <ContextMenuItem onClick={handleHide}>
+ <EyeOff className="mr-2 h-4 w-4" />
+ Hide Column
+ </ContextMenuItem>
+
+ {column.getCanGroup() && (
+ <>
+ <ContextMenuSeparator />
+ <ContextMenuItem onClick={handleToggleGrouping}>
+ {isGrouped ? (
+ <>
+ <Ungroup className="mr-2 h-4 w-4" />
+ Ungroup
+ </>
+ ) : (
+ <>
+ <Group className="mr-2 h-4 w-4" />
+ Group by {column.id}
+ </>
+ )}
+ </ContextMenuItem>
+ </>
+ )}
+
+ <ContextMenuSeparator />
+ <ContextMenuItem onClick={handlePinLeft}>
+ <MoveLeft className="mr-2 h-4 w-4" />
+ Pin Left
+ </ContextMenuItem>
+ <ContextMenuItem onClick={handlePinRight}>
+ <MoveRight className="mr-2 h-4 w-4" />
+ Pin Right
+ </ContextMenuItem>
+ {isPinned && (
+ <ContextMenuItem onClick={handleUnpin}>
+ <PinOff className="mr-2 h-4 w-4" />
+ Unpin
+ </ContextMenuItem>
+ )}
+ </ContextMenuContent>
+ </ContextMenu>
+ )
+}
diff --git a/components/client-table-v2/client-table-filter.tsx b/components/client-table-v2/client-table-filter.tsx
new file mode 100644
index 00000000..138f77eb
--- /dev/null
+++ b/components/client-table-v2/client-table-filter.tsx
@@ -0,0 +1,101 @@
+"use client"
+
+import * as React from "react"
+import { Column } from "@tanstack/react-table"
+import { Input } from "@/components/ui/input"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { ClientTableColumnMeta } from "./types"
+
+interface ClientTableFilterProps<TData, TValue> {
+ column: Column<TData, TValue>
+}
+
+export function ClientTableFilter<TData, TValue>({
+ column,
+}: ClientTableFilterProps<TData, TValue>) {
+ const columnFilterValue = column.getFilterValue()
+ // Cast meta to our local type
+ const meta = column.columnDef.meta as ClientTableColumnMeta | undefined
+
+ // Handle Boolean Filter
+ if (meta?.filterType === "boolean") {
+ return (
+ <div onClick={(e) => e.stopPropagation()} className="mt-2">
+ <Select
+ value={(columnFilterValue as string) ?? "all"}
+ onValueChange={(value) =>
+ column.setFilterValue(value === "all" ? undefined : value === "true")
+ }
+ >
+ <SelectTrigger className="h-8 w-full">
+ <SelectValue placeholder="All" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="all">All</SelectItem>
+ <SelectItem value="true">Yes</SelectItem>
+ <SelectItem value="false">No</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ )
+ }
+
+ // Handle Select Filter (for specific options)
+ if (meta?.filterType === "select" && meta.filterOptions) {
+ return (
+ <div onClick={(e) => e.stopPropagation()} className="mt-2">
+ <Select
+ value={(columnFilterValue as string) ?? "all"}
+ onValueChange={(value) =>
+ column.setFilterValue(value === "all" ? undefined : value)
+ }
+ >
+ <SelectTrigger className="h-8 w-full">
+ <SelectValue placeholder="All" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="all">All</SelectItem>
+ {meta.filterOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ )
+ }
+
+ // Default Text Filter
+ const [value, setValue] = React.useState(columnFilterValue)
+
+ React.useEffect(() => {
+ setValue(columnFilterValue)
+ }, [columnFilterValue])
+
+ React.useEffect(() => {
+ const timeout = setTimeout(() => {
+ column.setFilterValue(value)
+ }, 500)
+
+ return () => clearTimeout(timeout)
+ }, [value, column])
+
+ return (
+ <div onClick={(e) => e.stopPropagation()} className="mt-2">
+ <Input
+ type="text"
+ value={(value ?? "") as string}
+ onChange={(e) => setValue(e.target.value)}
+ placeholder="Search..."
+ className="h-8 w-full font-normal bg-background"
+ />
+ </div>
+ )
+}
diff --git a/components/client-table-v2/client-table-preset.tsx b/components/client-table-v2/client-table-preset.tsx
new file mode 100644
index 00000000..64930e7a
--- /dev/null
+++ b/components/client-table-v2/client-table-preset.tsx
@@ -0,0 +1,185 @@
+"use client";
+
+import * as React from "react";
+import { Table } from "@tanstack/react-table";
+import { useSession } from "next-auth/react";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Bookmark, Save, Trash2 } from "lucide-react";
+import {
+ getPresets,
+ savePreset,
+ deletePreset,
+} from "./preset-actions";
+import { Preset } from "./preset-types";
+import { toast } from "sonner";
+
+interface ClientTablePresetProps<TData> {
+ table: Table<TData>;
+ tableKey: string;
+}
+
+export function ClientTablePreset<TData>({
+ table,
+ tableKey,
+}: ClientTablePresetProps<TData>) {
+ const { data: session } = useSession();
+ const [savedPresets, setSavedPresets] = React.useState<Preset[]>([]);
+ const [isPresetDialogOpen, setIsPresetDialogOpen] = React.useState(false);
+ const [newPresetName, setNewPresetName] = React.useState("");
+ const [isLoading, setIsLoading] = React.useState(false);
+
+ const fetchSettings = React.useCallback(async () => {
+ const userIdVal = session?.user?.id;
+ if (!userIdVal) return;
+
+ const userId = Number(userIdVal);
+ if (isNaN(userId)) return;
+
+ const res = await getPresets(tableKey, userId);
+ if (res.success && res.data) {
+ setSavedPresets(res.data);
+ }
+ }, [session, tableKey]);
+
+ React.useEffect(() => {
+ if (session) {
+ fetchSettings();
+ }
+ }, [fetchSettings, session]);
+
+ const handleSavePreset = async () => {
+ const userIdVal = session?.user?.id;
+ if (!newPresetName.trim() || !userIdVal) return;
+ const userId = Number(userIdVal);
+ if (isNaN(userId)) return;
+
+ setIsLoading(true);
+ const state = table.getState();
+ const settingToSave = {
+ sorting: state.sorting,
+ columnFilters: state.columnFilters,
+ globalFilter: state.globalFilter,
+ columnVisibility: state.columnVisibility,
+ columnPinning: state.columnPinning,
+ columnOrder: state.columnOrder,
+ grouping: state.grouping,
+ pagination: { pageSize: state.pagination.pageSize },
+ };
+
+ const res = await savePreset(userId, tableKey, newPresetName, settingToSave);
+ setIsLoading(false);
+
+ if (res.success) {
+ toast.success("Preset saved successfully");
+ setIsPresetDialogOpen(false);
+ setNewPresetName("");
+ fetchSettings();
+ } else {
+ toast.error("Failed to save preset");
+ }
+ };
+
+ const handleLoadPreset = (preset: Preset) => {
+ const s = preset.setting as Record<string, any>;
+ if (!s) return;
+
+ if (s.sorting) table.setSorting(s.sorting);
+ if (s.columnFilters) table.setColumnFilters(s.columnFilters);
+ if (s.globalFilter !== undefined) table.setGlobalFilter(s.globalFilter);
+ if (s.columnVisibility) table.setColumnVisibility(s.columnVisibility);
+ if (s.columnPinning) table.setColumnPinning(s.columnPinning);
+ if (s.columnOrder) table.setColumnOrder(s.columnOrder);
+ if (s.grouping) table.setGrouping(s.grouping);
+ if (s.pagination?.pageSize) table.setPageSize(s.pagination.pageSize);
+
+ toast.success(`Preset "${preset.name}" loaded`);
+ };
+
+ const handleDeletePreset = async (e: React.MouseEvent, id: string) => {
+ e.stopPropagation();
+ if (!confirm("Are you sure you want to delete this preset?")) return;
+
+ const res = await deletePreset(id);
+ if (res.success) {
+ toast.success("Preset deleted");
+ fetchSettings();
+ } else {
+ toast.error("Failed to delete preset");
+ }
+ };
+
+ if (!session) return null;
+
+ return (
+ <>
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="outline" size="sm" className="ml-2 hidden h-8 lg:flex">
+ <Bookmark className="mr-2 h-4 w-4" />
+ Presets
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-[200px]">
+ <DropdownMenuLabel>Saved Presets</DropdownMenuLabel>
+ <DropdownMenuSeparator />
+ {savedPresets.length === 0 ? (
+ <div className="p-2 text-sm text-muted-foreground text-center">No saved presets</div>
+ ) : (
+ savedPresets.map((preset) => (
+ <DropdownMenuItem key={preset.id} onClick={() => handleLoadPreset(preset)} className="flex justify-between cursor-pointer">
+ <span className="truncate flex-1">{preset.name}</span>
+ <Button variant="ghost" size="icon" className="h-4 w-4" onClick={(e) => handleDeletePreset(e, preset.id)}>
+ <Trash2 className="h-3 w-3 text-destructive" />
+ </Button>
+ </DropdownMenuItem>
+ ))
+ )}
+ <DropdownMenuSeparator />
+ <DropdownMenuItem onClick={() => setIsPresetDialogOpen(true)} className="cursor-pointer">
+ <Save className="mr-2 h-4 w-4" />
+ Save Current Preset
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+
+ <Dialog open={isPresetDialogOpen} onOpenChange={setIsPresetDialogOpen}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Save Preset</DialogTitle>
+ <DialogDescription>
+ Save the current table configuration as a preset.
+ </DialogDescription>
+ </DialogHeader>
+ <div className="grid gap-4 py-4">
+ <Input
+ placeholder="Preset Name"
+ value={newPresetName}
+ onChange={(e) => setNewPresetName(e.target.value)}
+ />
+ </div>
+ <DialogFooter>
+ <Button variant="outline" onClick={() => setIsPresetDialogOpen(false)}>Cancel</Button>
+ <Button onClick={handleSavePreset} disabled={isLoading}>Save</Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </>
+ );
+}
diff --git a/components/client-table-v2/client-table-save-view.tsx b/components/client-table-v2/client-table-save-view.tsx
new file mode 100644
index 00000000..73935d00
--- /dev/null
+++ b/components/client-table-v2/client-table-save-view.tsx
@@ -0,0 +1,185 @@
+"use client";
+
+import * as React from "react";
+import { Table } from "@tanstack/react-table";
+import { useSession } from "next-auth/react";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Bookmark, Save, Trash2 } from "lucide-react";
+import {
+ getUserCustomSettings,
+ saveUserCustomSetting,
+ deleteUserCustomSetting,
+} from "@/actions/user-custom-data";
+import { toast } from "sonner";
+
+interface ClientTableSaveViewProps<TData> {
+ table: Table<TData>;
+ tableKey: string;
+}
+
+export function ClientTableSaveView<TData>({
+ table,
+ tableKey,
+}: ClientTableSaveViewProps<TData>) {
+ const { data: session } = useSession();
+ const [savedViews, setSavedViews] = React.useState<{ id: string; customSettingName: string; customSetting: Record<string, any> }[]>([]);
+ const [isSaveDialogOpen, setIsSaveDialogOpen] = React.useState(false);
+ const [newViewName, setNewViewName] = React.useState("");
+ const [isLoading, setIsLoading] = React.useState(false);
+
+ const fetchSettings = React.useCallback(async () => {
+ const userIdVal = session?.user?.id;
+ if (!userIdVal) return;
+
+ const userId = Number(userIdVal);
+ if (isNaN(userId)) return;
+
+ const res = await getUserCustomSettings(tableKey, userId);
+ if (res.success && res.data) {
+ // @ts-ignore - data from DB might need casting
+ setSavedViews(res.data);
+ }
+ }, [session, tableKey]);
+
+ React.useEffect(() => {
+ if (session) {
+ fetchSettings();
+ }
+ }, [fetchSettings, session]);
+
+ const handleSaveView = async () => {
+ const userIdVal = session?.user?.id;
+ if (!newViewName.trim() || !userIdVal) return;
+ const userId = Number(userIdVal);
+ if (isNaN(userId)) return;
+
+ setIsLoading(true);
+ const state = table.getState();
+ const settingToSave = {
+ sorting: state.sorting,
+ columnFilters: state.columnFilters,
+ globalFilter: state.globalFilter,
+ columnVisibility: state.columnVisibility,
+ columnPinning: state.columnPinning,
+ columnOrder: state.columnOrder,
+ grouping: state.grouping,
+ pagination: { pageSize: state.pagination.pageSize },
+ };
+
+ const res = await saveUserCustomSetting(userId, tableKey, newViewName, settingToSave);
+ setIsLoading(false);
+
+ if (res.success) {
+ toast.success("View saved successfully");
+ setIsSaveDialogOpen(false);
+ setNewViewName("");
+ fetchSettings();
+ } else {
+ toast.error("Failed to save view");
+ }
+ };
+
+ const handleLoadView = (setting: { customSetting: Record<string, any> | unknown; customSettingName: string }) => {
+ const s = setting.customSetting as Record<string, any>;
+ if (!s) return;
+
+ if (s.sorting) table.setSorting(s.sorting);
+ if (s.columnFilters) table.setColumnFilters(s.columnFilters);
+ if (s.globalFilter !== undefined) table.setGlobalFilter(s.globalFilter);
+ if (s.columnVisibility) table.setColumnVisibility(s.columnVisibility);
+ if (s.columnPinning) table.setColumnPinning(s.columnPinning);
+ if (s.columnOrder) table.setColumnOrder(s.columnOrder);
+ if (s.grouping) table.setGrouping(s.grouping);
+ if (s.pagination?.pageSize) table.setPageSize(s.pagination.pageSize);
+
+ toast.success(`View "${setting.customSettingName}" loaded`);
+ };
+
+ const handleDeleteView = async (e: React.MouseEvent, id: string) => {
+ e.stopPropagation();
+ if (!confirm("Are you sure you want to delete this view?")) return;
+
+ const res = await deleteUserCustomSetting(id);
+ if (res.success) {
+ toast.success("View deleted");
+ fetchSettings();
+ } else {
+ toast.error("Failed to delete view");
+ }
+ };
+
+ if (!session) return null;
+
+ return (
+ <>
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="outline" size="sm" className="ml-2 hidden h-8 lg:flex">
+ <Bookmark className="mr-2 h-4 w-4" />
+ Views
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-[200px]">
+ <DropdownMenuLabel>Saved Views</DropdownMenuLabel>
+ <DropdownMenuSeparator />
+ {savedViews.length === 0 ? (
+ <div className="p-2 text-sm text-muted-foreground text-center">No saved views</div>
+ ) : (
+ savedViews.map((view) => (
+ <DropdownMenuItem key={view.id} onClick={() => handleLoadView(view)} className="flex justify-between cursor-pointer">
+ <span className="truncate flex-1">{view.customSettingName}</span>
+ <Button variant="ghost" size="icon" className="h-4 w-4" onClick={(e) => handleDeleteView(e, view.id)}>
+ <Trash2 className="h-3 w-3 text-destructive" />
+ </Button>
+ </DropdownMenuItem>
+ ))
+ )}
+ <DropdownMenuSeparator />
+ <DropdownMenuItem onClick={() => setIsSaveDialogOpen(true)} className="cursor-pointer">
+ <Save className="mr-2 h-4 w-4" />
+ Save Current View
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+
+ <Dialog open={isSaveDialogOpen} onOpenChange={setIsSaveDialogOpen}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Save View</DialogTitle>
+ <DialogDescription>
+ Save the current table configuration as a preset.
+ </DialogDescription>
+ </DialogHeader>
+ <div className="grid gap-4 py-4">
+ <Input
+ placeholder="View Name"
+ value={newViewName}
+ onChange={(e) => setNewViewName(e.target.value)}
+ />
+ </div>
+ <DialogFooter>
+ <Button variant="outline" onClick={() => setIsSaveDialogOpen(false)}>Cancel</Button>
+ <Button onClick={handleSaveView} disabled={isLoading}>Save</Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </>
+ );
+}
diff --git a/components/client-table-v2/client-table-toolbar.tsx b/components/client-table-v2/client-table-toolbar.tsx
new file mode 100644
index 00000000..089501e1
--- /dev/null
+++ b/components/client-table-v2/client-table-toolbar.tsx
@@ -0,0 +1,59 @@
+"use client"
+
+import * as React from "react"
+import { Search, Download } from "lucide-react"
+import { Input } from "@/components/ui/input"
+import { Button } from "@/components/ui/button"
+
+interface ClientTableToolbarProps {
+ globalFilter: string
+ setGlobalFilter: (value: string) => void
+ totalRows: number
+ visibleRows: number
+ onExport?: () => void
+ actions?: React.ReactNode
+ customToolbar?: React.ReactNode
+ viewOptions?: React.ReactNode
+}
+
+export function ClientTableToolbar({
+ globalFilter,
+ setGlobalFilter,
+ totalRows,
+ visibleRows,
+ onExport,
+ actions,
+ customToolbar,
+ viewOptions,
+}: ClientTableToolbarProps) {
+ return (
+ <div className="flex w-full items-center justify-between gap-4 p-1 overflow-x-auto">
+ <div className="flex items-center gap-2">
+ <div className="relative max-w-sm min-w-[200px]">
+ <Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
+ <Input
+ placeholder="Search all columns..."
+ value={globalFilter ?? ""}
+ onChange={(e) => setGlobalFilter(e.target.value)}
+ className="pl-8"
+ />
+ </div>
+ <div className="text-sm text-muted-foreground whitespace-nowrap">
+ Showing {visibleRows} of {totalRows}
+ </div>
+ {viewOptions}
+ {onExport && (
+ <Button onClick={onExport} variant="outline" size="sm">
+ <Download className="mr-2 h-4 w-4" />
+ Export
+ </Button>
+ )}
+ </div>
+
+ <div className="flex items-center gap-2 shrink-0">
+ {customToolbar}
+ {actions}
+ </div>
+ </div>
+ )
+}
diff --git a/components/client-table-v2/client-table-view-options.tsx b/components/client-table-v2/client-table-view-options.tsx
new file mode 100644
index 00000000..3b659fcd
--- /dev/null
+++ b/components/client-table-v2/client-table-view-options.tsx
@@ -0,0 +1,67 @@
+"use client"
+
+import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu"
+import { MixerHorizontalIcon } from "@radix-ui/react-icons"
+import { Table } from "@tanstack/react-table"
+
+import { Button } from "@/components/ui/button"
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+} from "@/components/ui/dropdown-menu"
+
+interface ClientTableViewOptionsProps<TData> {
+ table: Table<TData>
+}
+
+export function ClientTableViewOptions<TData>({
+ table,
+}: ClientTableViewOptionsProps<TData>) {
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ className="ml-auto hidden h-8 lg:flex"
+ >
+ <MixerHorizontalIcon className="mr-2 h-4 w-4" />
+ View
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-[150px]">
+ <DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
+ <DropdownMenuSeparator />
+ {table
+ .getAllLeafColumns()
+ .filter(
+ (column) =>
+ typeof column.accessorFn !== "undefined" && column.getCanHide()
+ )
+ .map((column) => {
+ const header = column.columnDef.header
+ let label = column.id
+ if (typeof header === "string") {
+ label = header
+ }
+
+ return (
+ <DropdownMenuCheckboxItem
+ key={column.id}
+ className="capitalize"
+ checked={column.getIsVisible()}
+ onCheckedChange={(value) => column.toggleVisibility(!!value)}
+ onSelect={(e) => e.preventDefault()} // default action close the select menu.
+ >
+ {label}
+ </DropdownMenuCheckboxItem>
+ )
+ })}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+}
+
diff --git a/components/client-table-v2/client-virtual-table.tsx b/components/client-table-v2/client-virtual-table.tsx
new file mode 100644
index 00000000..1713369f
--- /dev/null
+++ b/components/client-table-v2/client-virtual-table.tsx
@@ -0,0 +1,626 @@
+"use client"
+
+import * as React from "react"
+import { rankItem } from "@tanstack/match-sorter-utils"
+import {
+ useReactTable,
+ getCoreRowModel,
+ getSortedRowModel,
+ getFilteredRowModel,
+ getPaginationRowModel,
+ getGroupedRowModel,
+ getExpandedRowModel,
+ getFacetedRowModel,
+ getFacetedUniqueValues,
+ getFacetedMinMaxValues,
+ ColumnDef,
+ SortingState,
+ ColumnFiltersState,
+ flexRender,
+ PaginationState,
+ OnChangeFn,
+ ColumnOrderState,
+ VisibilityState,
+ ColumnPinningState,
+ FilterFn,
+ Table,
+ RowSelectionState,
+ Row,
+ Column,
+ GroupingState,
+ ExpandedState,
+} from "@tanstack/react-table"
+import { useVirtualizer } from "@tanstack/react-virtual"
+import {
+ DndContext,
+ closestCenter,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors,
+ DragEndEvent,
+} from "@dnd-kit/core"
+import {
+ arrayMove,
+ SortableContext,
+ horizontalListSortingStrategy,
+} from "@dnd-kit/sortable"
+import { cn } from "@/lib/utils"
+import { Loader2, ChevronRight, ChevronDown } from "lucide-react"
+
+import { ClientTableToolbar } from "../client-table/client-table-toolbar"
+import { exportToExcel } from "../client-table/export-utils"
+import { ClientDataTablePagination } from "@/components/client-data-table/data-table-pagination"
+import { ClientTableColumnHeader } from "./client-table-column-header"
+import { ClientTableViewOptions } from "../client-table/client-table-view-options"
+import { ClientTablePreset } from "./client-table-preset"
+
+// Moved outside for stability (Performance Optimization)
+const fuzzyFilter: FilterFn<any> = (row, columnId, value, addMeta) => {
+ const itemRank = rankItem(row.getValue(columnId), value)
+ addMeta({ itemRank })
+ return itemRank.passed
+}
+
+export interface ClientVirtualTableProps<TData, TValue> {
+ data: TData[]
+ columns: ColumnDef<TData, TValue>[]
+ height?: string | number
+ estimateRowHeight?: number
+ className?: string
+ actions?: React.ReactNode
+ customToolbar?: React.ReactNode
+ enableExport?: boolean
+ onExport?: (data: TData[]) => void
+ isLoading?: boolean
+
+ /**
+ * 데이터 페칭 모드
+ * - client: 모든 데이터를 한번에 받아 클라이언트에서 처리 (기본값)
+ * - server: 서버에서 필터링/정렬/페이지네이션 된 데이터를 받음
+ */
+ fetchMode?: "client" | "server"
+
+ // --- User Preset Saving ---
+ enableUserPreset?: boolean
+ tableKey?: string
+
+ // --- State Control (Controlled or Uncontrolled) ---
+
+ // Pagination
+ enablePagination?: boolean
+ manualPagination?: boolean
+ pageCount?: number
+ rowCount?: number
+ pagination?: PaginationState
+ onPaginationChange?: OnChangeFn<PaginationState>
+
+ // Sorting
+ sorting?: SortingState
+ onSortingChange?: OnChangeFn<SortingState>
+
+ // Filtering
+ columnFilters?: ColumnFiltersState
+ onColumnFiltersChange?: OnChangeFn<ColumnFiltersState>
+ globalFilter?: string
+ onGlobalFilterChange?: OnChangeFn<string>
+
+ // Visibility
+ columnVisibility?: VisibilityState
+ onColumnVisibilityChange?: OnChangeFn<VisibilityState>
+
+ // Pinning
+ columnPinning?: ColumnPinningState
+ onColumnPinningChange?: OnChangeFn<ColumnPinningState>
+
+ // Order
+ columnOrder?: ColumnOrderState
+ onColumnOrderChange?: OnChangeFn<ColumnOrderState>
+
+ // Selection
+ enableRowSelection?: boolean | ((row: Row<TData>) => boolean)
+ enableMultiRowSelection?: boolean | ((row: Row<TData>) => boolean)
+ rowSelection?: RowSelectionState
+ onRowSelectionChange?: OnChangeFn<RowSelectionState>
+
+ // Grouping
+ enableGrouping?: boolean
+ grouping?: GroupingState
+ onGroupingChange?: OnChangeFn<GroupingState>
+ expanded?: ExpandedState
+ onExpandedChange?: OnChangeFn<ExpandedState>
+
+ // --- Event Handlers ---
+ onRowClick?: (row: Row<TData>, event: React.MouseEvent) => void
+
+ // --- Styling ---
+ getRowClassName?: (originalRow: TData, index: number) => string
+
+ // --- Advanced ---
+ meta?: Record<string, any>
+ getRowId?: (originalRow: TData, index: number, parent?: any) => string
+
+ // Custom Header Visual Feedback
+ renderHeaderVisualFeedback?: (props: {
+ column: Column<TData, TValue>
+ isPinned: boolean | string
+ isSorted: boolean | string
+ isFiltered: boolean
+ isGrouped: boolean
+ }) => React.ReactNode
+}
+
+function ClientVirtualTableInner<TData, TValue>(
+ {
+ data,
+ columns,
+ height = "100%",
+ estimateRowHeight = 40,
+ className,
+ actions,
+ customToolbar,
+ enableExport = true,
+ onExport,
+ isLoading = false,
+ fetchMode = "client",
+
+ // User Preset Saving
+ enableUserPreset = false,
+ tableKey,
+
+ // Pagination
+ enablePagination = false,
+ manualPagination: propManualPagination,
+ pageCount,
+ rowCount,
+ pagination: propPagination,
+ onPaginationChange,
+
+ // Sorting
+ sorting: propSorting,
+ onSortingChange,
+
+ // Filtering
+ columnFilters: propColumnFilters,
+ onColumnFiltersChange,
+ globalFilter: propGlobalFilter,
+ onGlobalFilterChange,
+
+ // Visibility
+ columnVisibility: propColumnVisibility,
+ onColumnVisibilityChange,
+
+ // Pinning
+ columnPinning: propColumnPinning,
+ onColumnPinningChange,
+
+ // Order
+ columnOrder: propColumnOrder,
+ onColumnOrderChange,
+
+ // Selection
+ enableRowSelection,
+ enableMultiRowSelection,
+ rowSelection: propRowSelection,
+ onRowSelectionChange,
+
+ // Grouping
+ enableGrouping = false,
+ grouping: propGrouping,
+ onGroupingChange,
+ expanded: propExpanded,
+ onExpandedChange,
+
+ // Style defaults
+ getRowClassName,
+
+ // Meta & RowID
+ meta,
+ getRowId,
+
+ // Event Handlers
+ onRowClick,
+
+ // Custom Header Visual Feedback
+ renderHeaderVisualFeedback,
+ }: ClientVirtualTableProps<TData, TValue>,
+ ref: React.Ref<Table<TData>>
+) {
+ // Internal States (used when props are undefined)
+ const [internalSorting, setInternalSorting] = React.useState<SortingState>([])
+ const [internalColumnFilters, setInternalColumnFilters] = React.useState<ColumnFiltersState>([])
+ const [internalGlobalFilter, setInternalGlobalFilter] = React.useState("")
+ const [internalColumnVisibility, setInternalColumnVisibility] = React.useState<VisibilityState>({})
+ const [internalColumnPinning, setInternalColumnPinning] = React.useState<ColumnPinningState>({ left: [], right: [] })
+ const [internalColumnOrder, setInternalColumnOrder] = React.useState<ColumnOrderState>(
+ () => columns.map((c) => c.id || (c as any).accessorKey) as string[]
+ )
+ const [internalPagination, setInternalPagination] = React.useState<PaginationState>({
+ pageIndex: 0,
+ pageSize: 10,
+ })
+ const [internalRowSelection, setInternalRowSelection] = React.useState<RowSelectionState>({})
+ const [internalGrouping, setInternalGrouping] = React.useState<GroupingState>([])
+ const [internalExpanded, setInternalExpanded] = React.useState<ExpandedState>({})
+
+ // Effective States
+ const sorting = propSorting ?? internalSorting
+ const setSorting = onSortingChange ?? setInternalSorting
+
+ const columnFilters = propColumnFilters ?? internalColumnFilters
+ const setColumnFilters = onColumnFiltersChange ?? setInternalColumnFilters
+
+ const globalFilter = propGlobalFilter ?? internalGlobalFilter
+ const setGlobalFilter = onGlobalFilterChange ?? setInternalGlobalFilter
+
+ const columnVisibility = propColumnVisibility ?? internalColumnVisibility
+ const setColumnVisibility = onColumnVisibilityChange ?? setInternalColumnVisibility
+
+ const columnPinning = propColumnPinning ?? internalColumnPinning
+ const setColumnPinning = onColumnPinningChange ?? setInternalColumnPinning
+
+ const columnOrder = propColumnOrder ?? internalColumnOrder
+ const setColumnOrder = onColumnOrderChange ?? setInternalColumnOrder
+
+ const pagination = propPagination ?? internalPagination
+ const setPagination = onPaginationChange ?? setInternalPagination
+
+ const rowSelection = propRowSelection ?? internalRowSelection
+ const setRowSelection = onRowSelectionChange ?? setInternalRowSelection
+
+ const grouping = propGrouping ?? internalGrouping
+ const setGrouping = onGroupingChange ?? setInternalGrouping
+
+ const expanded = propExpanded ?? internalExpanded
+ const setExpanded = onExpandedChange ?? setInternalExpanded
+
+ // Server Mode Logic
+ const isServer = fetchMode === "server"
+ // If server mode is enabled, we default manual flags to true unless explicitly overridden
+ const manualPagination = propManualPagination ?? isServer
+ const manualSorting = isServer
+ const manualFiltering = isServer
+ const manualGrouping = isServer
+
+ // Table Instance
+ const table = useReactTable({
+ data,
+ columns,
+ state: {
+ sorting,
+ columnFilters,
+ globalFilter,
+ pagination,
+ columnVisibility,
+ columnPinning,
+ columnOrder,
+ rowSelection,
+ grouping,
+ expanded,
+ },
+ manualPagination,
+ manualSorting,
+ manualFiltering,
+ manualGrouping,
+ pageCount: manualPagination ? pageCount : undefined,
+ rowCount: manualPagination ? rowCount : undefined,
+ onSortingChange: setSorting,
+ onColumnFiltersChange: setColumnFilters,
+ onGlobalFilterChange: setGlobalFilter,
+ onPaginationChange: setPagination,
+ onColumnVisibilityChange: setColumnVisibility,
+ onColumnPinningChange: setColumnPinning,
+ onColumnOrderChange: setColumnOrder,
+ onRowSelectionChange: setRowSelection,
+ onGroupingChange: setGrouping,
+ onExpandedChange: setExpanded,
+ enableRowSelection,
+ enableMultiRowSelection,
+ enableGrouping,
+ getCoreRowModel: getCoreRowModel(),
+
+ // Systematic Order of Operations:
+ // If server-side, we skip client-side processing models to avoid double processing
+ // and to ensure the table reflects exactly what the server returned.
+ getFilteredRowModel: !isServer ? getFilteredRowModel() : undefined,
+ getFacetedRowModel: !isServer ? getFacetedRowModel() : undefined,
+ getFacetedUniqueValues: !isServer ? getFacetedUniqueValues() : undefined,
+ getFacetedMinMaxValues: !isServer ? getFacetedMinMaxValues() : undefined,
+
+ getSortedRowModel: !isServer ? getSortedRowModel() : undefined,
+
+ getGroupedRowModel: (!isServer && enableGrouping) ? getGroupedRowModel() : undefined,
+ getExpandedRowModel: (!isServer && enableGrouping) ? getExpandedRowModel() : undefined,
+
+ getPaginationRowModel: (!isServer && enablePagination) ? getPaginationRowModel() : undefined,
+ columnResizeMode: "onChange",
+ filterFns: {
+ fuzzy: fuzzyFilter,
+ },
+ globalFilterFn: fuzzyFilter,
+ meta,
+ getRowId,
+ })
+
+ // Expose table instance via ref
+ React.useImperativeHandle(ref, () => table, [table])
+
+ // DnD Sensors
+ const sensors = useSensors(
+ useSensor(PointerSensor, {
+ activationConstraint: {
+ distance: 8,
+ },
+ }),
+ useSensor(KeyboardSensor)
+ )
+
+ // Handle Drag End
+ const handleDragEnd = (event: DragEndEvent) => {
+ const { active, over } = event
+ if (active && over && active.id !== over.id) {
+ const activeId = active.id as string
+ const overId = over.id as string
+
+ const activeColumn = table.getColumn(activeId)
+ const overColumn = table.getColumn(overId)
+
+ if (activeColumn && overColumn) {
+ const activePinState = activeColumn.getIsPinned()
+ const overPinState = overColumn.getIsPinned()
+
+ // If dragging between different pin states, update the pin state of the active column
+ if (activePinState !== overPinState) {
+ activeColumn.pin(overPinState)
+ }
+
+ // Reorder the columns
+ setColumnOrder((items) => {
+ const currentItems = Array.isArray(items) ? items : []
+ const oldIndex = items.indexOf(activeId)
+ const newIndex = items.indexOf(overId)
+ return arrayMove(items, oldIndex, newIndex)
+ })
+ }
+ }
+ }
+
+ // Virtualization
+ const tableContainerRef = React.useRef<HTMLDivElement>(null)
+ const { rows } = table.getRowModel()
+
+ const rowVirtualizer = useVirtualizer({
+ count: rows.length,
+ getScrollElement: () => tableContainerRef.current,
+ estimateSize: () => estimateRowHeight,
+ overscan: 10,
+ })
+
+ const virtualRows = rowVirtualizer.getVirtualItems()
+ const totalSize = rowVirtualizer.getTotalSize()
+
+ const paddingTop = virtualRows.length > 0 ? virtualRows?.[0]?.start || 0 : 0
+ const paddingBottom =
+ virtualRows.length > 0
+ ? totalSize - (virtualRows?.[virtualRows.length - 1]?.end || 0)
+ : 0
+
+ // Export Handler
+ const handleExport = async () => {
+ if (onExport) {
+ onExport(data)
+ return
+ }
+ const currentData = table.getFilteredRowModel().rows.map((row) => row.original)
+ await exportToExcel(currentData, columns, `export-${new Date().toISOString().slice(0, 10)}.xlsx`)
+ }
+
+ return (
+ <div
+ className={`flex flex-col gap-4 ${className || ""}`}
+ style={{ height }}
+ >
+ <ClientTableToolbar
+ globalFilter={globalFilter}
+ setGlobalFilter={setGlobalFilter}
+ totalRows={manualPagination ? (rowCount ?? data.length) : data.length}
+ visibleRows={rows.length}
+ onExport={enableExport ? handleExport : undefined}
+ viewOptions={
+ <>
+ <ClientTableViewOptions table={table} />
+ {enableUserPreset && tableKey && (
+ <ClientTablePreset table={table} tableKey={tableKey} />
+ )}
+ </>
+ }
+ customToolbar={customToolbar}
+ actions={actions}
+ />
+
+ <div
+ ref={tableContainerRef}
+ className="relative border rounded-md overflow-auto bg-background flex-1 min-h-0"
+ >
+ {isLoading && (
+ <div className="absolute inset-0 z-50 flex items-center justify-center bg-background/50 backdrop-blur-sm">
+ <Loader2 className="h-8 w-8 animate-spin text-primary" />
+ </div>
+ )}
+
+ <DndContext
+ sensors={sensors}
+ collisionDetection={closestCenter}
+ onDragEnd={handleDragEnd}
+ >
+ <table
+ className="table-fixed border-collapse w-full min-w-full"
+ style={{ width: table.getTotalSize() }}
+ >
+ <thead className="sticky top-0 z-40 bg-muted">
+ {table.getHeaderGroups().map((headerGroup) => (
+ <tr key={headerGroup.id}>
+ <SortableContext
+ items={headerGroup.headers.map((h) => h.id)}
+ strategy={horizontalListSortingStrategy}
+ >
+ {headerGroup.headers.map((header) => (
+ <ClientTableColumnHeader
+ key={header.id}
+ header={header}
+ enableReordering={true}
+ renderHeaderVisualFeedback={renderHeaderVisualFeedback}
+ />
+ ))}
+ </SortableContext>
+ </tr>
+ ))}
+ </thead>
+ <tbody>
+ {paddingTop > 0 && (
+ <tr>
+ <td style={{ height: `${paddingTop}px` }} />
+ </tr>
+ )}
+ {virtualRows.length === 0 && !isLoading ? (
+ <tr>
+ <td colSpan={columns.length} className="h-24 text-center">
+ No results.
+ </td>
+ </tr>
+ ) : (
+ virtualRows.map((virtualRow) => {
+ const row = rows[virtualRow.index]
+
+ // --- Group Header Rendering ---
+ if (row.getIsGrouped()) {
+ const groupingColumnId = row.groupingColumnId ?? "";
+ const groupingValue = row.getGroupingValue(groupingColumnId);
+
+ return (
+ <tr
+ key={row.id}
+ className="hover:bg-muted/50 border-b bg-muted/30"
+ style={{ height: `${virtualRow.size}px` }}
+ >
+ <td
+ colSpan={columns.length}
+ className="px-4 py-2 text-left font-medium cursor-pointer"
+ onClick={row.getToggleExpandedHandler()}
+ >
+ <div className="flex items-center gap-2">
+ {row.getIsExpanded() ? (
+ <ChevronDown className="h-4 w-4" />
+ ) : (
+ <ChevronRight className="h-4 w-4" />
+ )}
+ <span className="flex items-center gap-2">
+ <span className="font-bold capitalize">
+ {groupingColumnId}:
+ </span>
+ <span>
+ {String(groupingValue)}
+ </span>
+ <span className="text-muted-foreground text-sm font-normal">
+ ({row.subRows.length})
+ </span>
+ </span>
+ </div>
+ </td>
+ </tr>
+ )
+ }
+
+ // --- Normal Row Rendering ---
+ return (
+ <tr
+ key={row.id}
+ className={cn(
+ "hover:bg-muted/50 border-b last:border-0",
+ getRowClassName ? getRowClassName(row.original, row.index) : "",
+ onRowClick ? "cursor-pointer" : ""
+ )}
+ style={{ height: `${virtualRow.size}px` }}
+ onClick={(e) => onRowClick?.(row, e)}
+ >
+ {row.getVisibleCells().map((cell) => {
+ // Handle pinned cells
+ const isPinned = cell.column.getIsPinned()
+ const isGrouped = cell.column.getIsGrouped()
+
+ const style: React.CSSProperties = {
+ width: cell.column.getSize(),
+ }
+ if (isPinned === "left") {
+ style.position = "sticky"
+ style.left = `${cell.column.getStart("left")}px`
+ style.zIndex = 20
+ } else if (isPinned === "right") {
+ style.position = "sticky"
+ style.right = `${cell.column.getAfter("right")}px`
+ style.zIndex = 20
+ }
+
+ return (
+ <td
+ key={cell.id}
+ className={cn(
+ "px-2 py-0 text-sm truncate border-b bg-background",
+ isGrouped ? "bg-muted/20" : ""
+ )}
+ style={style}
+ >
+ {cell.getIsGrouped() ? (
+ // If this cell is grouped, usually we don't render it here if we have a group header row,
+ // but if we keep it, it acts as the expander for the next level (if multi-level grouping).
+ // Since we used a full-width row for the group header, this branch might not be hit for the group row itself,
+ // but for nested groups it might?
+ // Wait, row.getIsGrouped() is true for the group row.
+ // The cells inside the group row are not rendered because we return early above.
+ // The cells inside the "leaf" rows (normal rows) are rendered here.
+ // So cell.getIsGrouped() checks if the COLUMN is currently grouped.
+ // If the column is grouped, the cell value is usually redundant or hidden in normal rows.
+ // Standard practice: hide the cell content or dim it.
+ null
+ ) : cell.getIsAggregated() ? (
+ // If this cell is an aggregation of the group
+ flexRender(
+ cell.column.columnDef.aggregatedCell ??
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )
+ ) : (
+ // Normal cell
+ cell.getIsPlaceholder()
+ ? null
+ : flexRender(cell.column.columnDef.cell, cell.getContext())
+ )}
+ </td>
+ )
+ })}
+ </tr>
+ )
+ })
+ )}
+ {paddingBottom > 0 && (
+ <tr>
+ <td style={{ height: `${paddingBottom}px` }} />
+ </tr>
+ )}
+ </tbody>
+ </table>
+ </DndContext>
+ </div>
+
+ {enablePagination && (
+ <ClientDataTablePagination table={table} />
+ )}
+ </div>
+ )
+}
+
+export const ClientVirtualTable = React.memo(
+ React.forwardRef(ClientVirtualTableInner)
+) as <TData, TValue>(
+ props: ClientVirtualTableProps<TData, TValue> & { ref?: React.Ref<Table<TData>> }
+) => React.ReactElement
diff --git a/components/client-table-v2/export-utils.ts b/components/client-table-v2/export-utils.ts
new file mode 100644
index 00000000..edcc8dff
--- /dev/null
+++ b/components/client-table-v2/export-utils.ts
@@ -0,0 +1,136 @@
+import { ColumnDef } from "@tanstack/react-table"
+import ExcelJS from "exceljs"
+import { saveAs } from "file-saver"
+
+export async function exportToExcel<TData>(
+ data: TData[],
+ columns: ColumnDef<TData, any>[],
+ filename: string = "export.xlsx"
+) {
+ const workbook = new ExcelJS.Workbook()
+ const worksheet = workbook.addWorksheet("Data")
+
+ // Filter out utility columns and resolve headers
+ const exportableColumns = columns.filter(
+ (col) =>
+ col.id !== "select" &&
+ col.id !== "actions" &&
+ // @ts-ignore - simple check for now
+ (typeof col.header === "string" || typeof col.accessorKey === "string")
+ )
+
+ // Setup columns
+ worksheet.columns = exportableColumns.map((col) => {
+ let headerText = ""
+ if (typeof col.header === "string") {
+ headerText = col.header
+ } else if (typeof col.accessorKey === "string") {
+ headerText = col.accessorKey
+ }
+
+ return {
+ header: headerText,
+ key: (col.accessorKey as string) || col.id,
+ width: 20,
+ }
+ })
+
+ // Add rows
+ data.forEach((row) => {
+ const rowData: any = {}
+ exportableColumns.forEach((col) => {
+ const key = (col.accessorKey as string) || col.id
+ if (key) {
+ const value = getValueByPath(row, key)
+ rowData[key] = value
+ }
+ })
+ worksheet.addRow(rowData)
+ })
+
+ worksheet.getRow(1).font = { bold: true }
+
+ const buffer = await workbook.xlsx.writeBuffer()
+ const blob = new Blob([buffer], {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ })
+ saveAs(blob, filename)
+}
+
+function getValueByPath(obj: any, path: string) {
+ return path.split('.').reduce((acc, part) => acc && acc[part], obj)
+}
+
+interface CreateTemplateOptions<TData> {
+ columns: ColumnDef<TData, any>[]
+ filename?: string
+ includeColumns?: { key: string; header: string }[]
+ excludeColumns?: string[] // accessorKey or id to exclude
+}
+
+export async function createExcelTemplate<TData>({
+ columns,
+ filename = "template.xlsx",
+ includeColumns = [],
+ excludeColumns = [],
+}: CreateTemplateOptions<TData>) {
+ const workbook = new ExcelJS.Workbook()
+ const worksheet = workbook.addWorksheet("Template")
+
+ // 1. Filter columns from definition
+ const baseColumns = columns.filter((col) => {
+ const key = (col.accessorKey as string) || col.id
+
+ // Skip system columns
+ if (col.id === "select" || col.id === "actions") return false
+
+ // Skip excluded columns
+ if (excludeColumns.includes(key!) || (col.id && excludeColumns.includes(col.id))) return false
+
+ return true
+ })
+
+ // 2. Map to ExcelJS columns
+ const excelColumns = baseColumns.map((col) => {
+ let headerText = ""
+ if (typeof col.header === "string") {
+ headerText = col.header
+ } else if (typeof col.accessorKey === "string") {
+ headerText = col.accessorKey
+ }
+
+ return {
+ header: headerText,
+ key: (col.accessorKey as string) || col.id,
+ width: 20
+ }
+ })
+
+ // 3. Add extra included columns
+ includeColumns.forEach((col) => {
+ excelColumns.push({
+ header: col.header,
+ key: col.key,
+ width: 20
+ })
+ })
+
+ worksheet.columns = excelColumns
+
+ // Style Header
+ const headerRow = worksheet.getRow(1)
+ headerRow.font = { bold: true }
+ headerRow.fill = {
+ type: 'pattern',
+ pattern: 'solid',
+ fgColor: { argb: 'FFD3D3D3' } // Light Gray
+ }
+
+ // Add Data Validation or Comments if needed (future expansion)
+
+ const buffer = await workbook.xlsx.writeBuffer()
+ const blob = new Blob([buffer], {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ })
+ saveAs(blob, filename)
+}
diff --git a/components/client-table-v2/import-utils.ts b/components/client-table-v2/import-utils.ts
new file mode 100644
index 00000000..bc7f4b44
--- /dev/null
+++ b/components/client-table-v2/import-utils.ts
@@ -0,0 +1,100 @@
+import ExcelJS from "exceljs"
+
+interface ImportExcelOptions {
+ file: File
+ /**
+ * Map Excel header names to data keys.
+ * Example: { "Name": "name", "Age": "age" }
+ */
+ columnMapping?: Record<string, string>
+ /**
+ * Row offset to start reading data (0-based).
+ * Default: 1 (assuming row 0 is header)
+ */
+ dataStartRow?: number
+}
+
+export interface ExcelImportResult<T = any> {
+ data: T[]
+ errors: string[]
+}
+
+/**
+ * Generic function to read an Excel file and convert it to an array of objects.
+ * Does NOT handle database insertion or validation logic.
+ */
+export async function importFromExcel<T = any>({
+ file,
+ columnMapping = {},
+ dataStartRow = 1,
+}: ImportExcelOptions): Promise<ExcelImportResult<T>> {
+ const workbook = new ExcelJS.Workbook()
+ const arrayBuffer = await file.arrayBuffer()
+
+ try {
+ await workbook.xlsx.load(arrayBuffer)
+ } catch (error) {
+ return { data: [], errors: ["Failed to parse Excel file. Please ensure it is a valid .xlsx file."] }
+ }
+
+ const worksheet = workbook.worksheets[0] // Read the first sheet
+ const data: T[] = []
+ const errors: string[] = []
+
+ if (!worksheet) {
+ return { data: [], errors: ["No worksheet found in the Excel file."] }
+ }
+
+ // 1. Read Header Row (assumed to be row 1 for mapping if no explicit mapping provided,
+ // or we can use it to validate mapping)
+ const headers: string[] = []
+ const headerRow = worksheet.getRow(1)
+ headerRow.eachCell((cell, colNumber) => {
+ headers[colNumber] = String(cell.value).trim()
+ })
+
+ // 2. Iterate Data Rows
+ worksheet.eachRow((row, rowNumber) => {
+ if (rowNumber <= dataStartRow) return // Skip header/pre-header rows
+
+ const rowData: any = {}
+ let hasData = false
+
+ row.eachCell((cell, colNumber) => {
+ const headerText = headers[colNumber]
+ if (!headerText) return
+
+ // Determine the key to use for this column
+ // Priority: Explicit mapping -> Header text as key
+ const key = columnMapping[headerText] || headerText
+
+ let cellValue = cell.value
+
+ // Handle ExcelJS object values (e.g. formula results, hyperlinks)
+ if (cellValue && typeof cellValue === 'object') {
+ if ('result' in cellValue) {
+ // Formula result
+ cellValue = (cellValue as any).result
+ } else if ('text' in cellValue) {
+ // Hyperlink text
+ cellValue = (cellValue as any).text
+ } else if ('richText' in cellValue) {
+ // Rich text
+ cellValue = (cellValue as any).richText.map((t: any) => t.text).join('')
+ }
+ }
+
+ if (cellValue !== null && cellValue !== undefined && String(cellValue).trim() !== '') {
+ hasData = true
+ rowData[key] = cellValue
+ }
+ })
+
+ if (hasData) {
+ data.push(rowData as T)
+ }
+ })
+
+ return { data, errors }
+}
+
diff --git a/components/client-table-v2/index.ts b/components/client-table-v2/index.ts
new file mode 100644
index 00000000..2b3a1645
--- /dev/null
+++ b/components/client-table-v2/index.ts
@@ -0,0 +1,7 @@
+export * from "./client-virtual-table"
+export * from "./client-table-column-header"
+export * from "./client-table-filter"
+export * from "./client-table-view-options"
+export * from "./export-utils"
+export * from "./import-utils"
+export * from "./types"
diff --git a/components/client-table-v2/preset-actions.ts b/components/client-table-v2/preset-actions.ts
new file mode 100644
index 00000000..0b8b3adb
--- /dev/null
+++ b/components/client-table-v2/preset-actions.ts
@@ -0,0 +1,87 @@
+"use server";
+
+import db from "@/db/db";
+import { userCustomData } from "@/db/schema/user-custom-data/userCustomData";
+import { eq, and } from "drizzle-orm";
+import { Preset, PresetRepository } from "./preset-types";
+
+// Drizzle Implementation of PresetRepository
+// This file acts as the concrete repository implementation.
+// To swap DBs, you would replace the logic here or create a new implementation file.
+
+export async function getPresets(tableKey: string, userId: number): Promise<{ success: boolean; data?: Preset[]; error?: string }> {
+ try {
+ const settings = await db
+ .select()
+ .from(userCustomData)
+ .where(
+ and(
+ eq(userCustomData.tableKey, tableKey),
+ eq(userCustomData.userId, userId)
+ )
+ )
+ .orderBy(userCustomData.createdDate);
+
+ // Map DB entity to domain model
+ const data: Preset[] = settings.map(s => ({
+ id: s.id,
+ name: s.customSettingName,
+ setting: s.customSetting,
+ createdAt: s.createdDate,
+ updatedAt: s.updatedDate,
+ }));
+
+ return { success: true, data };
+ } catch (error) {
+ console.error("Failed to fetch presets:", error);
+ return { success: false, error: "Failed to fetch presets" };
+ }
+}
+
+export async function savePreset(
+ userId: number,
+ tableKey: string,
+ name: string,
+ setting: any
+): Promise<{ success: boolean; error?: string }> {
+ try {
+ const existing = await db.query.userCustomData.findFirst({
+ where: and(
+ eq(userCustomData.userId, userId),
+ eq(userCustomData.tableKey, tableKey),
+ eq(userCustomData.customSettingName, name)
+ )
+ });
+
+ if (existing) {
+ await db.update(userCustomData)
+ .set({
+ customSetting: setting,
+ updatedDate: new Date()
+ })
+ .where(eq(userCustomData.id, existing.id));
+ } else {
+ await db.insert(userCustomData).values({
+ userId,
+ tableKey,
+ customSettingName: name,
+ customSetting: setting,
+ });
+ }
+
+ return { success: true };
+ } catch (error) {
+ console.error("Failed to save preset:", error);
+ return { success: false, error: "Failed to save preset" };
+ }
+}
+
+export async function deletePreset(id: string): Promise<{ success: boolean; error?: string }> {
+ try {
+ await db.delete(userCustomData).where(eq(userCustomData.id, id));
+ return { success: true };
+ } catch (error) {
+ console.error("Failed to delete preset:", error);
+ return { success: false, error: "Failed to delete preset" };
+ }
+}
diff --git a/components/client-table-v2/preset-types.ts b/components/client-table-v2/preset-types.ts
new file mode 100644
index 00000000..072d918b
--- /dev/null
+++ b/components/client-table-v2/preset-types.ts
@@ -0,0 +1,13 @@
+export interface Preset {
+ id: string;
+ name: string;
+ setting: any; // JSON object for table state
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+export interface PresetRepository {
+ getPresets(tableKey: string, userId: number): Promise<{ success: boolean; data?: Preset[]; error?: string }>;
+ savePreset(userId: number, tableKey: string, name: string, setting: any): Promise<{ success: boolean; error?: string }>;
+ deletePreset(id: string): Promise<{ success: boolean; error?: string }>;
+}
diff --git a/components/client-table-v2/types.ts b/components/client-table-v2/types.ts
new file mode 100644
index 00000000..b0752bfa
--- /dev/null
+++ b/components/client-table-v2/types.ts
@@ -0,0 +1,11 @@
+import { ColumnDef, RowData } from "@tanstack/react-table"
+
+export interface ClientTableColumnMeta {
+ filterType?: "text" | "select" | "boolean"
+ filterOptions?: { label: string; value: string }[]
+}
+
+// Use this type instead of generic ColumnDef to get intellisense for 'meta'
+export type ClientTableColumnDef<TData extends RowData, TValue = unknown> = ColumnDef<TData, TValue> & {
+ meta?: ClientTableColumnMeta
+}
diff --git a/components/client-table/README.md b/components/client-table/README.md
new file mode 100644
index 00000000..053175d4
--- /dev/null
+++ b/components/client-table/README.md
@@ -0,0 +1,159 @@
+# Client Table Components
+
+A set of reusable, virtualized table components for client-side data rendering, built on top of `@tanstack/react-table` and `@tanstack/react-virtual`.
+
+## Features
+
+- **Virtualization**: Efficiently renders large datasets (50,000+ rows) by only rendering visible rows.
+- **Sorting**: Built-in column sorting (Ascending/Descending).
+- **Filtering**:
+ - Global search (all columns).
+ - Column-specific filters: Text (default), Select, Boolean.
+- **Pagination**: Supports both client-side and server-side (manual) pagination.
+- **Column Management**:
+ - **Reordering**: Drag and drop columns to change order.
+ - **Hiding**: Right-click header to hide columns.
+ - **Pinning**: Right-click header to pin columns (Left/Right).
+- **Excel Export**: Export current table view or custom datasets to `.xlsx`.
+- **Excel Import**: Utility to parse Excel files into JSON objects.
+- **Template Generation**: Create Excel templates for users to fill out and import.
+
+## Installation / Usage
+
+The components are located in `@/components/client-table`.
+
+### 1. Basic Usage
+
+```tsx
+import { ClientVirtualTable, ClientTableColumnDef } from "@/components/client-table"
+
+// 1. Define Data Type
+interface User {
+ id: string
+ name: string
+ role: string
+ active: boolean
+}
+
+// 2. Define Columns
+const columns: ClientTableColumnDef<User>[] = [
+ {
+ accessorKey: "name",
+ header: "Name",
+ // Default filter is text
+ },
+ {
+ accessorKey: "role",
+ header: "Role",
+ meta: {
+ filterType: "select",
+ filterOptions: [
+ { label: "Admin", value: "admin" },
+ { label: "User", value: "user" },
+ ]
+ }
+ },
+ {
+ accessorKey: "active",
+ header: "Active",
+ meta: {
+ filterType: "boolean"
+ }
+ }
+]
+
+// 3. Render Component
+export default function UserTable({ data }: { data: User[] }) {
+ return (
+ <ClientVirtualTable
+ data={data}
+ columns={columns}
+ height="600px" // Required for virtualization
+ enableExport={true} // Shows export button
+ enablePagination={true} // Shows pagination footer
+ />
+ )
+}
+```
+
+### 2. Server-Side Pagination
+
+For very large datasets where you don't want to fetch everything at once.
+
+```tsx
+<ClientVirtualTable
+ data={currentData} // Only the current page data
+ columns={columns}
+ manualPagination={true}
+ pageCount={totalPages}
+ rowCount={totalRows}
+ pagination={{ pageIndex, pageSize }}
+ onPaginationChange={setPaginationState}
+ enablePagination={true}
+/>
+```
+
+### 3. Excel Utilities
+
+#### Exporting Data
+
+Automatic export is available via the `enableExport` prop. For custom export logic:
+
+```tsx
+import { exportToExcel } from "@/components/client-table"
+
+await exportToExcel(data, columns, "my-data.xlsx")
+```
+
+#### Creating Import Templates
+
+Generate a blank Excel file with headers for users to fill in.
+
+```tsx
+import { createExcelTemplate } from "@/components/client-table"
+
+await createExcelTemplate({
+ columns,
+ filename: "user-import-template.xlsx",
+ excludeColumns: ["id", "createdAt"], // Columns to skip
+ includeColumns: [{ key: "notes", header: "Notes" }] // Extra columns
+})
+```
+
+#### Importing Data
+
+Parses an uploaded Excel file into a raw JSON array. Does **not** handle validation or DB insertion.
+
+```tsx
+import { importFromExcel } from "@/components/client-table"
+
+const handleFileUpload = async (file: File) => {
+ const { data, errors } = await importFromExcel({
+ file,
+ columnMapping: { "Name": "name", "Role": "role" } // Optional header mapping
+ })
+
+ if (errors.length > 0) {
+ console.error(errors)
+ return
+ }
+
+ // Send `data` to your API/Service for validation and insertion
+ await saveUsers(data)
+}
+```
+
+## Types
+
+We use a custom column definition type to support our extended `meta` properties.
+
+```typescript
+import { ClientTableColumnDef } from "@/components/client-table"
+
+const columns: ClientTableColumnDef<MyData>[] = [ ... ]
+```
+
+Supported `meta` properties:
+
+- `filterType`: `"text" | "select" | "boolean"`
+- `filterOptions`: `{ label: string; value: string }[]` (Required for `select`)
diff --git a/components/client-table/client-table-column-header.tsx b/components/client-table/client-table-column-header.tsx
new file mode 100644
index 00000000..2d8e5bce
--- /dev/null
+++ b/components/client-table/client-table-column-header.tsx
@@ -0,0 +1,235 @@
+"use client"
+
+import * as React from "react"
+import { Header, Column } from "@tanstack/react-table"
+import { useSortable } from "@dnd-kit/sortable"
+import { CSS } from "@dnd-kit/utilities"
+import { flexRender } from "@tanstack/react-table"
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuSeparator,
+ ContextMenuTrigger,
+} from "@/components/ui/context-menu"
+import {
+ ArrowDown,
+ ArrowUp,
+ ChevronsUpDown,
+ EyeOff,
+ PinOff,
+ MoveLeft,
+ MoveRight,
+ Group,
+ Ungroup,
+} from "lucide-react"
+import { cn } from "@/lib/utils"
+import { ClientTableFilter } from "../client-table/client-table-filter"
+
+interface ClientTableColumnHeaderProps<TData, TValue>
+ extends React.HTMLAttributes<HTMLTableHeaderCellElement> {
+ header: Header<TData, TValue>
+ enableReordering?: boolean
+ renderHeaderVisualFeedback?: (props: {
+ column: Column<TData, TValue>
+ isPinned: boolean | string
+ isSorted: boolean | string
+ isFiltered: boolean
+ isGrouped: boolean
+ }) => React.ReactNode
+}
+
+export function ClientTableColumnHeader<TData, TValue>({
+ header,
+ enableReordering = true,
+ renderHeaderVisualFeedback,
+ className,
+ ...props
+}: ClientTableColumnHeaderProps<TData, TValue>) {
+ const column = header.column
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({
+ id: header.id,
+ disabled: !enableReordering || column.getIsResizing(),
+ })
+
+ // -- Styles --
+ const style: React.CSSProperties = {
+ // Apply transform only if reordering is enabled and active
+ transform: enableReordering ? CSS.Translate.toString(transform) : undefined,
+ transition: enableReordering ? transition : undefined,
+ width: header.getSize(),
+ zIndex: isDragging ? 100 : 0,
+ position: "relative",
+ ...props.style,
+ }
+
+ // Pinning Styles
+ const isPinned = column.getIsPinned()
+ const isSorted = column.getIsSorted()
+ const isFiltered = column.getFilterValue() !== undefined
+ const isGrouped = column.getIsGrouped()
+
+ if (isPinned === "left") {
+ style.left = `${column.getStart("left")}px`
+ style.position = "sticky"
+ style.zIndex = 30 // Pinned columns needs to be higher than normal headers
+ } else if (isPinned === "right") {
+ style.right = `${column.getAfter("right")}px`
+ style.position = "sticky"
+ style.zIndex = 30 // Pinned columns needs to be higher than normal headers
+ }
+
+ // -- Handlers --
+ const handleHide = () => column.toggleVisibility(false)
+ const handlePinLeft = () => column.pin("left")
+ const handlePinRight = () => column.pin("right")
+ const handleUnpin = () => column.pin(false)
+ const handleToggleGrouping = () => column.toggleGrouping()
+
+ // -- Content --
+ const content = (
+ <>
+ <div
+ className={cn(
+ "flex items-center gap-2",
+ column.getCanSort() ? "cursor-pointer select-none" : ""
+ )}
+ onClick={column.getToggleSortingHandler()}
+ >
+ {flexRender(column.columnDef.header, header.getContext())}
+ {column.getCanSort() && (
+ <span className="flex items-center">
+ {column.getIsSorted() === "desc" ? (
+ <ArrowDown className="h-4 w-4" />
+ ) : column.getIsSorted() === "asc" ? (
+ <ArrowUp className="h-4 w-4" />
+ ) : (
+ <ChevronsUpDown className="h-4 w-4 opacity-50" />
+ )}
+ </span>
+ )}
+ {isGrouped && <Group className="h-4 w-4 text-blue-500" />}
+ </div>
+
+ {/* Resize Handle */}
+ <div
+ onMouseDown={header.getResizeHandler()}
+ onTouchStart={header.getResizeHandler()}
+ onPointerDown={(e) => e.stopPropagation()}
+ onClick={(e) => e.stopPropagation()} // Prevent sort trigger
+ className={cn(
+ "absolute right-0 top-0 h-full w-2 cursor-col-resize select-none touch-none z-10",
+ "after:absolute after:right-0 after:top-0 after:h-full after:w-[1px] after:bg-border", // 시각적 구분선
+ "hover:bg-primary/20 hover:w-4 hover:-right-2", // 호버 시 클릭 영역 확장
+ header.column.getIsResizing() ? "bg-primary/50 w-1" : "bg-transparent"
+ )}
+ />
+
+ {/* Filter */}
+ {column.getCanFilter() && <ClientTableFilter column={column} />}
+
+ {/* Visual Feedback Indicators */}
+ {renderHeaderVisualFeedback ? (
+ renderHeaderVisualFeedback({
+ column,
+ isPinned,
+ isSorted,
+ isFiltered,
+ isGrouped,
+ })
+ ) : (
+ (isPinned || isFiltered || isGrouped) && (
+ <div className="absolute top-0.5 right-1 flex gap-1 z-10 pointer-events-none">
+ {isPinned && <div className="h-1.5 w-1.5 rounded-full bg-blue-500" />}
+ {isFiltered && <div className="h-1.5 w-1.5 rounded-full bg-yellow-500" />}
+ {isGrouped && <div className="h-1.5 w-1.5 rounded-full bg-green-500" />}
+ </div>
+ )
+ )}
+ </>
+ )
+
+ if (header.isPlaceholder) {
+ return (
+ <th
+ colSpan={header.colSpan}
+ style={style}
+ className={cn("border-b px-4 py-2 text-left text-sm font-medium bg-muted", className)}
+ {...props}
+ >
+ {null}
+ </th>
+ )
+ }
+
+ return (
+ <ContextMenu>
+ <ContextMenuTrigger asChild>
+ <th
+ ref={setNodeRef}
+ colSpan={header.colSpan}
+ style={style}
+ className={cn(
+ "border-b px-4 py-2 text-left text-sm font-medium bg-muted group transition-colors",
+ isDragging ? "opacity-50 bg-accent" : "",
+ isPinned ? "shadow-[0_0_10px_rgba(0,0,0,0.1)]" : "",
+ className
+ )}
+ {...attributes}
+ {...listeners}
+ {...props}
+ >
+ {content}
+ </th>
+ </ContextMenuTrigger>
+ <ContextMenuContent className="w-48">
+ <ContextMenuItem onClick={handleHide}>
+ <EyeOff className="mr-2 h-4 w-4" />
+ Hide Column
+ </ContextMenuItem>
+
+ {column.getCanGroup() && (
+ <>
+ <ContextMenuSeparator />
+ <ContextMenuItem onClick={handleToggleGrouping}>
+ {isGrouped ? (
+ <>
+ <Ungroup className="mr-2 h-4 w-4" />
+ Ungroup
+ </>
+ ) : (
+ <>
+ <Group className="mr-2 h-4 w-4" />
+ Group by {column.id}
+ </>
+ )}
+ </ContextMenuItem>
+ </>
+ )}
+
+ <ContextMenuSeparator />
+ <ContextMenuItem onClick={handlePinLeft}>
+ <MoveLeft className="mr-2 h-4 w-4" />
+ Pin Left
+ </ContextMenuItem>
+ <ContextMenuItem onClick={handlePinRight}>
+ <MoveRight className="mr-2 h-4 w-4" />
+ Pin Right
+ </ContextMenuItem>
+ {isPinned && (
+ <ContextMenuItem onClick={handleUnpin}>
+ <PinOff className="mr-2 h-4 w-4" />
+ Unpin
+ </ContextMenuItem>
+ )}
+ </ContextMenuContent>
+ </ContextMenu>
+ )
+}
diff --git a/components/client-table/client-table-filter.tsx b/components/client-table/client-table-filter.tsx
new file mode 100644
index 00000000..138f77eb
--- /dev/null
+++ b/components/client-table/client-table-filter.tsx
@@ -0,0 +1,101 @@
+"use client"
+
+import * as React from "react"
+import { Column } from "@tanstack/react-table"
+import { Input } from "@/components/ui/input"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { ClientTableColumnMeta } from "./types"
+
+interface ClientTableFilterProps<TData, TValue> {
+ column: Column<TData, TValue>
+}
+
+export function ClientTableFilter<TData, TValue>({
+ column,
+}: ClientTableFilterProps<TData, TValue>) {
+ const columnFilterValue = column.getFilterValue()
+ // Cast meta to our local type
+ const meta = column.columnDef.meta as ClientTableColumnMeta | undefined
+
+ // Handle Boolean Filter
+ if (meta?.filterType === "boolean") {
+ return (
+ <div onClick={(e) => e.stopPropagation()} className="mt-2">
+ <Select
+ value={(columnFilterValue as string) ?? "all"}
+ onValueChange={(value) =>
+ column.setFilterValue(value === "all" ? undefined : value === "true")
+ }
+ >
+ <SelectTrigger className="h-8 w-full">
+ <SelectValue placeholder="All" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="all">All</SelectItem>
+ <SelectItem value="true">Yes</SelectItem>
+ <SelectItem value="false">No</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ )
+ }
+
+ // Handle Select Filter (for specific options)
+ if (meta?.filterType === "select" && meta.filterOptions) {
+ return (
+ <div onClick={(e) => e.stopPropagation()} className="mt-2">
+ <Select
+ value={(columnFilterValue as string) ?? "all"}
+ onValueChange={(value) =>
+ column.setFilterValue(value === "all" ? undefined : value)
+ }
+ >
+ <SelectTrigger className="h-8 w-full">
+ <SelectValue placeholder="All" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="all">All</SelectItem>
+ {meta.filterOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ )
+ }
+
+ // Default Text Filter
+ const [value, setValue] = React.useState(columnFilterValue)
+
+ React.useEffect(() => {
+ setValue(columnFilterValue)
+ }, [columnFilterValue])
+
+ React.useEffect(() => {
+ const timeout = setTimeout(() => {
+ column.setFilterValue(value)
+ }, 500)
+
+ return () => clearTimeout(timeout)
+ }, [value, column])
+
+ return (
+ <div onClick={(e) => e.stopPropagation()} className="mt-2">
+ <Input
+ type="text"
+ value={(value ?? "") as string}
+ onChange={(e) => setValue(e.target.value)}
+ placeholder="Search..."
+ className="h-8 w-full font-normal bg-background"
+ />
+ </div>
+ )
+}
diff --git a/components/client-table/client-table-preset.tsx b/components/client-table/client-table-preset.tsx
new file mode 100644
index 00000000..64930e7a
--- /dev/null
+++ b/components/client-table/client-table-preset.tsx
@@ -0,0 +1,185 @@
+"use client";
+
+import * as React from "react";
+import { Table } from "@tanstack/react-table";
+import { useSession } from "next-auth/react";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Bookmark, Save, Trash2 } from "lucide-react";
+import {
+ getPresets,
+ savePreset,
+ deletePreset,
+} from "./preset-actions";
+import { Preset } from "./preset-types";
+import { toast } from "sonner";
+
+interface ClientTablePresetProps<TData> {
+ table: Table<TData>;
+ tableKey: string;
+}
+
+export function ClientTablePreset<TData>({
+ table,
+ tableKey,
+}: ClientTablePresetProps<TData>) {
+ const { data: session } = useSession();
+ const [savedPresets, setSavedPresets] = React.useState<Preset[]>([]);
+ const [isPresetDialogOpen, setIsPresetDialogOpen] = React.useState(false);
+ const [newPresetName, setNewPresetName] = React.useState("");
+ const [isLoading, setIsLoading] = React.useState(false);
+
+ const fetchSettings = React.useCallback(async () => {
+ const userIdVal = session?.user?.id;
+ if (!userIdVal) return;
+
+ const userId = Number(userIdVal);
+ if (isNaN(userId)) return;
+
+ const res = await getPresets(tableKey, userId);
+ if (res.success && res.data) {
+ setSavedPresets(res.data);
+ }
+ }, [session, tableKey]);
+
+ React.useEffect(() => {
+ if (session) {
+ fetchSettings();
+ }
+ }, [fetchSettings, session]);
+
+ const handleSavePreset = async () => {
+ const userIdVal = session?.user?.id;
+ if (!newPresetName.trim() || !userIdVal) return;
+ const userId = Number(userIdVal);
+ if (isNaN(userId)) return;
+
+ setIsLoading(true);
+ const state = table.getState();
+ const settingToSave = {
+ sorting: state.sorting,
+ columnFilters: state.columnFilters,
+ globalFilter: state.globalFilter,
+ columnVisibility: state.columnVisibility,
+ columnPinning: state.columnPinning,
+ columnOrder: state.columnOrder,
+ grouping: state.grouping,
+ pagination: { pageSize: state.pagination.pageSize },
+ };
+
+ const res = await savePreset(userId, tableKey, newPresetName, settingToSave);
+ setIsLoading(false);
+
+ if (res.success) {
+ toast.success("Preset saved successfully");
+ setIsPresetDialogOpen(false);
+ setNewPresetName("");
+ fetchSettings();
+ } else {
+ toast.error("Failed to save preset");
+ }
+ };
+
+ const handleLoadPreset = (preset: Preset) => {
+ const s = preset.setting as Record<string, any>;
+ if (!s) return;
+
+ if (s.sorting) table.setSorting(s.sorting);
+ if (s.columnFilters) table.setColumnFilters(s.columnFilters);
+ if (s.globalFilter !== undefined) table.setGlobalFilter(s.globalFilter);
+ if (s.columnVisibility) table.setColumnVisibility(s.columnVisibility);
+ if (s.columnPinning) table.setColumnPinning(s.columnPinning);
+ if (s.columnOrder) table.setColumnOrder(s.columnOrder);
+ if (s.grouping) table.setGrouping(s.grouping);
+ if (s.pagination?.pageSize) table.setPageSize(s.pagination.pageSize);
+
+ toast.success(`Preset "${preset.name}" loaded`);
+ };
+
+ const handleDeletePreset = async (e: React.MouseEvent, id: string) => {
+ e.stopPropagation();
+ if (!confirm("Are you sure you want to delete this preset?")) return;
+
+ const res = await deletePreset(id);
+ if (res.success) {
+ toast.success("Preset deleted");
+ fetchSettings();
+ } else {
+ toast.error("Failed to delete preset");
+ }
+ };
+
+ if (!session) return null;
+
+ return (
+ <>
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="outline" size="sm" className="ml-2 hidden h-8 lg:flex">
+ <Bookmark className="mr-2 h-4 w-4" />
+ Presets
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-[200px]">
+ <DropdownMenuLabel>Saved Presets</DropdownMenuLabel>
+ <DropdownMenuSeparator />
+ {savedPresets.length === 0 ? (
+ <div className="p-2 text-sm text-muted-foreground text-center">No saved presets</div>
+ ) : (
+ savedPresets.map((preset) => (
+ <DropdownMenuItem key={preset.id} onClick={() => handleLoadPreset(preset)} className="flex justify-between cursor-pointer">
+ <span className="truncate flex-1">{preset.name}</span>
+ <Button variant="ghost" size="icon" className="h-4 w-4" onClick={(e) => handleDeletePreset(e, preset.id)}>
+ <Trash2 className="h-3 w-3 text-destructive" />
+ </Button>
+ </DropdownMenuItem>
+ ))
+ )}
+ <DropdownMenuSeparator />
+ <DropdownMenuItem onClick={() => setIsPresetDialogOpen(true)} className="cursor-pointer">
+ <Save className="mr-2 h-4 w-4" />
+ Save Current Preset
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+
+ <Dialog open={isPresetDialogOpen} onOpenChange={setIsPresetDialogOpen}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Save Preset</DialogTitle>
+ <DialogDescription>
+ Save the current table configuration as a preset.
+ </DialogDescription>
+ </DialogHeader>
+ <div className="grid gap-4 py-4">
+ <Input
+ placeholder="Preset Name"
+ value={newPresetName}
+ onChange={(e) => setNewPresetName(e.target.value)}
+ />
+ </div>
+ <DialogFooter>
+ <Button variant="outline" onClick={() => setIsPresetDialogOpen(false)}>Cancel</Button>
+ <Button onClick={handleSavePreset} disabled={isLoading}>Save</Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </>
+ );
+}
diff --git a/components/client-table/client-table-save-view.tsx b/components/client-table/client-table-save-view.tsx
new file mode 100644
index 00000000..73935d00
--- /dev/null
+++ b/components/client-table/client-table-save-view.tsx
@@ -0,0 +1,185 @@
+"use client";
+
+import * as React from "react";
+import { Table } from "@tanstack/react-table";
+import { useSession } from "next-auth/react";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Bookmark, Save, Trash2 } from "lucide-react";
+import {
+ getUserCustomSettings,
+ saveUserCustomSetting,
+ deleteUserCustomSetting,
+} from "@/actions/user-custom-data";
+import { toast } from "sonner";
+
+interface ClientTableSaveViewProps<TData> {
+ table: Table<TData>;
+ tableKey: string;
+}
+
+export function ClientTableSaveView<TData>({
+ table,
+ tableKey,
+}: ClientTableSaveViewProps<TData>) {
+ const { data: session } = useSession();
+ const [savedViews, setSavedViews] = React.useState<{ id: string; customSettingName: string; customSetting: Record<string, any> }[]>([]);
+ const [isSaveDialogOpen, setIsSaveDialogOpen] = React.useState(false);
+ const [newViewName, setNewViewName] = React.useState("");
+ const [isLoading, setIsLoading] = React.useState(false);
+
+ const fetchSettings = React.useCallback(async () => {
+ const userIdVal = session?.user?.id;
+ if (!userIdVal) return;
+
+ const userId = Number(userIdVal);
+ if (isNaN(userId)) return;
+
+ const res = await getUserCustomSettings(tableKey, userId);
+ if (res.success && res.data) {
+ // @ts-ignore - data from DB might need casting
+ setSavedViews(res.data);
+ }
+ }, [session, tableKey]);
+
+ React.useEffect(() => {
+ if (session) {
+ fetchSettings();
+ }
+ }, [fetchSettings, session]);
+
+ const handleSaveView = async () => {
+ const userIdVal = session?.user?.id;
+ if (!newViewName.trim() || !userIdVal) return;
+ const userId = Number(userIdVal);
+ if (isNaN(userId)) return;
+
+ setIsLoading(true);
+ const state = table.getState();
+ const settingToSave = {
+ sorting: state.sorting,
+ columnFilters: state.columnFilters,
+ globalFilter: state.globalFilter,
+ columnVisibility: state.columnVisibility,
+ columnPinning: state.columnPinning,
+ columnOrder: state.columnOrder,
+ grouping: state.grouping,
+ pagination: { pageSize: state.pagination.pageSize },
+ };
+
+ const res = await saveUserCustomSetting(userId, tableKey, newViewName, settingToSave);
+ setIsLoading(false);
+
+ if (res.success) {
+ toast.success("View saved successfully");
+ setIsSaveDialogOpen(false);
+ setNewViewName("");
+ fetchSettings();
+ } else {
+ toast.error("Failed to save view");
+ }
+ };
+
+ const handleLoadView = (setting: { customSetting: Record<string, any> | unknown; customSettingName: string }) => {
+ const s = setting.customSetting as Record<string, any>;
+ if (!s) return;
+
+ if (s.sorting) table.setSorting(s.sorting);
+ if (s.columnFilters) table.setColumnFilters(s.columnFilters);
+ if (s.globalFilter !== undefined) table.setGlobalFilter(s.globalFilter);
+ if (s.columnVisibility) table.setColumnVisibility(s.columnVisibility);
+ if (s.columnPinning) table.setColumnPinning(s.columnPinning);
+ if (s.columnOrder) table.setColumnOrder(s.columnOrder);
+ if (s.grouping) table.setGrouping(s.grouping);
+ if (s.pagination?.pageSize) table.setPageSize(s.pagination.pageSize);
+
+ toast.success(`View "${setting.customSettingName}" loaded`);
+ };
+
+ const handleDeleteView = async (e: React.MouseEvent, id: string) => {
+ e.stopPropagation();
+ if (!confirm("Are you sure you want to delete this view?")) return;
+
+ const res = await deleteUserCustomSetting(id);
+ if (res.success) {
+ toast.success("View deleted");
+ fetchSettings();
+ } else {
+ toast.error("Failed to delete view");
+ }
+ };
+
+ if (!session) return null;
+
+ return (
+ <>
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="outline" size="sm" className="ml-2 hidden h-8 lg:flex">
+ <Bookmark className="mr-2 h-4 w-4" />
+ Views
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-[200px]">
+ <DropdownMenuLabel>Saved Views</DropdownMenuLabel>
+ <DropdownMenuSeparator />
+ {savedViews.length === 0 ? (
+ <div className="p-2 text-sm text-muted-foreground text-center">No saved views</div>
+ ) : (
+ savedViews.map((view) => (
+ <DropdownMenuItem key={view.id} onClick={() => handleLoadView(view)} className="flex justify-between cursor-pointer">
+ <span className="truncate flex-1">{view.customSettingName}</span>
+ <Button variant="ghost" size="icon" className="h-4 w-4" onClick={(e) => handleDeleteView(e, view.id)}>
+ <Trash2 className="h-3 w-3 text-destructive" />
+ </Button>
+ </DropdownMenuItem>
+ ))
+ )}
+ <DropdownMenuSeparator />
+ <DropdownMenuItem onClick={() => setIsSaveDialogOpen(true)} className="cursor-pointer">
+ <Save className="mr-2 h-4 w-4" />
+ Save Current View
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+
+ <Dialog open={isSaveDialogOpen} onOpenChange={setIsSaveDialogOpen}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Save View</DialogTitle>
+ <DialogDescription>
+ Save the current table configuration as a preset.
+ </DialogDescription>
+ </DialogHeader>
+ <div className="grid gap-4 py-4">
+ <Input
+ placeholder="View Name"
+ value={newViewName}
+ onChange={(e) => setNewViewName(e.target.value)}
+ />
+ </div>
+ <DialogFooter>
+ <Button variant="outline" onClick={() => setIsSaveDialogOpen(false)}>Cancel</Button>
+ <Button onClick={handleSaveView} disabled={isLoading}>Save</Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </>
+ );
+}
diff --git a/components/client-table/client-table-toolbar.tsx b/components/client-table/client-table-toolbar.tsx
new file mode 100644
index 00000000..089501e1
--- /dev/null
+++ b/components/client-table/client-table-toolbar.tsx
@@ -0,0 +1,59 @@
+"use client"
+
+import * as React from "react"
+import { Search, Download } from "lucide-react"
+import { Input } from "@/components/ui/input"
+import { Button } from "@/components/ui/button"
+
+interface ClientTableToolbarProps {
+ globalFilter: string
+ setGlobalFilter: (value: string) => void
+ totalRows: number
+ visibleRows: number
+ onExport?: () => void
+ actions?: React.ReactNode
+ customToolbar?: React.ReactNode
+ viewOptions?: React.ReactNode
+}
+
+export function ClientTableToolbar({
+ globalFilter,
+ setGlobalFilter,
+ totalRows,
+ visibleRows,
+ onExport,
+ actions,
+ customToolbar,
+ viewOptions,
+}: ClientTableToolbarProps) {
+ return (
+ <div className="flex w-full items-center justify-between gap-4 p-1 overflow-x-auto">
+ <div className="flex items-center gap-2">
+ <div className="relative max-w-sm min-w-[200px]">
+ <Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
+ <Input
+ placeholder="Search all columns..."
+ value={globalFilter ?? ""}
+ onChange={(e) => setGlobalFilter(e.target.value)}
+ className="pl-8"
+ />
+ </div>
+ <div className="text-sm text-muted-foreground whitespace-nowrap">
+ Showing {visibleRows} of {totalRows}
+ </div>
+ {viewOptions}
+ {onExport && (
+ <Button onClick={onExport} variant="outline" size="sm">
+ <Download className="mr-2 h-4 w-4" />
+ Export
+ </Button>
+ )}
+ </div>
+
+ <div className="flex items-center gap-2 shrink-0">
+ {customToolbar}
+ {actions}
+ </div>
+ </div>
+ )
+}
diff --git a/components/client-table/client-table-view-options.tsx b/components/client-table/client-table-view-options.tsx
new file mode 100644
index 00000000..3b659fcd
--- /dev/null
+++ b/components/client-table/client-table-view-options.tsx
@@ -0,0 +1,67 @@
+"use client"
+
+import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu"
+import { MixerHorizontalIcon } from "@radix-ui/react-icons"
+import { Table } from "@tanstack/react-table"
+
+import { Button } from "@/components/ui/button"
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+} from "@/components/ui/dropdown-menu"
+
+interface ClientTableViewOptionsProps<TData> {
+ table: Table<TData>
+}
+
+export function ClientTableViewOptions<TData>({
+ table,
+}: ClientTableViewOptionsProps<TData>) {
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ className="ml-auto hidden h-8 lg:flex"
+ >
+ <MixerHorizontalIcon className="mr-2 h-4 w-4" />
+ View
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-[150px]">
+ <DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
+ <DropdownMenuSeparator />
+ {table
+ .getAllLeafColumns()
+ .filter(
+ (column) =>
+ typeof column.accessorFn !== "undefined" && column.getCanHide()
+ )
+ .map((column) => {
+ const header = column.columnDef.header
+ let label = column.id
+ if (typeof header === "string") {
+ label = header
+ }
+
+ return (
+ <DropdownMenuCheckboxItem
+ key={column.id}
+ className="capitalize"
+ checked={column.getIsVisible()}
+ onCheckedChange={(value) => column.toggleVisibility(!!value)}
+ onSelect={(e) => e.preventDefault()} // default action close the select menu.
+ >
+ {label}
+ </DropdownMenuCheckboxItem>
+ )
+ })}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+}
+
diff --git a/components/client-table/client-virtual-table.tsx b/components/client-table/client-virtual-table.tsx
new file mode 100644
index 00000000..507057c7
--- /dev/null
+++ b/components/client-table/client-virtual-table.tsx
@@ -0,0 +1,609 @@
+"use client"
+
+import * as React from "react"
+import { rankItem } from "@tanstack/match-sorter-utils"
+import {
+ useReactTable,
+ getCoreRowModel,
+ getSortedRowModel,
+ getFilteredRowModel,
+ getPaginationRowModel,
+ getGroupedRowModel,
+ getExpandedRowModel,
+ getFacetedRowModel,
+ getFacetedUniqueValues,
+ getFacetedMinMaxValues,
+ ColumnDef,
+ SortingState,
+ ColumnFiltersState,
+ flexRender,
+ PaginationState,
+ OnChangeFn,
+ ColumnOrderState,
+ VisibilityState,
+ ColumnPinningState,
+ FilterFn,
+ Table,
+ RowSelectionState,
+ Row,
+ Column,
+ GroupingState,
+ ExpandedState,
+} from "@tanstack/react-table"
+import { useVirtualizer } from "@tanstack/react-virtual"
+import {
+ DndContext,
+ closestCenter,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors,
+ DragEndEvent,
+} from "@dnd-kit/core"
+import {
+ arrayMove,
+ SortableContext,
+ horizontalListSortingStrategy,
+} from "@dnd-kit/sortable"
+import { cn } from "@/lib/utils"
+import { Loader2, ChevronRight, ChevronDown } from "lucide-react"
+
+import { ClientTableToolbar } from "../client-table/client-table-toolbar"
+import { exportToExcel } from "../client-table/export-utils"
+import { ClientDataTablePagination } from "@/components/client-data-table/data-table-pagination"
+import { ClientTableColumnHeader } from "./client-table-column-header"
+import { ClientTableViewOptions } from "../client-table/client-table-view-options"
+import { ClientTablePreset } from "./client-table-preset"
+
+// Moved outside for stability (Performance Optimization)
+const fuzzyFilter: FilterFn<any> = (row, columnId, value, addMeta) => {
+ const itemRank = rankItem(row.getValue(columnId), value)
+ addMeta({ itemRank })
+ return itemRank.passed
+}
+
+export interface ClientVirtualTableProps<TData, TValue> {
+ data: TData[]
+ columns: ColumnDef<TData, TValue>[]
+ height?: string | number
+ estimateRowHeight?: number
+ className?: string
+ actions?: React.ReactNode
+ customToolbar?: React.ReactNode
+ enableExport?: boolean
+ onExport?: (data: TData[]) => void
+ isLoading?: boolean
+
+ // --- User Preset Saving ---
+ enableUserPreset?: boolean
+ tableKey?: string
+
+ // --- State Control (Controlled or Uncontrolled) ---
+
+ // Pagination
+ enablePagination?: boolean
+ manualPagination?: boolean
+ pageCount?: number
+ rowCount?: number
+ pagination?: PaginationState
+ onPaginationChange?: OnChangeFn<PaginationState>
+
+ // Sorting
+ sorting?: SortingState
+ onSortingChange?: OnChangeFn<SortingState>
+
+ // Filtering
+ columnFilters?: ColumnFiltersState
+ onColumnFiltersChange?: OnChangeFn<ColumnFiltersState>
+ globalFilter?: string
+ onGlobalFilterChange?: OnChangeFn<string>
+
+ // Visibility
+ columnVisibility?: VisibilityState
+ onColumnVisibilityChange?: OnChangeFn<VisibilityState>
+
+ // Pinning
+ columnPinning?: ColumnPinningState
+ onColumnPinningChange?: OnChangeFn<ColumnPinningState>
+
+ // Order
+ columnOrder?: ColumnOrderState
+ onColumnOrderChange?: OnChangeFn<ColumnOrderState>
+
+ // Selection
+ enableRowSelection?: boolean | ((row: Row<TData>) => boolean)
+ enableMultiRowSelection?: boolean | ((row: Row<TData>) => boolean)
+ rowSelection?: RowSelectionState
+ onRowSelectionChange?: OnChangeFn<RowSelectionState>
+
+ // Grouping
+ enableGrouping?: boolean
+ grouping?: GroupingState
+ onGroupingChange?: OnChangeFn<GroupingState>
+ expanded?: ExpandedState
+ onExpandedChange?: OnChangeFn<ExpandedState>
+
+ // --- Event Handlers ---
+ onRowClick?: (row: Row<TData>, event: React.MouseEvent) => void
+
+ // --- Styling ---
+ getRowClassName?: (originalRow: TData, index: number) => string
+
+ // --- Advanced ---
+ meta?: Record<string, any>
+ getRowId?: (originalRow: TData, index: number, parent?: any) => string
+
+ // Custom Header Visual Feedback
+ renderHeaderVisualFeedback?: (props: {
+ column: Column<TData, TValue>
+ isPinned: boolean | string
+ isSorted: boolean | string
+ isFiltered: boolean
+ isGrouped: boolean
+ }) => React.ReactNode
+}
+
+function ClientVirtualTableInner<TData, TValue>(
+ {
+ data,
+ columns,
+ height = "100%",
+ estimateRowHeight = 40,
+ className,
+ actions,
+ customToolbar,
+ enableExport = true,
+ onExport,
+ isLoading = false,
+
+ // User Preset Saving
+ enableUserPreset = false,
+ tableKey,
+
+ // Pagination
+ enablePagination = false,
+ manualPagination = false,
+ pageCount,
+ rowCount,
+ pagination: propPagination,
+ onPaginationChange,
+
+ // Sorting
+ sorting: propSorting,
+ onSortingChange,
+
+ // Filtering
+ columnFilters: propColumnFilters,
+ onColumnFiltersChange,
+ globalFilter: propGlobalFilter,
+ onGlobalFilterChange,
+
+ // Visibility
+ columnVisibility: propColumnVisibility,
+ onColumnVisibilityChange,
+
+ // Pinning
+ columnPinning: propColumnPinning,
+ onColumnPinningChange,
+
+ // Order
+ columnOrder: propColumnOrder,
+ onColumnOrderChange,
+
+ // Selection
+ enableRowSelection,
+ enableMultiRowSelection,
+ rowSelection: propRowSelection,
+ onRowSelectionChange,
+
+ // Grouping
+ enableGrouping = false,
+ grouping: propGrouping,
+ onGroupingChange,
+ expanded: propExpanded,
+ onExpandedChange,
+
+ // Style defaults
+ getRowClassName,
+
+ // Meta & RowID
+ meta,
+ getRowId,
+
+ // Event Handlers
+ onRowClick,
+
+ // Custom Header Visual Feedback
+ renderHeaderVisualFeedback,
+ }: ClientVirtualTableProps<TData, TValue>,
+ ref: React.Ref<Table<TData>>
+) {
+ // Internal States (used when props are undefined)
+ const [internalSorting, setInternalSorting] = React.useState<SortingState>([])
+ const [internalColumnFilters, setInternalColumnFilters] = React.useState<ColumnFiltersState>([])
+ const [internalGlobalFilter, setInternalGlobalFilter] = React.useState("")
+ const [internalColumnVisibility, setInternalColumnVisibility] = React.useState<VisibilityState>({})
+ const [internalColumnPinning, setInternalColumnPinning] = React.useState<ColumnPinningState>({ left: [], right: [] })
+ const [internalColumnOrder, setInternalColumnOrder] = React.useState<ColumnOrderState>(
+ () => columns.map((c) => c.id || (c as any).accessorKey) as string[]
+ )
+ const [internalPagination, setInternalPagination] = React.useState<PaginationState>({
+ pageIndex: 0,
+ pageSize: 10,
+ })
+ const [internalRowSelection, setInternalRowSelection] = React.useState<RowSelectionState>({})
+ const [internalGrouping, setInternalGrouping] = React.useState<GroupingState>([])
+ const [internalExpanded, setInternalExpanded] = React.useState<ExpandedState>({})
+
+ // Effective States
+ const sorting = propSorting ?? internalSorting
+ const setSorting = onSortingChange ?? setInternalSorting
+
+ const columnFilters = propColumnFilters ?? internalColumnFilters
+ const setColumnFilters = onColumnFiltersChange ?? setInternalColumnFilters
+
+ const globalFilter = propGlobalFilter ?? internalGlobalFilter
+ const setGlobalFilter = onGlobalFilterChange ?? setInternalGlobalFilter
+
+ const columnVisibility = propColumnVisibility ?? internalColumnVisibility
+ const setColumnVisibility = onColumnVisibilityChange ?? setInternalColumnVisibility
+
+ const columnPinning = propColumnPinning ?? internalColumnPinning
+ const setColumnPinning = onColumnPinningChange ?? setInternalColumnPinning
+
+ const columnOrder = propColumnOrder ?? internalColumnOrder
+ const setColumnOrder = onColumnOrderChange ?? setInternalColumnOrder
+
+ const pagination = propPagination ?? internalPagination
+ const setPagination = onPaginationChange ?? setInternalPagination
+
+ const rowSelection = propRowSelection ?? internalRowSelection
+ const setRowSelection = onRowSelectionChange ?? setInternalRowSelection
+
+ const grouping = propGrouping ?? internalGrouping
+ const setGrouping = onGroupingChange ?? setInternalGrouping
+
+ const expanded = propExpanded ?? internalExpanded
+ const setExpanded = onExpandedChange ?? setInternalExpanded
+
+ // Table Instance
+ const table = useReactTable({
+ data,
+ columns,
+ state: {
+ sorting,
+ columnFilters,
+ globalFilter,
+ pagination,
+ columnVisibility,
+ columnPinning,
+ columnOrder,
+ rowSelection,
+ grouping,
+ expanded,
+ },
+ manualPagination,
+ pageCount: manualPagination ? pageCount : undefined,
+ rowCount: manualPagination ? rowCount : undefined,
+ onSortingChange: setSorting,
+ onColumnFiltersChange: setColumnFilters,
+ onGlobalFilterChange: setGlobalFilter,
+ onPaginationChange: setPagination,
+ onColumnVisibilityChange: setColumnVisibility,
+ onColumnPinningChange: setColumnPinning,
+ onColumnOrderChange: setColumnOrder,
+ onRowSelectionChange: setRowSelection,
+ onGroupingChange: setGrouping,
+ onExpandedChange: setExpanded,
+ enableRowSelection,
+ enableMultiRowSelection,
+ enableGrouping,
+ getCoreRowModel: getCoreRowModel(),
+
+ // Systematic Order of Operations:
+ // 1. Filtering (Rows are filtered first)
+ getFilteredRowModel: getFilteredRowModel(),
+ getFacetedRowModel: getFacetedRowModel(),
+ getFacetedUniqueValues: getFacetedUniqueValues(),
+ getFacetedMinMaxValues: getFacetedMinMaxValues(),
+
+ // 2. Sorting (Filtered rows are then sorted)
+ getSortedRowModel: getSortedRowModel(),
+
+ // 3. Grouping (Sorted rows are grouped)
+ getGroupedRowModel: enableGrouping ? getGroupedRowModel() : undefined,
+ getExpandedRowModel: enableGrouping ? getExpandedRowModel() : undefined,
+
+ // 4. Pagination (Final rows are paginated)
+ getPaginationRowModel: enablePagination ? getPaginationRowModel() : undefined,
+ columnResizeMode: "onChange",
+ filterFns: {
+ fuzzy: fuzzyFilter,
+ },
+ globalFilterFn: fuzzyFilter,
+ meta,
+ getRowId,
+ })
+
+ // Expose table instance via ref
+ React.useImperativeHandle(ref, () => table, [table])
+
+ // DnD Sensors
+ const sensors = useSensors(
+ useSensor(PointerSensor, {
+ activationConstraint: {
+ distance: 8,
+ },
+ }),
+ useSensor(KeyboardSensor)
+ )
+
+ // Handle Drag End
+ const handleDragEnd = (event: DragEndEvent) => {
+ const { active, over } = event
+ if (active && over && active.id !== over.id) {
+ const activeId = active.id as string
+ const overId = over.id as string
+
+ const activeColumn = table.getColumn(activeId)
+ const overColumn = table.getColumn(overId)
+
+ if (activeColumn && overColumn) {
+ const activePinState = activeColumn.getIsPinned()
+ const overPinState = overColumn.getIsPinned()
+
+ // If dragging between different pin states, update the pin state of the active column
+ if (activePinState !== overPinState) {
+ activeColumn.pin(overPinState)
+ }
+
+ // Reorder the columns
+ setColumnOrder((items) => {
+ const currentItems = Array.isArray(items) ? items : []
+ const oldIndex = items.indexOf(activeId)
+ const newIndex = items.indexOf(overId)
+ return arrayMove(items, oldIndex, newIndex)
+ })
+ }
+ }
+ }
+
+ // Virtualization
+ const tableContainerRef = React.useRef<HTMLDivElement>(null)
+ const { rows } = table.getRowModel()
+
+ const rowVirtualizer = useVirtualizer({
+ count: rows.length,
+ getScrollElement: () => tableContainerRef.current,
+ estimateSize: () => estimateRowHeight,
+ overscan: 10,
+ })
+
+ const virtualRows = rowVirtualizer.getVirtualItems()
+ const totalSize = rowVirtualizer.getTotalSize()
+
+ const paddingTop = virtualRows.length > 0 ? virtualRows?.[0]?.start || 0 : 0
+ const paddingBottom =
+ virtualRows.length > 0
+ ? totalSize - (virtualRows?.[virtualRows.length - 1]?.end || 0)
+ : 0
+
+ // Export Handler
+ const handleExport = async () => {
+ if (onExport) {
+ onExport(data)
+ return
+ }
+ const currentData = table.getFilteredRowModel().rows.map((row) => row.original)
+ await exportToExcel(currentData, columns, `export-${new Date().toISOString().slice(0, 10)}.xlsx`)
+ }
+
+ return (
+ <div
+ className={`flex flex-col gap-4 ${className || ""}`}
+ style={{ height }}
+ >
+ <ClientTableToolbar
+ globalFilter={globalFilter}
+ setGlobalFilter={setGlobalFilter}
+ totalRows={manualPagination ? (rowCount ?? data.length) : data.length}
+ visibleRows={rows.length}
+ onExport={enableExport ? handleExport : undefined}
+ viewOptions={
+ <>
+ <ClientTableViewOptions table={table} />
+ {enableUserPreset && tableKey && (
+ <ClientTablePreset table={table} tableKey={tableKey} />
+ )}
+ </>
+ }
+ customToolbar={customToolbar}
+ actions={actions}
+ />
+
+ <div
+ ref={tableContainerRef}
+ className="relative border rounded-md overflow-auto bg-background flex-1 min-h-0"
+ >
+ {isLoading && (
+ <div className="absolute inset-0 z-50 flex items-center justify-center bg-background/50 backdrop-blur-sm">
+ <Loader2 className="h-8 w-8 animate-spin text-primary" />
+ </div>
+ )}
+
+ <DndContext
+ sensors={sensors}
+ collisionDetection={closestCenter}
+ onDragEnd={handleDragEnd}
+ >
+ <table
+ className="table-fixed border-collapse w-full min-w-full"
+ style={{ width: table.getTotalSize() }}
+ >
+ <thead className="sticky top-0 z-40 bg-muted">
+ {table.getHeaderGroups().map((headerGroup) => (
+ <tr key={headerGroup.id}>
+ <SortableContext
+ items={headerGroup.headers.map((h) => h.id)}
+ strategy={horizontalListSortingStrategy}
+ >
+ {headerGroup.headers.map((header) => (
+ <ClientTableColumnHeader
+ key={header.id}
+ header={header}
+ enableReordering={true}
+ renderHeaderVisualFeedback={renderHeaderVisualFeedback}
+ />
+ ))}
+ </SortableContext>
+ </tr>
+ ))}
+ </thead>
+ <tbody>
+ {paddingTop > 0 && (
+ <tr>
+ <td style={{ height: `${paddingTop}px` }} />
+ </tr>
+ )}
+ {virtualRows.length === 0 && !isLoading ? (
+ <tr>
+ <td colSpan={columns.length} className="h-24 text-center">
+ No results.
+ </td>
+ </tr>
+ ) : (
+ virtualRows.map((virtualRow) => {
+ const row = rows[virtualRow.index]
+
+ // --- Group Header Rendering ---
+ if (row.getIsGrouped()) {
+ const groupingColumnId = row.groupingColumnId ?? "";
+ const groupingValue = row.getGroupingValue(groupingColumnId);
+
+ return (
+ <tr
+ key={row.id}
+ className="hover:bg-muted/50 border-b bg-muted/30"
+ style={{ height: `${virtualRow.size}px` }}
+ >
+ <td
+ colSpan={columns.length}
+ className="px-4 py-2 text-left font-medium cursor-pointer"
+ onClick={row.getToggleExpandedHandler()}
+ >
+ <div className="flex items-center gap-2">
+ {row.getIsExpanded() ? (
+ <ChevronDown className="h-4 w-4" />
+ ) : (
+ <ChevronRight className="h-4 w-4" />
+ )}
+ <span className="flex items-center gap-2">
+ <span className="font-bold capitalize">
+ {groupingColumnId}:
+ </span>
+ <span>
+ {String(groupingValue)}
+ </span>
+ <span className="text-muted-foreground text-sm font-normal">
+ ({row.subRows.length})
+ </span>
+ </span>
+ </div>
+ </td>
+ </tr>
+ )
+ }
+
+ // --- Normal Row Rendering ---
+ return (
+ <tr
+ key={row.id}
+ className={cn(
+ "hover:bg-muted/50 border-b last:border-0",
+ getRowClassName ? getRowClassName(row.original, row.index) : "",
+ onRowClick ? "cursor-pointer" : ""
+ )}
+ style={{ height: `${virtualRow.size}px` }}
+ onClick={(e) => onRowClick?.(row, e)}
+ >
+ {row.getVisibleCells().map((cell) => {
+ // Handle pinned cells
+ const isPinned = cell.column.getIsPinned()
+ const isGrouped = cell.column.getIsGrouped()
+
+ const style: React.CSSProperties = {
+ width: cell.column.getSize(),
+ }
+ if (isPinned === "left") {
+ style.position = "sticky"
+ style.left = `${cell.column.getStart("left")}px`
+ style.zIndex = 20
+ } else if (isPinned === "right") {
+ style.position = "sticky"
+ style.right = `${cell.column.getAfter("right")}px`
+ style.zIndex = 20
+ }
+
+ return (
+ <td
+ key={cell.id}
+ className={cn(
+ "px-2 py-0 text-sm truncate border-b bg-background",
+ isGrouped ? "bg-muted/20" : ""
+ )}
+ style={style}
+ >
+ {cell.getIsGrouped() ? (
+ // If this cell is grouped, usually we don't render it here if we have a group header row,
+ // but if we keep it, it acts as the expander for the next level (if multi-level grouping).
+ // Since we used a full-width row for the group header, this branch might not be hit for the group row itself,
+ // but for nested groups it might?
+ // Wait, row.getIsGrouped() is true for the group row.
+ // The cells inside the group row are not rendered because we return early above.
+ // The cells inside the "leaf" rows (normal rows) are rendered here.
+ // So cell.getIsGrouped() checks if the COLUMN is currently grouped.
+ // If the column is grouped, the cell value is usually redundant or hidden in normal rows.
+ // Standard practice: hide the cell content or dim it.
+ null
+ ) : cell.getIsAggregated() ? (
+ // If this cell is an aggregation of the group
+ flexRender(
+ cell.column.columnDef.aggregatedCell ??
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )
+ ) : (
+ // Normal cell
+ cell.getIsPlaceholder()
+ ? null
+ : flexRender(cell.column.columnDef.cell, cell.getContext())
+ )}
+ </td>
+ )
+ })}
+ </tr>
+ )
+ })
+ )}
+ {paddingBottom > 0 && (
+ <tr>
+ <td style={{ height: `${paddingBottom}px` }} />
+ </tr>
+ )}
+ </tbody>
+ </table>
+ </DndContext>
+ </div>
+
+ {enablePagination && (
+ <ClientDataTablePagination table={table} />
+ )}
+ </div>
+ )
+}
+
+export const ClientVirtualTable = React.memo(
+ React.forwardRef(ClientVirtualTableInner)
+) as <TData, TValue>(
+ props: ClientVirtualTableProps<TData, TValue> & { ref?: React.Ref<Table<TData>> }
+) => React.ReactElement
diff --git a/components/client-table/export-utils.ts b/components/client-table/export-utils.ts
new file mode 100644
index 00000000..edcc8dff
--- /dev/null
+++ b/components/client-table/export-utils.ts
@@ -0,0 +1,136 @@
+import { ColumnDef } from "@tanstack/react-table"
+import ExcelJS from "exceljs"
+import { saveAs } from "file-saver"
+
+export async function exportToExcel<TData>(
+ data: TData[],
+ columns: ColumnDef<TData, any>[],
+ filename: string = "export.xlsx"
+) {
+ const workbook = new ExcelJS.Workbook()
+ const worksheet = workbook.addWorksheet("Data")
+
+ // Filter out utility columns and resolve headers
+ const exportableColumns = columns.filter(
+ (col) =>
+ col.id !== "select" &&
+ col.id !== "actions" &&
+ // @ts-ignore - simple check for now
+ (typeof col.header === "string" || typeof col.accessorKey === "string")
+ )
+
+ // Setup columns
+ worksheet.columns = exportableColumns.map((col) => {
+ let headerText = ""
+ if (typeof col.header === "string") {
+ headerText = col.header
+ } else if (typeof col.accessorKey === "string") {
+ headerText = col.accessorKey
+ }
+
+ return {
+ header: headerText,
+ key: (col.accessorKey as string) || col.id,
+ width: 20,
+ }
+ })
+
+ // Add rows
+ data.forEach((row) => {
+ const rowData: any = {}
+ exportableColumns.forEach((col) => {
+ const key = (col.accessorKey as string) || col.id
+ if (key) {
+ const value = getValueByPath(row, key)
+ rowData[key] = value
+ }
+ })
+ worksheet.addRow(rowData)
+ })
+
+ worksheet.getRow(1).font = { bold: true }
+
+ const buffer = await workbook.xlsx.writeBuffer()
+ const blob = new Blob([buffer], {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ })
+ saveAs(blob, filename)
+}
+
+function getValueByPath(obj: any, path: string) {
+ return path.split('.').reduce((acc, part) => acc && acc[part], obj)
+}
+
+interface CreateTemplateOptions<TData> {
+ columns: ColumnDef<TData, any>[]
+ filename?: string
+ includeColumns?: { key: string; header: string }[]
+ excludeColumns?: string[] // accessorKey or id to exclude
+}
+
+export async function createExcelTemplate<TData>({
+ columns,
+ filename = "template.xlsx",
+ includeColumns = [],
+ excludeColumns = [],
+}: CreateTemplateOptions<TData>) {
+ const workbook = new ExcelJS.Workbook()
+ const worksheet = workbook.addWorksheet("Template")
+
+ // 1. Filter columns from definition
+ const baseColumns = columns.filter((col) => {
+ const key = (col.accessorKey as string) || col.id
+
+ // Skip system columns
+ if (col.id === "select" || col.id === "actions") return false
+
+ // Skip excluded columns
+ if (excludeColumns.includes(key!) || (col.id && excludeColumns.includes(col.id))) return false
+
+ return true
+ })
+
+ // 2. Map to ExcelJS columns
+ const excelColumns = baseColumns.map((col) => {
+ let headerText = ""
+ if (typeof col.header === "string") {
+ headerText = col.header
+ } else if (typeof col.accessorKey === "string") {
+ headerText = col.accessorKey
+ }
+
+ return {
+ header: headerText,
+ key: (col.accessorKey as string) || col.id,
+ width: 20
+ }
+ })
+
+ // 3. Add extra included columns
+ includeColumns.forEach((col) => {
+ excelColumns.push({
+ header: col.header,
+ key: col.key,
+ width: 20
+ })
+ })
+
+ worksheet.columns = excelColumns
+
+ // Style Header
+ const headerRow = worksheet.getRow(1)
+ headerRow.font = { bold: true }
+ headerRow.fill = {
+ type: 'pattern',
+ pattern: 'solid',
+ fgColor: { argb: 'FFD3D3D3' } // Light Gray
+ }
+
+ // Add Data Validation or Comments if needed (future expansion)
+
+ const buffer = await workbook.xlsx.writeBuffer()
+ const blob = new Blob([buffer], {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ })
+ saveAs(blob, filename)
+}
diff --git a/components/client-table/import-utils.ts b/components/client-table/import-utils.ts
new file mode 100644
index 00000000..bc7f4b44
--- /dev/null
+++ b/components/client-table/import-utils.ts
@@ -0,0 +1,100 @@
+import ExcelJS from "exceljs"
+
+interface ImportExcelOptions {
+ file: File
+ /**
+ * Map Excel header names to data keys.
+ * Example: { "Name": "name", "Age": "age" }
+ */
+ columnMapping?: Record<string, string>
+ /**
+ * Row offset to start reading data (0-based).
+ * Default: 1 (assuming row 0 is header)
+ */
+ dataStartRow?: number
+}
+
+export interface ExcelImportResult<T = any> {
+ data: T[]
+ errors: string[]
+}
+
+/**
+ * Generic function to read an Excel file and convert it to an array of objects.
+ * Does NOT handle database insertion or validation logic.
+ */
+export async function importFromExcel<T = any>({
+ file,
+ columnMapping = {},
+ dataStartRow = 1,
+}: ImportExcelOptions): Promise<ExcelImportResult<T>> {
+ const workbook = new ExcelJS.Workbook()
+ const arrayBuffer = await file.arrayBuffer()
+
+ try {
+ await workbook.xlsx.load(arrayBuffer)
+ } catch (error) {
+ return { data: [], errors: ["Failed to parse Excel file. Please ensure it is a valid .xlsx file."] }
+ }
+
+ const worksheet = workbook.worksheets[0] // Read the first sheet
+ const data: T[] = []
+ const errors: string[] = []
+
+ if (!worksheet) {
+ return { data: [], errors: ["No worksheet found in the Excel file."] }
+ }
+
+ // 1. Read Header Row (assumed to be row 1 for mapping if no explicit mapping provided,
+ // or we can use it to validate mapping)
+ const headers: string[] = []
+ const headerRow = worksheet.getRow(1)
+ headerRow.eachCell((cell, colNumber) => {
+ headers[colNumber] = String(cell.value).trim()
+ })
+
+ // 2. Iterate Data Rows
+ worksheet.eachRow((row, rowNumber) => {
+ if (rowNumber <= dataStartRow) return // Skip header/pre-header rows
+
+ const rowData: any = {}
+ let hasData = false
+
+ row.eachCell((cell, colNumber) => {
+ const headerText = headers[colNumber]
+ if (!headerText) return
+
+ // Determine the key to use for this column
+ // Priority: Explicit mapping -> Header text as key
+ const key = columnMapping[headerText] || headerText
+
+ let cellValue = cell.value
+
+ // Handle ExcelJS object values (e.g. formula results, hyperlinks)
+ if (cellValue && typeof cellValue === 'object') {
+ if ('result' in cellValue) {
+ // Formula result
+ cellValue = (cellValue as any).result
+ } else if ('text' in cellValue) {
+ // Hyperlink text
+ cellValue = (cellValue as any).text
+ } else if ('richText' in cellValue) {
+ // Rich text
+ cellValue = (cellValue as any).richText.map((t: any) => t.text).join('')
+ }
+ }
+
+ if (cellValue !== null && cellValue !== undefined && String(cellValue).trim() !== '') {
+ hasData = true
+ rowData[key] = cellValue
+ }
+ })
+
+ if (hasData) {
+ data.push(rowData as T)
+ }
+ })
+
+ return { data, errors }
+}
+
diff --git a/components/client-table/index.ts b/components/client-table/index.ts
new file mode 100644
index 00000000..2b3a1645
--- /dev/null
+++ b/components/client-table/index.ts
@@ -0,0 +1,7 @@
+export * from "./client-virtual-table"
+export * from "./client-table-column-header"
+export * from "./client-table-filter"
+export * from "./client-table-view-options"
+export * from "./export-utils"
+export * from "./import-utils"
+export * from "./types"
diff --git a/components/client-table/preset-actions.ts b/components/client-table/preset-actions.ts
new file mode 100644
index 00000000..0b8b3adb
--- /dev/null
+++ b/components/client-table/preset-actions.ts
@@ -0,0 +1,87 @@
+"use server";
+
+import db from "@/db/db";
+import { userCustomData } from "@/db/schema/user-custom-data/userCustomData";
+import { eq, and } from "drizzle-orm";
+import { Preset, PresetRepository } from "./preset-types";
+
+// Drizzle Implementation of PresetRepository
+// This file acts as the concrete repository implementation.
+// To swap DBs, you would replace the logic here or create a new implementation file.
+
+export async function getPresets(tableKey: string, userId: number): Promise<{ success: boolean; data?: Preset[]; error?: string }> {
+ try {
+ const settings = await db
+ .select()
+ .from(userCustomData)
+ .where(
+ and(
+ eq(userCustomData.tableKey, tableKey),
+ eq(userCustomData.userId, userId)
+ )
+ )
+ .orderBy(userCustomData.createdDate);
+
+ // Map DB entity to domain model
+ const data: Preset[] = settings.map(s => ({
+ id: s.id,
+ name: s.customSettingName,
+ setting: s.customSetting,
+ createdAt: s.createdDate,
+ updatedAt: s.updatedDate,
+ }));
+
+ return { success: true, data };
+ } catch (error) {
+ console.error("Failed to fetch presets:", error);
+ return { success: false, error: "Failed to fetch presets" };
+ }
+}
+
+export async function savePreset(
+ userId: number,
+ tableKey: string,
+ name: string,
+ setting: any
+): Promise<{ success: boolean; error?: string }> {
+ try {
+ const existing = await db.query.userCustomData.findFirst({
+ where: and(
+ eq(userCustomData.userId, userId),
+ eq(userCustomData.tableKey, tableKey),
+ eq(userCustomData.customSettingName, name)
+ )
+ });
+
+ if (existing) {
+ await db.update(userCustomData)
+ .set({
+ customSetting: setting,
+ updatedDate: new Date()
+ })
+ .where(eq(userCustomData.id, existing.id));
+ } else {
+ await db.insert(userCustomData).values({
+ userId,
+ tableKey,
+ customSettingName: name,
+ customSetting: setting,
+ });
+ }
+
+ return { success: true };
+ } catch (error) {
+ console.error("Failed to save preset:", error);
+ return { success: false, error: "Failed to save preset" };
+ }
+}
+
+export async function deletePreset(id: string): Promise<{ success: boolean; error?: string }> {
+ try {
+ await db.delete(userCustomData).where(eq(userCustomData.id, id));
+ return { success: true };
+ } catch (error) {
+ console.error("Failed to delete preset:", error);
+ return { success: false, error: "Failed to delete preset" };
+ }
+}
diff --git a/components/client-table/preset-types.ts b/components/client-table/preset-types.ts
new file mode 100644
index 00000000..072d918b
--- /dev/null
+++ b/components/client-table/preset-types.ts
@@ -0,0 +1,13 @@
+export interface Preset {
+ id: string;
+ name: string;
+ setting: any; // JSON object for table state
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+export interface PresetRepository {
+ getPresets(tableKey: string, userId: number): Promise<{ success: boolean; data?: Preset[]; error?: string }>;
+ savePreset(userId: number, tableKey: string, name: string, setting: any): Promise<{ success: boolean; error?: string }>;
+ deletePreset(id: string): Promise<{ success: boolean; error?: string }>;
+}
diff --git a/components/client-table/types.ts b/components/client-table/types.ts
new file mode 100644
index 00000000..b0752bfa
--- /dev/null
+++ b/components/client-table/types.ts
@@ -0,0 +1,11 @@
+import { ColumnDef, RowData } from "@tanstack/react-table"
+
+export interface ClientTableColumnMeta {
+ filterType?: "text" | "select" | "boolean"
+ filterOptions?: { label: string; value: string }[]
+}
+
+// Use this type instead of generic ColumnDef to get intellisense for 'meta'
+export type ClientTableColumnDef<TData extends RowData, TValue = unknown> = ColumnDef<TData, TValue> & {
+ meta?: ClientTableColumnMeta
+}
diff --git a/db/schema/bRfq.ts b/db/schema/bRfq.ts
deleted file mode 100644
index 6eef6ee3..00000000
--- a/db/schema/bRfq.ts
+++ /dev/null
@@ -1,1409 +0,0 @@
-import { pgTable, pgView, serial, varchar, text, timestamp, boolean, integer, numeric, date, uniqueIndex, foreignKey } from "drizzle-orm/pg-core";
-import { eq, sql, and, relations } from "drizzle-orm";
-import { projects } from "./projects";
-import { users } from "./users";
-import { vendors } from "./vendors";
-import { incoterms, paymentTerms } from "./procurementRFQ";
-
-export const bRfqs = pgTable(
- "b_rfqs",
- {
- id: serial("id").primaryKey(),
-
- // RFQ 고유 코드
- rfqCode: varchar("rfq_code", { length: 50 }).unique(), // ex) "RFQ-2025-001"
-
- // 프로젝트 참조
- projectId: integer("project_id")
- .references(() => projects.id, { onDelete: "set null" }),
-
- description: varchar("description", { length: 255 }),
-
- remark: text("remark"),
-
- dueDate: date("due_date", { mode: "date" })
- .$type<Date>()
- .notNull(),
-
- status: varchar("status", { length: 30 })
- .$type<"DRAFT" | "Doc. Received" | "PIC Assigned" | "Doc. Confirmed" | "Init. RFQ Sent" | "Init. RFQ Answered" | "TBE started" | "TBE finished" | "Final RFQ Sent" | "Quotation Received" | "Vendor Selected">()
- .default("DRAFT")
- .notNull(),
-
- picCode: varchar("pic_code", { length: 50 }),
- picName: varchar("pic_name", { length: 50 }),
- EngPicName: varchar("eng_pic_name", { length: 50 }),
-
- projectCompany: varchar("project_company", { length: 255 }),
- projectFlag: varchar("project_flag", { length: 255 }),
- projectSite: varchar("project_site", { length: 255 }),
-
- packageNo: varchar("package_no", { length: 50 }),
- packageName: varchar("package_name", { length: 255 }),
-
- // 생성자
- createdBy: integer("created_by")
- .notNull()
- .references(() => users.id, { onDelete: "set null" }),
-
- updatedBy: integer("updated_by")
- .notNull()
- .references(() => users.id, { onDelete: "set null" }),
-
- createdAt: timestamp("created_at").defaultNow().notNull(),
- updatedAt: timestamp("updated_at").defaultNow().notNull(),
- }
-);
-
-export const initialRfq = pgTable("initial_rfq", {
- id: serial("id").primaryKey(),
- rfqId: integer("rfq_id")
- .notNull()
- .references(() => bRfqs.id),
-
- initialRfqStatus: varchar("initial_rfq_status", { length: 30 })
- .$type<"DRAFT" | "Init. RFQ Sent" | "S/L Decline" | "Init. RFQ Answered">()
- .default("DRAFT")
- .notNull(),
-
- vendorId: integer("vendor_id")
- .notNull()
- .references(() => vendors.id),
-
- dueDate: date("due_date", { mode: "date" })
- .$type<Date>()
- .notNull(),
-
- validDate: date("valid_date", { mode: "date" })
- .$type<Date>(),
-
- incotermsCode: varchar("incoterms_code", { length: 20 })
- .references(() => incoterms.code, { onDelete: "set null" }),
-
- gtc: varchar("gtc", { length: 255 }),
- gtcValidDate: varchar("gtc_valid_date", { length: 255 }),
-
- classification: varchar("classification", { length: 255 }),
- sparepart: varchar("sparepart", { length: 255 }),
-
- shortList: boolean('short_list').notNull().default(false),
- returnYn: boolean('return_yn').notNull().default(false),
-
- cpRequestYn: boolean('cp_request_yn').notNull().default(false),
-
- prjectGtcYn: boolean('prject_gtc_yn').notNull().default(false),
-
- returnRevision: integer("return_revision")
- .notNull().default(0),
-
- rfqRevision: integer("rfq_revision")
- .notNull().default(0),
-
- createdAt: timestamp("created_at").defaultNow().notNull(),
- updatedAt: timestamp("updated_at").defaultNow().notNull(),
-});
-
-export const finalRfq = pgTable("final_rfq", {
- id: serial("id").primaryKey(),
- rfqId: integer("rfq_id")
- .notNull()
- .references(() => bRfqs.id),
-
- finalRfqStatus: varchar("final_rfq_status", { length: 30 })
- .$type<"DRAFT" | "Final RFQ Sent" | "Final RFQ Answered">()
- .default("DRAFT")
- .notNull(),
-
- vendorId: integer("vendor_id")
- .notNull()
- .references(() => vendors.id),
-
- dueDate: date("due_date", { mode: "date" })
- .$type<Date>()
- .notNull(),
-
- validDate: date("valid_date", { mode: "date" })
- .$type<Date>(),
-
- incotermsCode: varchar("incoterms_code", { length: 20 })
- .references(() => incoterms.code, { onDelete: "set null" }),
-
- gtc: varchar("gtc", { length: 255 }),
- gtcValidDate: varchar("gtc_valid_date", { length: 255 }),
-
- classification: varchar("classification", { length: 255 }),
- sparepart: varchar("sparepart", { length: 255 }),
-
- shortList: boolean('short_list').notNull().default(false),
- returnYn: boolean('return_yn').notNull().default(false),
-
- cpRequestYn: boolean('cp_request_yn').notNull().default(false),
-
- prjectGtcYn: boolean('prject_gtc_yn').notNull().default(true),
-
- returnRevision: integer("return_revision")
- .notNull().default(0),
-
- currency: varchar("currency", { length: 10 }).default("KRW"),
-
- paymentTermsCode: varchar("payment_terms_code", { length: 50 })
- .references(() => paymentTerms.code, { onDelete: "set null" }),
-
- taxCode: varchar("tax_code", { length: 255 }).default("VV"),
- deliveryDate: date("delivery_date", { mode: "date" })
- .$type<Date>()
- .notNull(),
-
- placeOfShipping: varchar("place_of_shipping", { length: 255 }),
- placeOfDestination: varchar("place_of_destination", { length: 255 }),
-
- firsttimeYn: boolean('firsttime_yn').notNull().default(true),
- materialPriceRelatedYn: boolean("material_price_related_yn").default(false),
- remark: text("remark"),
-
- vendorRemark: text("vendor_remark"),
-
- createdAt: timestamp("created_at").defaultNow().notNull(),
- updatedAt: timestamp("updated_at").defaultNow().notNull(),
-});
-
-export const bRfqsAttachments = pgTable(
- "b_rfq_attachments",
- {
- id: serial("id").primaryKey(),
- attachmentType: varchar("attachment_type", { length: 50 }).notNull(),
- serialNo: varchar("serial_no", { length: 50 }).notNull(),
- rfqId: integer("rfq_id")
- .notNull()
- .references(() => bRfqs.id),
-
- // 현재 리비전 정보 (빠른 접근용)
- currentRevision: varchar("current_revision", { length: 10 }).notNull().default("Rev.0"),
- latestRevisionId: integer("latest_revision_id"), // self-reference to bRfqAttachmentRevisions
-
- // 메타 정보
- description: varchar("description", { length: 500 }),
- createdBy: integer("created_by")
- .references(() => users.id, { onDelete: "set null" })
- .notNull(),
- createdAt: timestamp("created_at").defaultNow().notNull(),
- updatedAt: timestamp("updated_at").defaultNow().notNull(),
- }
-)
-
-// 리비전 테이블 (모든 파일 버전 관리)
-export const bRfqAttachmentRevisions = pgTable(
- "b_rfq_attachment_revisions",
- {
- id: serial("id").primaryKey(),
- attachmentId: integer("attachment_id")
- .notNull()
- .references(() => bRfqsAttachments.id, { onDelete: "cascade" }),
-
- // 리비전 정보
- revisionNo: varchar("revision_no", { length: 10 }).notNull(), // "Rev.0", "Rev.1", "Rev.2"
- revisionComment: text("revision_comment"),
- isLatest: boolean("is_latest").notNull().default(true),
-
- // 파일 정보
- fileName: varchar("file_name", { length: 255 }).notNull(),
- originalFileName: varchar("original_file_name", { length: 255 }).notNull(),
- filePath: varchar("file_path", { length: 512 }).notNull(),
- fileSize: integer("file_size"),
- fileType: varchar("file_type", { length: 100 }),
-
- // 리비전 생성 정보
- createdBy: integer("created_by")
- .references(() => users.id, { onDelete: "set null" })
- .notNull(),
- createdAt: timestamp("created_at").defaultNow().notNull(),
-
-
- },
- (t) => ({
- // 첨부파일당 하나의 최신 리비전만 허용
- latestRevisionIdx: uniqueIndex('latest_revision_idx')
- .on(t.attachmentId, t.isLatest)
- .where(eq(t.isLatest, true)),
-
- // 첨부파일 + 리비전 번호 유니크
- attachmentRevisionIdx: uniqueIndex('attachment_revision_idx')
- .on(t.attachmentId, t.revisionNo),
- })
-)
-
-// 첨부파일 + 최신 리비전 뷰
-export const attachmentsWithLatestRevisionView = pgView("attachments_with_latest_revision", {
- // 메인 첨부파일 정보
- attachmentId: integer("attachment_id"),
- attachmentType: varchar("attachment_type", { length: 50 }),
- serialNo: varchar("serial_no", { length: 50 }),
- rfqId: integer("rfq_id"),
- description: varchar("description", { length: 500 }),
- currentRevision: varchar("current_revision", { length: 10 }),
-
- // 최신 리비전 파일 정보
- revisionId: integer("revision_id"),
- fileName: varchar("file_name", { length: 255 }),
- originalFileName: varchar("original_file_name", { length: 255 }),
- filePath: varchar("file_path", { length: 512 }),
- fileSize: integer("file_size"),
- fileType: varchar("file_type", { length: 100 }),
- revisionComment: text("revision_comment"),
-
- // 생성/수정 정보
- createdBy: integer("created_by"),
- createdByName: varchar("created_by_name", { length: 255 }),
- createdAt: timestamp("created_at"),
- updatedAt: timestamp("updated_at"),
-}).as(sql`
- SELECT
- a.id as attachment_id,
- a.attachment_type,
- a.serial_no,
- a.rfq_id,
- a.description,
- a.current_revision,
-
- r.id as revision_id,
- r.file_name,
- r.original_file_name,
- r.file_path,
- r.file_size,
- r.file_type,
- r.revision_comment,
-
- a.created_by,
- u.name as created_by_name,
- a.created_at,
- a.updated_at
- FROM b_rfq_attachments a
- LEFT JOIN b_rfq_attachment_revisions r ON a.latest_revision_id = r.id
- LEFT JOIN users u ON a.created_by = u.id
- `)
-
-// 2. 벤더별 첨부파일 응답 현황 관리
-export const vendorAttachmentResponses = pgTable(
- "vendor_attachment_responses",
- {
- id: serial("id").primaryKey(),
- attachmentId: integer("attachment_id")
- .notNull()
- .references(() => bRfqsAttachments.id, { onDelete: "cascade" }),
-
- vendorId: integer("vendor_id")
- .notNull()
- .references(() => vendors.id, { onDelete: "cascade" }),
-
- rfqType: varchar("rfq_type", { length: 20 })
- .$type<"INITIAL" | "FINAL">()
- .notNull(), // initial_rfq 또는 final_rfq 구분
-
- rfqRecordId: integer("rfq_record_id").notNull(), // initialRfq.id 또는 finalRfq.id
-
- responseStatus: varchar("response_status", { length: 30 })
- .$type<"NOT_RESPONDED" | "RESPONDED" | "REVISION_REQUESTED" | "WAIVED">()
- .default("NOT_RESPONDED")
- .notNull(),
-
- currentRevision: varchar("current_revision", { length: 10 }).default("Rev.0"),
- respondedRevision: varchar("responded_revision", { length: 10 }),
-
- responseComment: text("response_comment"),
- vendorComment: text("vendor_comment"),
-
- revisionRequestComment: text("revision_request_comment"),
-
-
- // 응답 관련 날짜
- requestedAt: timestamp("requested_at").notNull(),
- respondedAt: timestamp("responded_at"),
- revisionRequestedAt: timestamp("revision_requested_at"),
-
- createdAt: timestamp("created_at").defaultNow().notNull(),
- updatedAt: timestamp("updated_at").defaultNow().notNull(),
-
- createdBy: integer("created_by")
- .references(() => users.id, { onDelete: "set null" })
- ,
-
- updatedBy: integer("updated_by")
- .references(() => users.id, { onDelete: "set null" })
- ,
- }, (t) => ({
- // attachmentId + vendorId + rfqType 유니크
- vendorResponseIdx: uniqueIndex('vendor_response_idx').on(
- t.attachmentId.asc(),
- t.vendorId.asc(),
- t.rfqType.asc(),
- ),
- }));
-
-// 3. 벤더 응답 첨부파일
-export const vendorResponseAttachmentsB = pgTable(
- "vendor_response_attachments_b",
- {
- id: serial("id").primaryKey(),
- vendorResponseId: integer("vendor_response_id")
- .notNull()
- .references(() => vendorAttachmentResponses.id, { onDelete: "cascade" }),
-
- fileName: varchar("file_name", { length: 255 }).notNull(),
- originalFileName: varchar("original_file_name", { length: 255 }).notNull(),
- filePath: varchar("file_path", { length: 512 }).notNull(),
- fileSize: integer("file_size"),
- fileType: varchar("file_type", { length: 100 }),
-
- description: varchar("description", { length: 500 }),
-
- uploadedBy: integer("uploaded_by")
- .references(() => users.id, { onDelete: "set null" }),
- uploadedAt: timestamp("uploaded_at").defaultNow().notNull(),
- },
-);
-
-// 4. 응답 히스토리 추적 (선택사항)
-export const vendorResponseHistory = pgTable(
- "vendor_response_history",
- {
- id: serial("id").primaryKey(),
- vendorResponseId: integer("vendor_response_id")
- .notNull()
- .references(() => vendorAttachmentResponses.id, { onDelete: "cascade" }),
-
- action: varchar("action", { length: 50 })
- .$type<"REQUESTED" | "RESPONDED" | "REVISION_REQUESTED" | "REVISED" | "WAIVED">()
- .notNull(),
-
- previousStatus: varchar("previous_status", { length: 30 }),
- newStatus: varchar("new_status", { length: 30 }),
-
- comment: text("comment"),
-
- actionBy: integer("action_by")
- .references(() => users.id, { onDelete: "set null" }),
- actionAt: timestamp("action_at").defaultNow().notNull(),
- },
-);
-
-// === 유용한 뷰들 ===
-
-// 1. bRfqs 기본 마스터 뷰 (프로젝트 정보 포함)
-export const bRfqsMasterView = pgView("b_rfqs_master", {
- rfqId: integer("rfq_id"),
- rfqCode: varchar("rfq_code", { length: 50 }),
- description: varchar("description", { length: 255 }),
- status: varchar("status", { length: 30 }),
- dueDate: date("due_date"),
- picCode: varchar("pic_code", { length: 50 }),
- picName: varchar("pic_name", { length: 50 }),
- EngPicName: varchar("eng_pic_name", { length: 50 }),
- packageNo: varchar("package_no", { length: 50 }),
- packageName: varchar("package_name", { length: 255 }),
- projectId: integer("project_id"),
- projectCode: varchar("project_code", { length: 50 }),
- projectName: text("project_name"),
- projectType: varchar("project_type", { length: 20 }),
- projectCompany: varchar("project_company", { length: 255 }),
- projectFlag: varchar("project_flag", { length: 255 }),
- projectSite: varchar("project_site", { length: 255 }),
- totalAttachments: integer("total_attachments"),
- createdAt: timestamp("created_at"),
- updatedAt: timestamp("updated_at"),
-}).as(sql`
- SELECT
- br.id as rfq_id,
- br.rfq_code,
- br.description,
- br.status,
- br.due_date,
- br.pic_code,
- br.pic_name,
- br.eng_pic_name,
- br.package_no,
- br.package_name,
- br.project_id,
- p.code as project_code,
- p.name as project_name,
- p.type as project_type,
- br.project_company,
- br.project_flag,
- br.project_site,
- COALESCE(att_count.total_attachments, 0) as total_attachments,
- br.created_at,
- br.updated_at
- FROM b_rfqs br
- LEFT JOIN projects p ON br.project_id = p.id
- LEFT JOIN (
- SELECT rfq_id, COUNT(*) as total_attachments
- FROM b_rfq_attachments
- GROUP BY rfq_id
- ) att_count ON br.id = att_count.rfq_id
-`);
-
-// 2. Initial RFQ 상세 뷰 (벤더, 인코텀즈 정보 포함)
-export const initialRfqDetailView = pgView("initial_rfq_detail", {
- rfqId: integer("rfq_id"),
- rfqCode: varchar("rfq_code", { length: 50 }),
- rfqStatus: varchar("rfq_status", { length: 30 }),
- initialRfqId: integer("initial_rfq_id"),
- initialRfqStatus: varchar("initial_rfq_status", { length: 30 }),
- vendorId: integer("vendor_id"),
- vendorCode: varchar("vendor_code", { length: 50 }),
- vendorName: varchar("vendor_name", { length: 255 }),
- vendorCategory: varchar("vendor_category", { length: 255 }),
- vendorCountry: varchar("vendor_country", { length: 100 }),
- vendorBusinessSize: varchar("vendor_business_size", { length: 50 }),
- dueDate: date("due_date"),
- validDate: date("valid_date"),
- incotermsCode: varchar("incoterms_code", { length: 20 }),
- incotermsDescription: varchar("incoterms_description", { length: 255 }),
- shortList: boolean("short_list"),
- returnYn: boolean("return_yn"),
- cpRequestYn: boolean("cp_request_yn"),
- prjectGtcYn: boolean("prject_gtc_yn"),
- returnRevision: integer("return_revision"),
- rfqRevision: integer("rfq_revision"),
- gtc: varchar("gtc", { length: 255 }),
- gtcValidDate: varchar("gtc_valid_date", { length: 255 }),
- classification: varchar("classification", { length: 255 }),
- sparepart: varchar("sparepart", { length: 255 }),
- createdAt: timestamp("created_at"),
- updatedAt: timestamp("updated_at"),
-}).as(sql`
- SELECT
- br.id as rfq_id,
- br.rfq_code,
- br.status as rfq_status,
- ir.id as initial_rfq_id,
- ir.initial_rfq_status,
- ir.vendor_id,
- v.vendor_code,
- v.vendor_name,
- v.country as vendor_country,
- v.business_size as vendor_business_size,
- v.vendor_category as vendor_category,
- ir.due_date,
- ir.valid_date,
- ir.incoterms_code,
- inc.description as incoterms_description,
- ir.short_list,
- ir.return_yn,
- ir.cp_request_yn,
- ir.prject_gtc_yn,
- ir.return_revision,
- ir.rfq_revision,
- ir.gtc,
- ir.gtc_valid_date,
- ir.classification,
- ir.sparepart,
- ir.created_at,
- ir.updated_at
- FROM b_rfqs br
- JOIN initial_rfq ir ON br.id = ir.rfq_id
- LEFT JOIN vendors_with_types v ON ir.vendor_id = v.id
- LEFT JOIN incoterms inc ON ir.incoterms_code = inc.code
-`);
-
-// 3. Final RFQ 상세 뷰 (벤더, 인코텀즈, 결제조건 정보 포함)
-export const finalRfqDetailView = pgView("final_rfq_detail", {
- rfqId: integer("rfq_id"),
- rfqCode: varchar("rfq_code", { length: 50 }),
- rfqStatus: varchar("rfq_status", { length: 30 }),
- finalRfqId: integer("final_rfq_id"),
- finalRfqStatus: varchar("final_rfq_status", { length: 30 }),
- vendorId: integer("vendor_id"),
- vendorCode: varchar("vendor_code", { length: 50 }),
- vendorName: varchar("vendor_name", { length: 255 }),
- vendorCountry: varchar("vendor_country", { length: 100 }),
- vendorBusinessSize: varchar("vendor_business_size", { length: 50 }),
- dueDate: date("due_date"),
- validDate: date("valid_date"),
- deliveryDate: date("delivery_date"),
- incotermsCode: varchar("incoterms_code", { length: 20 }),
- incotermsDescription: varchar("incoterms_description", { length: 255 }),
- paymentTermsCode: varchar("payment_terms_code", { length: 50 }),
- paymentTermsDescription: varchar("payment_terms_description", { length: 255 }),
- currency: varchar("currency", { length: 10 }),
- taxCode: varchar("tax_code", { length: 255 }),
- placeOfShipping: varchar("place_of_shipping", { length: 255 }),
- placeOfDestination: varchar("place_of_destination", { length: 255 }),
- shortList: boolean("short_list"),
- returnYn: boolean("return_yn"),
- cpRequestYn: boolean("cp_request_yn"),
- prjectGtcYn: boolean("prject_gtc_yn"),
- firsttimeYn: boolean("firsttime_yn"),
- materialPriceRelatedYn: boolean("material_price_related_yn"),
- returnRevision: integer("return_revision"),
- gtc: varchar("gtc", { length: 255 }),
- gtcValidDate: varchar("gtc_valid_date", { length: 255 }),
- classification: varchar("classification", { length: 255 }),
- sparepart: varchar("sparepart", { length: 255 }),
- remark: text("remark"),
- vendorRemark: text("vendor_remark"),
- createdAt: timestamp("created_at"),
- updatedAt: timestamp("updated_at"),
-}).as(sql`
- SELECT
- br.id as rfq_id,
- br.rfq_code,
- br.status as rfq_status,
- fr.id as final_rfq_id,
- fr.final_rfq_status,
- fr.vendor_id,
- v.vendor_code,
- v.vendor_name,
- v.country as vendor_country,
- v.business_size as vendor_business_size,
- fr.due_date,
- fr.valid_date,
- fr.delivery_date,
- fr.incoterms_code,
- inc.description as incoterms_description,
- fr.payment_terms_code,
- pt.description as payment_terms_description,
- fr.currency,
- fr.tax_code,
- fr.place_of_shipping,
- fr.place_of_destination,
- fr.short_list,
- fr.return_yn,
- fr.cp_request_yn,
- fr.prject_gtc_yn,
- fr.firsttime_yn,
- fr.material_price_related_yn,
- fr.return_revision,
- fr.gtc,
- fr.gtc_valid_date,
- fr.classification,
- fr.sparepart,
- fr.remark,
- fr.vendor_remark,
- fr.created_at,
- fr.updated_at
- FROM b_rfqs br
- JOIN final_rfq fr ON br.id = fr.rfq_id
- LEFT JOIN vendors v ON fr.vendor_id = v.id
- LEFT JOIN incoterms inc ON fr.incoterms_code = inc.code
- LEFT JOIN payment_terms pt ON fr.payment_terms_code = pt.code
-`);
-
-// 4. 벤더 응답 현황 요약 뷰
-export const vendorResponseSummaryView2 = pgView("vendor_response_summary", {
- rfqId: integer("rfq_id"),
- rfqCode: varchar("rfq_code", { length: 50 }),
- rfqStatus: varchar("rfq_status", { length: 30 }),
- vendorId: integer("vendor_id"),
- vendorCode: varchar("vendor_code", { length: 50 }),
- vendorName: varchar("vendor_name", { length: 255 }),
- vendorCountry: varchar("vendor_country", { length: 100 }),
- vendorBusinessSize: varchar("vendor_business_size", { length: 50 }),
- rfqType: varchar("rfq_type", { length: 20 }),
- totalAttachments: integer("total_attachments"),
- respondedCount: integer("responded_count"),
- pendingCount: integer("pending_count"),
- waivedCount: integer("waived_count"),
- revisionRequestedCount: integer("revision_requested_count"),
- responseRate: numeric("response_rate", { precision: 5, scale: 2 }),
- completionRate: numeric("completion_rate", { precision: 5, scale: 2 }),
-}).as(sql`
- SELECT
- br.id as rfq_id,
- br.rfq_code,
- br.status as rfq_status,
- v.id as vendor_id,
- v.vendor_code,
- v.vendor_name,
- v.country as vendor_country,
- v.business_size as vendor_business_size,
- var.rfq_type,
- COUNT(var.id) as total_attachments,
- COUNT(CASE WHEN var.response_status = 'RESPONDED' THEN 1 END) as responded_count,
- COUNT(CASE WHEN var.response_status = 'NOT_RESPONDED' THEN 1 END) as pending_count,
- COUNT(CASE WHEN var.response_status = 'WAIVED' THEN 1 END) as waived_count,
- COUNT(CASE WHEN var.response_status = 'REVISION_REQUESTED' THEN 1 END) as revision_requested_count,
- ROUND(
- (COUNT(CASE WHEN var.response_status = 'RESPONDED' THEN 1 END) * 100.0 /
- NULLIF(COUNT(CASE WHEN var.response_status != 'WAIVED' THEN 1 END), 0)),
- 2
- ) as response_rate,
- ROUND(
- ((COUNT(CASE WHEN var.response_status = 'RESPONDED' THEN 1 END) +
- COUNT(CASE WHEN var.response_status = 'WAIVED' THEN 1 END)) * 100.0 / COUNT(var.id)),
- 2
- ) as completion_rate
- FROM b_rfqs br
- JOIN b_rfq_attachments bra ON br.id = bra.rfq_id
- JOIN vendor_attachment_responses var ON bra.id = var.attachment_id
- JOIN vendors v ON var.vendor_id = v.id
- GROUP BY br.id, br.rfq_code, br.status, v.id, v.vendor_code, v.vendor_name, v.country, v.business_size, var.rfq_type
-`);
-
-// 5. RFQ 전체 진행 현황 대시보드 뷰
-export const rfqDashboardView = pgView("rfq_dashboard", {
- rfqId: integer("rfq_id"),
- rfqCode: varchar("rfq_code", { length: 50 }),
- description: varchar("description", { length: 255 }),
- status: varchar("status", { length: 30 }),
- dueDate: date("due_date"),
- projectCode: varchar("project_code", { length: 50 }),
- projectName: text("project_name"),
- packageNo: varchar("package_no", { length: 50 }),
- packageName: varchar("package_name", { length: 255 }),
- picCode: varchar("pic_code", { length: 50 }),
- picName: varchar("pic_name", { length: 50 }),
- engPicName: varchar("eng_pic_name", { length: 50 }),
- projectCompany: varchar("project_company", { length: 255 }),
- projectFlag: varchar("project_flag", { length: 255 }),
- projectSite: varchar("project_site", { length: 255 }),
- totalAttachments: integer("total_attachments"),
- initialVendorCount: integer("initial_vendor_count"),
- finalVendorCount: integer("final_vendor_count"),
- initialResponseRate: numeric("initial_response_rate", { precision: 5, scale: 2 }),
- finalResponseRate: numeric("final_response_rate", { precision: 5, scale: 2 }),
- overallProgress: numeric("overall_progress", { precision: 5, scale: 2 }),
- daysToDeadline: integer("days_to_deadline"),
- createdAt: timestamp("created_at"),
- updatedAt: timestamp("updated_at"),
- remark: text("remark"),
- updatedByName: varchar("updated_by_name", { length: 255 }),
- updatedByEmail: varchar("updated_by_email", { length: 255 }),
-}).as(sql`
- -- ② SELECT 절 확장 -------------------------------------------
- SELECT
- br.id AS rfq_id,
- br.rfq_code,
- br.description,
- br.status,
- br.due_date,
- p.code AS project_code,
- p.name AS project_name,
- br.package_no,
- br.package_name,
- br.pic_code,
- br.pic_name,
- br.eng_pic_name,
- br.project_company,
- br.project_flag,
- br.project_site,
- br.remark,
-
- -- 첨부/벤더 요약 -----------------------
- COALESCE(att_count.total_attachments, 0) AS total_attachments,
- COALESCE(init_summary.vendor_count, 0) AS initial_vendor_count,
- COALESCE(final_summary.vendor_count, 0) AS final_vendor_count,
- COALESCE(init_summary.avg_response_rate, 0) AS initial_response_rate,
- COALESCE(final_summary.avg_response_rate, 0) AS final_response_rate,
-
- -- 진행률·마감까지 일수 --------------
- CASE
- WHEN br.status = 'DRAFT' THEN 0
- WHEN br.status = 'Doc. Received' THEN 10
- WHEN br.status = 'PIC Assigned' THEN 20
- WHEN br.status = 'Doc. Confirmed' THEN 30
- WHEN br.status = 'Init. RFQ Sent' THEN 40
- WHEN br.status = 'Init. RFQ Answered' THEN 50
- WHEN br.status = 'TBE started' THEN 60
- WHEN br.status = 'TBE finished' THEN 70
- WHEN br.status = 'Final RFQ Sent' THEN 80
- WHEN br.status = 'Quotation Received' THEN 90
- WHEN br.status = 'Vendor Selected' THEN 100
- ELSE 0
- END AS overall_progress,
- (br.due_date - CURRENT_DATE) AS days_to_deadline,
-
- br.created_at,
- br.updated_at,
-
- -- 💡 추가되는 컬럼 -------------------
- upd.name AS updated_by_name,
- upd.email AS updated_by_email
- FROM b_rfqs br
- LEFT JOIN projects p ON br.project_id = p.id
-
- -- ③ 사용자 정보 조인 --------------------
- LEFT JOIN users upd ON br.updated_by = upd.id
-
- -- (나머지 이미 있던 JOIN 들은 그대로) -----
- LEFT JOIN (
- SELECT rfq_id, COUNT(*) AS total_attachments
- FROM b_rfq_attachments
- GROUP BY rfq_id
- ) att_count ON br.id = att_count.rfq_id
-
- LEFT JOIN (
- SELECT
- rfq_id,
- COUNT(DISTINCT vendor_id) AS vendor_count,
- AVG(response_rate) AS avg_response_rate
- FROM vendor_response_summary
- WHERE rfq_type = 'INITIAL'
- GROUP BY rfq_id
- ) init_summary ON br.id = init_summary.rfq_id
-
- LEFT JOIN (
- SELECT
- rfq_id,
- COUNT(DISTINCT vendor_id) AS vendor_count,
- AVG(response_rate) AS avg_response_rate
- FROM vendor_response_summary
- WHERE rfq_type = 'FINAL'
- GROUP BY rfq_id
- ) final_summary ON br.id = final_summary.rfq_id
- `);
-
-// 사용 예시 타입 정의
-export type VendorAttachmentResponse = typeof vendorAttachmentResponses.$inferSelect;
-export type NewVendorAttachmentResponse = typeof vendorAttachmentResponses.$inferInsert;
-export type AttachmentRevision = typeof bRfqAttachmentRevisions.$inferSelect;
-export type ResponseAttachment = typeof vendorResponseAttachmentsB.$inferSelect;
-
-export type InitialRfqDetailView = typeof initialRfqDetailView.$inferSelect;
-export type FinalRfqDetailView = typeof finalRfqDetailView.$inferSelect;
-export type RfqDashboardView = typeof rfqDashboardView.$inferSelect;
-
-
-export const bRfqsRelations = relations(bRfqs, ({ one, many }) => ({
- // 단일 관계
- project: one(projects, {
- fields: [bRfqs.projectId],
- references: [projects.id],
- }),
- createdByUser: one(users, {
- fields: [bRfqs.createdBy],
- references: [users.id],
- relationName: "bRfqCreatedBy"
- }),
- updatedByUser: one(users, {
- fields: [bRfqs.updatedBy],
- references: [users.id],
- relationName: "bRfqUpdatedBy"
- }),
-
- // 다중 관계
- attachments: many(bRfqsAttachments),
- initialRfqs: many(initialRfq),
- finalRfqs: many(finalRfq),
- }));
-
- // bRfqsAttachments 관계 정의
- export const bRfqsAttachmentsRelations = relations(bRfqsAttachments, ({ one, many }) => ({
- // 단일 관계
- rfq: one(bRfqs, {
- fields: [bRfqsAttachments.rfqId],
- references: [bRfqs.id],
- }),
- createdByUser: one(users, {
- fields: [bRfqsAttachments.createdBy],
- references: [users.id],
- }),
- latestRevision: one(bRfqAttachmentRevisions, {
- fields: [bRfqsAttachments.latestRevisionId],
- references: [bRfqAttachmentRevisions.id],
- relationName: "attachmentLatestRevision"
- }),
-
- // 다중 관계
- revisions: many(bRfqAttachmentRevisions),
- vendorResponses: many(vendorAttachmentResponses),
- }));
-
- // bRfqAttachmentRevisions 관계 정의
- export const bRfqAttachmentRevisionsRelations = relations(bRfqAttachmentRevisions, ({ one }) => ({
- attachment: one(bRfqsAttachments, {
- fields: [bRfqAttachmentRevisions.attachmentId],
- references: [bRfqsAttachments.id],
- }),
- createdByUser: one(users, {
- fields: [bRfqAttachmentRevisions.createdBy],
- references: [users.id],
- }),
- }));
-
- // vendorAttachmentResponses 관계 정의
- export const vendorAttachmentResponsesRelations = relations(vendorAttachmentResponses, ({ one, many }) => ({
- // 단일 관계
- attachment: one(bRfqsAttachments, {
- fields: [vendorAttachmentResponses.attachmentId],
- references: [bRfqsAttachments.id],
- }),
- vendor: one(vendors, {
- fields: [vendorAttachmentResponses.vendorId],
- references: [vendors.id],
- }),
-
- // 다중 관계
- responseAttachments: many(vendorResponseAttachmentsB),
- history: many(vendorResponseHistory),
- }));
-
- // vendorResponseAttachmentsB 관계 정의
- export const vendorResponseAttachmentsBRelations = relations(vendorResponseAttachmentsB, ({ one }) => ({
- vendorResponse: one(vendorAttachmentResponses, {
- fields: [vendorResponseAttachmentsB.vendorResponseId],
- references: [vendorAttachmentResponses.id],
- }),
- uploadedByUser: one(users, {
- fields: [vendorResponseAttachmentsB.uploadedBy],
- references: [users.id],
- }),
- }));
-
- // vendorResponseHistory 관계 정의
- export const vendorResponseHistoryRelations_old = relations(vendorResponseHistory, ({ one }) => ({
- vendorResponse: one(vendorAttachmentResponses, {
- fields: [vendorResponseHistory.vendorResponseId],
- references: [vendorAttachmentResponses.id],
- }),
- actionByUser: one(users, {
- fields: [vendorResponseHistory.actionBy],
- references: [users.id],
- }),
- }));
-
- // initialRfq 관계 정의
- export const initialRfqRelations = relations(initialRfq, ({ one }) => ({
- rfq: one(bRfqs, {
- fields: [initialRfq.rfqId],
- references: [bRfqs.id],
- }),
- vendor: one(vendors, {
- fields: [initialRfq.vendorId],
- references: [vendors.id],
- }),
- }));
-
- // finalRfq 관계 정의
- export const finalRfqRelations = relations(finalRfq, ({ one }) => ({
- rfq: one(bRfqs, {
- fields: [finalRfq.rfqId],
- references: [bRfqs.id],
- }),
- vendor: one(vendors, {
- fields: [finalRfq.vendorId],
- references: [vendors.id],
- }),
- }));
-
-
-
-// 업데이트된 vendorResponseAttachmentsEnhanced 뷰
-export const vendorResponseAttachmentsEnhanced = pgView("vendor_response_attachments_enhanced", {
- // 벤더 응답 파일 기본 정보
- responseAttachmentId: integer("response_attachment_id"),
- vendorResponseId: integer("vendor_response_id"),
- fileName: varchar("file_name", { length: 255 }),
- originalFileName: varchar("original_file_name", { length: 255 }),
- filePath: varchar("file_path", { length: 512 }),
- fileSize: integer("file_size"),
- fileType: varchar("file_type", { length: 100 }),
- description: varchar("description", { length: 500 }),
- uploadedAt: timestamp("uploaded_at"),
-
- // 응답 기본 정보
- attachmentId: integer("attachment_id"),
- vendorId: integer("vendor_id"),
- rfqType: varchar("rfq_type", { length: 20 }),
- rfqRecordId: integer("rfq_record_id"),
- responseStatus: varchar("response_status", { length: 30 }),
- currentRevision: varchar("current_revision", { length: 10 }),
- respondedRevision: varchar("responded_revision", { length: 10 }),
-
- // 코멘트 관련 필드들 (새로 추가된 필드 포함)
- responseComment: text("response_comment"),
- vendorComment: text("vendor_comment"),
- revisionRequestComment: text("revision_request_comment"), // 새로 추가
-
- // 날짜 관련 필드들 (새로 추가된 필드 포함)
- requestedAt: timestamp("requested_at"),
- respondedAt: timestamp("responded_at"),
- revisionRequestedAt: timestamp("revision_requested_at"), // 새로 추가
-
- // 첨부파일 정보
- attachmentType: varchar("attachment_type", { length: 50 }),
- serialNo: varchar("serial_no", { length: 50 }),
- rfqId: integer("rfq_id"),
-
- // 벤더 정보
- vendorCode: varchar("vendor_code", { length: 50 }),
- vendorName: varchar("vendor_name", { length: 255 }),
- vendorCountry: varchar("vendor_country", { length: 100 }),
-
- // 발주처 현재 리비전 정보
- latestClientRevisionId: integer("latest_client_revision_id"),
- latestClientRevisionNo: varchar("latest_client_revision_no", { length: 10 }),
- latestClientFileName: varchar("latest_client_file_name", { length: 255 }),
-
- // 리비전 비교 정보
- isVersionMatched: boolean("is_version_matched"),
- versionLag: integer("version_lag"), // 몇 버전 뒤처져 있는지
- needsUpdate: boolean("needs_update"),
-
- // 응답 파일 순서 (같은 응답에 대한 여러 파일이 있을 경우)
- fileSequence: integer("file_sequence"),
- isLatestResponseFile: boolean("is_latest_response_file"),
-
-}).as(sql`
- SELECT
- vra.id as response_attachment_id,
- vra.vendor_response_id,
- vra.file_name,
- vra.original_file_name,
- vra.file_path,
- vra.file_size,
- vra.file_type,
- vra.description,
- vra.uploaded_at,
-
- -- 응답 기본 정보
- var.attachment_id,
- var.vendor_id,
- var.rfq_type,
- var.rfq_record_id,
- var.response_status,
- var.current_revision,
- var.responded_revision,
-
- -- 코멘트 (새로 추가된 필드 포함)
- var.response_comment,
- var.vendor_comment,
- var.revision_request_comment,
-
- -- 날짜 (새로 추가된 필드 포함)
- var.requested_at,
- var.responded_at,
- var.revision_requested_at,
-
- -- 첨부파일 정보
- ba.attachment_type,
- ba.serial_no,
- ba.rfq_id,
-
- -- 벤더 정보
- v.vendor_code,
- v.vendor_name,
- v.country as vendor_country,
-
- -- 발주처 현재 리비전 정보
- latest_rev.id as latest_client_revision_id,
- latest_rev.revision_no as latest_client_revision_no,
- latest_rev.original_file_name as latest_client_file_name,
-
- -- 리비전 비교
- CASE
- WHEN var.responded_revision = ba.current_revision THEN true
- ELSE false
- END as is_version_matched,
-
- -- 버전 차이 계산 (Rev.0, Rev.1 형태 가정)
- CASE
- WHEN var.responded_revision IS NULL THEN NULL
- WHEN ba.current_revision IS NULL THEN NULL
- ELSE CAST(SUBSTRING(ba.current_revision FROM '[0-9]+') AS INTEGER) -
- CAST(SUBSTRING(var.responded_revision FROM '[0-9]+') AS INTEGER)
- END as version_lag,
-
- CASE
- WHEN var.response_status = 'RESPONDED'
- AND var.responded_revision != ba.current_revision THEN true
- ELSE false
- END as needs_update,
-
- -- 파일 순서
- ROW_NUMBER() OVER (
- PARTITION BY var.id
- ORDER BY vra.uploaded_at DESC
- ) as file_sequence,
-
- -- 최신 응답 파일 여부
- CASE
- WHEN ROW_NUMBER() OVER (
- PARTITION BY var.id
- ORDER BY vra.uploaded_at DESC
- ) = 1 THEN true
- ELSE false
- END as is_latest_response_file
-
- FROM vendor_response_attachments_b vra
- JOIN vendor_attachment_responses var ON vra.vendor_response_id = var.id
- JOIN b_rfq_attachments ba ON var.attachment_id = ba.id
- LEFT JOIN vendors v ON var.vendor_id = v.id
- LEFT JOIN b_rfq_attachment_revisions latest_rev ON ba.latest_revision_id = latest_rev.id
-`);
-
-// 2. 첨부파일별 리비전 히스토리 전체 뷰
-export const attachmentRevisionHistoryView = pgView("attachment_revision_history", {
- rfqId: integer("rfq_id"),
- rfqCode: varchar("rfq_code", { length: 50 }),
- attachmentId: integer("attachment_id"),
- attachmentType: varchar("attachment_type", { length: 50 }),
- serialNo: varchar("serial_no", { length: 50 }),
-
- // 발주처 리비전 정보
- clientRevisionId: integer("client_revision_id"),
- clientRevisionNo: varchar("client_revision_no", { length: 10 }),
- clientFileName: varchar("client_file_name", { length: 255 }),
- clientFilePath: varchar("client_file_path", { length: 512 }),
- clientFileSize: integer("client_file_size"),
- clientRevisionComment: text("client_revision_comment"),
- clientRevisionCreatedAt: timestamp("client_revision_created_at"),
- isLatestClientRevision: boolean("is_latest_client_revision"),
-
- // 이 리비전에 대한 벤더 응답 통계
- totalVendorResponses: integer("total_vendor_responses"),
- respondedVendors: integer("responded_vendors"),
- pendingVendors: integer("pending_vendors"),
- totalResponseFiles: integer("total_response_files"),
-
-}).as(sql`
- SELECT
- br.id as rfq_id,
- br.rfq_code,
- ba.id as attachment_id,
- ba.attachment_type,
- ba.serial_no,
-
- -- 발주처 리비전 정보
- rev.id as client_revision_id,
- rev.revision_no as client_revision_no,
- rev.original_file_name as client_file_name,
- rev.file_size as client_file_size,
- rev.file_path as client_file_path,
- rev.revision_comment as client_revision_comment,
- rev.created_at as client_revision_created_at,
- rev.is_latest as is_latest_client_revision,
-
- -- 벤더 응답 통계
- COALESCE(response_stats.total_responses, 0) as total_vendor_responses,
- COALESCE(response_stats.responded_count, 0) as responded_vendors,
- COALESCE(response_stats.pending_count, 0) as pending_vendors,
- COALESCE(response_stats.total_files, 0) as total_response_files
-
- FROM b_rfqs br
- JOIN b_rfq_attachments ba ON br.id = ba.rfq_id
- JOIN b_rfq_attachment_revisions rev ON ba.id = rev.attachment_id
- LEFT JOIN (
- SELECT
- var.attachment_id,
- COUNT(*) as total_responses,
- COUNT(CASE WHEN var.response_status = 'RESPONDED' THEN 1 END) as responded_count,
- COUNT(CASE WHEN var.response_status = 'NOT_RESPONDED' THEN 1 END) as pending_count,
- COUNT(vra.id) as total_files
- FROM vendor_attachment_responses var
- LEFT JOIN vendor_response_attachments_b vra ON var.id = vra.vendor_response_id
- GROUP BY var.attachment_id
- ) response_stats ON ba.id = response_stats.attachment_id
-
- ORDER BY ba.id, rev.created_at DESC
-`);
-
-// 3. 벤더별 응답 현황 상세 뷰 (리비전 정보 포함)
-// 업데이트된 vendorResponseDetailView
-export const vendorResponseDetailView = pgView("vendor_response_detail", {
- // 기본 식별 정보
- responseId: integer("response_id"),
- rfqId: integer("rfq_id"),
- rfqCode: varchar("rfq_code", { length: 50 }),
- rfqType: varchar("rfq_type", { length: 20 }),
- rfqRecordId: integer("rfq_record_id"),
-
- // 첨부파일 정보
- attachmentId: integer("attachment_id"),
- attachmentType: varchar("attachment_type", { length: 50 }),
- serialNo: varchar("serial_no", { length: 50 }),
- attachmentDescription: varchar("attachment_description", { length: 500 }),
-
- // 벤더 정보
- vendorId: integer("vendor_id"),
- vendorCode: varchar("vendor_code", { length: 50 }),
- vendorName: varchar("vendor_name", { length: 255 }),
- vendorCountry: varchar("vendor_country", { length: 100 }),
-
- // 응답 상태 정보
- responseStatus: varchar("response_status", { length: 30 }),
- currentRevision: varchar("current_revision", { length: 10 }),
- respondedRevision: varchar("responded_revision", { length: 10 }),
-
- // 코멘트 관련 필드들 (새로 추가된 필드 포함)
- responseComment: text("response_comment"),
- vendorComment: text("vendor_comment"),
- revisionRequestComment: text("revision_request_comment"), // 새로 추가
-
- // 날짜 관련 필드들 (새로 추가된 필드 포함)
- requestedAt: timestamp("requested_at"),
- respondedAt: timestamp("responded_at"),
- revisionRequestedAt: timestamp("revision_requested_at"), // 새로 추가
-
- // 발주처 최신 리비전 정보
- latestClientRevisionNo: varchar("latest_client_revision_no", { length: 10 }),
- latestClientFileName: varchar("latest_client_file_name", { length: 255 }),
- latestClientFileSize: integer("latest_client_file_size"),
- latestClientRevisionComment: text("latest_client_revision_comment"),
-
- // 리비전 분석
- isVersionMatched: boolean("is_version_matched"),
- versionLag: integer("version_lag"),
- needsUpdate: boolean("needs_update"),
- hasMultipleRevisions: boolean("has_multiple_revisions"),
-
- // 응답 파일 통계
- totalResponseFiles: integer("total_response_files"),
- latestResponseFileName: varchar("latest_response_file_name", { length: 255 }),
- latestResponseFileSize: integer("latest_response_file_size"),
- latestResponseUploadedAt: timestamp("latest_response_uploaded_at"),
-
- // 효과적인 상태 (UI 표시용)
- effectiveStatus: varchar("effective_status", { length: 50 }),
-
-}).as(sql`
- SELECT
- var.id as response_id,
- ba.rfq_id,
- br.rfq_code,
- var.rfq_type,
- var.rfq_record_id,
-
- -- 첨부파일 정보
- ba.id as attachment_id,
- ba.attachment_type,
- ba.serial_no,
- ba.description as attachment_description,
-
- -- 벤더 정보
- v.id as vendor_id,
- v.vendor_code,
- v.vendor_name,
- v.country as vendor_country,
-
- -- 응답 상태
- var.response_status,
- var.current_revision,
- var.responded_revision,
-
- -- 코멘트 (새로 추가된 필드 포함)
- var.response_comment,
- var.vendor_comment,
- var.revision_request_comment,
-
- -- 날짜 (새로 추가된 필드 포함)
- var.requested_at,
- var.responded_at,
- var.revision_requested_at,
-
- -- 발주처 최신 리비전
- latest_rev.revision_no as latest_client_revision_no,
- latest_rev.original_file_name as latest_client_file_name,
- latest_rev.file_size as latest_client_file_size,
- latest_rev.revision_comment as latest_client_revision_comment,
-
- -- 리비전 분석
- CASE
- WHEN var.responded_revision = ba.current_revision THEN true
- ELSE false
- END as is_version_matched,
-
- CASE
- WHEN var.responded_revision IS NULL OR ba.current_revision IS NULL THEN NULL
- ELSE CAST(SUBSTRING(ba.current_revision FROM '[0-9]+') AS INTEGER) -
- CAST(SUBSTRING(var.responded_revision FROM '[0-9]+') AS INTEGER)
- END as version_lag,
-
- CASE
- WHEN var.response_status = 'RESPONDED'
- AND var.responded_revision != ba.current_revision THEN true
- ELSE false
- END as needs_update,
-
- CASE
- WHEN revision_count.total_revisions > 1 THEN true
- ELSE false
- END as has_multiple_revisions,
-
- -- 응답 파일 정보
- COALESCE(file_stats.total_files, 0) as total_response_files,
- file_stats.latest_file_name as latest_response_file_name,
- file_stats.latest_file_size as latest_response_file_size,
- file_stats.latest_uploaded_at as latest_response_uploaded_at,
-
- -- 효과적인 상태
- CASE
- WHEN var.response_status = 'NOT_RESPONDED' THEN 'NOT_RESPONDED'
- WHEN var.response_status = 'WAIVED' THEN 'WAIVED'
- WHEN var.response_status = 'REVISION_REQUESTED' THEN 'REVISION_REQUESTED'
- WHEN var.response_status = 'RESPONDED' AND var.responded_revision = ba.current_revision THEN 'UP_TO_DATE'
- WHEN var.response_status = 'RESPONDED' AND var.responded_revision != ba.current_revision THEN 'VERSION_MISMATCH'
- ELSE var.response_status
- END as effective_status
-
- FROM vendor_attachment_responses var
- JOIN b_rfq_attachments ba ON var.attachment_id = ba.id
- JOIN b_rfqs br ON ba.rfq_id = br.id
- LEFT JOIN vendors v ON var.vendor_id = v.id
- LEFT JOIN b_rfq_attachment_revisions latest_rev ON ba.latest_revision_id = latest_rev.id
- LEFT JOIN (
- SELECT
- attachment_id,
- COUNT(*) as total_revisions
- FROM b_rfq_attachment_revisions
- GROUP BY attachment_id
- ) revision_count ON ba.id = revision_count.attachment_id
- LEFT JOIN (
- SELECT
- vendor_response_id,
- COUNT(*) as total_files,
- MAX(original_file_name) as latest_file_name,
- MAX(file_size) as latest_file_size,
- MAX(uploaded_at) as latest_uploaded_at
- FROM vendor_response_attachments_b
- GROUP BY vendor_response_id
- ) file_stats ON var.id = file_stats.vendor_response_id
-`);
-
-// 4. RFQ 진행 현황 요약 뷰 (리비전 정보 포함)
-export const rfqProgressSummaryView = pgView("rfq_progress_summary", {
- rfqId: integer("rfq_id"),
- rfqCode: varchar("rfq_code", { length: 50 }),
- rfqStatus: varchar("rfq_status", { length: 30 }),
- dueDate: date("due_date"),
- daysToDeadline: integer("days_to_deadline"),
-
- // 첨부파일 통계
- totalAttachments: integer("total_attachments"),
- attachmentsWithMultipleRevisions: integer("attachments_with_multiple_revisions"),
- totalClientRevisions: integer("total_client_revisions"),
-
- // 응답 통계 (INITIAL)
- initialVendorCount: integer("initial_vendor_count"),
- initialTotalResponses: integer("initial_total_responses"),
- initialRespondedCount: integer("initial_responded_count"),
- initialUpToDateCount: integer("initial_up_to_date_count"),
- initialVersionMismatchCount: integer("initial_version_mismatch_count"),
- initialResponseRate: numeric("initial_response_rate", { precision: 5, scale: 2 }),
- initialVersionMatchRate: numeric("initial_version_match_rate", { precision: 5, scale: 2 }),
-
- // 응답 통계 (FINAL)
- finalVendorCount: integer("final_vendor_count"),
- finalTotalResponses: integer("final_total_responses"),
- finalRespondedCount: integer("final_responded_count"),
- finalUpToDateCount: integer("final_up_to_date_count"),
- finalVersionMismatchCount: integer("final_version_mismatch_count"),
- finalResponseRate: numeric("final_response_rate", { precision: 5, scale: 2 }),
- finalVersionMatchRate: numeric("final_version_match_rate", { precision: 5, scale: 2 }),
-
- // 전체 파일 통계
- totalResponseFiles: integer("total_response_files"),
-
-}).as(sql`
- SELECT
- br.id as rfq_id,
- br.rfq_code,
- br.status as rfq_status,
- br.due_date,
- (br.due_date - CURRENT_DATE) as days_to_deadline,
-
- -- 첨부파일 통계
- attachment_stats.total_attachments,
- attachment_stats.attachments_with_multiple_revisions,
- attachment_stats.total_client_revisions,
-
- -- Initial RFQ 통계
- COALESCE(initial_stats.vendor_count, 0) as initial_vendor_count,
- COALESCE(initial_stats.total_responses, 0) as initial_total_responses,
- COALESCE(initial_stats.responded_count, 0) as initial_responded_count,
- COALESCE(initial_stats.up_to_date_count, 0) as initial_up_to_date_count,
- COALESCE(initial_stats.version_mismatch_count, 0) as initial_version_mismatch_count,
- COALESCE(initial_stats.response_rate, 0) as initial_response_rate,
- COALESCE(initial_stats.version_match_rate, 0) as initial_version_match_rate,
-
- -- Final RFQ 통계
- COALESCE(final_stats.vendor_count, 0) as final_vendor_count,
- COALESCE(final_stats.total_responses, 0) as final_total_responses,
- COALESCE(final_stats.responded_count, 0) as final_responded_count,
- COALESCE(final_stats.up_to_date_count, 0) as final_up_to_date_count,
- COALESCE(final_stats.version_mismatch_count, 0) as final_version_mismatch_count,
- COALESCE(final_stats.response_rate, 0) as final_response_rate,
- COALESCE(final_stats.version_match_rate, 0) as final_version_match_rate,
-
- COALESCE(file_stats.total_files, 0) as total_response_files
-
- FROM b_rfqs br
- LEFT JOIN (
- SELECT
- ba.rfq_id,
- COUNT(*) as total_attachments,
- COUNT(CASE WHEN rev_count.total_revisions > 1 THEN 1 END) as attachments_with_multiple_revisions,
- SUM(rev_count.total_revisions) as total_client_revisions
- FROM b_rfq_attachments ba
- LEFT JOIN (
- SELECT
- attachment_id,
- COUNT(*) as total_revisions
- FROM b_rfq_attachment_revisions
- GROUP BY attachment_id
- ) rev_count ON ba.id = rev_count.attachment_id
- GROUP BY ba.rfq_id
- ) attachment_stats ON br.id = attachment_stats.rfq_id
-
- LEFT JOIN (
- SELECT
- br.id as rfq_id,
- COUNT(DISTINCT var.vendor_id) as vendor_count,
- COUNT(*) as total_responses,
- COUNT(CASE WHEN var.response_status = 'RESPONDED' THEN 1 END) as responded_count,
- COUNT(CASE WHEN vrd.effective_status = 'UP_TO_DATE' THEN 1 END) as up_to_date_count,
- COUNT(CASE WHEN vrd.effective_status = 'VERSION_MISMATCH' THEN 1 END) as version_mismatch_count,
- ROUND(
- COUNT(CASE WHEN var.response_status = 'RESPONDED' THEN 1 END) * 100.0 /
- NULLIF(COUNT(*), 0), 2
- ) as response_rate,
- ROUND(
- COUNT(CASE WHEN vrd.effective_status = 'UP_TO_DATE' THEN 1 END) * 100.0 /
- NULLIF(COUNT(CASE WHEN var.response_status = 'RESPONDED' THEN 1 END), 0), 2
- ) as version_match_rate
- FROM b_rfqs br
- JOIN vendor_response_detail vrd ON br.id = vrd.rfq_id
- JOIN vendor_attachment_responses var ON vrd.response_id = var.id
- WHERE var.rfq_type = 'INITIAL'
- GROUP BY br.id
- ) initial_stats ON br.id = initial_stats.rfq_id
-
- LEFT JOIN (
- SELECT
- br.id as rfq_id,
- COUNT(DISTINCT var.vendor_id) as vendor_count,
- COUNT(*) as total_responses,
- COUNT(CASE WHEN var.response_status = 'RESPONDED' THEN 1 END) as responded_count,
- COUNT(CASE WHEN vrd.effective_status = 'UP_TO_DATE' THEN 1 END) as up_to_date_count,
- COUNT(CASE WHEN vrd.effective_status = 'VERSION_MISMATCH' THEN 1 END) as version_mismatch_count,
- ROUND(
- COUNT(CASE WHEN var.response_status = 'RESPONDED' THEN 1 END) * 100.0 /
- NULLIF(COUNT(*), 0), 2
- ) as response_rate,
- ROUND(
- COUNT(CASE WHEN vrd.effective_status = 'UP_TO_DATE' THEN 1 END) * 100.0 /
- NULLIF(COUNT(CASE WHEN var.response_status = 'RESPONDED' THEN 1 END), 0), 2
- ) as version_match_rate
- FROM b_rfqs br
- JOIN vendor_response_detail vrd ON br.id = vrd.rfq_id
- JOIN vendor_attachment_responses var ON vrd.response_id = var.id
- WHERE var.rfq_type = 'FINAL'
- GROUP BY br.id
- ) final_stats ON br.id = final_stats.rfq_id
-
- LEFT JOIN (
- SELECT
- br.id as rfq_id,
- COUNT(vra.id) as total_files
- FROM b_rfqs br
- JOIN b_rfq_attachments ba ON br.id = ba.rfq_id
- JOIN vendor_attachment_responses var ON ba.id = var.attachment_id
- LEFT JOIN vendor_response_attachments_b vra ON var.id = vra.vendor_response_id
- GROUP BY br.id
- ) file_stats ON br.id = file_stats.rfq_id
-`);
-
-// 타입 정의
-export type VendorResponseAttachmentEnhanced = typeof vendorResponseAttachmentsEnhanced.$inferSelect;
-export type AttachmentRevisionHistory = typeof attachmentRevisionHistoryView.$inferSelect;
-export type VendorResponseDetail = typeof vendorResponseDetailView.$inferSelect;
-export type RfqProgressSummary = typeof rfqProgressSummaryView.$inferSelect; \ No newline at end of file
diff --git a/db/schema/index.ts b/db/schema/index.ts
index cd54e032..6463e0ec 100644
--- a/db/schema/index.ts
+++ b/db/schema/index.ts
@@ -1,24 +1,29 @@
+export * from './vendors';
export * from './companies';
export * from './contract';
export * from './items';
export * from './pq';
export * from './projects';
-export * from './rfq';
+// 미사용 테이블 주석 처리
+// export * from './rfq';
+
export * from './users';
export * from './vendorData';
export * from './vendorDocu';
-export * from './vendors';
export * from './tasks';
export * from './logs';
export * from './basicContractDocumnet';
+
+// 미사용 테이블 주석 처리
export * from './procurementRFQ';
export * from './agreementComments';
export * from './setting';
export * from './techSales';
export * from './ocr';
// 명시적 import/export로 vendorResponseSummaryView 이름 충돌 방지
-export * from './bRfq';
+// 미사용 스키마 제거
+// export * from './bRfq';
export * from './techVendors';
export * from './evaluation';
export * from './evaluationTarget';
@@ -50,6 +55,8 @@ export * from './permissions';
export * from './fileSystem';
+export * from './user-custom-data/userCustomData';
+
// 부서별 도메인 할당 관리
export * from './departmentDomainAssignments';
diff --git a/db/schema/procurementRFQ.ts b/db/schema/procurementRFQ.ts
index fe60bb0e..2756f934 100644
--- a/db/schema/procurementRFQ.ts
+++ b/db/schema/procurementRFQ.ts
@@ -1,65 +1,4 @@
-import { foreignKey, pgTable, pgView, serial, varchar, text, timestamp, boolean, integer, numeric, date, alias, check } from "drizzle-orm/pg-core";
-import { eq, sql, relations } from "drizzle-orm";
-import { projects } from "./projects";
-import { users } from "./users";
-import { vendors } from "./vendors";
-
-export const procurementRfqs = pgTable(
- "procurement_rfqs",
- {
- id: serial("id").primaryKey(),
-
- // RFQ 고유 코드
- rfqCode: varchar("rfq_code", { length: 50 }).unique(), // ex) "RFQ-2025-001"
-
- // 프로젝트: ECC RFQ는 프로젝트 테이블과 1:N 관계를 가져야 함
- // WHY?: 여러 프로젝트 혹은 여러 시리즈의 동일 품목을 PR로 묶어 올리기 때문
- projectId: varchar("project_id", { length: 1000 }),
-
- // SS, II, null 값을 가질 수 있음.
- // SS = 시리즈 통합, II = 품목 통합, 공란 = 통합 없음
- series: varchar("series", { length: 50 }),
-
- // 자재코드, 자재명: ECC RFQ는 자재코드, 자재명을 가지지 않음
- // WHY?: 여러 프로젝트 혹은 여러 시리즈의 동일 품목을 PR로 묶어 올리기 때문
- // 아래 컬럼은 대표 자재코드, 대표 자재명으로 사용
- itemCode: varchar("item_code", { length: 100 }),
- itemName: varchar("item_name", { length: 255 }),
-
- dueDate: date("due_date", { mode: "date" })
- .$type<Date>(), // 인터페이스한 값은 dueDate가 없으므로 notNull 제약조건 제거
-
- rfqSendDate: date("rfq_send_date", { mode: "date" })
- .$type<Date | null>(), // notNull() 제약조건 제거, null 허용 (ECC에서 수신 후 보내지 않은 RFQ)
-
- status: varchar("status", { length: 30 })
- .$type<"RFQ Created" | "RFQ Vendor Assignned" | "RFQ Sent" | "Quotation Analysis" | "PO Transfer" | "PO Create">()
- .default("RFQ Created")
- .notNull(),
-
- rfqSealedYn: boolean("rfq_sealed_yn").default(false),
- picCode: varchar("pic_code", { length: 50 }), // 구매그룹에 대응시킴 (담당자 코드로 3자리)
-
- remark: text("remark"),
- // 생성자
-
- sentBy: integer("sent_by")
- .references(() => users.id, { onDelete: "set null" }),
-
-
- createdBy: integer("created_by")
- .notNull()
- .references(() => users.id, { onDelete: "set null" }),
-
- updatedBy: integer("updated_by")
- .notNull()
- .references(() => users.id, { onDelete: "set null" }),
-
- createdAt: timestamp("created_at").defaultNow().notNull(),
- updatedAt: timestamp("updated_at").defaultNow().notNull(),
- },
-
-);
+import { pgTable, varchar, timestamp, boolean } from "drizzle-orm/pg-core";
/**
* 지불조건, 인코텀즈, 선적/하역(출발지, 도착지) 테이블은 Non-SAP에서 동기화 (Oracle DB to PostgreSQL)
@@ -91,712 +30,4 @@ export const placeOfShipping = pgTable("place_of_shipping", {
createdAt: timestamp("created_at").defaultNow().notNull(),
});
-export const procurementRfqDetails = pgTable(
- "procurement_rfq_details",
- {
- id: serial("id").primaryKey(),
- procurementRfqsId: integer("procurement_rfqs_id")
- .references(() => procurementRfqs.id, { onDelete: "set null" }),
- vendorsId: integer("vendors_id")
- .references(() => vendors.id, { onDelete: "set null" }),
- currency: varchar("currency", { length: 10 }).default("USD"),
-
- // 정규화된 paymentTerms 참조
- paymentTermsCode: varchar("payment_terms_code", { length: 50 })
- .references(() => paymentTerms.code, { onDelete: "set null" }),
- // paymentTerms 필드는 제거 (코드로 조회)
-
- // 정규화된 incoterms 참조
- incotermsCode: varchar("incoterms_code", { length: 20 })
- .references(() => incoterms.code, { onDelete: "set null" }),
- incotermsDetail: varchar("incoterms_detail", { length: 255 }),
-
- deliveryDate: date("delivery_date", { mode: "date" })
- .$type<Date>()
- .notNull(),
- taxCode: varchar("tax_code", { length: 255 }).default("VV"),
- placeOfShipping: varchar("place_of_shipping", { length: 255 }),
- placeOfDestination: varchar("place_of_destination", { length: 255 }),
- remark: text("remark"),
- cancelReason: text("cancel_reason"),
- updatedBy: integer("updated_by")
- .notNull()
- .references(() => users.id, { onDelete: "set null" }),
- updatedAt: timestamp("updated_at").defaultNow().notNull(),
- materialPriceRelatedYn: boolean("material_price_related_yn").default(false),
- }
-);
-
-export const prItems = pgTable(
- "pr_items",
- {
- id: serial("id").primaryKey(),
- procurementRfqsId: integer("procurement_rfqs_id")
- .references(() => procurementRfqs.id, { onDelete: "set null" }),
-
- rfqItem: varchar("rfq_item", { length: 50 }), // 단위
- prItem: varchar("pr_item", { length: 50 }), // 단위
- prNo: varchar("pr_no", { length: 50 }), // 단위
-
- // itemId: integer("item_id")
- // .references(() => items.id, { onDelete: "set null" }),
-
- materialCode: varchar("material_code", { length: 255 }),
- materialCategory: varchar("material_category", { length: 255 }),
- acc: varchar("acc", { length: 255 }),
- materialDescription: varchar("material_description", { length: 255 }),
- size: varchar("size", { length: 255 }),
- deliveryDate: date("delivery_date", { mode: "date" })
- .$type<Date>(),
- quantity: numeric("quantity", { precision: 12, scale: 2 })
- .$type<number>()
- .default(1),
- uom: varchar("uom", { length: 50 }), // 단위
- grossWeight: numeric("gross_weight", { precision: 12, scale: 2 })
- .$type<number>()
- .default(1),
- gwUom: varchar("gw_uom", { length: 50 }), // 단위
-
- specNo: varchar("spec_no", { length: 255 }),
- specUrl: varchar("spec_url", { length: 255 }),
- trackingNo: varchar("tracking_no", { length: 255 }),
-
- majorYn: boolean("major_yn").default(false),
-
- projectDef: varchar("project_def", { length: 255 }),
- projectSc: varchar("project_sc", { length: 255 }),
- projectKl: varchar("project_kl", { length: 255 }),
- projectLc: varchar("project_lc", { length: 255 }),
- projectDl: varchar("project_dl", { length: 255 }),
-
- remark: text("remark"),
-
- },
-);
-
-
-//view
-export const procurementRfqsView = pgView("procurement_rfqs_view").as((qb) => {
- const createdByUser = alias(users, "created_by_user");
- const updatedByUser = alias(users, "updated_by_user");
- const sentByUser = alias(users, "sent_by_user");
-
- return qb
- .select({
- // Basic RFQ identification
- id: sql<number>`${procurementRfqs.id}`.as("id"),
- rfqCode: sql<string>`${procurementRfqs.rfqCode}`.as("rfq_code"),
- series: sql<string | null>`${procurementRfqs.series}`.as("series"),
- rfqSealedYn: sql<string | null>`${procurementRfqs.rfqSealedYn}`.as("rfq_sealed_yn"),
-
- // Project information
- projectCode: sql<string | null>`${projects.code}`.as("project_code"),
- projectName: sql<string | null>`${projects.name}`.as("project_name"),
-
- // Item information
- itemCode: sql<string | null>`${procurementRfqs.itemCode}`.as("item_code"),
- itemName: sql<string | null>`${procurementRfqs.itemName}`.as("item_name"),
-
- // Status and dates
- status: sql<string>`${procurementRfqs.status}`.as("status"),
- picCode: sql<string | null>`${procurementRfqs.picCode}`.as("pic_code"),
- rfqSendDate: sql<Date | null>`${procurementRfqs.rfqSendDate}`.as("rfq_send_date"),
- dueDate: sql<Date | null>`${procurementRfqs.dueDate}`.as("due_date"),
-
- // 가장 빠른 견적서 제출 날짜 추가
- earliestQuotationSubmittedAt: sql<Date | null>`(
- SELECT MIN(submitted_at)
- FROM procurement_vendor_quotations
- WHERE rfq_id = ${procurementRfqs.id}
- AND submitted_at IS NOT NULL
- )`.as("earliest_quotation_submitted_at"),
-
- // Audit information
- createdByUserName: sql<string | null>`${createdByUser.name}`.as("created_by_user_name"),
- sentByUserName: sql<string | null>`${sentByUser.name}`.as("sent_by_user_name"),
- updatedAt: sql<Date>`${procurementRfqs.updatedAt}`.as("updated_at"),
- updatedByUserName: sql<string | null>`${updatedByUser.name}`.as("updated_by_user_name"),
- remark: sql<string | null>`${procurementRfqs.remark}`.as("remark"),
-
- // Related item information
- majorItemMaterialCode: sql<string | null>`(
- SELECT material_code
- FROM pr_items
- WHERE procurement_rfqs_id = ${procurementRfqs.id}
- AND major_yn = true
- LIMIT 1
- )`.as("major_item_material_code"),
-
- poNo: sql<string | null>`(
- SELECT pr_no
- FROM pr_items
- WHERE procurement_rfqs_id = ${procurementRfqs.id}
- AND major_yn = true
- LIMIT 1
- )`.as("po_no"),
-
- prItemsCount: sql<number>`(
- SELECT COUNT(*)
- FROM pr_items
- WHERE procurement_rfqs_id = ${procurementRfqs.id}
- )`.as("pr_items_count")
- })
- .from(procurementRfqs)
- .leftJoin(projects, eq(procurementRfqs.projectId, projects.id))
- // .leftJoin(items, eq(procurementRfqs.itemId, items.id))
- .leftJoin(createdByUser, eq(procurementRfqs.createdBy, createdByUser.id))
- .leftJoin(updatedByUser, eq(procurementRfqs.updatedBy, updatedByUser.id))
- .leftJoin(sentByUser, eq(procurementRfqs.sentBy, sentByUser.id));
-});
-
-// 수정된 pr_items_view
-export const prItemsView = pgView("pr_items_view").as((qb) => {
- return qb
- .select({
- id: prItems.id,
- procurementRfqsId: prItems.procurementRfqsId,
- rfqItem: prItems.rfqItem,
- prItem: prItems.prItem,
- prNo: prItems.prNo,
- // itemId: prItems.itemId,
- materialCode: prItems.materialCode,
- materialCategory: prItems.materialCategory,
- acc: prItems.acc,
- materialDescription: prItems.materialDescription,
- size: prItems.size,
- deliveryDate: prItems.deliveryDate,
- quantity: prItems.quantity,
- uom: prItems.uom,
- grossWeight: prItems.grossWeight, // 필드명 수정
- gwUom: prItems.gwUom,
- specNo: prItems.specNo,
- specUrl: prItems.specUrl,
- trackingNo: prItems.trackingNo,
- majorYn: prItems.majorYn,
- projectDef: prItems.projectDef,
- projectSc: prItems.projectSc,
- projectKl: prItems.projectKl,
- projectLc: prItems.projectLc,
- projectDl: prItems.projectDl,
- remark: prItems.remark,
- rfqCode: procurementRfqs.rfqCode,
- itemCode: procurementRfqs.itemCode,
- itemName: procurementRfqs.itemName
- })
- .from(prItems)
- .leftJoin(procurementRfqs, eq(prItems.procurementRfqsId, procurementRfqs.id))
-});
-
-export const procurementRfqDetailsView = pgView("procurement_rfq_details_view").as((qb) => {
- // 기존 별칭 정의 유지
- const rfqDetailsTable = alias(procurementRfqDetails, "rfq_details");
- const rfqsTable = alias(procurementRfqs, "rfqs");
- const projectsTable = alias(projects, "projects");
- const vendorsTable = alias(vendors, "vendors");
- const paymentTermsTable = alias(paymentTerms, "payment_terms");
- const incotermsTable = alias(incoterms, "incoterms");
- const updatedByUser = alias(users, "updated_by_user");
-
- return qb
- .select({
- // procurementRfqDetails 필드
- detailId: sql<number>`${rfqDetailsTable.id}`.as("detail_id"),
- rfqId: sql<number>`${rfqsTable.id}`.as("rfq_id"),
- rfqCode: sql<string>`${rfqsTable.rfqCode}`.as("rfq_code"),
-
- // 프로젝트 관련 필드
- projectCode: sql<string | null>`${projectsTable.code}`.as("project_code"),
- projectName: sql<string | null>`${projectsTable.name}`.as("project_name"),
-
- // 아이템 관련 필드
- itemCode: sql<string | null>`${rfqsTable.itemCode}`.as("item_code"),
- itemName: sql<string | null>`${rfqsTable.itemName}`.as("item_name"),
-
- // 벤더 관련 필드
- vendorName: sql<string | null>`${vendorsTable.vendorName}`.as("vendor_name"),
- vendorCode: sql<string | null>`${vendorsTable.vendorCode}`.as("vendor_code"),
- vendorId: sql<string | null>`${vendorsTable.id}`.as("vendor_id"),
- vendorCountry: sql<string | null>`${vendorsTable.country}`.as("vendor_country"),
-
- // RFQ 상세 정보 필드
- currency: sql<string | null>`${rfqDetailsTable.currency}`.as("currency"),
- paymentTermsCode: sql<string | null>`${paymentTermsTable.code}`.as("payment_terms_code"),
- paymentTermsDescription: sql<string | null>`${paymentTermsTable.description}`.as("payment_terms_description"),
- incotermsCode: sql<string | null>`${incotermsTable.code}`.as("incoterms_code"),
- incotermsDescription: sql<string | null>`${incotermsTable.description}`.as("incoterms_description"),
- incotermsDetail: sql<string | null>`${rfqDetailsTable.incotermsDetail}`.as("incoterms_detail"),
- deliveryDate: sql<Date | null>`${rfqDetailsTable.deliveryDate}`.as("delivery_date"),
- taxCode: sql<string | null>`${rfqDetailsTable.taxCode}`.as("tax_code"),
- placeOfShipping: sql<string | null>`${rfqDetailsTable.placeOfShipping}`.as("place_of_shipping"),
- placeOfDestination: sql<string | null>`${rfqDetailsTable.placeOfDestination}`.as("place_of_destination"),
- materialPriceRelatedYn: sql<boolean | null>`${rfqDetailsTable.materialPriceRelatedYn}`.as("material_price_related_yn"),
-
- // 업데이트 관련 필드
- updatedByUserName: sql<string | null>`${updatedByUser.name}`.as("updated_by_user_name"),
- updatedAt: sql<Date | null>`${rfqDetailsTable.updatedAt}`.as("updated_at"),
-
- // 추가적으로 pr_items 개수도 카운트하는 필드 추가
- prItemsCount: sql<number>`(
- SELECT COUNT(*)
- FROM pr_items
- WHERE procurement_rfqs_id = ${rfqsTable.id}
- )`.as("pr_items_count"),
-
- // 메이저 아이템 개수도 카운트
- majorItemsCount: sql<number>`(
- SELECT COUNT(*)
- FROM pr_items
- WHERE procurement_rfqs_id = ${rfqsTable.id}
- AND major_yn = true
- )`.as("major_items_count"),
-
- // 새로운 필드 추가: 코멘트 수 카운트
- commentCount: sql<number>`(
- SELECT COUNT(*)
- FROM procurement_rfq_comments
- WHERE rfq_id = ${rfqsTable.id} AND vendor_id = ${rfqDetailsTable.vendorsId}
- )`.as("comment_count"),
-
- // 새로운 필드 추가: 최근 코멘트 날짜
- lastCommentDate: sql<Date | null>`(
- SELECT created_at
- FROM procurement_rfq_comments
- WHERE rfq_id = ${rfqsTable.id} AND vendor_id = ${rfqDetailsTable.vendorsId}
- ORDER BY created_at DESC LIMIT 1
- )`.as("last_comment_date"),
-
- // 새로운 필드 추가: 벤더가 마지막으로 코멘트한 날짜
- lastVendorCommentDate: sql<Date | null>`(
- SELECT created_at
- FROM procurement_rfq_comments
- WHERE rfq_id = ${rfqsTable.id} AND vendor_id = ${rfqDetailsTable.vendorsId} AND is_vendor_comment = true
- ORDER BY created_at DESC LIMIT 1
- )`.as("last_vendor_comment_date"),
-
- // 새로운 필드 추가: 첨부파일 수 카운트
- attachmentCount: sql<number>`(
- SELECT COUNT(*)
- FROM procurement_rfq_attachments
- WHERE rfq_id = ${rfqsTable.id} AND vendor_id = ${rfqDetailsTable.vendorsId}
- )`.as("attachment_count"),
-
- // 새로운 필드 추가: 견적서 제출 여부
- hasQuotation: sql<boolean>`(
- SELECT COUNT(*) > 0
- FROM procurement_vendor_quotations
- WHERE rfq_id = ${rfqsTable.id} AND vendor_id = ${rfqDetailsTable.vendorsId}
- )`.as("has_quotation"),
-
- // 새로운 필드 추가: 견적서 상태
- quotationStatus: sql<string | null>`(
- SELECT status
- FROM procurement_vendor_quotations
- WHERE rfq_id = ${rfqsTable.id} AND vendor_id = ${rfqDetailsTable.vendorsId}
- ORDER BY created_at DESC LIMIT 1
- )`.as("quotation_status"),
-
- // 새로운 필드 추가: 견적서 총액
- quotationTotalPrice: sql<number | null>`(
- SELECT total_price
- FROM procurement_vendor_quotations
- WHERE rfq_id = ${rfqsTable.id} AND vendor_id = ${rfqDetailsTable.vendorsId}
- ORDER BY created_at DESC LIMIT 1
- )`.as("quotation_total_price"),
-
- // 최신 견적서 버전
- quotationVersion: sql<number | null>`(
- SELECT quotation_version
- FROM procurement_vendor_quotations
- WHERE rfq_id = ${rfqsTable.id} AND vendor_id = ${rfqDetailsTable.vendorsId}
- ORDER BY quotation_version DESC LIMIT 1
- )`.as("quotation_version"),
-
- // 총 견적서 버전 수
- quotationVersionCount: sql<number>`(
- SELECT COUNT(DISTINCT quotation_version)
- FROM procurement_vendor_quotations
- WHERE rfq_id = ${rfqsTable.id} AND vendor_id = ${rfqDetailsTable.vendorsId}
- )`.as("quotation_version_count"),
-
- // 마지막 견적서 생성 날짜
- lastQuotationDate: sql<Date | null>`(
- SELECT created_at
- FROM procurement_vendor_quotations
- WHERE rfq_id = ${rfqsTable.id} AND vendor_id = ${rfqDetailsTable.vendorsId}
- ORDER BY quotation_version DESC LIMIT 1
- )`.as("last_quotation_date"),
- })
- .from(rfqDetailsTable)
- // 기존 조인 유지
- .leftJoin(rfqsTable, eq(rfqDetailsTable.procurementRfqsId, rfqsTable.id))
- .leftJoin(projectsTable, eq(rfqsTable.projectId, projectsTable.id))
- // .leftJoin(itemsTable, eq(rfqsTable.itemId, itemsTable.id))
- .leftJoin(vendorsTable, eq(rfqDetailsTable.vendorsId, vendorsTable.id))
- .leftJoin(paymentTermsTable, eq(rfqDetailsTable.paymentTermsCode, paymentTermsTable.code))
- .leftJoin(incotermsTable, eq(rfqDetailsTable.incotermsCode, incotermsTable.code))
- .leftJoin(updatedByUser, eq(rfqDetailsTable.updatedBy, updatedByUser.id));
-});
-
-export const procurementAttachments = pgTable(
- "procurement_attachments",
- {
- id: serial("id").primaryKey(),
- attachmentType: varchar("attachment_type", { length: 50 }).notNull(), // 'RFQ_COMMON', 'VENDOR_SPECIFIC'
- procurementRfqsId: integer("procurement_rfqs_id")
- .references(() => procurementRfqs.id, { onDelete: "cascade" }),
- procurementRfqDetailsId: integer("procurement_rfq_details_id")
- .references(() => procurementRfqDetails.id, { onDelete: "cascade" }),
- fileName: varchar("file_name", { length: 255 }).notNull(),
- originalFileName: varchar("original_file_name", { length: 255 }).notNull(),
- filePath: varchar("file_path", { length: 512 }).notNull(),
- fileSize: integer("file_size"),
- fileType: varchar("file_type", { length: 100 }),
- description: varchar("description", { length: 500 }),
- createdBy: integer("created_by")
- .references(() => users.id, { onDelete: "set null" })
- .notNull(),
- createdAt: timestamp("created_at").defaultNow().notNull(),
- },
- (table) => ({
- attachmentTypeCheck: check(
- "attachment_type_check",
- sql`${table.procurementRfqsId} IS NOT NULL OR ${table.procurementRfqDetailsId} IS NOT NULL`
- )
- })
-);
-
-export type ProcurementRfqsView = typeof procurementRfqsView.$inferSelect;
-export type PrItemsView = typeof prItemsView.$inferSelect;
-export type ProcurementRfqDetailsView = typeof procurementRfqDetailsView.$inferSelect;
-
-
-//vendor response
-export const procurementVendorQuotations = pgTable(
- "procurement_vendor_quotations",
- {
- id: serial("id").primaryKey(),
- rfqId: integer("rfq_id")
- .notNull()
- .references(() => procurementRfqs.id, { onDelete: "cascade" }),
- vendorId: integer("vendor_id")
- .notNull()
- .references(() => vendors.id, { onDelete: "set null" }),
-
- // 견적 요약 정보
- quotationCode: varchar("quotation_code", { length: 50 }),
- quotationVersion: integer("quotation_version").default(1),
- totalItemsCount: integer("total_items_count").default(0),
- subTotal: numeric("sub_total").default("0"),
- taxTotal: numeric("tax_total").default("0"),
- discountTotal: numeric("discount_total").default("0"),
- totalPrice: numeric("total_price").default("0"),
- currency: varchar("currency", { length: 10 }).default("USD"),
-
- // 견적 유효성 및 배송 정보
- validUntil: date("valid_until", { mode: "date" }).$type<Date>(),
- estimatedDeliveryDate: date("estimated_delivery_date", { mode: "date" }).$type<Date>(),
-
- // 지불 조건 등 상세 정보
- paymentTermsCode: varchar("payment_terms_code", { length: 50 })
- .references(() => paymentTerms.code, { onDelete: "set null" }),
- incotermsCode: varchar("incoterms_code", { length: 20 })
- .references(() => incoterms.code, { onDelete: "set null" }),
- incotermsDetail: varchar("incoterms_detail", { length: 255 }),
-
- // 상태 관리
- status: varchar("status", { length: 30 })
- .$type<"Draft" | "Submitted" | "Revised" | "Rejected" | "Accepted">()
- .default("Draft")
- .notNull(),
-
- // 기타 정보
- remark: text("remark"),
- rejectionReason: text("rejection_reason"),
- submittedAt: timestamp("submitted_at"),
- acceptedAt: timestamp("accepted_at"),
-
- // 감사 필드
- createdBy: integer("created_by"),
- updatedBy: integer("updated_by"),
- createdAt: timestamp("created_at").defaultNow().notNull(),
- updatedAt: timestamp("updated_at").defaultNow().notNull(),
- }
-);
-
-export const procurementRfqComments = pgTable(
- "procurement_rfq_comments",
- {
- id: serial("id").primaryKey(),
- rfqId: integer("rfq_id")
- .notNull()
- .references(() => procurementRfqs.id, { onDelete: "cascade" }),
- vendorId: integer("vendor_id")
- .references(() => vendors.id, { onDelete: "set null" }),
- userId: integer("user_id")
- .references(() => users.id, { onDelete: "set null" }),
- content: text("content").notNull(),
- isVendorComment: boolean("is_vendor_comment").default(false),
- isRead: boolean("is_read").default(false), // 읽음 상태 추가
- parentCommentId: integer("parent_comment_id"),
- createdAt: timestamp("created_at").defaultNow().notNull(),
- updatedAt: timestamp("updated_at").defaultNow().notNull(),
- },
- // 자기참조 FK 정의
- (table) => {
- return {
- parentFk: foreignKey({
- columns: [table.parentCommentId],
- foreignColumns: [table.id],
- }).onDelete("set null"),
- };
- }
-);
-
-export const procurementRfqAttachments = pgTable(
- "procurement_rfq_attachments",
- {
- id: serial("id").primaryKey(),
- rfqId: integer("rfq_id")
- .notNull()
- .references(() => procurementRfqs.id, { onDelete: "cascade" }),
- commentId: integer("comment_id")
- .references(() => procurementRfqComments.id, { onDelete: "cascade" }),
- quotationId: integer("quotation_id")
- .references(() => procurementVendorQuotations.id, { onDelete: "cascade" }),
- fileName: varchar("file_name", { length: 255 }).notNull(),
- fileSize: integer("file_size").notNull(),
- fileType: varchar("file_type", { length: 100 }),
- filePath: varchar("file_path", { length: 500 }).notNull(),
- isVendorUpload: boolean("is_vendor_upload").default(false),
- uploadedBy: integer("uploaded_by")
- .references(() => users.id, { onDelete: "set null" }),
- vendorId: integer("vendor_id")
- .references(() => vendors.id, { onDelete: "set null" }),
- uploadedAt: timestamp("uploaded_at").defaultNow().notNull(),
- }
-);
-
-export const procurementQuotationItems = pgTable(
- "procurement_quotation_items",
- {
- id: serial("id").primaryKey(),
- quotationId: integer("quotation_id")
- .notNull()
- .references(() => procurementVendorQuotations.id, { onDelete: "cascade" }),
-
- // PR 아이템과의 연결 추가
- prItemId: integer("pr_item_id")
- .notNull()
- .references(() => prItems.id, { onDelete: "cascade" }),
-
- // 원본 PR 아이템 정보 참조 (읽기 전용)
- materialCode: varchar("material_code", { length: 50 }),
- materialDescription: varchar("material_description", { length: 255 }),
- quantity: numeric("quantity").notNull(),
- uom: varchar("uom", { length: 20 }),
-
- // 벤더가 입력하는 정보
- unitPrice: numeric("unit_price").notNull(),
- totalPrice: numeric("total_price").notNull(),
- currency: varchar("currency", { length: 10 }).default("USD"),
- vendorMaterialCode: varchar("vendor_material_code", { length: 50 }),
- vendorMaterialDescription: varchar("vendor_material_description", { length: 255 }),
-
- // 배송 관련 정보
- deliveryDate: date("delivery_date", { mode: "date" }).$type<Date>(),
- leadTimeInDays: integer("lead_time_in_days"),
-
- // 세금 및 기타 정보
- taxRate: numeric("tax_rate"),
- taxAmount: numeric("tax_amount"),
- discountRate: numeric("discount_rate"),
- discountAmount: numeric("discount_amount"),
-
- // 기타 정보
- remark: text("remark"),
- isAlternative: boolean("is_alternative").default(false),
- isRecommended: boolean("is_recommended").default(false),
- createdAt: timestamp("created_at").defaultNow().notNull(),
- updatedAt: timestamp("updated_at").defaultNow().notNull(),
- }
-);
-
-
-// procurementRfqComments 테이블의 관계 정의 - 타입 명시
-export const procurementRfqCommentsRelations = relations(
- procurementRfqComments,
- ({ one, many }) => ({
- user: one(users, {
- fields: [procurementRfqComments.userId],
- references: [users.id],
- }),
- vendor: one(vendors, {
- fields: [procurementRfqComments.vendorId],
- references: [vendors.id],
- }),
- rfq: one(procurementRfqs, {
- fields: [procurementRfqComments.rfqId],
- references: [procurementRfqs.id],
- }),
- parentComment: one(procurementRfqComments, {
- fields: [procurementRfqComments.parentCommentId],
- references: [procurementRfqComments.id],
- relationName: "commentHierarchy",
- }),
- childComments: many(procurementRfqComments, { relationName: "commentHierarchy" }),
- attachments: many(procurementRfqAttachments, { relationName: "commentAttachments" }),
- })
-);
-
-export const procurementRfqAttachmentsRelations = relations(
- procurementRfqAttachments,
- ({ one }) => ({
- comment: one(procurementRfqComments, {
- fields: [procurementRfqAttachments.commentId],
- references: [procurementRfqComments.id],
- relationName: "commentAttachments",
- }),
- rfq: one(procurementRfqs, {
- fields: [procurementRfqAttachments.rfqId],
- references: [procurementRfqs.id],
- }),
- uploader: one(users, {
- fields: [procurementRfqAttachments.uploadedBy],
- references: [users.id],
- }),
- vendor: one(vendors, {
- fields: [procurementRfqAttachments.vendorId],
- references: [vendors.id],
- }),
- })
-);
-
-
-export const procurementRfqsRelations = relations(
- procurementRfqs,
- ({ one, many }) => ({
- project: one(projects, {
- fields: [procurementRfqs.projectId],
- references: [projects.id],
- }),
- // item: one(items, {
- // fields: [procurementRfqs.itemId],
- // references: [items.id],
- // }),
- createdByUser: one(users, {
- fields: [procurementRfqs.createdBy],
- references: [users.id],
- relationName: "rfqCreator",
- }),
- updatedByUser: one(users, {
- fields: [procurementRfqs.updatedBy],
- references: [users.id],
- relationName: "rfqUpdater",
- }),
- rfqDetails: many(procurementRfqDetails),
- prItems: many(prItems, { relationName: "rfqPrItems" }),
- quotations: many(procurementVendorQuotations),
-
- })
-);
-
-export const prItemsRelations = relations(
- prItems,
- ({ one }) => ({
- rfq: one(procurementRfqs, {
- fields: [prItems.procurementRfqsId],
- references: [procurementRfqs.id],
- relationName: "rfqPrItems"
- }),
- })
-);
-
-// procurementRfqDetails 테이블의 관계 정의
-export const procurementRfqDetailsRelations = relations(
- procurementRfqDetails,
- ({ one }) => ({
- rfq: one(procurementRfqs, {
- fields: [procurementRfqDetails.procurementRfqsId],
- references: [procurementRfqs.id],
- }),
- vendor: one(vendors, {
- fields: [procurementRfqDetails.vendorsId],
- references: [vendors.id],
- }),
- paymentTerms: one(paymentTerms, {
- fields: [procurementRfqDetails.paymentTermsCode],
- references: [paymentTerms.code],
- }),
- incoterms: one(incoterms, {
- fields: [procurementRfqDetails.incotermsCode],
- references: [incoterms.code],
- }),
- updatedByUser: one(users, {
- fields: [procurementRfqDetails.updatedBy],
- references: [users.id],
- }),
- })
-);
-
-export const vendorsRelations = relations(
- vendors,
- ({ many }) => ({
- users: many(users, {
- relationName: "vendorUsers"
- }),
- quotations: many(procurementVendorQuotations),
- })
-);
-
-export const usersRelations = relations(
- users,
- ({ one }) => ({
- vendor: one(vendors, {
- fields: [users.companyId],
- references: [vendors.id],
- relationName: "vendorUsers"
- }),
- })
-);
-
-export const procurementVendorQuotationsRelations = relations(
- procurementVendorQuotations,
- ({ one, many }) => ({
- rfq: one(procurementRfqs, {
- fields: [procurementVendorQuotations.rfqId],
- references: [procurementRfqs.id],
- // relationName 제거
- }),
- vendor: one(vendors, {
- fields: [procurementVendorQuotations.vendorId],
- references: [vendors.id],
- // relationName 제거
- }),
- items: many(procurementQuotationItems),
- paymentTerms: one(paymentTerms, {
- fields: [procurementVendorQuotations.paymentTermsCode],
- references: [paymentTerms.code],
- }),
- incoterms: one(incoterms, {
- fields: [procurementVendorQuotations.incotermsCode],
- references: [incoterms.code],
- }),
- })
-);
-
-export const procurementQuotationItemsRelations = relations(
- procurementQuotationItems,
- ({ one }) => ({
- quotation: one(procurementVendorQuotations, {
- fields: [procurementQuotationItems.quotationId],
- references: [procurementVendorQuotations.id],
- }),
- prItem: one(prItems, {
- fields: [procurementQuotationItems.prItemId],
- references: [prItems.id],
- }),
- })
-);
-
-export type ProcurementVendorQuotations = typeof procurementVendorQuotations.$inferSelect;
export type Incoterm = typeof incoterms.$inferSelect;
diff --git a/db/schema/rfqLastTBE.ts b/db/schema/rfqLastTBE.ts
index e690ce4b..20cefba3 100644
--- a/db/schema/rfqLastTBE.ts
+++ b/db/schema/rfqLastTBE.ts
@@ -1,6 +1,6 @@
import { pgTable, pgView, serial, varchar, text, timestamp, boolean, integer, numeric, date, alias, jsonb, uniqueIndex, index } from "drizzle-orm/pg-core";
import { eq, sql, relations } from "drizzle-orm";
-import { rfqsLast, rfqLastDetails, rfqLastAttachments, rfqLastAttachmentRevisions } from "./rfqLast";
+import { rfqsLast, rfqLastDetails, rfqLastAttachments, rfqLastAttachmentRevisions, rfqPrItems } from "./rfqLast";
import { users } from "./users";
import { vendors } from "./vendors";
import { rfqLastVendorAttachments } from "./rfqVendor";
@@ -393,23 +393,23 @@ export const tbeSessionSummaryView = pgView("tbe_session_summary_view").as((qb)
// 문서 검토 통계
totalDocuments: sql<number>`(
SELECT COUNT(*)
- FROM rfq_last_tbe_document_reviews
- WHERE tbe_session_id = ${tbeSession.id}
+ FROM ${rfqLastTbeDocumentReviews}
+ WHERE ${rfqLastTbeDocumentReviews.tbeSessionId} = ${tbeSession.id}
)`.as("total_documents"),
reviewedDocuments: sql<number>`(
SELECT COUNT(*)
- FROM rfq_last_tbe_document_reviews
- WHERE tbe_session_id = ${tbeSession.id}
- AND review_status IN ('검토완료', '승인')
+ FROM ${rfqLastTbeDocumentReviews}
+ WHERE ${rfqLastTbeDocumentReviews.tbeSessionId} = ${tbeSession.id}
+ AND ${rfqLastTbeDocumentReviews.reviewStatus} IN ('검토완료', '승인')
)`.as("reviewed_documents"),
// PDFTron 코멘트 통계
totalComments: sql<number>`(
- SELECT COUNT(*)
- FROM rfq_last_tbe_pdftron_comments pc
- JOIN rfq_last_tbe_document_reviews dr ON pc.document_review_id = dr.id
- WHERE dr.tbe_session_id = ${tbeSession.id}
+ SELECT COALESCE(SUM((${rfqLastTbePdftronComments.commentSummary}->>'total')::int), 0)
+ FROM ${rfqLastTbePdftronComments}
+ JOIN ${rfqLastTbeDocumentReviews} ON ${rfqLastTbePdftronComments.documentReviewId} = ${rfqLastTbeDocumentReviews.id}
+ WHERE ${rfqLastTbeDocumentReviews.tbeSessionId} = ${tbeSession.id}
)`.as("total_comments"),
@@ -537,54 +537,53 @@ export const tbeLastView = pgView("tbe_last_view").as((qb) => {
// PR 아이템 수
prItemsCount: sql<number>`(
SELECT COUNT(*)
- FROM rfq_pr_items
- WHERE rfqs_last_id = ${rfqsLast.id}
+ FROM ${rfqPrItems}
+ WHERE ${rfqPrItems.rfqsLastId} = ${rfqsLast.id}
)`.as("pr_items_count"),
majorItemsCount: sql<number>`(
SELECT COUNT(*)
- FROM rfq_pr_items
- WHERE rfqs_last_id = ${rfqsLast.id}
- AND major_yn = true
+ FROM ${rfqPrItems}
+ WHERE ${rfqPrItems.rfqsLastId} = ${rfqsLast.id}
+ AND ${rfqPrItems.majorYn} = true
)`.as("major_items_count"),
// 구매자 문서 수 (설계 문서)
buyerDocumentsCount: sql<number>`(
SELECT COUNT(*)
- FROM rfq_last_tbe_document_reviews
- WHERE tbe_session_id = ${rfqLastTbeSessions.id}
- AND document_source = 'buyer'
+ FROM ${rfqLastTbeDocumentReviews}
+ WHERE ${rfqLastTbeDocumentReviews.tbeSessionId} = ${rfqLastTbeSessions.id}
+ AND ${rfqLastTbeDocumentReviews.documentSource} = 'buyer'
)`.as("buyer_documents_count"),
// 벤더 문서 수
vendorDocumentsCount: sql<number>`(
SELECT COUNT(*)
- FROM rfq_last_tbe_vendor_documents
- WHERE tbe_session_id = ${rfqLastTbeSessions.id}
+ FROM ${rfqLastTbeVendorDocuments}
+ WHERE ${rfqLastTbeVendorDocuments.tbeSessionId} = ${rfqLastTbeSessions.id}
)`.as("vendor_documents_count"),
// 검토 완료 문서 수
reviewedDocumentsCount: sql<number>`(
SELECT COUNT(*)
- FROM rfq_last_tbe_document_reviews
- WHERE tbe_session_id = ${rfqLastTbeSessions.id}
- AND review_status IN ('검토완료', '승인')
+ FROM ${rfqLastTbeDocumentReviews}
+ WHERE ${rfqLastTbeDocumentReviews.tbeSessionId} = ${rfqLastTbeSessions.id}
+ AND ${rfqLastTbeDocumentReviews.reviewStatus} IN ('검토완료', '승인')
)`.as("reviewed_documents_count"),
// PDFTron 코멘트 수
totalCommentsCount: sql<number>`(
- SELECT COUNT(*)
- FROM rfq_last_tbe_pdftron_comments pc
- JOIN rfq_last_tbe_document_reviews dr ON pc.document_review_id = dr.id
- WHERE dr.tbe_session_id = ${rfqLastTbeSessions.id}
+ SELECT COALESCE(SUM((${rfqLastTbePdftronComments.commentSummary}->>'total')::int), 0)
+ FROM ${rfqLastTbePdftronComments}
+ JOIN ${rfqLastTbeDocumentReviews} ON ${rfqLastTbePdftronComments.documentReviewId} = ${rfqLastTbeDocumentReviews.id}
+ WHERE ${rfqLastTbeDocumentReviews.tbeSessionId} = ${rfqLastTbeSessions.id}
)`.as("total_comments_count"),
unresolvedCommentsCount: sql<number>`(
- SELECT COUNT(*)
- FROM rfq_last_tbe_pdftron_comments pc
- JOIN rfq_last_tbe_document_reviews dr ON pc.document_review_id = dr.id
- WHERE dr.tbe_session_id = ${rfqLastTbeSessions.id}
- AND pc.status = 'open'
+ SELECT COALESCE(SUM((${rfqLastTbePdftronComments.commentSummary}->>'open')::int), 0)
+ FROM ${rfqLastTbePdftronComments}
+ JOIN ${rfqLastTbeDocumentReviews} ON ${rfqLastTbePdftronComments.documentReviewId} = ${rfqLastTbeDocumentReviews.id}
+ WHERE ${rfqLastTbeDocumentReviews.tbeSessionId} = ${rfqLastTbeSessions.id}
)`.as("unresolved_comments_count"),
// 타임스탬프
diff --git a/db/schema/user-custom-data/userCustomData.ts b/db/schema/user-custom-data/userCustomData.ts
new file mode 100644
index 00000000..bf529679
--- /dev/null
+++ b/db/schema/user-custom-data/userCustomData.ts
@@ -0,0 +1,16 @@
+/**
+ * user custom data
+ *
+ * */
+import { integer, json, pgTable, timestamp, uuid, varchar } from "drizzle-orm/pg-core";
+import { users } from "../users";
+
+export const userCustomData = pgTable("user_custom_data", {
+ id: uuid("id").primaryKey().defaultRandom(),
+ userId: integer("user_id").references(() => users.id),
+ tableKey: varchar("table_key", { length: 255 }).notNull(),
+ customSettingName: varchar("custom_setting_name", { length: 255 }).notNull(),
+ customSetting: json("custom_setting"),
+ createdDate: timestamp("created_date", { withTimezone: true }).defaultNow().notNull(),
+ updatedDate: timestamp("updated_date", { withTimezone: true }).defaultNow().notNull(),
+});
diff --git a/db/schema/vendors.ts b/db/schema/vendors.ts
index d587d441..c194cf61 100644
--- a/db/schema/vendors.ts
+++ b/db/schema/vendors.ts
@@ -114,8 +114,7 @@ export const vendorPossibleItems = pgTable("vendor_possible_items", {
vendorId: integer("vendor_id").notNull().references(() => vendors.id),
// itemId: integer("item_id"), // 별도 item 테이블 연동시
itemCode: varchar("item_code", { length: 100 })
- .notNull()
- .references(() => items.itemCode, { onDelete: "cascade" }),
+ .notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
diff --git a/lib/table/server-query-builder.ts b/lib/table/server-query-builder.ts
new file mode 100644
index 00000000..7ea25313
--- /dev/null
+++ b/lib/table/server-query-builder.ts
@@ -0,0 +1,129 @@
+import {
+ ColumnFiltersState,
+ SortingState,
+ PaginationState,
+ GroupingState
+} from "@tanstack/react-table";
+import {
+ SQL,
+ and,
+ or,
+ eq,
+ ilike,
+ like,
+ gt,
+ lt,
+ gte,
+ lte,
+ inArray,
+ asc,
+ desc,
+ not,
+ sql
+} from "drizzle-orm";
+import { PgTable } from "drizzle-orm/pg-core";
+
+/**
+ * Table State를 Drizzle Query 조건으로 변환하는 유틸리티
+ */
+export class TableQueryBuilder {
+ private table: PgTable;
+ private searchableColumns: string[];
+
+ constructor(table: PgTable, searchableColumns: string[] = []) {
+ this.table = table;
+ this.searchableColumns = searchableColumns;
+ }
+
+ /**
+ * Pagination State -> Limit/Offset
+ */
+ getPagination(pagination: PaginationState) {
+ return {
+ limit: pagination.pageSize,
+ offset: pagination.pageIndex * pagination.pageSize,
+ };
+ }
+
+ /**
+ * Sorting State -> Order By
+ */
+ getOrderBy(sorting: SortingState) {
+ if (!sorting.length) return [];
+
+ return sorting.map((sort) => {
+ // 컬럼 이름이 테이블에 존재하는지 확인
+ const column = this.table[sort.id as keyof typeof this.table];
+ if (!column) return null;
+
+ return sort.desc ? desc(column) : asc(column);
+ }).filter(Boolean) as SQL[];
+ }
+
+ /**
+ * Column Filters -> Where Clause
+ */
+ getWhere(columnFilters: ColumnFiltersState, globalFilter?: string) {
+ const conditions: SQL[] = [];
+
+ // 1. Column Filters
+ for (const filter of columnFilters) {
+ const column = this.table[filter.id as keyof typeof this.table];
+ if (!column) continue;
+
+ const value = filter.value;
+
+ // 값의 타입에 따라 적절한 연산자 선택 (기본적인 예시)
+ if (Array.isArray(value)) {
+ // 범위 필터 (예: 날짜, 숫자 범위)
+ if (value.length === 2) {
+ const [min, max] = value;
+ if (min !== null && max !== null) {
+ conditions.push(and(gte(column, min), lte(column, max))!);
+ } else if (min !== null) {
+ conditions.push(gte(column, min)!);
+ } else if (max !== null) {
+ conditions.push(lte(column, max)!);
+ }
+ }
+ // 다중 선택 (Select)
+ else {
+ conditions.push(inArray(column, value)!);
+ }
+ } else if (typeof value === 'string') {
+ // 텍스트 검색 (Partial Match)
+ conditions.push(ilike(column, `%${value}%`)!);
+ } else if (typeof value === 'boolean') {
+ conditions.push(eq(column, value)!);
+ } else if (typeof value === 'number') {
+ conditions.push(eq(column, value)!);
+ }
+ }
+
+ // 2. Global Filter (검색창)
+ if (globalFilter && this.searchableColumns.length > 0) {
+ const searchConditions = this.searchableColumns.map(colName => {
+ const column = this.table[colName as keyof typeof this.table];
+ if (!column) return null;
+ return ilike(column, `%${globalFilter}%`);
+ }).filter(Boolean) as SQL[];
+
+ if (searchConditions.length > 0) {
+ conditions.push(or(...searchConditions)!);
+ }
+ }
+
+ return conditions.length > 0 ? and(...conditions) : undefined;
+ }
+
+ /**
+ * Grouping State -> Group By & Select
+ * 주의: Group By 사용 시 집계 함수가 필요할 수 있음
+ */
+ getGroupBy(grouping: GroupingState) {
+ return grouping.map(g => {
+ const column = this.table[g as keyof typeof this.table];
+ return column;
+ }).filter(Boolean);
+ }
+}
diff --git a/package-lock.json b/package-lock.json
index b95cecae..1423b5df 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -62,6 +62,7 @@
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-hover-card": "^1.1.4",
+ "@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-menubar": "^1.1.4",
"@radix-ui/react-navigation-menu": "^1.2.3",
@@ -82,6 +83,7 @@
"@radix-ui/react-toggle-group": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.6",
"@t3-oss/env-nextjs": "^0.11.1",
+ "@tanstack/match-sorter-utils": "^8.19.4",
"@tanstack/react-table": "^8.20.6",
"@tanstack/react-virtual": "^3.13.12",
"@tiptap/extension-blockquote": "^2.23.1",
@@ -142,6 +144,7 @@
"knex": "^3.1.0",
"libphonenumber-js": "^1.12.10",
"lucide-react": "^0.468.0",
+ "match-sorter": "^8.2.0",
"next": "15.1.0",
"next-auth": "^4.24.11",
"next-i18n-router": "^5.5.1",
@@ -4138,6 +4141,15 @@
}
}
},
+ "node_modules/@radix-ui/react-icons": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.2.tgz",
+ "integrity": "sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc"
+ }
+ },
"node_modules/@radix-ui/react-id": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
@@ -5094,6 +5106,22 @@
}
}
},
+ "node_modules/@tanstack/match-sorter-utils": {
+ "version": "8.19.4",
+ "resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-8.19.4.tgz",
+ "integrity": "sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg==",
+ "license": "MIT",
+ "dependencies": {
+ "remove-accents": "0.5.0"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
"node_modules/@tanstack/react-table": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
@@ -12622,6 +12650,16 @@
"markdown-it": "bin/markdown-it.mjs"
}
},
+ "node_modules/match-sorter": {
+ "version": "8.2.0",
+ "resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-8.2.0.tgz",
+ "integrity": "sha512-qRVB7wYMJXizAWR4TKo5UYwgW7oAVzA8V9jve0wGzRvV91ou9dcqL+/2gJtD0PZ/Pm2Fq6cVT4VHXHmDFVMGRA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.23.8",
+ "remove-accents": "0.5.0"
+ }
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -15403,6 +15441,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/remove-accents": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz",
+ "integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==",
+ "license": "MIT"
+ },
"node_modules/request": {
"version": "2.16.6",
"resolved": "https://registry.npmjs.org/request/-/request-2.16.6.tgz",
diff --git a/package.json b/package.json
index 6c9a85a5..2f435dbe 100644
--- a/package.json
+++ b/package.json
@@ -64,6 +64,7 @@
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-hover-card": "^1.1.4",
+ "@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-menubar": "^1.1.4",
"@radix-ui/react-navigation-menu": "^1.2.3",
@@ -84,6 +85,7 @@
"@radix-ui/react-toggle-group": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.6",
"@t3-oss/env-nextjs": "^0.11.1",
+ "@tanstack/match-sorter-utils": "^8.19.4",
"@tanstack/react-table": "^8.20.6",
"@tanstack/react-virtual": "^3.13.12",
"@tiptap/extension-blockquote": "^2.23.1",
@@ -144,6 +146,7 @@
"knex": "^3.1.0",
"libphonenumber-js": "^1.12.10",
"lucide-react": "^0.468.0",
+ "match-sorter": "^8.2.0",
"next": "15.1.0",
"next-auth": "^4.24.11",
"next-i18n-router": "^5.5.1",