From 12af09245b38da8cc3fdb851ebb03bc0de45c8be Mon Sep 17 00:00:00 2001 From: joonhoekim <26rote@gmail.com> Date: Sat, 29 Nov 2025 22:58:24 +0900 Subject: (김준회) 미사용 스키마 코드 제거, 미사용 페이지 제거 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../(evcp)/(not-used)/b-rfq/[id]/final/page.tsx | 52 ---------- .../(evcp)/(not-used)/b-rfq/[id]/initial/page.tsx | 52 ---------- .../evcp/(evcp)/(not-used)/b-rfq/[id]/layout.tsx | 87 ---------------- .../evcp/(evcp)/(not-used)/b-rfq/[id]/page.tsx | 53 ---------- app/[lng]/evcp/(evcp)/(not-used)/b-rfq/page.tsx | 83 --------------- app/[lng]/evcp/(evcp)/(not-used)/bqcbe/page.tsx | 74 -------------- app/[lng]/evcp/(evcp)/(not-used)/bqtbe/page.tsx | 72 ------------- .../(not-used)/budgetary-rfq/[id]/cbe/page.tsx | 56 ---------- .../(not-used)/budgetary-rfq/[id]/layout.tsx | 90 ---------------- .../(evcp)/(not-used)/budgetary-rfq/[id]/page.tsx | 57 ----------- .../(not-used)/budgetary-rfq/[id]/tbe/page.tsx | 55 ---------- .../evcp/(evcp)/(not-used)/budgetary-rfq/page.tsx | 89 ---------------- .../(evcp)/(not-used)/budgetary/[id]/cbe/page.tsx | 56 ---------- .../(evcp)/(not-used)/budgetary/[id]/layout.tsx | 90 ---------------- .../evcp/(evcp)/(not-used)/budgetary/[id]/page.tsx | 57 ----------- .../(evcp)/(not-used)/budgetary/[id]/tbe/page.tsx | 55 ---------- .../evcp/(evcp)/(not-used)/budgetary/page.tsx | 86 ---------------- .../evcp/(evcp)/(not-used)/materials/page.tsx | 76 -------------- app/[lng]/evcp/(evcp)/(not-used)/po-rfq/page.tsx | 64 ------------ app/[lng]/evcp/(evcp)/(not-used)/poa/page.tsx | 64 ------------ .../evcp/(evcp)/(not-used)/project-gtc/page.tsx | 66 ------------ .../(evcp)/(not-used)/project-vendors/page.tsx | 77 -------------- .../evcp/(evcp)/(not-used)/rfq/[id]/cbe/page.tsx | 55 ---------- .../evcp/(evcp)/(not-used)/rfq/[id]/layout.tsx | 89 ---------------- app/[lng]/evcp/(evcp)/(not-used)/rfq/[id]/page.tsx | 55 ---------- .../evcp/(evcp)/(not-used)/rfq/[id]/tbe/page.tsx | 55 ---------- app/[lng]/evcp/(evcp)/(not-used)/rfq/page.tsx | 80 --------------- app/[lng]/evcp/(evcp)/(not-used)/tasks/page.tsx | 63 ------------ app/[lng]/evcp/(evcp)/(not-used)/tbe/page.tsx | 113 --------------------- 29 files changed, 2021 deletions(-) delete mode 100644 app/[lng]/evcp/(evcp)/(not-used)/b-rfq/[id]/final/page.tsx delete mode 100644 app/[lng]/evcp/(evcp)/(not-used)/b-rfq/[id]/initial/page.tsx delete mode 100644 app/[lng]/evcp/(evcp)/(not-used)/b-rfq/[id]/layout.tsx delete mode 100644 app/[lng]/evcp/(evcp)/(not-used)/b-rfq/[id]/page.tsx delete mode 100644 app/[lng]/evcp/(evcp)/(not-used)/b-rfq/page.tsx delete mode 100644 app/[lng]/evcp/(evcp)/(not-used)/bqcbe/page.tsx delete mode 100644 app/[lng]/evcp/(evcp)/(not-used)/bqtbe/page.tsx delete mode 100644 app/[lng]/evcp/(evcp)/(not-used)/budgetary-rfq/[id]/cbe/page.tsx delete mode 100644 app/[lng]/evcp/(evcp)/(not-used)/budgetary-rfq/[id]/layout.tsx delete mode 100644 app/[lng]/evcp/(evcp)/(not-used)/budgetary-rfq/[id]/page.tsx delete mode 100644 app/[lng]/evcp/(evcp)/(not-used)/budgetary-rfq/[id]/tbe/page.tsx delete mode 100644 app/[lng]/evcp/(evcp)/(not-used)/budgetary-rfq/page.tsx delete mode 100644 app/[lng]/evcp/(evcp)/(not-used)/budgetary/[id]/cbe/page.tsx delete mode 100644 app/[lng]/evcp/(evcp)/(not-used)/budgetary/[id]/layout.tsx delete mode 100644 app/[lng]/evcp/(evcp)/(not-used)/budgetary/[id]/page.tsx delete mode 100644 app/[lng]/evcp/(evcp)/(not-used)/budgetary/[id]/tbe/page.tsx delete mode 100644 app/[lng]/evcp/(evcp)/(not-used)/budgetary/page.tsx delete mode 100644 app/[lng]/evcp/(evcp)/(not-used)/materials/page.tsx delete mode 100644 app/[lng]/evcp/(evcp)/(not-used)/po-rfq/page.tsx delete mode 100644 app/[lng]/evcp/(evcp)/(not-used)/poa/page.tsx delete mode 100644 app/[lng]/evcp/(evcp)/(not-used)/project-gtc/page.tsx delete mode 100644 app/[lng]/evcp/(evcp)/(not-used)/project-vendors/page.tsx delete mode 100644 app/[lng]/evcp/(evcp)/(not-used)/rfq/[id]/cbe/page.tsx delete mode 100644 app/[lng]/evcp/(evcp)/(not-used)/rfq/[id]/layout.tsx delete mode 100644 app/[lng]/evcp/(evcp)/(not-used)/rfq/[id]/page.tsx delete mode 100644 app/[lng]/evcp/(evcp)/(not-used)/rfq/[id]/tbe/page.tsx delete mode 100644 app/[lng]/evcp/(evcp)/(not-used)/rfq/page.tsx delete mode 100644 app/[lng]/evcp/(evcp)/(not-used)/tasks/page.tsx delete mode 100644 app/[lng]/evcp/(evcp)/(not-used)/tbe/page.tsx (limited to 'app') 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 -} - -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 ( -
-
-

- Fianl RFQ List -

-

- 업체에게 최종 RFQ를 송부하는 화면입니다. -

-
- -
- -
-
- ) -} \ 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 -} - -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 ( -
-
-

- Initial RFQ List -

-

- 설계로부터 받은 RFQ 문서와 구매 RFQ 문서 및 사전 계약자료를 Vendor에 발송하기 위한 RFQ 생성 및 관리하는 화면입니다. -

-
- -
- -
-
- ) -} \ 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 ( - <> -
-
-
-
- - - -
-
- {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */} -

- {rfq - ? `${rfq.rfqCode ?? ""} | ${rfq.packageNo ?? ""} | ${rfq.packageName ?? ""}` - : "Loading RFQ..."} -

- -

- PR발행 전 RFQ를 생성하여 관리하는 화면입니다. -

-

Due Date:{rfq && rfq?.dueDate && {formatDate(rfq?.dueDate, "KR")}}

-
- -
- -
{children}
-
-
-
-
- - ) -} \ 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 -} - -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 ( -
-
-

- 견적 RFQ 문서관리 -

-

- 설계로부터 받은 RFQ 문서와 구매 RFQ 문서를 관리하고 Vendor 회신을 점검/관리하는 화면입니다. -

-
- -
- -
-
- ) -} \ 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 -} - -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 ( - -
-
-
-
-

- 견적 RFQ -

- -
-
-
-
- - {/* Items처럼 직접 테이블 렌더링 */} - - } - > - - -
- ) -} \ 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 - 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 ( - -
-
-
-

- CBE 보내기 -

- {/*

- 초대된 협력업체에게 CBE를 보낼 수 있습니다.
체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 CBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. -

*/} -
-
-
- - - } - > - - -
- ) -} \ 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 - 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 ( - -
-
-
-

- TBE 보내기 -

- {/*

- 초대된 협력업체에게 TBE를 보낼 수 있습니다.
체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. -

*/} -
-
-
- - - } - > - - -
- ) -} \ 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 -} - -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 ( -
-
-

- Commercial Bid Evaluation -

-

- 초대된 협력업체에게 CBE를 보낼 수 있습니다.
체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 CBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. -

-
- -
- -
-
- ) -} \ 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 ( - <> -
-
-
-
- - - -
-
- {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */} -

- {rfq - ? `${rfq.projectCode ?? ""} ${rfq.rfqCode ?? ""} 관리` - : "Loading RFQ..."} -

- -

- {rfq - ? `${rfq.description ?? ""} ${rfq.lines.map(line => line.itemCode).join(", ")}` - : ""} -

-

Due Date:{rfq && rfq?.dueDate && {formatDate(rfq?.dueDate, "KR")}}

-
- -
- -
{children}
-
-
-
-
- - ) -} \ 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 - 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 ( -
-
-

- Vendors -

-

- 등록된 협력업체 중에서 이 RFQ 아이템에 매칭되는 업체를 보여줍니다.
"발행하기" 버튼을 통해 RFQ를 전송하면 첨부파일과 함께 RFQ 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. -

-
- -
- -
-
- ) -} \ 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 -} - -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 ( -
-
-

- Technical Bid Evaluation -

-

- 초대된 협력업체에게 TBE를 보낼 수 있습니다.
체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. -

-
- -
- -
-
- ) -} \ 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; - 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 ( - -
-
-
-
-

- {title} -

- -
- {/*

- {description} - 기본적인 정보와 RFQ를 위한 아이템 등록 및 첨부를 한 후, - - - 버튼 - 을 클릭하면 "Proceed"를 통해 상세화면으로 이동하여 진행할 수 있습니다. -

*/} -
-
-
- - }> - {/* */} - - - } - > - - -
- ) -} \ 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 -} - -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 ( -
-
-

- Commercial Bid Evaluation -

-

- 초대된 협력업체에게 CBE를 보낼 수 있습니다.
체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 CBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. -

-
- -
- -
-
- ) -} \ 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 ( - <> -
-
-
- {/* RFQ 목록으로 돌아가는 링크 추가 */} -
- - - -
- -
- {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */} -

- {rfq - ? `${rfq.projectCode ?? ""} ${rfq.rfqCode ?? ""} 관리` - : "Loading RFQ..."} -

- -

- {rfq - ? `${rfq.description ?? ""} ${rfq.lines.map(line => line.itemCode).join(", ")}` - : ""} -

-

Due Date:{rfq && rfq?.dueDate && {formatDate(rfq?.dueDate, "KR")}}

-
- -
- -
{children}
-
-
-
-
- - ) -} \ 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 - 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 ( -
-
-

- Vendors -

-

- 등록된 협력업체 중에서 이 RFQ 아이템에 매칭되는 업체를 보여줍니다.
"발행하기" 버튼을 통해 RFQ를 전송하면 첨부파일과 함께 RFQ 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. -

-
- -
- -
-
- ) -} \ 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 -} - -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 ( -
-
-

- Technical Bid Evaluation -

-

- 초대된 협력업체에게 TBE를 보낼 수 있습니다.
체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. -

-
- -
- -
-
- ) -} \ 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; - 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 ( - -
-
-
-

- {title} -

- {/*

- {description} - 기본적인 정보와 RFQ를 위한 아이템 등록 및 첨부를 한 후, - - - 버튼 - 을 클릭하면 "Proceed"를 통해 상세화면으로 이동하여 진행할 수 있습니다. -

*/} -
-
-
- - }> - {/* */} - - - } - > - - -
- ) -} \ 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 -} - -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 ( - -
-
-
-
-

- 자재마스터 -

- -
-

- MDG로부터 수신된 자재마스터 정보입니다. -

-
-
-
- - }> - {/* 추가 컴포넌트가 필요한 경우 여기에 */} - - - - } - > - - -
- ) -} \ 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 -} - -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 ( - {/* fullscreen variant 사용 */} - {/* 고정 헤더 영역 */} -
-
-
-
-

- RFQ -

- -
-
-
-
- - {/* 테이블 영역 - 남은 공간 모두 차지 */} -
- - } - > - - -
-
- ) -} \ 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 -} - -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 ( - -
-
-
-
-

- 변경 PO 확인 및 전자서명 -

- -
- {/*

- 발행된 PO의 변경 내역을 확인하고 관리할 수 있습니다. -

*/} -
-
-
- - }> - - - } - > - - -
- ) -} \ 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 -} - -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 ( - -
-
-
-
-

- Project GTC 관리 -

- -
- {/*

- 프로젝트별 GTC(General Terms and Conditions) 파일을 관리할 수 있습니다. - 각 프로젝트마다 하나의 GTC 파일을 업로드할 수 있으며, 파일 업로드 시 기존 파일은 자동으로 교체됩니다. -

*/} -
-
-
- - }> - {/* 추가 기능이 필요하면 여기에 추가 */} - - - - } - > - - -
- ) -} \ 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 -} - -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 ( - -
-
-
-
-

- 프로젝트 AVL 리스트 -

- -
- {/*

- 프로젝트 PQ를 통과한 벤더의 리스트를 보여줍니다.{" "} - - - 버튼 - - 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. -

*/} -
-
-
- - }> - {/* */} - - - } - > - - -
- ) -} 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 -} - -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 ( -
-
-

- Commercial Bid Evaluation -

-

- 초대된 협력업체에게 CBE를 보낼 수 있습니다.
"발행하기" 버튼을 통해 CBE를 전송하면 CBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. -

-
- -
- -
-
- ) -} \ 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 ( - <> -
-
-
-
- - - -
-
- {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */} -

- {rfq - ? `${rfq.projectCode ?? ""} ${rfq.rfqCode ?? ""} 관리` - : "Loading RFQ..."} -

- -

- {rfq - ? `${rfq.description ?? ""} ${rfq.lines.map(line => line.itemCode).join(", ")}` - : ""} -

-

Due Date:{rfq && rfq?.dueDate && {formatDate(rfq?.dueDate, "KR")}}

-
- -
- -
{children}
-
-
-
-
- - ) -} \ 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 -} - -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 ( -
-
-

- Vendors -

-

- 등록된 협력업체 중에서 이 RFQ 아이템에 매칭되는 업체를 보여줍니다.
"발행하기" 버튼을 통해 RFQ를 전송하면 첨부파일과 함께 RFQ 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. -

-
- -
- -
-
- ) -} \ 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 -} - -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 ( -
-
-

- Technical Bid Evaluation -

-

- 초대된 협력업체에게 TBE를 보낼 수 있습니다.
"발행하기" 버튼을 통해 TBE를 전송하면 첨부파일과 함께 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. -

-
- -
- -
-
- ) -} \ 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; - 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 ( - -
-
-
-

- {title} -

- {/*

- {description} -

*/} -
-
-
- - }> - {/* */} - - - } - > - - -
- ) -} \ 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 -} - -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 ( - - }> - - - - } - > - - - - ) -} 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 -} - -// 타입별 페이지 설명 구성 (Budgetary 제외) -const typeConfig: Record = { - "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 ( - -
-
-
-

