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