summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-03-25 15:55:45 +0900
committerjoonhoekim <26rote@gmail.com>2025-03-25 15:55:45 +0900
commit1a2241c40e10193c5ff7008a7b7b36cc1d855d96 (patch)
tree8a5587f10ca55b162d7e3254cb088b323a34c41b /app
initial commit
Diffstat (limited to 'app')
-rw-r--r--app/[lng]/evcp/bqtbe/page.tsx72
-rw-r--r--app/[lng]/evcp/budgetary/[id]/cbe/page.tsx56
-rw-r--r--app/[lng]/evcp/budgetary/[id]/layout.tsx80
-rw-r--r--app/[lng]/evcp/budgetary/[id]/page.tsx57
-rw-r--r--app/[lng]/evcp/budgetary/[id]/tbe/page.tsx55
-rw-r--r--app/[lng]/evcp/budgetary/page.tsx86
-rw-r--r--app/[lng]/evcp/equip-class/page.tsx75
-rw-r--r--app/[lng]/evcp/faq/manage/actions.ts48
-rw-r--r--app/[lng]/evcp/faq/manage/page.tsx38
-rw-r--r--app/[lng]/evcp/faq/page.tsx62
-rw-r--r--app/[lng]/evcp/form-list/page.tsx75
-rw-r--r--app/[lng]/evcp/items/page.tsx74
-rw-r--r--app/[lng]/evcp/layout.tsx17
-rw-r--r--app/[lng]/evcp/page.tsx8
-rw-r--r--app/[lng]/evcp/po/page.tsx65
-rw-r--r--app/[lng]/evcp/pq-criteria/page.tsx71
-rw-r--r--app/[lng]/evcp/pq/[vendorId]/page.tsx38
-rw-r--r--app/[lng]/evcp/pq/page.tsx71
-rw-r--r--app/[lng]/evcp/rfq/[id]/cbe/page.tsx53
-rw-r--r--app/[lng]/evcp/rfq/[id]/layout.tsx80
-rw-r--r--app/[lng]/evcp/rfq/[id]/page.tsx55
-rw-r--r--app/[lng]/evcp/rfq/[id]/tbe/page.tsx55
-rw-r--r--app/[lng]/evcp/rfq/page.tsx80
-rw-r--r--app/[lng]/evcp/settings/layout.tsx68
-rw-r--r--app/[lng]/evcp/settings/page.tsx18
-rw-r--r--app/[lng]/evcp/settings/preferences/page.tsx17
-rw-r--r--app/[lng]/evcp/system/admin-users/page.tsx60
-rw-r--r--app/[lng]/evcp/system/layout.tsx75
-rw-r--r--app/[lng]/evcp/system/page.tsx56
-rw-r--r--app/[lng]/evcp/system/permissions/page.tsx17
-rw-r--r--app/[lng]/evcp/system/roles/page.tsx68
-rw-r--r--app/[lng]/evcp/tag-numbering/page.tsx74
-rw-r--r--app/[lng]/evcp/tasks/page.tsx63
-rw-r--r--app/[lng]/evcp/vendors/[id]/info/items/page.tsx56
-rw-r--r--app/[lng]/evcp/vendors/[id]/info/layout.tsx79
-rw-r--r--app/[lng]/evcp/vendors/[id]/info/page.tsx56
-rw-r--r--app/[lng]/evcp/vendors/[id]/info/rfq-history/page.tsx55
-rw-r--r--app/[lng]/evcp/vendors/page.tsx78
-rw-r--r--app/[lng]/login/page.tsx15
-rw-r--r--app/[lng]/partners/(partners)/document-list/[contractId]/page.tsx44
-rw-r--r--app/[lng]/partners/(partners)/document-list/layout.tsx45
-rw-r--r--app/[lng]/partners/(partners)/document-list/page.tsx21
-rw-r--r--app/[lng]/partners/(partners)/documents/[contractId]/page.tsx47
-rw-r--r--app/[lng]/partners/(partners)/documents/layout.tsx44
-rw-r--r--app/[lng]/partners/(partners)/documents/page.tsx21
-rw-r--r--app/[lng]/partners/(partners)/layout.tsx17
-rw-r--r--app/[lng]/partners/(partners)/rfq/page.tsx133
-rw-r--r--app/[lng]/partners/(partners)/system/page.tsx8
-rw-r--r--app/[lng]/partners/(partners)/tbe/page.tsx85
-rw-r--r--app/[lng]/partners/(partners)/vendor-data/form/[packageId]/[formId]/page.tsx41
-rw-r--r--app/[lng]/partners/(partners)/vendor-data/layout.tsx69
-rw-r--r--app/[lng]/partners/(partners)/vendor-data/page.tsx29
-rw-r--r--app/[lng]/partners/(partners)/vendor-data/tag/[id]/page.tsx43
-rw-r--r--app/[lng]/partners/page.tsx21
-rw-r--r--app/[lng]/partners/pq/page.tsx39
-rw-r--r--app/[lng]/partners/repository/page.tsx11
-rw-r--r--app/[lng]/partners/signup/page.tsx21
-rw-r--r--app/[lng]/qna/layout.tsx18
-rw-r--r--app/[lng]/qna/page.tsx8
-rw-r--r--app/api/auth/[...nextauth]/route.ts111
-rw-r--r--app/api/files/[...path]/route.ts74
-rw-r--r--app/api/rfq-download/route.ts121
-rw-r--r--app/api/rfq-upload/route.ts36
-rw-r--r--app/api/upload/route.ts38
-rw-r--r--app/api/vendors/attachments/download-temp/route.ts102
-rw-r--r--app/api/vendors/erp/route.ts144
-rw-r--r--app/favicon.icobin0 -> 25931 bytes
-rw-r--r--app/globals.css168
-rw-r--r--app/layout.tsx85
-rw-r--r--app/page.tsx101
70 files changed, 3971 insertions, 0 deletions
diff --git a/app/[lng]/evcp/bqtbe/page.tsx b/app/[lng]/evcp/bqtbe/page.tsx
new file mode 100644
index 00000000..655bd30a
--- /dev/null
+++ b/app/[lng]/evcp/bqtbe/page.tsx
@@ -0,0 +1,72 @@
+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">
+ Technical Bid Evaluation
+ </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/budgetary/[id]/cbe/page.tsx b/app/[lng]/evcp/budgetary/[id]/cbe/page.tsx
new file mode 100644
index 00000000..9a4ae7eb
--- /dev/null
+++ b/app/[lng]/evcp/budgetary/[id]/cbe/page.tsx
@@ -0,0 +1,56 @@
+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/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 벤더가 입력할 수 있게 자동 생성됩니다.
+ </p>
+ </div>
+ <Separator />
+ <div>
+ <CbeTable promises={promises} rfqId={idAsNumber}/>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/budgetary/[id]/layout.tsx b/app/[lng]/evcp/budgetary/[id]/layout.tsx
new file mode 100644
index 00000000..39f045e5
--- /dev/null
+++ b/app/[lng]/evcp/budgetary/[id]/layout.tsx
@@ -0,0 +1,80 @@
+import { Metadata } from "next"
+
+import { Separator } from "@/components/ui/separator"
+import { SidebarNav } from "@/components/layout/sidebar-nav"
+import { findVendorById } from "@/lib/vendors/service" // 가정: 여기에 findVendorById가 있다고 가정
+import { Rfq, RfqWithItems } from "@/db/schema/rfq"
+import { findRfqById } from "@/lib/rfqs/service"
+import { formatDate } from "@/lib/utils"
+
+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: RfqWithItems | 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="space-y-0.5">
+ {/* 4) 벤더 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */}
+ <h2 className="text-2xl font-bold tracking-tight">
+ {rfq
+ ? `${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 && <strong>{formatDate(rfq?.dueDate)}</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="-mx-4 lg:w-1/6">
+ <SidebarNav items={sidebarNavItems} />
+ </aside>
+ <div className="flex-1">{children}</div>
+ </div>
+ </div>
+ </section>
+ </div>
+ </>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/budgetary/[id]/page.tsx b/app/[lng]/evcp/budgetary/[id]/page.tsx
new file mode 100644
index 00000000..f6160574
--- /dev/null
+++ b/app/[lng]/evcp/budgetary/[id]/page.tsx
@@ -0,0 +1,57 @@
+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/budgetary/[id]/tbe/page.tsx b/app/[lng]/evcp/budgetary/[id]/tbe/page.tsx
new file mode 100644
index 00000000..a6259696
--- /dev/null
+++ b/app/[lng]/evcp/budgetary/[id]/tbe/page.tsx
@@ -0,0 +1,55 @@
+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/budgetary/page.tsx b/app/[lng]/evcp/budgetary/page.tsx
new file mode 100644
index 00000000..04550353
--- /dev/null
+++ b/app/[lng]/evcp/budgetary/page.tsx
@@ -0,0 +1,86 @@
+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/equip-class/page.tsx b/app/[lng]/evcp/equip-class/page.tsx
new file mode 100644
index 00000000..fcda1c1d
--- /dev/null
+++ b/app/[lng]/evcp/equip-class/page.tsx
@@ -0,0 +1,75 @@
+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/equip-class/validation"
+import { FormListsTable } from "@/lib/form-list/table/formLists-table"
+import { getTagClassists } from "@/lib/equip-class/service"
+import { EquipClassTable } from "@/lib/equip-class/table/equipClass-table"
+
+
+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([
+ getTagClassists({
+ ...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>
+ <h2 className="text-2xl font-bold tracking-tight">
+ Object Class List from S-EDP
+ </h2>
+ <p className="text-muted-foreground">
+ 벤더 데이터 입력을 위한 Form 리스트입니다.{" "}
+ {/* <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
+ />
+ }
+ >
+ <EquipClassTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+}
diff --git a/app/[lng]/evcp/faq/manage/actions.ts b/app/[lng]/evcp/faq/manage/actions.ts
new file mode 100644
index 00000000..bc443a8a
--- /dev/null
+++ b/app/[lng]/evcp/faq/manage/actions.ts
@@ -0,0 +1,48 @@
+'use server';
+
+import { promises as fs } from 'fs';
+import path from 'path';
+import { FaqCategory } from '@/components/faq/FaqCard';
+import { fallbackLng } from '@/i18n/settings';
+
+const FAQ_CONFIG_PATH = path.join(process.cwd(), 'config', 'faqDataConfig.ts');
+
+export async function updateFaqData(lng: string, newData: FaqCategory[]) {
+ try {
+ const fileContent = await fs.readFile(FAQ_CONFIG_PATH, 'utf-8');
+ const dataMatch = fileContent.match(/export const faqCategories[^=]*=\s*(\{[\s\S]*\});/);
+ if (!dataMatch) {
+ throw new Error('FAQ 데이터 형식이 올바르지 않습니다.');
+ }
+
+ const allData = eval(`(${dataMatch[1]})`);
+ const updatedData = {
+ ...allData,
+ [lng]: newData
+ };
+
+ const newFileContent = `import { FaqCategory } from "@/components/faq/FaqCard";\n\ninterface LocalizedFaqCategories {\n [lng: string]: FaqCategory[];\n}\n\nexport const faqCategories: LocalizedFaqCategories = ${JSON.stringify(updatedData, null, 4)};`;
+ await fs.writeFile(FAQ_CONFIG_PATH, newFileContent, 'utf-8');
+
+ return { success: true };
+ } catch (error) {
+ console.error('FAQ 데이터 업데이트 중 오류 발생:', error);
+ return { success: false, error: '데이터 업데이트 중 오류가 발생했습니다.' };
+ }
+}
+
+export async function getFaqData(lng: string): Promise<{ data: FaqCategory[] }> {
+ try {
+ const fileContent = await fs.readFile(FAQ_CONFIG_PATH, 'utf-8');
+ const dataMatch = fileContent.match(/export const faqCategories[^=]*=\s*(\{[\s\S]*\});/);
+ if (!dataMatch) {
+ throw new Error('FAQ 데이터 형식이 올바르지 않습니다.');
+ }
+
+ const allData = eval(`(${dataMatch[1]})`);
+ return { data: allData[lng] || allData[fallbackLng] || [] };
+ } catch (error) {
+ console.error('FAQ 데이터 읽기 중 오류 발생:', error);
+ return { data: [] };
+ }
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/faq/manage/page.tsx b/app/[lng]/evcp/faq/manage/page.tsx
new file mode 100644
index 00000000..011bbfa4
--- /dev/null
+++ b/app/[lng]/evcp/faq/manage/page.tsx
@@ -0,0 +1,38 @@
+import { FaqManager } from '@/components/faq/FaqManager';
+import { getFaqData, updateFaqData } from './actions';
+import { revalidatePath } from 'next/cache';
+import { FaqCategory } from '@/components/faq/FaqCard';
+
+interface Props {
+ params: {
+ lng: string;
+ }
+}
+
+export default async function FaqManagePage(props: Props) {
+ const resolvedParams = await props.params
+ const lng = resolvedParams.lng
+ const { data } = await getFaqData(lng);
+
+ async function handleSave(newData: FaqCategory[]) {
+ 'use server';
+ await updateFaqData(lng, newData);
+ revalidatePath(`/${lng}/evcp/faq`);
+ }
+
+ return (
+ <div className="container py-6">
+ <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
+ <div className="space-y-6 p-10 pb-16">
+ <div className="space-y-0.5">
+ <h2 className="text-2xl font-bold tracking-tight">FAQ Management</h2>
+ <p className="text-muted-foreground">
+ Manage FAQ categories and items for {lng.toUpperCase()} language.
+ </p>
+ </div>
+ <FaqManager initialData={data} onSave={handleSave} lng={lng} />
+ </div>
+ </section>
+ </div>
+ );
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/faq/page.tsx b/app/[lng]/evcp/faq/page.tsx
new file mode 100644
index 00000000..9b62b7e4
--- /dev/null
+++ b/app/[lng]/evcp/faq/page.tsx
@@ -0,0 +1,62 @@
+import { Separator } from "@/components/ui/separator"
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import { faqCategories } from "@/config/faqDataConfig"
+import { FaqCard } from "@/components/faq/FaqCard"
+import { Button } from "@/components/ui/button"
+import { Settings } from "lucide-react"
+import Link from "next/link"
+import { fallbackLng } from "@/i18n/settings"
+
+interface Props {
+ params: {
+ lng: string;
+ }
+}
+
+export default async function FaqPage(props: Props) {
+ const resolvedParams = await props.params
+ const lng = resolvedParams.lng
+ const localizedFaqCategories = faqCategories[lng] || faqCategories[fallbackLng];
+
+ return (
+ <div className="container py-6">
+ <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
+ <div className="space-y-6 p-10 pb-16">
+ <div className="flex justify-between items-center">
+ <div className="space-y-0.5">
+ <h2 className="text-2xl font-bold tracking-tight">Frequently Asked Questions</h2>
+ <p className="text-muted-foreground">
+ Find answers to common questions about using the EVCP system.
+ </p>
+ </div>
+ <Link href={`/${lng}/evcp/faq/manage`}>
+ <Button variant="outline">
+ <Settings className="w-4 h-4 mr-2" />
+ Manage FAQ
+ </Button>
+ </Link>
+ </div>
+ <Separator className="my-6" />
+
+ <Tabs defaultValue={localizedFaqCategories[0]?.label} className="space-y-4">
+ <TabsList>
+ {localizedFaqCategories.map((category) => (
+ <TabsTrigger key={category.label} value={category.label}>
+ {category.label}
+ </TabsTrigger>
+ ))}
+ </TabsList>
+
+ {localizedFaqCategories.map((category) => (
+ <TabsContent key={category.label} value={category.label} className="space-y-4">
+ {category.items.map((item, index) => (
+ <FaqCard key={index} item={item} />
+ ))}
+ </TabsContent>
+ ))}
+ </Tabs>
+ </div>
+ </section>
+ </div>
+ )
+}
diff --git a/app/[lng]/evcp/form-list/page.tsx b/app/[lng]/evcp/form-list/page.tsx
new file mode 100644
index 00000000..f96917d6
--- /dev/null
+++ b/app/[lng]/evcp/form-list/page.tsx
@@ -0,0 +1,75 @@
+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/form-list/validation"
+import { ItemsTable } from "@/lib/items/table/items-table"
+import { getFormLists } from "@/lib/form-list/service"
+import { FormListsTable } from "@/lib/form-list/table/formLists-table"
+
+
+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([
+ getFormLists({
+ ...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>
+ <h2 className="text-2xl font-bold tracking-tight">
+ Form List from S-EDP
+ </h2>
+ <p className="text-muted-foreground">
+ 벤더 데이터 입력을 위한 Form 리스트입니다.{" "}
+ {/* <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
+ />
+ }
+ >
+ <FormListsTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+}
diff --git a/app/[lng]/evcp/items/page.tsx b/app/[lng]/evcp/items/page.tsx
new file mode 100644
index 00000000..144689ff
--- /dev/null
+++ b/app/[lng]/evcp/items/page.tsx
@@ -0,0 +1,74 @@
+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/items/validations"
+import { getItems } from "@/lib/items/service"
+import { ItemsTable } from "@/lib/items/table/items-table"
+
+
+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([
+ getItems({
+ ...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>
+ <h2 className="text-2xl font-bold tracking-tight">
+ Package Items
+ </h2>
+ <p className="text-muted-foreground">
+ Item을 등록하고 관리할 수 있습니다.{" "}
+ {/* <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
+ />
+ }
+ >
+ <ItemsTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+}
diff --git a/app/[lng]/evcp/layout.tsx b/app/[lng]/evcp/layout.tsx
new file mode 100644
index 00000000..9dc39f7b
--- /dev/null
+++ b/app/[lng]/evcp/layout.tsx
@@ -0,0 +1,17 @@
+import { ReactNode } from 'react';
+import { Header } from '@/components/layout/Header';
+import { SiteFooter } from '@/components/layout/Footer';
+
+export default function EvcpLayout({ children }: { children: ReactNode }) {
+ return (
+ <div className="relative flex min-h-svh flex-col bg-background">
+ <Header />
+ <main className="flex flex-1 flex-col">
+ <div className='container-wrapper'>
+ {children}
+ </div>
+ </main>
+ <SiteFooter/>
+ </div>
+ );
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/page.tsx b/app/[lng]/evcp/page.tsx
new file mode 100644
index 00000000..a1e9f8be
--- /dev/null
+++ b/app/[lng]/evcp/page.tsx
@@ -0,0 +1,8 @@
+
+export default function Pages() {
+ return (
+ <>
+ test
+ </>
+ )
+ } \ No newline at end of file
diff --git a/app/[lng]/evcp/po/page.tsx b/app/[lng]/evcp/po/page.tsx
new file mode 100644
index 00000000..fa528df0
--- /dev/null
+++ b/app/[lng]/evcp/po/page.tsx
@@ -0,0 +1,65 @@
+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 { getPOs } from "@/lib/po/service"
+import { searchParamsCache } from "@/lib/po/validations"
+import { PoListsTable } from "@/lib/po/table/po-table"
+
+
+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([
+ getPOs({
+ ...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>
+ <h2 className="text-2xl font-bold tracking-tight">
+ PO 확인 및 전자서명
+ </h2>
+ <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
+ />
+ }
+ >
+ <PoListsTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+}
diff --git a/app/[lng]/evcp/pq-criteria/page.tsx b/app/[lng]/evcp/pq-criteria/page.tsx
new file mode 100644
index 00000000..d924890d
--- /dev/null
+++ b/app/[lng]/evcp/pq-criteria/page.tsx
@@ -0,0 +1,71 @@
+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/pq/validations"
+import { getPQs } from "@/lib/pq/service"
+import { PqsTable } from "@/lib/pq/table/pq-table"
+
+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([
+ getPQs({
+ ...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>
+ <h2 className="text-2xl font-bold tracking-tight">
+ Pre-Qualification Check Sheet
+ </h2>
+ <p className="text-muted-foreground">
+ 벤더 등록을 위한, 벤더가 제출할 PQ 항목을 관리할 수 있습니다.
+
+ </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
+ />
+ }
+ >
+ <PqsTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+}
diff --git a/app/[lng]/evcp/pq/[vendorId]/page.tsx b/app/[lng]/evcp/pq/[vendorId]/page.tsx
new file mode 100644
index 00000000..cb4277f1
--- /dev/null
+++ b/app/[lng]/evcp/pq/[vendorId]/page.tsx
@@ -0,0 +1,38 @@
+import * as React from "react"
+import { Shell } from "@/components/shell"
+import { Skeleton } from "@/components/ui/skeleton"
+
+import { type SearchParams } from "@/types/table"
+import { getPQDataByVendorId } from "@/lib/pq/service"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Vendor } from "@/db/schema/vendors"
+import { findVendorById } from "@/lib/vendors/service"
+import VendorPQReviewPage from "@/components/pq/pq-review-detail"
+import VendorPQAdminReview from "@/components/pq/pq-review-detail"
+
+interface IndexPageProps {
+ params: {
+ vendorId: string // Updated from 'id' to 'contractId' to match route parameter
+ }
+ searchParams: Promise<SearchParams>
+}
+
+export default async function DocumentListPage(props: IndexPageProps) {
+ const resolvedParams = await props.params
+ const vendorId = resolvedParams.vendorId // Updated from 'id' to 'contractId'
+
+ const idAsNumber = Number(vendorId)
+
+ const data = await getPQDataByVendorId(idAsNumber)
+
+ const vendor: Vendor | null = await findVendorById(idAsNumber)
+
+ // 4) 렌더링
+ return (
+ <Shell className="gap-2">
+ {vendor &&
+ <VendorPQAdminReview data={data} vendor={vendor} />
+ }
+ </Shell>
+ )
+}
diff --git a/app/[lng]/evcp/pq/page.tsx b/app/[lng]/evcp/pq/page.tsx
new file mode 100644
index 00000000..46b22b12
--- /dev/null
+++ b/app/[lng]/evcp/pq/page.tsx
@@ -0,0 +1,71 @@
+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 { getVendorsInPQ } from "@/lib/pq/service"
+import { searchParamsCache } from "@/lib/vendors/validations"
+import { VendorsPQReviewTable } from "@/lib/pq/pq-review-table/vendors-table"
+
+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([
+ getVendorsInPQ({
+ ...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>
+ <h2 className="text-2xl font-bold tracking-tight">
+ Pre-Qualification Review
+ </h2>
+ <p className="text-muted-foreground">
+ 벤더가 제출한 PQ를 확인하고 수정 요청 등을 할 수 있으며 PQ 종료 후에는 통과 여부를 결정할 수 있습니다.
+
+ </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
+ />
+ }
+ >
+ <VendorsPQReviewTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+}
diff --git a/app/[lng]/evcp/rfq/[id]/cbe/page.tsx b/app/[lng]/evcp/rfq/[id]/cbe/page.tsx
new file mode 100644
index 00000000..bc32641f
--- /dev/null
+++ b/app/[lng]/evcp/rfq/[id]/cbe/page.tsx
@@ -0,0 +1,53 @@
+import { Separator } from "@/components/ui/separator"
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { searchParamsTBECache } from "@/lib/rfqs/validations"
+
+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 = searchParamsTBECache.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">
+ Technical Bid Evaluation
+ </h3>
+ <p className="text-sm text-muted-foreground">
+ 초대된 벤더에게 CBE를 보낼 수 있습니다. <br/>"발행하기" 버튼을 통해 CBE를 전송하면 CBE 내용이 메일로 전달되고 eVCP에도 벤더가 입력할 수 있게 자동 생성됩니다.
+ </p>
+ </div>
+ <Separator />
+ <div>
+
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/rfq/[id]/layout.tsx b/app/[lng]/evcp/rfq/[id]/layout.tsx
new file mode 100644
index 00000000..2aac90eb
--- /dev/null
+++ b/app/[lng]/evcp/rfq/[id]/layout.tsx
@@ -0,0 +1,80 @@
+import { Metadata } from "next"
+
+import { Separator } from "@/components/ui/separator"
+import { SidebarNav } from "@/components/layout/sidebar-nav"
+import { findVendorById } from "@/lib/vendors/service" // 가정: 여기에 findVendorById가 있다고 가정
+import { Rfq, RfqWithItems } from "@/db/schema/rfq"
+import { findRfqById } from "@/lib/rfqs/service"
+import { formatDate } from "@/lib/utils"
+
+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: RfqWithItems | 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="space-y-0.5">
+ {/* 4) 벤더 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */}
+ <h2 className="text-2xl font-bold tracking-tight">
+ {rfq
+ ? `${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 && <strong>{formatDate(rfq?.dueDate)}</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="-mx-4 lg:w-1/6">
+ <SidebarNav items={sidebarNavItems} />
+ </aside>
+ <div className="flex-1">{children}</div>
+ </div>
+ </div>
+ </section>
+ </div>
+ </>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/rfq/[id]/page.tsx b/app/[lng]/evcp/rfq/[id]/page.tsx
new file mode 100644
index 00000000..026ca5ac
--- /dev/null
+++ b/app/[lng]/evcp/rfq/[id]/page.tsx
@@ -0,0 +1,55 @@
+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/rfq/[id]/tbe/page.tsx b/app/[lng]/evcp/rfq/[id]/tbe/page.tsx
new file mode 100644
index 00000000..15c5d93c
--- /dev/null
+++ b/app/[lng]/evcp/rfq/[id]/tbe/page.tsx
@@ -0,0 +1,55 @@
+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/rfq/page.tsx b/app/[lng]/evcp/rfq/page.tsx
new file mode 100644
index 00000000..3417b0bf
--- /dev/null
+++ b/app/[lng]/evcp/rfq/page.tsx
@@ -0,0 +1,80 @@
+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/settings/layout.tsx b/app/[lng]/evcp/settings/layout.tsx
new file mode 100644
index 00000000..6f373567
--- /dev/null
+++ b/app/[lng]/evcp/settings/layout.tsx
@@ -0,0 +1,68 @@
+import { Metadata } from "next"
+
+import { Separator } from "@/components/ui/separator"
+import { SidebarNav } from "@/components/layout/sidebar-nav"
+
+export const metadata: Metadata = {
+ title: "Settings",
+ // description: "Advanced form example using react-hook-form and Zod.",
+}
+
+
+interface SettingsLayoutProps {
+ children: React.ReactNode
+ params: { lng: string }
+}
+
+export default async function SettingsLayout({
+ children,
+ params,
+}: {
+ children: React.ReactNode
+ params: { lng: string }
+}) {
+ const resolvedParams = await params
+ const lng = resolvedParams.lng
+
+
+ const sidebarNavItems = [
+
+ {
+ title: "Account",
+ href: `/${lng}/evcp/settings`,
+ },
+ {
+ title: "Preferences",
+ href: `/${lng}/evcp/settings/preferences`,
+ }
+
+
+ ]
+
+
+ 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="space-y-0.5">
+ <h2 className="text-2xl font-bold tracking-tight">Settings</h2>
+ <p className="text-muted-foreground">
+ Manage your account settings and preferences.
+ </p>
+ </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="-mx-4 lg:w-1/5">
+ <SidebarNav items={sidebarNavItems} />
+ </aside>
+ <div className="flex-1 ">{children}</div>
+ </div>
+ </div>
+ </section>
+ </div>
+
+
+ </>
+ )
+}
diff --git a/app/[lng]/evcp/settings/page.tsx b/app/[lng]/evcp/settings/page.tsx
new file mode 100644
index 00000000..a6eaac90
--- /dev/null
+++ b/app/[lng]/evcp/settings/page.tsx
@@ -0,0 +1,18 @@
+import { Separator } from "@/components/ui/separator"
+import { AccountForm } from "@/components/settings/account-form"
+
+export default function SettingsAccountPage() {
+ return (
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">Account</h3>
+ <p className="text-sm text-muted-foreground">
+ Update your account settings. Set your preferred language and
+ timezone.
+ </p>
+ </div>
+ <Separator />
+ <AccountForm />
+ </div>
+ )
+}
diff --git a/app/[lng]/evcp/settings/preferences/page.tsx b/app/[lng]/evcp/settings/preferences/page.tsx
new file mode 100644
index 00000000..e2a88021
--- /dev/null
+++ b/app/[lng]/evcp/settings/preferences/page.tsx
@@ -0,0 +1,17 @@
+import { Separator } from "@/components/ui/separator"
+import { AppearanceForm } from "@/components/settings/appearance-form"
+
+export default function SettingsAppearancePage() {
+ return (
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">Preference</h3>
+ <p className="text-sm text-muted-foreground">
+ Customize the preference of the app.
+ </p>
+ </div>
+ <Separator />
+ <AppearanceForm />
+ </div>
+ )
+}
diff --git a/app/[lng]/evcp/system/admin-users/page.tsx b/app/[lng]/evcp/system/admin-users/page.tsx
new file mode 100644
index 00000000..11a9e9fb
--- /dev/null
+++ b/app/[lng]/evcp/system/admin-users/page.tsx
@@ -0,0 +1,60 @@
+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 { Separator } from "@/components/ui/separator"
+
+import { searchParamsCache } from "@/lib/admin-users/validations"
+import { getAllCompanies, getAllRoles, getUserCountGroupByCompany, getUserCountGroupByRole, getUsers } from "@/lib/admin-users/service"
+import { AdmUserTable } from "@/lib/admin-users/table/ausers-table"
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function UserTable(props: IndexPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsCache.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getUsers({
+ ...search,
+ filters: validFilters,
+ }),
+ getUserCountGroupByCompany(),
+ getUserCountGroupByRole(),
+ getAllCompanies(),
+ getAllRoles()
+ ])
+
+ return (
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">Vendor Admin User Management</h3>
+ <p className="text-sm text-muted-foreground">
+ 협력업체의 유저 전체를 조회하고 어드민 유저를 생성할 수 있는 페이지입니다. 이곳에서 초기 유저를 생성시킬 수 있습니다. <br />생성 후에는 생성된 사용자의 이메일로 생성 통보 이메일이 발송되며 사용자는 이메일과 OTP로 로그인이 가능합니다.
+ </p>
+ </div>
+ <Separator />
+ <AdmUserTable promises={promises} />
+ </div>
+ </React.Suspense>
+
+ )
+}
diff --git a/app/[lng]/evcp/system/layout.tsx b/app/[lng]/evcp/system/layout.tsx
new file mode 100644
index 00000000..4885a028
--- /dev/null
+++ b/app/[lng]/evcp/system/layout.tsx
@@ -0,0 +1,75 @@
+import { Metadata } from "next"
+
+import { Separator } from "@/components/ui/separator"
+import { SidebarNav } from "@/components/layout/sidebar-nav"
+
+export const metadata: Metadata = {
+ title: "System Setting",
+ // description: "Advanced form example using react-hook-form and Zod.",
+}
+
+
+interface SettingsLayoutProps {
+ children: React.ReactNode
+ params: { lng: string }
+}
+
+export default async function SettingsLayout({
+ children,
+ params,
+}: {
+ children: React.ReactNode
+ params: { lng: string }
+}) {
+ const resolvedParams = await params
+ const lng = resolvedParams.lng
+
+
+ const sidebarNavItems = [
+
+ {
+ title: "Users",
+ href: `/${lng}/evcp/system`,
+ },
+ {
+ title: "Roles",
+ href: `/${lng}/evcp/system/roles`,
+ },
+ {
+ title: "Permissions",
+ href: `/${lng}/evcp/system/permissions`,
+ },
+ {
+ title: "Vendor Users",
+ href: `/${lng}/evcp/system/admin-users`,
+ },
+
+ ]
+
+
+ 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="space-y-0.5">
+ <h2 className="text-2xl font-bold tracking-tight">시스템 설정</h2>
+ <p className="text-muted-foreground">
+ 사용자, 롤, 접근 권한을 관리하세요.
+ </p>
+ </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="-mx-4 lg:w-1/5">
+ <SidebarNav items={sidebarNavItems} />
+ </aside>
+ <div className="flex-1 ">{children}</div>
+ </div>
+ </div>
+ </section>
+ </div>
+
+
+ </>
+ )
+}
diff --git a/app/[lng]/evcp/system/page.tsx b/app/[lng]/evcp/system/page.tsx
new file mode 100644
index 00000000..2d180028
--- /dev/null
+++ b/app/[lng]/evcp/system/page.tsx
@@ -0,0 +1,56 @@
+import { Separator } from "@/components/ui/separator"
+import { type SearchParams } from "@/types/table"
+import * as React from "react"
+import { getValidFilters } from "@/lib/data-table"
+import { searchParamsCache } from "@/lib/admin-users/validations"
+import { getAllRoles, getUsersEVCP } from "@/lib/users/service"
+import { getUserCountGroupByRole } from "@/lib/admin-users/service"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { UserTable } from "@/lib/users/table/users-table"
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function SystemUserPage(props: IndexPageProps) {
+
+ const searchParams = await props.searchParams
+ const search = searchParamsCache.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getUsersEVCP({
+ ...search,
+ filters: validFilters,
+ }),
+ getUserCountGroupByRole(),
+ getAllRoles()
+ ])
+
+ return (
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "12rem", "12rem", "12rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">Users</h3>
+ <p className="text-sm text-muted-foreground">
+ 시스템 전체 사용자들을 조회하고 관리할 수 있는 페이지입니다. 사용자에게 롤을 할당하는 것으로 메뉴별 권한을 관리할 수 있습니다.
+ </p>
+ </div>
+ <Separator />
+ <UserTable promises={promises} />
+ </div>
+ </React.Suspense>
+
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/system/permissions/page.tsx b/app/[lng]/evcp/system/permissions/page.tsx
new file mode 100644
index 00000000..6aa2b693
--- /dev/null
+++ b/app/[lng]/evcp/system/permissions/page.tsx
@@ -0,0 +1,17 @@
+import PermissionsTree from "@/components/system/permissionsTree"
+import { Separator } from "@/components/ui/separator"
+
+export default function PermissionsPage() {
+ return (
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">Permissions</h3>
+ <p className="text-sm text-muted-foreground">
+ Set permissions to the menu by Role
+ </p>
+ </div>
+ <Separator />
+ <PermissionsTree/>
+ </div>
+ )
+}
diff --git a/app/[lng]/evcp/system/roles/page.tsx b/app/[lng]/evcp/system/roles/page.tsx
new file mode 100644
index 00000000..fe074600
--- /dev/null
+++ b/app/[lng]/evcp/system/roles/page.tsx
@@ -0,0 +1,68 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Separator } from "@/components/ui/separator"
+
+import { searchParamsCache } from "@/lib/roles/validations"
+import { searchParamsCache as searchParamsCache2 } from "@/lib/admin-users/validations"
+import { RolesTable } from "@/lib/roles/table/roles-table"
+import { getRolesWithCount } from "@/lib/roles/services"
+import { getUsersAll } from "@/lib/users/service"
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function UserTable(props: IndexPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsCache.parse(searchParams)
+ const search2 = searchParamsCache2.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getRolesWithCount({
+ ...search,
+ filters: validFilters,
+ }),
+
+
+ ])
+
+
+ const promises2 = Promise.all([
+ getUsersAll({
+ ...search2,
+ filters: validFilters,
+ }, "evcp"),
+ ])
+
+
+ return (
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">Role Management</h3>
+ <p className="text-sm text-muted-foreground">
+ 역할을 생성하고 역할에 유저를 할당할 수 있는 페이지입니다. 역할에 메뉴의 접근 권한 역시 할당할 수 있습니다.
+ </p>
+ </div>
+ <Separator />
+ <RolesTable promises={promises} promises2={promises2} />
+ </div>
+ </React.Suspense>
+
+ )
+}
diff --git a/app/[lng]/evcp/tag-numbering/page.tsx b/app/[lng]/evcp/tag-numbering/page.tsx
new file mode 100644
index 00000000..9d5b903a
--- /dev/null
+++ b/app/[lng]/evcp/tag-numbering/page.tsx
@@ -0,0 +1,74 @@
+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/tag-numbering/validation"
+import { getTagNumbering } from "@/lib/tag-numbering/service"
+import { TagNumberingTable } from "@/lib/tag-numbering/table/tagNumbering-table"
+
+
+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([
+ getTagNumbering({
+ ...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>
+ <h2 className="text-2xl font-bold tracking-tight">
+ Tag Numbering from S-EDP
+ </h2>
+ <p className="text-muted-foreground">
+ 태그 넘버링을 위한 룰셋을 S-EDP로부터 가져오고 확인할 수 있습니다{" "}
+ {/* <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
+ />
+ }
+ >
+ <TagNumberingTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+}
diff --git a/app/[lng]/evcp/tasks/page.tsx b/app/[lng]/evcp/tasks/page.tsx
new file mode 100644
index 00000000..f14cc757
--- /dev/null
+++ b/app/[lng]/evcp/tasks/page.tsx
@@ -0,0 +1,63 @@
+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/vendors/[id]/info/items/page.tsx b/app/[lng]/evcp/vendors/[id]/info/items/page.tsx
new file mode 100644
index 00000000..e9ff17b4
--- /dev/null
+++ b/app/[lng]/evcp/vendors/[id]/info/items/page.tsx
@@ -0,0 +1,56 @@
+import { Separator } from "@/components/ui/separator"
+import { getVendorItems } from "@/lib/vendors/service"
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { searchParamsItemCache } from "@/lib/vendors/validations"
+import { VendorItemsTable } from "@/lib/vendors/items-table/item-table"
+
+interface IndexPageProps {
+ // Next.js 13 App Router에서 기본으로 주어지는 객체들
+ params: {
+ lng: string
+ id: string
+ }
+ searchParams: Promise<SearchParams>
+}
+
+export default async function SettingsAccountPage(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 = searchParamsItemCache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+
+
+ const promises = Promise.all([
+ getVendorItems({
+ ...search,
+ filters: validFilters,
+ },
+ idAsNumber)
+ ])
+ // 4) 렌더링
+ return (
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">
+ Possible Items
+ </h3>
+ <p className="text-sm text-muted-foreground">
+ 딜리버리가 가능한 아이템 리스트를 확인할 수 있습니다.
+ </p>
+ </div>
+ <Separator />
+ <div>
+ <VendorItemsTable promises={promises} vendorId={idAsNumber}/>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/vendors/[id]/info/layout.tsx b/app/[lng]/evcp/vendors/[id]/info/layout.tsx
new file mode 100644
index 00000000..39e0bac0
--- /dev/null
+++ b/app/[lng]/evcp/vendors/[id]/info/layout.tsx
@@ -0,0 +1,79 @@
+import { Metadata } from "next"
+
+import { Separator } from "@/components/ui/separator"
+import { SidebarNav } from "@/components/layout/sidebar-nav"
+import { findVendorById } from "@/lib/vendors/service" // 가정: 여기에 findVendorById가 있다고 가정
+import { Vendor } from "@/db/schema/vendors"
+
+export const metadata: Metadata = {
+ title: "Vendor Detail",
+}
+
+export default async function SettingsLayout({
+ 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 vendor: Vendor | null = await findVendorById(idAsNumber)
+
+ // 3) 사이드바 메뉴
+ const sidebarNavItems = [
+ {
+ title: "Contacts",
+ href: `/${lng}/evcp/vendors/${id}/info`,
+ },
+ {
+ title: "Items",
+ href: `/${lng}/evcp/vendors/${id}/info/items`,
+ },
+ {
+ title: "RFQ History",
+ href: `/${lng}/evcp/vendors/${id}/info/rfq-history`,
+ },
+ {
+ title: "Bidding History",
+ href: `/${lng}/evcp/vendors/${id}/info/bid-history`,
+ },
+ {
+ title: "Contract History",
+ href: `/${lng}/evcp/vendors/${id}/info/contract-history`,
+ },
+ ]
+
+ 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="space-y-0.5">
+ {/* 4) 벤더 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */}
+ <h2 className="text-2xl font-bold tracking-tight">
+ {vendor
+ ? `${vendor.vendorCode ?? ""} - ${vendor.vendorName} 상세 정보`
+ : "Loading Vendor..."}
+ </h2>
+ <p className="text-muted-foreground">벤더 관련 상세사항을 확인하세요.</p>
+ </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="-mx-4 lg:w-1/5">
+ <SidebarNav items={sidebarNavItems} />
+ </aside>
+ <div className="flex-1">{children}</div>
+ </div>
+ </div>
+ </section>
+ </div>
+ </>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/vendors/[id]/info/page.tsx b/app/[lng]/evcp/vendors/[id]/info/page.tsx
new file mode 100644
index 00000000..6279e924
--- /dev/null
+++ b/app/[lng]/evcp/vendors/[id]/info/page.tsx
@@ -0,0 +1,56 @@
+import { Separator } from "@/components/ui/separator"
+import { getVendorContacts } from "@/lib/vendors/service"
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { searchParamsContactCache } from "@/lib/vendors/validations"
+import { VendorContactsTable } from "@/lib/vendors/contacts-table/contact-table"
+
+interface IndexPageProps {
+ // Next.js 13 App Router에서 기본으로 주어지는 객체들
+ params: {
+ lng: string
+ id: string
+ }
+ searchParams: Promise<SearchParams>
+}
+
+export default async function SettingsAccountPage(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 = searchParamsContactCache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+
+
+ const promises = Promise.all([
+ getVendorContacts({
+ ...search,
+ filters: validFilters,
+ },
+ idAsNumber)
+ ])
+ // 4) 렌더링
+ return (
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">
+ Contacts
+ </h3>
+ <p className="text-sm text-muted-foreground">
+ 업무별 담당자 정보를 확인하세요.
+ </p>
+ </div>
+ <Separator />
+ <div>
+ <VendorContactsTable promises={promises} vendorId={idAsNumber}/>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/vendors/[id]/info/rfq-history/page.tsx b/app/[lng]/evcp/vendors/[id]/info/rfq-history/page.tsx
new file mode 100644
index 00000000..1d2f618c
--- /dev/null
+++ b/app/[lng]/evcp/vendors/[id]/info/rfq-history/page.tsx
@@ -0,0 +1,55 @@
+import { Separator } from "@/components/ui/separator"
+import { getRfqHistory } from "@/lib/vendors/service"
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { searchParamsRfqHistoryCache } from "@/lib/vendors/validations"
+import { VendorRfqHistoryTable } from "@/lib/vendors/rfq-history-table/rfq-history-table"
+
+interface IndexPageProps {
+ // Next.js 13 App Router에서 기본으로 주어지는 객체들
+ params: {
+ lng: string
+ id: string
+ }
+ searchParams: Promise<SearchParams>
+}
+
+export default async function RfqHistoryPage(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 = searchParamsRfqHistoryCache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getRfqHistory({
+ ...search,
+ filters: validFilters,
+ },
+ idAsNumber)
+ ])
+
+ // 4) 렌더링
+ return (
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">
+ RFQ History
+ </h3>
+ <p className="text-sm text-muted-foreground">
+ 벤더의 RFQ 참여 이력을 확인할 수 있습니다.
+ </p>
+ </div>
+ <Separator />
+ <div>
+ <VendorRfqHistoryTable promises={promises} />
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/vendors/page.tsx b/app/[lng]/evcp/vendors/page.tsx
new file mode 100644
index 00000000..e3cc7fdc
--- /dev/null
+++ b/app/[lng]/evcp/vendors/page.tsx
@@ -0,0 +1,78 @@
+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/vendors/validations"
+import { getVendors, getVendorStatusCounts } from "@/lib/vendors/service"
+import { VendorsTable } from "@/lib/vendors/table/vendors-table"
+import { Ellipsis } from "lucide-react"
+
+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([
+ getVendors({
+ ...search,
+ filters: validFilters,
+ }),
+ getVendorStatusCounts(),
+ ])
+
+ 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">
+ Vendor Information
+ </h2>
+ <p className="text-muted-foreground">
+ 벤더에 대한 요약 정보를 확인하고{" "}
+ <span className="inline-flex items-center whitespace-nowrap">
+ <Ellipsis className="size-3" />
+ <span className="ml-1">버튼</span>
+ </span>
+ 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. <br/>벤더의 상태에 따라 가입을 승인해주거나 PQ 요청을 할 수 있고 검토가 완료된 벤더를 기간계 시스템에 전송하여 벤더 코드를 따올 수 있습니다.
+ </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
+ />
+ }
+ >
+ <VendorsTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+}
diff --git a/app/[lng]/login/page.tsx b/app/[lng]/login/page.tsx
new file mode 100644
index 00000000..0e3a498c
--- /dev/null
+++ b/app/[lng]/login/page.tsx
@@ -0,0 +1,15 @@
+import { LoginForm } from "@/components/login/login-form"
+import { Suspense } from "react"
+
+export default function LoginPage() {
+ return (
+ <Suspense fallback={<div>Loading login form...</div>}>
+ <LoginForm />
+ </Suspense>
+ // <div className="flex min-h-svh flex-col items-center justify-center bg-muted p-6 md:p-10">
+ // <div className="w-full max-w-sm md:max-w-3xl">
+
+ // </div>
+ // </div>
+ )
+}
diff --git a/app/[lng]/partners/(partners)/document-list/[contractId]/page.tsx b/app/[lng]/partners/(partners)/document-list/[contractId]/page.tsx
new file mode 100644
index 00000000..65df0b1f
--- /dev/null
+++ b/app/[lng]/partners/(partners)/document-list/[contractId]/page.tsx
@@ -0,0 +1,44 @@
+import { Separator } from "@/components/ui/separator"
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { searchParamsCache } from "@/lib/vendor-document-list/validations"
+import { getVendorDocuments } from "@/lib/vendor-document-list/service"
+import { DocumentsTable } from "@/lib/vendor-document-list/table/doc-table"
+
+interface IndexPageProps {
+ params: {
+ contractId: string // Updated from 'id' to 'contractId' to match route parameter
+ }
+ searchParams: Promise<SearchParams>
+}
+
+export default async function DocumentListPage(props: IndexPageProps) {
+ const resolvedParams = await props.params
+ const contractId = resolvedParams.contractId // Updated from 'id' to 'contractId'
+
+ const idAsNumber = Number(contractId)
+
+ // 2) SearchParams 파싱 (Zod)
+ // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
+ const searchParams = await props.searchParams
+ const search = searchParamsCache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+ const projectType = searchParams.projectType === "plant" ? "plant" : "ship"
+
+ const promises = Promise.all([
+ getVendorDocuments({
+ ...search,
+ filters: validFilters,
+ }, idAsNumber)
+ ])
+
+ // 4) 렌더링
+ return (
+ <div className="space-y-6">
+ <div>
+ <DocumentsTable promises={promises} selectedPackageId={idAsNumber} projectType={projectType}/>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/partners/(partners)/document-list/layout.tsx b/app/[lng]/partners/(partners)/document-list/layout.tsx
new file mode 100644
index 00000000..a75cdf7d
--- /dev/null
+++ b/app/[lng]/partners/(partners)/document-list/layout.tsx
@@ -0,0 +1,45 @@
+
+import { cookies } from "next/headers"
+import { Shell } from "@/components/shell"
+import DocumentContainer from "@/components/documents/document-container"
+import { getVendorProjectsAndContracts } from "@/lib/vendor-data/services"
+import { getVendorDocumentLists } from "@/lib/vendor-document/service"
+import VendorDocumentsClient from "@/components/documents/vendor-docs.client"
+import VendorDocumentListClient from "@/components/document-lists/vendor-doc-list-client"
+
+
+
+// Layout 컴포넌트는 서버 컴포넌트입니다
+export default async function VendorDocuments({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ // const session = await getServerSession(authOptions)
+ // const vendorId = session?.user.companyId
+ const vendorId = "17"
+ const idAsNumber = Number(vendorId)
+
+ const projects = await getVendorProjectsAndContracts(idAsNumber)
+
+
+ // 레이아웃 설정 쿠키 가져오기
+ // Next.js 15에서는 cookies()가 Promise를 반환하므로 await 사용
+ const cookieStore = await cookies()
+
+ // 이제 cookieStore.get() 메서드 사용 가능
+ const layout = cookieStore.get("react-resizable-panels:layout:mail")
+ const collapsed = cookieStore.get("react-resizable-panels:collapsed")
+
+ const defaultLayout = layout ? JSON.parse(layout.value) : undefined
+ const defaultCollapsed = collapsed ? JSON.parse(collapsed.value) : undefined
+
+
+ return (
+ <Shell className="gap-2">
+ <VendorDocumentListClient projects={projects}>
+ {children}
+ </VendorDocumentListClient>
+ </Shell>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/partners/(partners)/document-list/page.tsx b/app/[lng]/partners/(partners)/document-list/page.tsx
new file mode 100644
index 00000000..721eb408
--- /dev/null
+++ b/app/[lng]/partners/(partners)/document-list/page.tsx
@@ -0,0 +1,21 @@
+// app/vendor-data/page.tsx
+import * as React from "react"
+import { Separator } from "@/components/ui/separator"
+
+export default async function IndexPage() {
+ return (
+ <div className="space-y-6">
+
+ <div className="grid gap-4">
+ <div className="rounded-lg border p-4">
+ <h4 className="text-sm font-medium">시작하는 방법</h4>
+ <p className="text-sm text-muted-foreground mt-1">
+ 오른쪽 상단에서 프로젝트/계약을 선택하세요.<br />
+
+
+ </p>
+ </div>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/partners/(partners)/documents/[contractId]/page.tsx b/app/[lng]/partners/(partners)/documents/[contractId]/page.tsx
new file mode 100644
index 00000000..7bf50c15
--- /dev/null
+++ b/app/[lng]/partners/(partners)/documents/[contractId]/page.tsx
@@ -0,0 +1,47 @@
+import { Separator } from "@/components/ui/separator"
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { TagsTable } from "@/lib/tags/table/tag-table"
+import { searchParamsCache } from "@/lib/vendor-document/validations"
+import { getTags } from "@/lib/tags/service"
+import { getVendorDocumentLists } from "@/lib/vendor-document/service"
+import { DocumentListTable } from "@/lib/vendor-document/table/doc-table"
+import DocumentContainer from "@/components/documents/document-container"
+
+interface IndexPageProps {
+ params: {
+ contractId: string // Updated from 'id' to 'contractId' to match route parameter
+ }
+ searchParams: Promise<SearchParams>
+}
+
+export default async function DocumentListPage(props: IndexPageProps) {
+ const resolvedParams = await props.params
+ const contractId = resolvedParams.contractId // Updated from 'id' to 'contractId'
+
+ const idAsNumber = Number(contractId)
+
+ console.log(idAsNumber)
+
+ // 2) SearchParams 파싱 (Zod)
+ // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
+ const searchParams = await props.searchParams
+ const search = searchParamsCache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getVendorDocumentLists({
+ ...search,
+ filters: validFilters,
+ }, idAsNumber)
+ ])
+
+ // 4) 렌더링
+ return (
+ <div className="space-y-6">
+ <div>
+ <DocumentContainer promises={promises} selectedPackageId={idAsNumber}/>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/partners/(partners)/documents/layout.tsx b/app/[lng]/partners/(partners)/documents/layout.tsx
new file mode 100644
index 00000000..3ac0c573
--- /dev/null
+++ b/app/[lng]/partners/(partners)/documents/layout.tsx
@@ -0,0 +1,44 @@
+
+import { cookies } from "next/headers"
+import { Shell } from "@/components/shell"
+import DocumentContainer from "@/components/documents/document-container"
+import { getVendorProjectsAndContracts } from "@/lib/vendor-data/services"
+import { getVendorDocumentLists } from "@/lib/vendor-document/service"
+import VendorDocumentsClient from "@/components/documents/vendor-docs.client"
+
+
+
+// Layout 컴포넌트는 서버 컴포넌트입니다
+export default async function VendorDocuments({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ // const session = await getServerSession(authOptions)
+ // const vendorId = session?.user.companyId
+ const vendorId = "17"
+ const idAsNumber = Number(vendorId)
+
+ const projects = await getVendorProjectsAndContracts(idAsNumber)
+
+
+ // 레이아웃 설정 쿠키 가져오기
+ // Next.js 15에서는 cookies()가 Promise를 반환하므로 await 사용
+ const cookieStore = await cookies()
+
+ // 이제 cookieStore.get() 메서드 사용 가능
+ const layout = cookieStore.get("react-resizable-panels:layout:mail")
+ const collapsed = cookieStore.get("react-resizable-panels:collapsed")
+
+ const defaultLayout = layout ? JSON.parse(layout.value) : undefined
+ const defaultCollapsed = collapsed ? JSON.parse(collapsed.value) : undefined
+
+
+ return (
+ <Shell className="gap-2">
+ <VendorDocumentsClient projects={projects}>
+ {children}
+ </VendorDocumentsClient>
+ </Shell>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/partners/(partners)/documents/page.tsx b/app/[lng]/partners/(partners)/documents/page.tsx
new file mode 100644
index 00000000..721eb408
--- /dev/null
+++ b/app/[lng]/partners/(partners)/documents/page.tsx
@@ -0,0 +1,21 @@
+// app/vendor-data/page.tsx
+import * as React from "react"
+import { Separator } from "@/components/ui/separator"
+
+export default async function IndexPage() {
+ return (
+ <div className="space-y-6">
+
+ <div className="grid gap-4">
+ <div className="rounded-lg border p-4">
+ <h4 className="text-sm font-medium">시작하는 방법</h4>
+ <p className="text-sm text-muted-foreground mt-1">
+ 오른쪽 상단에서 프로젝트/계약을 선택하세요.<br />
+
+
+ </p>
+ </div>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/partners/(partners)/layout.tsx b/app/[lng]/partners/(partners)/layout.tsx
new file mode 100644
index 00000000..9dc39f7b
--- /dev/null
+++ b/app/[lng]/partners/(partners)/layout.tsx
@@ -0,0 +1,17 @@
+import { ReactNode } from 'react';
+import { Header } from '@/components/layout/Header';
+import { SiteFooter } from '@/components/layout/Footer';
+
+export default function EvcpLayout({ children }: { children: ReactNode }) {
+ return (
+ <div className="relative flex min-h-svh flex-col bg-background">
+ <Header />
+ <main className="flex flex-1 flex-col">
+ <div className='container-wrapper'>
+ {children}
+ </div>
+ </main>
+ <SiteFooter/>
+ </div>
+ );
+} \ No newline at end of file
diff --git a/app/[lng]/partners/(partners)/rfq/page.tsx b/app/[lng]/partners/(partners)/rfq/page.tsx
new file mode 100644
index 00000000..34b66115
--- /dev/null
+++ b/app/[lng]/partners/(partners)/rfq/page.tsx
@@ -0,0 +1,133 @@
+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 { searchParamsRfqsForVendorsCache } from "@/lib/rfqs/validations"
+import { RfqsVendorTable } from "@/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table"
+import { getServerSession } from "next-auth"
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+import Link from "next/link"
+import { Button } from "@/components/ui/button"
+import { LogIn } from "lucide-react"
+import { getRfqResponsesForVendor } from "@/lib/vendor-rfq-response/service"
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsRfqsForVendorsCache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+ // Get session
+ const session = await getServerSession(authOptions)
+
+ // Check if user is logged in
+ if (!session || !session.user) {
+ // Return login required UI instead of redirecting
+ return (
+ <Shell className="gap-6">
+ <div className="flex items-center justify-between">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ RFQ
+ </h2>
+ <p className="text-muted-foreground">
+ RFQ를 응답하고 커뮤니케이션을 할 수 있습니다.
+ </p>
+ </div>
+ </div>
+
+ <div className="flex flex-col items-center justify-center py-12 text-center">
+ <div className="rounded-lg border border-dashed p-10 shadow-sm">
+ <h3 className="mb-2 text-xl font-semibold">로그인이 필요합니다</h3>
+ <p className="mb-6 text-muted-foreground">
+ RFQ를 확인하려면 먼저 로그인하세요.
+ </p>
+ <Button size="lg" asChild>
+ <Link href="/partners">
+ <LogIn className="mr-2 h-4 w-4" />
+ 로그인하기
+ </Link>
+ </Button>
+ </div>
+ </div>
+ </Shell>
+ )
+ }
+
+ // User is logged in, proceed with vendor ID
+ const vendorId = session.user.companyId
+
+ // Validate vendorId (should be a number)
+ const idAsNumber = Number(vendorId)
+
+ if (isNaN(idAsNumber)) {
+ // Handle invalid vendor ID (this shouldn't happen if authentication is working properly)
+ return (
+ <Shell className="gap-6">
+ <div className="flex items-center justify-between">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ RFQ
+ </h2>
+ </div>
+ </div>
+ <div className="flex flex-col items-center justify-center py-12 text-center">
+ <div className="rounded-lg border border-dashed p-10 shadow-sm">
+ <h3 className="mb-2 text-xl font-semibold">계정 오류</h3>
+ <p className="mb-6 text-muted-foreground">
+ 업체 정보가 올바르게 설정되지 않았습니다. 관리자에게 문의하세요.
+ </p>
+ </div>
+ </div>
+ </Shell>
+ )
+ }
+
+ // If we got here, we have a valid vendor ID
+ const promises = Promise.all([
+ getRfqResponsesForVendor({
+ ...search,
+ filters: validFilters,
+ }, idAsNumber)
+ ])
+
+ 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">
+ RFQ
+ </h2>
+ <p className="text-muted-foreground">
+ RFQ를 응답하고 커뮤니케이션을 할 수 있습니다.
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ {/* DateRangePicker can go here */}
+ </React.Suspense>
+
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <RfqsVendorTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/partners/(partners)/system/page.tsx b/app/[lng]/partners/(partners)/system/page.tsx
new file mode 100644
index 00000000..a1e9f8be
--- /dev/null
+++ b/app/[lng]/partners/(partners)/system/page.tsx
@@ -0,0 +1,8 @@
+
+export default function Pages() {
+ return (
+ <>
+ test
+ </>
+ )
+ } \ No newline at end of file
diff --git a/app/[lng]/partners/(partners)/tbe/page.tsx b/app/[lng]/partners/(partners)/tbe/page.tsx
new file mode 100644
index 00000000..ab51659c
--- /dev/null
+++ b/app/[lng]/partners/(partners)/tbe/page.tsx
@@ -0,0 +1,85 @@
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { getTBEforVendor } from "@/lib/rfqs/service"
+import { searchParamsTBECache } from "@/lib/rfqs/validations"
+import { getServerSession } from "next-auth"
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+import { TbeVendorTable } from "@/lib/vendor-rfq-response/vendor-tbe-table/tbe-table"
+import * as React from "react"
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+
+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
+
+ // 2) SearchParams 파싱 (Zod)
+ // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
+ const searchParams = await props.searchParams
+ const search = searchParamsTBECache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+ const session = await getServerSession(authOptions)
+ const vendorId = session?.user.companyId
+ // const vendorId = "17"
+
+ const idAsNumber = Number(vendorId)
+
+ const promises = Promise.all([
+ getTBEforVendor({
+ ...search,
+ filters: validFilters,
+ },
+ idAsNumber)
+ ])
+
+
+ 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">
+ Technical Bid Evaluation
+ </h2>
+ <p className="text-sm text-muted-foreground">
+ TBE에 응답하고 커뮤니케이션을 할 수 있습니다.{" "}
+ </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
+ />
+ }
+ >
+ <TbeVendorTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+}
diff --git a/app/[lng]/partners/(partners)/vendor-data/form/[packageId]/[formId]/page.tsx b/app/[lng]/partners/(partners)/vendor-data/form/[packageId]/[formId]/page.tsx
new file mode 100644
index 00000000..248bd7fc
--- /dev/null
+++ b/app/[lng]/partners/(partners)/vendor-data/form/[packageId]/[formId]/page.tsx
@@ -0,0 +1,41 @@
+import DynamicTable from "@/components/form-data/form-data-table"
+import { getFormData } from "@/lib/forms/services"
+
+interface IndexPageProps {
+ params: {
+ lng: string
+ packageId: string
+ formId: string
+ }
+}
+
+export default async function FormPage({ params }: IndexPageProps) {
+ // 1) 구조 분해 할당
+ const resolvedParams = await params
+
+ // 2) 구조 분해 할당
+ const { lng, packageId, formId } = resolvedParams
+
+ // 2) 변환
+ const packageIdAsNumber = Number(packageId)
+
+ // 3) DB 조회
+ const { columns, data } = await getFormData(formId, packageIdAsNumber)
+
+ // 4) 예외 처리
+ if (!columns) {
+ return <p className="text-red-500">해당 폼의 메타 정보를 불러올 수 없습니다.</p>
+ }
+
+ // 5) 렌더링
+ return (
+ <div className="space-y-6">
+ <DynamicTable
+ contractItemId={packageIdAsNumber}
+ formCode={formId}
+ columnsJSON={columns}
+ dataJSON={data}
+ />
+ </div>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/partners/(partners)/vendor-data/layout.tsx b/app/[lng]/partners/(partners)/vendor-data/layout.tsx
new file mode 100644
index 00000000..a8b51c52
--- /dev/null
+++ b/app/[lng]/partners/(partners)/vendor-data/layout.tsx
@@ -0,0 +1,69 @@
+// app/vendor-data/layout.tsx
+import * as React from "react"
+import { cookies } from "next/headers"
+import { Shell } from "@/components/shell"
+import { getVendorProjectsAndContracts } from "@/lib/vendor-data/services"
+import { VendorDataContainer } from "@/components/vendor-data/vendor-data-container"
+
+// Layout 컴포넌트는 서버 컴포넌트입니다
+export default async function VendorDataLayout({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ // const session = await getServerSession(authOptions)
+ // const vendorId = session?.user.companyId
+ const vendorId = "17"
+ const idAsNumber = Number(vendorId)
+
+ // 프로젝트 데이터 가져오기
+ const projects = await getVendorProjectsAndContracts(idAsNumber)
+
+ // 레이아웃 설정 쿠키 가져오기
+ // Next.js 15에서는 cookies()가 Promise를 반환하므로 await 사용
+ const cookieStore = await cookies()
+
+ // 이제 cookieStore.get() 메서드 사용 가능
+ const layout = cookieStore.get("react-resizable-panels:layout:mail")
+ const collapsed = cookieStore.get("react-resizable-panels:collapsed")
+
+ const defaultLayout = layout ? JSON.parse(layout.value) : undefined
+ const defaultCollapsed = collapsed ? JSON.parse(collapsed.value) : undefined
+
+ 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">
+ Vendor Data
+ </h2>
+ <p className="text-muted-foreground">
+ 각종 Data 입력할 수 있습니다
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
+ <div className="hidden flex-col md:flex">
+ {projects.length === 0 ? (
+ <div className="p-4 text-center text-sm text-muted-foreground">
+ No projects found for this vendor.
+ </div>
+ ) : (
+ <VendorDataContainer
+ projects={projects}
+ defaultLayout={defaultLayout}
+ defaultCollapsed={defaultCollapsed}
+ navCollapsedSize={4}
+ >
+ {/* 페이지별 콘텐츠가 여기에 들어갑니다 */}
+ {children}
+ </VendorDataContainer>
+ )}
+ </div>
+ </section>
+ </Shell>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/partners/(partners)/vendor-data/page.tsx b/app/[lng]/partners/(partners)/vendor-data/page.tsx
new file mode 100644
index 00000000..3eead226
--- /dev/null
+++ b/app/[lng]/partners/(partners)/vendor-data/page.tsx
@@ -0,0 +1,29 @@
+// app/vendor-data/page.tsx
+import * as React from "react"
+import { Separator } from "@/components/ui/separator"
+
+export default async function IndexPage() {
+ return (
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">벤더 데이터 대시보드</h3>
+ <p className="text-sm text-muted-foreground">
+ 왼쪽 사이드바에서 패키지를 선택하여 태그를 관리하세요.
+ </p>
+ </div>
+ <Separator />
+ <div className="grid gap-4">
+ <div className="rounded-lg border p-4">
+ <h4 className="text-sm font-medium">시작하는 방법</h4>
+ <p className="text-sm text-muted-foreground mt-1">
+ 1. 왼쪽 상단에서 프로젝트/계약을 선택하세요.<br />
+ 2. 사이드바에서 패키지 항목을 클릭하세요.<br />
+ 3. 선택한 패키지의 태그 정보를 확인하고 관리할 수 있습니다.<br />
+ 4. 사이드바에서 폼 항목을 클릭하세요.<br />
+ 5. 선택함 폼의 칼럼 정보를 확인하고 관리할 수 있습니다.
+ </p>
+ </div>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/partners/(partners)/vendor-data/tag/[id]/page.tsx b/app/[lng]/partners/(partners)/vendor-data/tag/[id]/page.tsx
new file mode 100644
index 00000000..7250732f
--- /dev/null
+++ b/app/[lng]/partners/(partners)/vendor-data/tag/[id]/page.tsx
@@ -0,0 +1,43 @@
+import { Separator } from "@/components/ui/separator"
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { TagsTable } from "@/lib/tags/table/tag-table"
+import { searchParamsCache } from "@/lib/tags/validations"
+import { getTags } from "@/lib/tags/service"
+
+interface IndexPageProps {
+ params: {
+ id: string
+ }
+ searchParams: Promise<SearchParams>
+}
+
+export default async function TagPage(props: IndexPageProps) {
+ const resolvedParams = await props.params
+ const id = resolvedParams.id
+
+ const idAsNumber = Number(id)
+
+ // 2) SearchParams 파싱 (Zod)
+ // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
+ const searchParams = await props.searchParams
+ const search = searchParamsCache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getTags({
+ ...search,
+ filters: validFilters,
+ },
+ idAsNumber)
+ ])
+
+ // 4) 렌더링
+ return (
+ <div className="space-y-6">
+ <div>
+ <TagsTable promises={promises} selectedPackageId={idAsNumber}/>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/partners/page.tsx b/app/[lng]/partners/page.tsx
new file mode 100644
index 00000000..245d0228
--- /dev/null
+++ b/app/[lng]/partners/page.tsx
@@ -0,0 +1,21 @@
+import { Metadata } from "next"
+import { LoginForm } from "@/components/login/login-form"
+import { Suspense } from "react"
+import { LoginFormSkeleton } from "@/components/login/login-form-skeleton"
+
+export const metadata: Metadata = {
+ title: "Partner Portal",
+ description: "",
+}
+
+export default function AuthenticationPage() {
+
+
+ return (
+ <>
+ <Suspense fallback={<LoginFormSkeleton/>}>
+ <LoginForm />
+ </Suspense>
+ </>
+ )
+}
diff --git a/app/[lng]/partners/pq/page.tsx b/app/[lng]/partners/pq/page.tsx
new file mode 100644
index 00000000..8ad23f6e
--- /dev/null
+++ b/app/[lng]/partners/pq/page.tsx
@@ -0,0 +1,39 @@
+import { getServerSession } from "next-auth"
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+import * as React from "react"
+import { Shell } from "@/components/shell"
+import { Skeleton } from "@/components/ui/skeleton"
+import { getPQDataByVendorId } from "@/lib/pq/service"
+import { PQInputTabs } from "@/components/pq/pq-input-tabs"
+
+
+export default async function PQInputPage() {
+ // 세션
+ const session = await getServerSession(authOptions)
+ // 예: 세션에서 vendorId 가져오기
+ // const vendorId = session?.user.companyId
+ const vendorId = 17 // 임시
+ const idAsNumber = Number(vendorId)
+
+ // 1) 서버에서 PQ 데이터 조회 (groupName별로 묶인 구조)
+ const pqData = await getPQDataByVendorId(idAsNumber)
+
+ return (
+ <Shell className="gap-2">
+ <div className="space-y-2">
+ <h2 className="text-2xl font-bold tracking-tight">
+ Pre-Qualification Check Sheet
+ </h2>
+ <p className="text-muted-foreground">
+ PQ에 적절한 응답을 제출하시기 바랍니다. 진행 중 문의가 있으면 담당자에게 연락바랍니다.
+ </p>
+ </div>
+
+ {/* 클라이언트 탭 UI 로드 (Suspense는 여기서는 크게 필요치 않을 수도 있음) */}
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ <PQInputTabs data={pqData} vendorId={idAsNumber} />
+ </React.Suspense>
+
+ </Shell>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/partners/repository/page.tsx b/app/[lng]/partners/repository/page.tsx
new file mode 100644
index 00000000..51c0fae5
--- /dev/null
+++ b/app/[lng]/partners/repository/page.tsx
@@ -0,0 +1,11 @@
+import { CompanyAuthForm } from "@/components/login/partner-auth-form"
+import { Suspense } from "react"
+
+export default function RepositiryPage() {
+ return (
+ <Suspense fallback={<div>Loading ...</div>}>
+ <CompanyAuthForm />
+ </Suspense>
+
+ )
+}
diff --git a/app/[lng]/partners/signup/page.tsx b/app/[lng]/partners/signup/page.tsx
new file mode 100644
index 00000000..26c2944b
--- /dev/null
+++ b/app/[lng]/partners/signup/page.tsx
@@ -0,0 +1,21 @@
+import { Suspense } from "react"
+import { Metadata } from "next"
+import { JoinForm } from "@/components/signup/join-form"
+import { JoinFormSkeleton } from "@/components/signup/join-form-skeleton"
+
+// (Optional) If Next.js attempts to statically optimize this page and you need full runtime
+// behavior for query params, you may also need:
+// export const dynamic = "force-dynamic"
+
+export const metadata: Metadata = {
+ title: "Partner Portal",
+ description: "Authentication forms built using the components.",
+}
+
+export default function SignUpPage() {
+ return (
+ <Suspense fallback={<JoinFormSkeleton/>}>
+ <JoinForm />
+ </Suspense>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/qna/layout.tsx b/app/[lng]/qna/layout.tsx
new file mode 100644
index 00000000..87651e92
--- /dev/null
+++ b/app/[lng]/qna/layout.tsx
@@ -0,0 +1,18 @@
+import { ReactNode } from 'react';
+
+
+export default function EvcpLayout({ children }: { children: ReactNode }) {
+ return (
+ <div className="relative flex min-h-svh flex-col bg-background">
+ <main className="flex flex-1 flex-col">
+ <div className='container-wrapper'>
+ <div className="container flex-1 items-start md:grid md:grid-cols-[220px_minmax(0,1fr)] md:gap-6 lg:grid-cols-[240px_minmax(0,1fr)] lg:gap-10">
+ {children}
+ </div>
+
+ </div>
+
+ </main>
+ </div>
+ );
+} \ No newline at end of file
diff --git a/app/[lng]/qna/page.tsx b/app/[lng]/qna/page.tsx
new file mode 100644
index 00000000..10280464
--- /dev/null
+++ b/app/[lng]/qna/page.tsx
@@ -0,0 +1,8 @@
+
+export default function Pages() {
+ return (
+ <>
+ qna
+ </>
+ )
+ } \ No newline at end of file
diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts
new file mode 100644
index 00000000..609a63d7
--- /dev/null
+++ b/app/api/auth/[...nextauth]/route.ts
@@ -0,0 +1,111 @@
+// (1) next-auth에서 필요한 타입들을 import
+import NextAuth, {
+ NextAuthOptions, // authOptions에 쓸 타입
+ Session,
+ User
+} from 'next-auth'
+import { JWT } from "next-auth/jwt"
+
+import CredentialsProvider from 'next-auth/providers/credentials'
+
+import { verifyOtp } from '@/lib/users/verifyOtp'
+
+// 1) 모듈 보강 선언
+declare module "next-auth" {
+ /**
+ * Session 객체를 확장
+ */
+ interface Session {
+ user: {
+ /** 우리가 필요로 하는 user id */
+ id: string
+
+ // 기본적으로 NextAuth가 제공하는 name/email/image 필드
+ name?: string | null
+ email?: string | null
+ image?: string | null
+ companyId?: number | null
+ domain?: string | null
+
+ }
+ }
+
+ /**
+ * User 객체를 확장
+ */
+ interface User {
+ id: string
+ imageUrl?: string | null
+ companyId?: number | null
+ domain?: string | null
+ // 필요한 필드를 추가로 선언 가능
+ }
+}
+
+// (2) authOptions에 NextAuthOptions 타입 지정
+export const authOptions: NextAuthOptions = {
+ providers: [
+ CredentialsProvider({
+ name: 'Credentials',
+ credentials: {
+ email: { label: 'Email', type: 'text' },
+ code: { label: 'OTP code', type: 'text' },
+ },
+ async authorize(credentials, req) {
+ const { email, code } = credentials ?? {}
+
+ // OTP 검증
+ const user = await verifyOtp(email ?? '', code ?? '')
+ if (!user) {
+ return null
+ }
+
+ return {
+ id: String(user.id ?? email ?? "dts"),
+ email: user.email,
+ imageUrl: user.imageUrl ?? null,
+ name: user.name, // DB에서 가져온 실제 이름
+ companyId: user.companyId, // DB에서 가져온 실제 이름
+ domain: user.domain, // DB에서 가져온 실제 이름
+ }
+ },
+ })
+ ],
+ // (3) session.strategy는 'jwt'가 되도록 선언
+ // 필요하다면 as SessionStrategy 라고 명시해줄 수도 있음
+ // 예) strategy: 'jwt' as SessionStrategy
+ session: {
+ strategy: 'jwt',
+ },
+ callbacks: {
+ // (4) 콜백에서 token, user, session 등의 타입을 좀 더 명시해주고 싶다면 아래처럼 destructuring에 제네릭/타입 지정
+ async jwt({ token, user }: { token: JWT; user?: User }) {
+ if (user) {
+ token.id = user.id
+ token.email = user.email
+ token.name = user.name
+ token.companyId = user.companyId
+ token.domain = user.domain
+ ; (token as any).imageUrl = (user as any).imageUrl
+ }
+ return token
+ },
+ async session({ session, token }: { session: Session; token: JWT }) {
+ if (token) {
+ session.user = {
+ id: token.id as string,
+ email: token.email as string,
+ name: token.name as string,
+ domain: token.domain as string,
+ companyId: token.companyId as number,
+ image: (token as any).imageUrl ?? null
+ }
+ }
+ return session
+ },
+ },
+}
+
+const handler = NextAuth(authOptions)
+
+export { handler as GET, handler as POST } \ No newline at end of file
diff --git a/app/api/files/[...path]/route.ts b/app/api/files/[...path]/route.ts
new file mode 100644
index 00000000..f92dd1d8
--- /dev/null
+++ b/app/api/files/[...path]/route.ts
@@ -0,0 +1,74 @@
+// app/api/files/[...path]/route.ts
+import { NextRequest, NextResponse } from 'next/server'
+import { readFile } from 'fs/promises'
+import { join } from 'path'
+import { stat } from 'fs/promises'
+
+export async function GET(
+ request: NextRequest,
+ { params }: { params: { path: string[] } }
+) {
+ try {
+
+ const path = request.nextUrl.searchParams.get("path");
+
+
+ // 경로 파라미터에서 파일 경로 조합
+ const filePath = join(process.cwd(), 'uploads', ...params.path)
+
+ // 파일 존재 여부 확인
+ try {
+ await stat(filePath)
+ } catch (error) {
+ return NextResponse.json(
+ { error: 'File not found' },
+ { status: 404 }
+ )
+ }
+
+ // 파일 읽기
+ const fileBuffer = await readFile(filePath)
+
+ // 파일 확장자에 따른 MIME 타입 설정
+ const fileName = params.path[params.path.length - 1]
+ const fileExtension = fileName.split('.').pop()?.toLowerCase()
+
+ let contentType = 'application/octet-stream'
+
+ if (fileExtension) {
+ const mimeTypes: Record<string, string> = {
+ 'pdf': 'application/pdf',
+ 'doc': 'application/msword',
+ 'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ 'xls': 'application/vnd.ms-excel',
+ 'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ 'ppt': 'application/vnd.ms-powerpoint',
+ 'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
+ 'txt': 'text/plain',
+ 'csv': 'text/csv',
+ 'png': 'image/png',
+ 'jpg': 'image/jpeg',
+ 'jpeg': 'image/jpeg',
+ 'gif': 'image/gif',
+ }
+
+ contentType = mimeTypes[fileExtension] || contentType
+ }
+
+ // 다운로드 설정
+ const headers = new Headers()
+ headers.set('Content-Type', contentType)
+ headers.set('Content-Disposition', `attachment; filename="${fileName}"`)
+
+ return new NextResponse(fileBuffer, {
+ status: 200,
+ headers,
+ })
+ } catch (error) {
+ console.error('Error downloading file:', error)
+ return NextResponse.json(
+ { error: 'Failed to download file' },
+ { status: 500 }
+ )
+ }
+} \ No newline at end of file
diff --git a/app/api/rfq-download/route.ts b/app/api/rfq-download/route.ts
new file mode 100644
index 00000000..19991128
--- /dev/null
+++ b/app/api/rfq-download/route.ts
@@ -0,0 +1,121 @@
+// app/api/rfq-download/route.ts
+import { NextRequest, NextResponse } from 'next/server';
+import { readFile, access, constants } from 'fs/promises';
+import { join } from 'path';
+import db from '@/db/db';
+import { rfqAttachments } from '@/db/schema/rfq';
+import { eq } from 'drizzle-orm';
+
+export async function GET(request: NextRequest) {
+ try {
+ // 파일 경로 파라미터 받기
+ const path = request.nextUrl.searchParams.get("path");
+
+ if (!path) {
+ return NextResponse.json(
+ { error: "File path is required" },
+ { status: 400 }
+ );
+ }
+
+ // DB에서 파일 정보 조회 (정확히 일치하는 filePath로 검색)
+ const [dbRecord] = await db
+ .select({
+ fileName: rfqAttachments.fileName,
+ filePath: rfqAttachments.filePath
+ })
+ .from(rfqAttachments)
+ .where(eq(rfqAttachments.filePath, path));
+
+ // 파일 정보 설정
+ let fileName;
+
+ if (dbRecord) {
+ // DB에서 찾은 경우 원본 파일명 사용
+ fileName = dbRecord.fileName;
+ console.log("DB에서 원본 파일명 찾음:", fileName);
+ } else {
+ // DB에서 찾지 못한 경우 경로에서 파일명 추출
+ fileName = path.split('/').pop() || 'download';
+ }
+
+ // 파일 경로 구성
+ const storedPath = path.replace(/^\/+/, ""); // 앞쪽 슬래시 제거
+
+ // 파일 경로 시도
+ const possiblePaths = [
+ join(process.cwd(), "public", storedPath)
+ ];
+
+ // 실제 파일 찾기
+ let actualPath = null;
+ for (const testPath of possiblePaths) {
+ try {
+ await access(testPath, constants.R_OK);
+ actualPath = testPath;
+ break;
+ } catch (err) {
+ console.log("❌ 경로에 파일 없음:", testPath);
+ }
+ }
+
+ if (!actualPath) {
+ return NextResponse.json(
+ {
+ error: "File not found on server",
+ details: {
+ path: path,
+ triedPaths: possiblePaths
+ }
+ },
+ { status: 404 }
+ );
+ }
+
+ const fileBuffer = await readFile(actualPath);
+
+ // MIME 타입 결정
+ const fileExtension = fileName.split('.').pop()?.toLowerCase() || '';
+
+ let contentType = 'application/octet-stream'; // 기본 바이너리
+
+ // 확장자에 따른 MIME 타입 매핑
+ const mimeTypes: Record<string, string> = {
+ 'pdf': 'application/pdf',
+ 'doc': 'application/msword',
+ 'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ 'xls': 'application/vnd.ms-excel',
+ 'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ 'ppt': 'application/vnd.ms-powerpoint',
+ 'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
+ 'txt': 'text/plain',
+ 'csv': 'text/csv',
+ 'png': 'image/png',
+ 'jpg': 'image/jpeg',
+ 'jpeg': 'image/jpeg',
+ 'gif': 'image/gif',
+ };
+
+ contentType = mimeTypes[fileExtension] || contentType;
+
+ // 다운로드용 헤더 설정
+ const headers = new Headers();
+ headers.set('Content-Type', contentType);
+ headers.set('Content-Disposition', `attachment; filename="${encodeURIComponent(fileName)}"`);
+ headers.set('Content-Length', fileBuffer.length.toString());
+
+ return new NextResponse(fileBuffer, {
+ status: 200,
+ headers,
+ });
+ } catch (error) {
+ console.error('❌ RFQ 파일 다운로드 오류:', error);
+ return NextResponse.json(
+ {
+ error: 'Failed to download file',
+ details: String(error)
+ },
+ { status: 500 }
+ );
+ }
+} \ No newline at end of file
diff --git a/app/api/rfq-upload/route.ts b/app/api/rfq-upload/route.ts
new file mode 100644
index 00000000..97beafb1
--- /dev/null
+++ b/app/api/rfq-upload/route.ts
@@ -0,0 +1,36 @@
+import { NextRequest, NextResponse } from "next/server"
+import { promises as fs } from "fs"
+import path from "path"
+import { v4 as uuidv4 } from "uuid"
+
+export async function POST(req: NextRequest) {
+ try {
+ const formData = await req.formData()
+ const file = formData.get("file") as File // "file" is default name from FilePond
+ if (!file) {
+ return NextResponse.json({ error: "No file" }, { status: 400 })
+ }
+
+ // e.g. parse a query param? or read 'rfqId' if we appended it
+ // const rfqId = ... (FilePond advanced config or handle differently)
+
+ // read file
+ const arrayBuffer = await file.arrayBuffer()
+ const buffer = Buffer.from(arrayBuffer)
+
+ // unique filename
+ const uniqueName = uuidv4() + "-" + file.name
+ const targetDir = path.join(process.cwd(), "public", "rfq", "123") // or your rfqId
+ await fs.mkdir(targetDir, { recursive: true })
+ const targetPath = path.join(targetDir, uniqueName)
+
+ // write
+ await fs.writeFile(targetPath, buffer)
+
+ // Return success. Typically you'd insert DB record here or return some reference
+ return NextResponse.json({ success: true, filePath: `/rfq/123/${uniqueName}` })
+ } catch (error) {
+ console.error("upload error:", error)
+ return NextResponse.json({ error: String(error) }, { status: 500 })
+ }
+} \ No newline at end of file
diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts
new file mode 100644
index 00000000..3b1d8be0
--- /dev/null
+++ b/app/api/upload/route.ts
@@ -0,0 +1,38 @@
+// app/api/upload/route.ts
+import { NextRequest, NextResponse } from "next/server"
+import { createWriteStream } from "fs"
+import path from "path"
+import { v4 as uuid } from "uuid"
+
+export async function POST(request: NextRequest) {
+ const formData = await request.formData()
+ const file = formData.get("file") as File | null
+ if (!file) {
+ return NextResponse.json({ error: "No file" }, { status: 400 })
+ }
+
+ // 여기서는 로컬 /public/uploads 에 저장한다고 가정
+ const fileExt = path.extname(file.name)
+ const newFileName = `${uuid()}${fileExt}`
+ const filePath = path.join(process.cwd(), "public", "uploads", newFileName)
+
+ const arrayBuffer = await file.arrayBuffer()
+ const buffer = Buffer.from(arrayBuffer)
+
+ // 로컬에 저장
+ await new Promise<void>((resolve, reject) => {
+ const writeStream = createWriteStream(filePath)
+ writeStream.write(buffer)
+ writeStream.end()
+ writeStream.on("finish", resolve)
+ writeStream.on("error", reject)
+ })
+
+ // /uploads/xxxx.ext 로 접근 가능
+ const url = `/uploads/${newFileName}`
+ return NextResponse.json({
+ fileName: file.name,
+ url,
+ size: file.size,
+ })
+} \ No newline at end of file
diff --git a/app/api/vendors/attachments/download-temp/route.ts b/app/api/vendors/attachments/download-temp/route.ts
new file mode 100644
index 00000000..987e421d
--- /dev/null
+++ b/app/api/vendors/attachments/download-temp/route.ts
@@ -0,0 +1,102 @@
+// app/api/vendors/attachments/download-temp/route.ts
+import { NextRequest, NextResponse } from 'next/server';
+import path from 'path';
+import fs from 'fs';
+import { promises as fsPromises } from 'fs';
+import { cleanupTempFiles } from '@/lib/vendors/service';
+
+export async function GET(request: NextRequest) {
+ try {
+ // 파일명 파라미터 추출
+ const searchParams = request.nextUrl.searchParams;
+ const fileName = searchParams.get('file');
+
+ if (!fileName) {
+ return NextResponse.json(
+ { success: false, error: 'File name is required' },
+ { status: 400 }
+ );
+ }
+
+ // 보안: 파일명에 경로 문자가 포함되어 있는지 확인 (경로 탐색 공격 방지)
+ if (fileName.includes('/') || fileName.includes('\\')) {
+ return NextResponse.json(
+ { success: false, error: 'Invalid file name' },
+ { status: 400 }
+ );
+ }
+
+ // 임시 디렉토리의 파일 경로 생성
+ const tempDir = path.join(process.cwd(), 'tmp');
+ const filePath = path.join(tempDir, fileName);
+
+ // 파일 존재 확인
+ try {
+ await fsPromises.access(filePath, fs.constants.F_OK);
+ } catch {
+ return NextResponse.json(
+ { success: false, error: 'File not found' },
+ { status: 404 }
+ );
+ }
+
+ // 파일 읽기
+ const fileBuffer = await fsPromises.readFile(filePath);
+
+ // 파일명에서 UUID 부분 제거하여 표시용 이름 생성
+ const uuidPattern = /-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.zip$/i;
+ const displayName = fileName.replace(uuidPattern, '.zip');
+
+ // 파일 응답 반환
+ const response = new NextResponse(fileBuffer, {
+ headers: {
+ 'Content-Type': 'application/zip',
+ 'Content-Disposition': `attachment; filename="${encodeURIComponent(displayName)}"`,
+ },
+ });
+
+ // 비동기적으로 파일 정리 요청 (별도 API 호출)
+ // Note: Next.js 환경에 따라 작동하지 않을 수 있음
+ try {
+ fetch(`${request.nextUrl.origin}/api/vendors/cleanup-temp-files?file=${encodeURIComponent(fileName)}`, {
+ method: 'POST',
+ }).catch(e => console.error('임시 파일 정리 요청 실패:', e));
+ } catch (e) {
+ console.error('파일 정리 요청 오류:', e);
+ }
+
+ return response;
+ } catch (error) {
+ console.error('임시 파일 다운로드 오류:', error);
+ return NextResponse.json(
+ { success: false, error: 'Failed to download file' },
+ { status: 500 }
+ );
+ }
+}
+
+// 임시 파일 정리 API 엔드포인트
+// app/api/vendors/cleanup-temp-files/route.ts
+export async function POST(request: NextRequest) {
+ try {
+ const searchParams = request.nextUrl.searchParams;
+ const fileName = searchParams.get('file');
+
+ if (!fileName) {
+ return NextResponse.json({ success: false, error: 'File name is required' }, { status: 400 });
+ }
+
+ // 보안 검증
+ if (fileName.includes('/') || fileName.includes('\\')) {
+ return NextResponse.json({ success: false, error: 'Invalid file name' }, { status: 400 });
+ }
+
+ // 서버 액션 호출하여 파일 정리
+ await cleanupTempFiles(fileName);
+
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ console.error('임시 파일 정리 API 오류:', error);
+ return NextResponse.json({ success: false, error: '임시 파일 정리 중 오류가 발생했습니다.' }, { status: 500 });
+ }
+} \ No newline at end of file
diff --git a/app/api/vendors/erp/route.ts b/app/api/vendors/erp/route.ts
new file mode 100644
index 00000000..0724eeeb
--- /dev/null
+++ b/app/api/vendors/erp/route.ts
@@ -0,0 +1,144 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { headers } from 'next/headers';
+import { getErrorMessage } from '@/lib/handle-error';
+
+/**
+ * 기간계 시스템에 벤더 정보를 전송하는 API 엔드포인트
+ * 서버 액션 내부에서 호출됨
+ */
+export async function POST(request: NextRequest) {
+ try {
+
+ // 요청 본문 파싱
+ const vendorData = await request.json();
+
+ // 기간계 시스템 API 설정
+ const erpApiUrl = process.env.ERP_API_URL;
+ const erpApiKey = process.env.ERP_API_KEY;
+
+ if (!erpApiUrl || !erpApiKey) {
+ return NextResponse.json(
+ { success: false, message: 'ERP API configuration is missing' },
+ { status: 500 }
+ );
+ }
+
+ // 기간계 시스템이 요구하는 형식으로 데이터 변환
+ const erpRequestData = {
+ vendor: {
+ name: vendorData.vendorName,
+ tax_id: vendorData.taxId,
+ address: vendorData.address || "",
+ country: vendorData.country || "",
+ phone: vendorData.phone || "",
+ email: vendorData.email || "",
+ website: vendorData.website || "",
+ external_id: vendorData.id.toString(),
+ },
+ contacts: vendorData.contacts.map((contact: any) => ({
+ name: contact.contactName,
+ position: contact.contactPosition || "",
+ email: contact.contactEmail,
+ phone: contact.contactPhone || "",
+ is_primary: contact.isPrimary ? 1 : 0,
+ })),
+ items: vendorData.possibleItems.map((item: any) => ({
+ item_code: item.itemCode,
+ description: item.description || "",
+ })),
+ attachments: vendorData.attachments.map((attach: any) => ({
+ file_name: attach.fileName,
+ file_path: attach.filePath,
+ })),
+ };
+
+ // 기간계 시스템 API 호출
+ const response = await fetch(erpApiUrl, {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${erpApiKey}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(erpRequestData),
+ // Next.js의 fetch는 기본 30초 타임아웃
+ });
+
+ // 응답 처리
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}));
+ return NextResponse.json(
+ {
+ success: false,
+ message: `ERP system error: ${response.status} ${response.statusText}`,
+ details: errorData
+ },
+ { status: 502 } // Bad Gateway (외부 서버 오류)
+ );
+ }
+
+ const result = await response.json();
+
+ // 벤더 코드 검증
+ if (!result.vendor_code) {
+ return NextResponse.json(
+ { success: false, message: 'Vendor code not provided in ERP response' },
+ { status: 502 }
+ );
+ }
+
+ // 성공 응답
+ return NextResponse.json({
+ success: true,
+ vendorCode: result.vendor_code,
+ message: 'Vendor successfully registered in ERP system',
+ ...result
+ });
+ } catch (error) {
+ console.error('Error in ERP API:', error);
+ return NextResponse.json(
+ {
+ success: false,
+ message: getErrorMessage(error)
+ },
+ { status: 500 }
+ );
+ }
+}
+
+/**
+ * 기간계 시스템 연결 상태 확인 (헬스 체크)
+ */
+export async function GET() {
+ try {
+ const healthCheckUrl = process.env.ERP_HEALTH_CHECK_URL;
+
+ if (!healthCheckUrl) {
+ return NextResponse.json(
+ { success: false, message: 'ERP health check URL not configured' },
+ { status: 500 }
+ );
+ }
+
+ const response = await fetch(healthCheckUrl, {
+ method: 'GET',
+ next: { revalidate: 60 } // 1분마다 재검증
+ });
+
+ const isAvailable = response.ok;
+
+ return NextResponse.json({
+ success: true,
+ available: isAvailable,
+ status: response.status,
+ timestamp: new Date().toISOString()
+ });
+ } catch (error) {
+ console.error('ERP health check error:', error);
+ return NextResponse.json({
+ success: false,
+ available: false,
+ error: getErrorMessage(error),
+ timestamp: new Date().toISOString()
+ });
+ }
+} \ No newline at end of file
diff --git a/app/favicon.ico b/app/favicon.ico
new file mode 100644
index 00000000..718d6fea
--- /dev/null
+++ b/app/favicon.ico
Binary files differ
diff --git a/app/globals.css b/app/globals.css
new file mode 100644
index 00000000..c427b92f
--- /dev/null
+++ b/app/globals.css
@@ -0,0 +1,168 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+body {
+ font-family: Arial, Helvetica, sans-serif;
+}
+
+@layer base {
+ :root {
+ --background: 0 0% 100%;
+ --foreground: 222.2 84% 4.9%;
+ --card: 0 0% 100%;
+ --card-foreground: 222.2 84% 4.9%;
+ --popover: 0 0% 100%;
+ --popover-foreground: 222.2 84% 4.9%;
+ --primary: 222.2 47.4% 11.2%;
+ --samsung: 222.2 47.4% 11.2%;
+ --primary-foreground: 210 40% 98%;
+ --secondary: 210 40% 96.1%;
+ --secondary-foreground: 222.2 47.4% 11.2%;
+ --muted: 210 40% 96.1%;
+ --muted-foreground: 215.4 16.3% 46.9%;
+ --accent: 210 40% 96.1%;
+ --accent-foreground: 222.2 47.4% 11.2%;
+ --destructive: 0 84.2% 60.2%;
+ --destructive-foreground: 210 40% 98%;
+ --border: 214.3 31.8% 91.4%;
+ --input: 214.3 31.8% 91.4%;
+ --ring: 222.2 84% 4.9%;
+ --chart-1: 12 76% 61%;
+ --chart-2: 173 58% 39%;
+ --chart-3: 197 37% 24%;
+ --chart-4: 43 74% 66%;
+ --chart-5: 27 87% 67%;
+ --radius: 0.5rem;
+ --sidebar-background: 0 0% 98%;
+ --sidebar-foreground: 240 5.3% 26.1%;
+ --sidebar-primary: 240 5.9% 10%;
+ --sidebar-primary-foreground: 0 0% 98%;
+ --sidebar-accent: 240 4.8% 95.9%;
+ --sidebar-accent-foreground: 240 5.9% 10%;
+ --sidebar-border: 220 13% 91%;
+ --sidebar-ring: 217.2 91.2% 59.8%;
+ }
+ .dark {
+ --background: 222.2 84% 4.9%;
+ --foreground: 210 40% 98%;
+ --card: 222.2 84% 4.9%;
+ --card-foreground: 210 40% 98%;
+ --popover: 222.2 84% 4.9%;
+ --popover-foreground: 210 40% 98%;
+ --primary: 210 40% 98%;
+ --primary-foreground: 222.2 47.4% 11.2%;
+ --secondary: 217.2 32.6% 17.5%;
+ --secondary-foreground: 210 40% 98%;
+ --muted: 217.2 32.6% 17.5%;
+ --muted-foreground: 215 20.2% 65.1%;
+ --accent: 217.2 32.6% 17.5%;
+ --accent-foreground: 210 40% 98%;
+ --destructive: 0 62.8% 30.6%;
+ --destructive-foreground: 210 40% 98%;
+ --border: 217.2 32.6% 17.5%;
+ --input: 217.2 32.6% 17.5%;
+ --ring: 212.7 26.8% 83.9%;
+ --chart-1: 220 70% 50%;
+ --chart-2: 160 60% 45%;
+ --chart-3: 30 80% 55%;
+ --chart-4: 280 65% 60%;
+ --chart-5: 340 75% 55%;
+ --sidebar-background: 240 5.9% 10%;
+ --sidebar-foreground: 240 4.8% 95.9%;
+ --sidebar-primary: 224.3 76.3% 48%;
+ --sidebar-primary-foreground: 0 0% 100%;
+ --sidebar-accent: 240 3.7% 15.9%;
+ --sidebar-accent-foreground: 240 4.8% 95.9%;
+ --sidebar-border: 240 3.7% 15.9%;
+ --sidebar-ring: 217.2 91.2% 59.8%;
+ }
+}
+
+@layer base {
+ * {
+ @apply border-border;
+ }
+ html {
+ @apply scroll-smooth;
+ }
+ body {
+ @apply bg-background text-foreground overscroll-none;
+ /* font-feature-settings: "rlig" 1, "calt" 1; */
+ font-synthesis-weight: none;
+ text-rendering: optimizeLegibility;
+ }
+
+ @supports (font: -apple-system-body) and (-webkit-appearance: none) {
+ [data-wrapper] {
+ @apply min-[1800px]:border-t;
+ }
+ }
+ /* Custom scrollbar styling. Thanks @pranathiperii. */
+ ::-webkit-scrollbar {
+ width: 5px;
+ }
+ ::-webkit-scrollbar-track {
+ background: transparent;
+ }
+ ::-webkit-scrollbar-thumb {
+ background: hsl(var(--border));
+ border-radius: 5px;
+ }
+ * {
+ scrollbar-width: thin;
+ scrollbar-color: hsl(var(--border)) transparent;
+ }
+}
+
+@layer utilities {
+ .step {
+ counter-increment: step;
+ }
+
+ .step:before {
+ @apply absolute w-9 h-9 bg-muted rounded-full font-mono font-medium text-center text-base inline-flex items-center justify-center -indent-px border-4 border-background;
+ @apply ml-[-50px] mt-[-4px];
+ content: counter(step);
+ }
+
+ .chunk-container {
+ @apply shadow-none;
+ }
+
+ .chunk-container::after {
+ content: "";
+ @apply absolute -inset-4 shadow-xl rounded-xl border;
+ }
+
+ /* Hide scrollbar for Chrome, Safari and Opera */
+ .no-scrollbar::-webkit-scrollbar {
+ display: none;
+ }
+ /* Hide scrollbar for IE, Edge and Firefox */
+ .no-scrollbar {
+ -ms-overflow-style: none; /* IE and Edge */
+ scrollbar-width: none; /* Firefox */
+ }
+
+ .border-grid {
+ @apply border-border/30 dark:border-border;
+ }
+
+ .container-wrapper {
+ @apply min-[1800px]:max-w-[1536px] min-[1800px]:border-x border-border/30 dark:border-border mx-auto w-full;
+ }
+
+ .container {
+ @apply px-4 xl:px-6 2xl:px-4 mx-auto max-w-[1536px];
+ }
+}
+
+
+.MuiTreeItem-label{
+ font-size: 0.875rem!important;
+}
+
+.pdftron-container {
+ all: unset !important;
+} \ No newline at end of file
diff --git a/app/layout.tsx b/app/layout.tsx
new file mode 100644
index 00000000..7ed768a5
--- /dev/null
+++ b/app/layout.tsx
@@ -0,0 +1,85 @@
+import type { Metadata } from "next";
+import { Inter } from "next/font/google";
+import "./globals.css";
+import { languages } from "@/i18n/settings";
+import { Toaster } from "@/components/ui/toaster"
+import { ThemeProvider } from "@/components/layout/providers";
+import { cn } from "@/lib/utils"
+import { META_THEME_COLORS, siteConfig } from "@/config/site"
+import { LicenseInfo } from '@mui/x-license';
+import { ToasterSonner } from "@/components/ui/toasterSonner";
+
+LicenseInfo.setLicenseKey(process.env.NEXT_PUBLIC_MUI_KEY as string);
+
+const inter = Inter({ subsets: ['latin'] });
+
+export const generateStaticParams = async () => {
+ return languages.map((lng) => ({ lng }));
+};
+
+export const metadata: Metadata = {
+ title: {
+ default: siteConfig.name,
+ template: `%s - ${siteConfig.name}`,
+ },
+ metadataBase: new URL(siteConfig.url),
+ description: siteConfig.description,
+ authors: [
+ {
+ name: "DTS",
+ url: "https://dtsoution.io",
+ },
+ ],
+ creator: "dujin",
+};
+
+export default async function RootLayout({
+ children,
+ params: { lng },
+}: {
+ children: React.ReactNode;
+ params: { lng: string };
+}) {
+
+
+ return (
+ <html lang={lng} suppressHydrationWarning>
+ <head>
+ <script
+ dangerouslySetInnerHTML={{
+ __html: `
+ try {
+ if (localStorage.theme === 'dark' || ((!('theme' in localStorage) || localStorage.theme === 'system') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
+ document.querySelector('meta[name="theme-color"]').setAttribute('content', '${META_THEME_COLORS.dark}')
+ }
+ } catch (_) {}
+ `,
+ }}
+ />
+ </head>
+
+ <body
+ className={cn(
+ "min-h-svh bg-background font-sans antialiased",
+ inter.className,
+ )}
+ >
+ <ThemeProvider
+ attribute="class"
+ defaultTheme="system"
+ enableSystem
+ disableTransitionOnChange
+ enableColorScheme
+ >
+ <div vaul-drawer-wrapper="">
+ <div className="relative flex min-h-svh flex-col bg-background">
+ {children}
+ </div>
+ </div>
+ <Toaster />
+ <ToasterSonner/>
+ </ThemeProvider>
+ </body>
+ </html>
+ );
+} \ No newline at end of file
diff --git a/app/page.tsx b/app/page.tsx
new file mode 100644
index 00000000..9007252c
--- /dev/null
+++ b/app/page.tsx
@@ -0,0 +1,101 @@
+import Image from "next/image";
+
+export default function Home() {
+ return (
+ <div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
+ <main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
+ <Image
+ className="dark:invert"
+ src="/next.svg"
+ alt="Next.js logo"
+ width={180}
+ height={38}
+ priority
+ />
+ <ol className="list-inside list-decimal text-sm text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
+ <li className="mb-2">
+ Get started by editing{" "}
+ <code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-semibold">
+ app/page.tsx
+ </code>
+ .
+ </li>
+ <li>Save and see your changes instantly.</li>
+ </ol>
+
+ <div className="flex gap-4 items-center flex-col sm:flex-row">
+ <a
+ className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5"
+ href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ <Image
+ className="dark:invert"
+ src="/vercel.svg"
+ alt="Vercel logomark"
+ width={20}
+ height={20}
+ />
+ Deploy now
+ </a>
+ <a
+ className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:min-w-44"
+ href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ Read our docs
+ </a>
+ </div>
+ </main>
+ <footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
+ <a
+ className="flex items-center gap-2 hover:underline hover:underline-offset-4"
+ href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ <Image
+ aria-hidden
+ src="/file.svg"
+ alt="File icon"
+ width={16}
+ height={16}
+ />
+ Learn
+ </a>
+ <a
+ className="flex items-center gap-2 hover:underline hover:underline-offset-4"
+ href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ <Image
+ aria-hidden
+ src="/window.svg"
+ alt="Window icon"
+ width={16}
+ height={16}
+ />
+ Examples
+ </a>
+ <a
+ className="flex items-center gap-2 hover:underline hover:underline-offset-4"
+ href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ <Image
+ aria-hidden
+ src="/globe.svg"
+ alt="Globe icon"
+ width={16}
+ height={16}
+ />
+ Go to nextjs.org →
+ </a>
+ </footer>
+ </div>
+ );
+}