- TBE 관리 -

- {/*

- 초대된 협력업체에게 TBE를 보낼 수 있습니다.
- 체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. -

*/} -
-
-
- - {/* 타입 선택 탭 (Budgetary 제외) */} - - - - Purchase - - - Purchase Budgetary - - - -
-

- {typeConfig[validType].description} -

-
-
- - - } - > - - -
- ) -} \ No newline at end of file -- cgit v1.2.3 From 81aa92fecc298d66eb420468316bcf7a7213171c Mon Sep 17 00:00:00 2001 From: joonhoekim <26rote@gmail.com> Date: Sun, 30 Nov 2025 17:12:17 +0900 Subject: (김준회) group 기능 추가, 기타 버그 수정, preset 기능추가, 테스트 페이지 추가 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/[lng]/test/table/page.tsx | 168 ++++++++ .../client-table/client-table-column-header.tsx | 70 +++- components/client-table/client-table-preset.tsx | 185 +++++++++ components/client-table/client-table-save-view.tsx | 185 +++++++++ components/client-table/client-table-toolbar.tsx | 23 +- .../client-table/client-table-view-options.tsx | 9 +- components/client-table/client-virtual-table.tsx | 457 ++++++++++++++++----- components/client-table/preset-actions.ts | 87 ++++ components/client-table/preset-types.ts | 13 + db/schema/index.ts | 2 + db/schema/user-custom-data/userCustomData.ts | 16 + 11 files changed, 1093 insertions(+), 122 deletions(-) create mode 100644 app/[lng]/test/table/page.tsx create mode 100644 components/client-table/client-table-preset.tsx create mode 100644 components/client-table/client-table-save-view.tsx create mode 100644 components/client-table/preset-actions.ts create mode 100644 components/client-table/preset-types.ts create mode 100644 db/schema/user-custom-data/userCustomData.ts (limited to 'app') 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([]) + 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[] = [ + { + 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 ( + + {role} + + ) + } + }, + { + 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 ( +
+
+ {status} +
+ ) + } + }, + { + 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: () => ( + + ), + enablePinning: true, + } + ] + + return ( +
+
+
+

Virtual Table Test

+

+ Testing the ClientVirtualTable component with 1000 generated rows. +

+
+
+ +
+
+ +
+ console.log("Row clicked:", row.original)} + enableUserPreset={true} + tableKey="test-table" + /> +
+
+ ) +} diff --git a/components/client-table/client-table-column-header.tsx b/components/client-table/client-table-column-header.tsx index 12dc57ac..2d8e5bce 100644 --- a/components/client-table/client-table-column-header.tsx +++ b/components/client-table/client-table-column-header.tsx @@ -1,7 +1,7 @@ "use client" import * as React from "react" -import { Header } from "@tanstack/react-table" +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" @@ -20,19 +20,29 @@ import { PinOff, MoveLeft, MoveRight, + Group, + Ungroup, } from "lucide-react" import { cn } from "@/lib/utils" -import { ClientTableFilter } from "./client-table-filter" +import { ClientTableFilter } from "../client-table/client-table-filter" interface ClientTableColumnHeaderProps extends React.HTMLAttributes { header: Header enableReordering?: boolean + renderHeaderVisualFeedback?: (props: { + column: Column + isPinned: boolean | string + isSorted: boolean | string + isFiltered: boolean + isGrouped: boolean + }) => React.ReactNode } export function ClientTableColumnHeader({ header, enableReordering = true, + renderHeaderVisualFeedback, className, ...props }: ClientTableColumnHeaderProps) { @@ -46,7 +56,7 @@ export function ClientTableColumnHeader({ isDragging, } = useSortable({ id: header.id, - disabled: !enableReordering, + disabled: !enableReordering || column.getIsResizing(), }) // -- Styles -- @@ -62,14 +72,18 @@ export function ClientTableColumnHeader({ // 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 = 20 + 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 = 20 + style.zIndex = 30 // Pinned columns needs to be higher than normal headers } // -- Handlers -- @@ -77,6 +91,7 @@ export function ClientTableColumnHeader({ const handlePinLeft = () => column.pin("left") const handlePinRight = () => column.pin("right") const handleUnpin = () => column.pin(false) + const handleToggleGrouping = () => column.toggleGrouping() // -- Content -- const content = ( @@ -100,12 +115,14 @@ export function ClientTableColumnHeader({ )} )} + {isGrouped && }
{/* Resize Handle */}
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", @@ -117,6 +134,25 @@ export function ClientTableColumnHeader({ {/* Filter */} {column.getCanFilter() && } + + {/* Visual Feedback Indicators */} + {renderHeaderVisualFeedback ? ( + renderHeaderVisualFeedback({ + column, + isPinned, + isSorted, + isFiltered, + isGrouped, + }) + ) : ( + (isPinned || isFiltered || isGrouped) && ( +
+ {isPinned &&
} + {isFiltered &&
} + {isGrouped &&
} +
+ ) + )} ) @@ -141,9 +177,9 @@ export function ClientTableColumnHeader({ colSpan={header.colSpan} style={style} className={cn( - "border-b px-4 py-2 text-left text-sm font-medium bg-muted group", + "border-b px-4 py-2 text-left text-sm font-medium bg-muted group transition-colors", isDragging ? "opacity-50 bg-accent" : "", - isPinned ? "bg-muted shadow-[0_0_10px_rgba(0,0,0,0.1)]" : "", + isPinned ? "shadow-[0_0_10px_rgba(0,0,0,0.1)]" : "", className )} {...attributes} @@ -158,6 +194,26 @@ export function ClientTableColumnHeader({ Hide Column + + {column.getCanGroup() && ( + <> + + + {isGrouped ? ( + <> + + Ungroup + + ) : ( + <> + + Group by {column.id} + + )} + + + )} + 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 { + table: Table; + tableKey: string; +} + +export function ClientTablePreset({ + table, + tableKey, +}: ClientTablePresetProps) { + const { data: session } = useSession(); + const [savedPresets, setSavedPresets] = React.useState([]); + 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; + 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 ( + <> + + + + + + Saved Presets + + {savedPresets.length === 0 ? ( +
No saved presets
+ ) : ( + savedPresets.map((preset) => ( + handleLoadPreset(preset)} className="flex justify-between cursor-pointer"> + {preset.name} + + + )) + )} + + setIsPresetDialogOpen(true)} className="cursor-pointer"> + + Save Current Preset + +
+
+ + + + + Save Preset + + Save the current table configuration as a preset. + + +
+ setNewPresetName(e.target.value)} + /> +
+ + + + +
+
+ + ); +} 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 { + table: Table; + tableKey: string; +} + +export function ClientTableSaveView({ + table, + tableKey, +}: ClientTableSaveViewProps) { + const { data: session } = useSession(); + const [savedViews, setSavedViews] = React.useState<{ id: string; customSettingName: string; customSetting: Record }[]>([]); + 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 | unknown; customSettingName: string }) => { + const s = setting.customSetting as Record; + 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 ( + <> + + + + + + Saved Views + + {savedViews.length === 0 ? ( +
No saved views
+ ) : ( + savedViews.map((view) => ( + handleLoadView(view)} className="flex justify-between cursor-pointer"> + {view.customSettingName} + + + )) + )} + + setIsSaveDialogOpen(true)} className="cursor-pointer"> + + Save Current View + +
+
+ + + + + Save View + + Save the current table configuration as a preset. + + +
+ setNewViewName(e.target.value)} + /> +
+ + + + +
+
+ + ); +} diff --git a/components/client-table/client-table-toolbar.tsx b/components/client-table/client-table-toolbar.tsx index 43b0a032..089501e1 100644 --- a/components/client-table/client-table-toolbar.tsx +++ b/components/client-table/client-table-toolbar.tsx @@ -12,6 +12,8 @@ interface ClientTableToolbarProps { visibleRows: number onExport?: () => void actions?: React.ReactNode + customToolbar?: React.ReactNode + viewOptions?: React.ReactNode } export function ClientTableToolbar({ @@ -21,11 +23,13 @@ export function ClientTableToolbar({ visibleRows, onExport, actions, + customToolbar, + viewOptions, }: ClientTableToolbarProps) { return ( -
-
-
+
+
+
Showing {visibleRows} of {totalRows}
-
- -
- {actions} + {viewOptions} {onExport && ( - )} + )} +
+ +
+ {customToolbar} + {actions}
) } - diff --git a/components/client-table/client-table-view-options.tsx b/components/client-table/client-table-view-options.tsx index b65049b4..3b659fcd 100644 --- a/components/client-table/client-table-view-options.tsx +++ b/components/client-table/client-table-view-options.tsx @@ -42,14 +42,21 @@ export function ClientTableViewOptions({ typeof column.accessorFn !== "undefined" && column.getCanHide() ) .map((column) => { + const header = column.columnDef.header + let label = column.id + if (typeof header === "string") { + label = header + } + return ( column.toggleVisibility(!!value)} + onSelect={(e) => e.preventDefault()} // default action close the select menu. > - {column.id} + {label} ) })} diff --git a/components/client-table/client-virtual-table.tsx b/components/client-table/client-virtual-table.tsx index 4825741f..507057c7 100644 --- a/components/client-table/client-virtual-table.tsx +++ b/components/client-table/client-virtual-table.tsx @@ -8,6 +8,11 @@ import { getSortedRowModel, getFilteredRowModel, getPaginationRowModel, + getGroupedRowModel, + getExpandedRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getFacetedMinMaxValues, ColumnDef, SortingState, ColumnFiltersState, @@ -21,6 +26,9 @@ import { Table, RowSelectionState, Row, + Column, + GroupingState, + ExpandedState, } from "@tanstack/react-table" import { useVirtualizer } from "@tanstack/react-virtual" import { @@ -38,23 +46,41 @@ import { horizontalListSortingStrategy, } from "@dnd-kit/sortable" import { cn } from "@/lib/utils" +import { Loader2, ChevronRight, ChevronDown } from "lucide-react" -import { ClientTableToolbar } from "./client-table-toolbar" -import { exportToExcel } from "./export-utils" +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-view-options" +import { ClientTableViewOptions } from "../client-table/client-table-view-options" +import { ClientTablePreset } from "./client-table-preset" + +// Moved outside for stability (Performance Optimization) +const fuzzyFilter: FilterFn = (row, columnId, value, addMeta) => { + const itemRank = rankItem(row.getValue(columnId), value) + addMeta({ itemRank }) + return itemRank.passed +} export interface ClientVirtualTableProps { data: TData[] columns: ColumnDef[] height?: string | number + estimateRowHeight?: number className?: string actions?: React.ReactNode + customToolbar?: React.ReactNode enableExport?: boolean onExport?: (data: TData[]) => void - - // Pagination Props + isLoading?: boolean + + // --- User Preset Saving --- + enableUserPreset?: boolean + tableKey?: string + + // --- State Control (Controlled or Uncontrolled) --- + + // Pagination enablePagination?: boolean manualPagination?: boolean pageCount?: number @@ -62,88 +88,184 @@ export interface ClientVirtualTableProps { pagination?: PaginationState onPaginationChange?: OnChangeFn - // Style Props - getRowClassName?: (originalRow: TData, index: number) => string + // Sorting + sorting?: SortingState + onSortingChange?: OnChangeFn - // Table Meta - meta?: any - - // Row ID - getRowId?: (originalRow: TData, index: number, parent?: any) => string + // Filtering + columnFilters?: ColumnFiltersState + onColumnFiltersChange?: OnChangeFn + globalFilter?: string + onGlobalFilterChange?: OnChangeFn + + // Visibility + columnVisibility?: VisibilityState + onColumnVisibilityChange?: OnChangeFn + + // Pinning + columnPinning?: ColumnPinningState + onColumnPinningChange?: OnChangeFn - // Selection Props + // Order + columnOrder?: ColumnOrderState + onColumnOrderChange?: OnChangeFn + + // Selection enableRowSelection?: boolean | ((row: Row) => boolean) enableMultiRowSelection?: boolean | ((row: Row) => boolean) rowSelection?: RowSelectionState onRowSelectionChange?: OnChangeFn + + // Grouping + enableGrouping?: boolean + grouping?: GroupingState + onGroupingChange?: OnChangeFn + expanded?: ExpandedState + onExpandedChange?: OnChangeFn + + // --- Event Handlers --- + onRowClick?: (row: Row, event: React.MouseEvent) => void + + // --- Styling --- + getRowClassName?: (originalRow: TData, index: number) => string + + // --- Advanced --- + meta?: Record + getRowId?: (originalRow: TData, index: number, parent?: any) => string + + // Custom Header Visual Feedback + renderHeaderVisualFeedback?: (props: { + column: Column + isPinned: boolean | string + isSorted: boolean | string + isFiltered: boolean + isGrouped: boolean + }) => React.ReactNode } function ClientVirtualTableInner( { data, columns, - height = "500px", // Default height + height = "100%", + estimateRowHeight = 40, className, actions, + customToolbar, enableExport = true, onExport, - - // Pagination defaults + isLoading = false, + + // User Preset Saving + enableUserPreset = false, + tableKey, + + // Pagination enablePagination = false, manualPagination = false, pageCount, rowCount, - pagination: controlledPagination, + 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, - // Selection defaults - enableRowSelection, - enableMultiRowSelection, - rowSelection: controlledRowSelection, - onRowSelectionChange, + // Event Handlers + onRowClick, + + // Custom Header Visual Feedback + renderHeaderVisualFeedback, }: ClientVirtualTableProps, ref: React.Ref> ) { - // State - const [sorting, setSorting] = React.useState([]) - const [columnFilters, setColumnFilters] = React.useState([]) - const [globalFilter, setGlobalFilter] = React.useState("") - const [columnVisibility, setColumnVisibility] = React.useState({}) - const [columnPinning, setColumnPinning] = React.useState({ left: [], right: [] }) - const [columnOrder, setColumnOrder] = React.useState( + // Internal States (used when props are undefined) + const [internalSorting, setInternalSorting] = React.useState([]) + const [internalColumnFilters, setInternalColumnFilters] = React.useState([]) + const [internalGlobalFilter, setInternalGlobalFilter] = React.useState("") + const [internalColumnVisibility, setInternalColumnVisibility] = React.useState({}) + const [internalColumnPinning, setInternalColumnPinning] = React.useState({ left: [], right: [] }) + const [internalColumnOrder, setInternalColumnOrder] = React.useState( () => columns.map((c) => c.id || (c as any).accessorKey) as string[] ) - - // Internal Pagination State const [internalPagination, setInternalPagination] = React.useState({ pageIndex: 0, - pageSize: 50, + pageSize: 10, }) - - // Internal Row Selection State const [internalRowSelection, setInternalRowSelection] = React.useState({}) + const [internalGrouping, setInternalGrouping] = React.useState([]) + const [internalExpanded, setInternalExpanded] = React.useState({}) - // Fuzzy Filter - const fuzzyFilter: FilterFn = (row, columnId, value, addMeta) => { - const itemRank = rankItem(row.getValue(columnId), value) - addMeta({ itemRank }) - return itemRank.passed - } + // 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 - // Combine controlled and uncontrolled states - const pagination = controlledPagination ?? internalPagination + 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 = controlledRowSelection ?? internalRowSelection + + 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, @@ -157,6 +279,8 @@ function ClientVirtualTableInner( columnPinning, columnOrder, rowSelection, + grouping, + expanded, }, manualPagination, pageCount: manualPagination ? pageCount : undefined, @@ -169,11 +293,28 @@ function ClientVirtualTableInner( onColumnPinningChange: setColumnPinning, onColumnOrderChange: setColumnOrder, onRowSelectionChange: setRowSelection, + onGroupingChange: setGrouping, + onExpandedChange: setExpanded, enableRowSelection, enableMultiRowSelection, + enableGrouping, getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), + + // 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: { @@ -191,7 +332,7 @@ function ClientVirtualTableInner( const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { - distance: 8, // 8px movement required to start drag + distance: 8, }, }), useSensor(KeyboardSensor) @@ -201,11 +342,29 @@ function ClientVirtualTableInner( const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event if (active && over && active.id !== over.id) { - setColumnOrder((items) => { - const oldIndex = items.indexOf(active.id as string) - const newIndex = items.indexOf(over.id as string) - return arrayMove(items, oldIndex, newIndex) - }) + 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) + }) + } } } @@ -216,7 +375,7 @@ function ClientVirtualTableInner( const rowVirtualizer = useVirtualizer({ count: rows.length, getScrollElement: () => tableContainerRef.current, - estimateSize: () => 40, // Estimated row height + estimateSize: () => estimateRowHeight, overscan: 10, }) @@ -236,30 +395,42 @@ function ClientVirtualTableInner( return } const currentData = table.getFilteredRowModel().rows.map((row) => row.original) - await exportToExcel(currentData, columns, `export-${new Date().toISOString().slice(0,10)}.xlsx`) + await exportToExcel(currentData, columns, `export-${new Date().toISOString().slice(0, 10)}.xlsx`) } return ( -
+
- {actions} + {enableUserPreset && tableKey && ( + + )} } + customToolbar={customToolbar} + actions={actions} />
+ {isLoading && ( +
+ +
+ )} + ( className="table-fixed border-collapse w-full min-w-full" style={{ width: table.getTotalSize() }} > - + {table.getHeaderGroups().map((headerGroup) => ( h.id)} strategy={horizontalListSortingStrategy} > {headerGroup.headers.map((header) => ( @@ -281,6 +452,7 @@ function ClientVirtualTableInner( key={header.id} header={header} enableReordering={true} + renderHeaderVisualFeedback={renderHeaderVisualFeedback} /> ))} @@ -293,51 +465,126 @@ function ClientVirtualTableInner( )} - {virtualRows.map((virtualRow) => { - const row = rows[virtualRow.index] - return ( - - {row.getVisibleCells().map((cell) => { - // Handle pinned cells - const isPinned = cell.column.getIsPinned() - const style: React.CSSProperties = { - width: cell.column.getSize(), - } - if (isPinned === "left") { - style.position = "sticky" - style.left = `${cell.column.getStart("left")}px` - style.zIndex = 10 - style.backgroundColor = "var(--background)" // Ensure opacity - } else if (isPinned === "right") { - style.position = "sticky" - style.right = `${cell.column.getAfter("right")}px` - style.zIndex = 10 - style.backgroundColor = "var(--background)" - } - - return ( + {virtualRows.length === 0 && !isLoading ? ( + + + No results. + + + ) : ( + virtualRows.map((virtualRow) => { + const row = rows[virtualRow.index] + + // --- Group Header Rendering --- + if (row.getIsGrouped()) { + const groupingColumnId = row.groupingColumnId ?? ""; + const groupingValue = row.getGroupingValue(groupingColumnId); + + return ( + - {flexRender( - cell.column.columnDef.cell, - cell.getContext() - )} +
+ {row.getIsExpanded() ? ( + + ) : ( + + )} + + + {groupingColumnId}: + + + {String(groupingValue)} + + + ({row.subRows.length}) + + +
- ) - })} - - ) - })} + + ) + } + + // --- Normal Row Rendering --- + return ( + 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 ( + + {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()) + )} + + ) + })} + + ) + }) + )} {paddingBottom > 0 && ( @@ -347,9 +594,9 @@ function ClientVirtualTableInner(
- + {enablePagination && ( - + )}
) 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/db/schema/index.ts b/db/schema/index.ts index cd54e032..fdc25afc 100644 --- a/db/schema/index.ts +++ b/db/schema/index.ts @@ -50,6 +50,8 @@ export * from './permissions'; export * from './fileSystem'; +export * from './user-custom-data/userCustomData'; + // 부서별 도메인 할당 관리 export * from './departmentDomainAssignments'; 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(), +}); -- cgit v1.2.3