summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-04-28 02:13:30 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-04-28 02:13:30 +0000
commitef4c533ebacc2cdc97e518f30e9a9350004fcdfb (patch)
tree345251a3ed0f4429716fa5edaa31024d8f4cb560 /app
parent9ceed79cf32c896f8a998399bf1b296506b2cd4a (diff)
~20250428 작업사항
Diffstat (limited to 'app')
-rw-r--r--app/[lng]/evcp/(evcp)/basic-contract-template/page.tsx74
-rw-r--r--app/[lng]/evcp/(evcp)/basic-contract/page.tsx74
-rw-r--r--app/[lng]/evcp/(evcp)/bid-projects/page.tsx74
-rw-r--r--app/[lng]/evcp/(evcp)/bqcbe/page.tsx74
-rw-r--r--app/[lng]/evcp/(evcp)/bqtbe/page.tsx2
-rw-r--r--app/[lng]/evcp/(evcp)/budgetary-rfq/[id]/cbe/page.tsx2
-rw-r--r--app/[lng]/evcp/(evcp)/budgetary-rfq/[id]/layout.tsx30
-rw-r--r--app/[lng]/evcp/(evcp)/budgetary-rfq/[id]/page.tsx2
-rw-r--r--app/[lng]/evcp/(evcp)/budgetary-rfq/[id]/tbe/page.tsx2
-rw-r--r--app/[lng]/evcp/(evcp)/budgetary/[id]/cbe/page.tsx2
-rw-r--r--app/[lng]/evcp/(evcp)/budgetary/[id]/layout.tsx42
-rw-r--r--app/[lng]/evcp/(evcp)/budgetary/[id]/page.tsx2
-rw-r--r--app/[lng]/evcp/(evcp)/budgetary/[id]/tbe/page.tsx2
-rw-r--r--app/[lng]/evcp/(evcp)/dashboard/page.tsx17
-rw-r--r--app/[lng]/evcp/(evcp)/equip-class/page.tsx4
-rw-r--r--app/[lng]/evcp/(evcp)/form-list/page.tsx4
-rw-r--r--app/[lng]/evcp/(evcp)/po/page.tsx2
-rw-r--r--app/[lng]/evcp/(evcp)/pq-criteria/[id]/page.tsx2
-rw-r--r--app/[lng]/evcp/(evcp)/pq-criteria/page.tsx2
-rw-r--r--app/[lng]/evcp/(evcp)/pq/[vendorId]/page.tsx5
-rw-r--r--app/[lng]/evcp/(evcp)/project-vendors/page.tsx74
-rw-r--r--app/[lng]/evcp/(evcp)/report/page.tsx53
-rw-r--r--app/[lng]/evcp/(evcp)/rfq/[id]/cbe/page.tsx26
-rw-r--r--app/[lng]/evcp/(evcp)/rfq/[id]/layout.tsx33
-rw-r--r--app/[lng]/evcp/(evcp)/rfq/[id]/page.tsx2
-rw-r--r--app/[lng]/evcp/(evcp)/rfq/[id]/tbe/page.tsx2
-rw-r--r--app/[lng]/evcp/(evcp)/tag-numbering/page.tsx2
-rw-r--r--app/[lng]/evcp/(evcp)/tasks/page.tsx4
-rw-r--r--app/[lng]/evcp/(evcp)/tbe/page.tsx4
-rw-r--r--app/[lng]/evcp/(evcp)/vendor-candidates/page.tsx30
-rw-r--r--app/[lng]/evcp/(evcp)/vendor-type/page.tsx70
-rw-r--r--app/[lng]/evcp/(evcp)/vendors/[id]/info/layout.tsx16
-rw-r--r--app/[lng]/evcp/(evcp)/vendors/[id]/info/rfq-history/page.tsx2
-rw-r--r--app/[lng]/evcp/(evcp)/vendors/page.tsx6
-rw-r--r--app/[lng]/partners/(partners)/basic-contract/page.tsx77
-rw-r--r--app/[lng]/partners/(partners)/cbe/page.tsx86
-rw-r--r--app/[lng]/partners/(partners)/dashboard/page.tsx53
-rw-r--r--app/[lng]/partners/(partners)/document-list/layout.tsx8
-rw-r--r--app/[lng]/partners/(partners)/documents/layout.tsx8
-rw-r--r--app/[lng]/partners/(partners)/report/page.tsx17
-rw-r--r--app/[lng]/partners/(partners)/vendor-data/form/[packageId]/[formId]/page.tsx41
-rw-r--r--app/[lng]/partners/(partners)/vendor-data/layout.tsx8
-rw-r--r--app/[lng]/partners/(partners)/vendor-data/page.tsx2
-rw-r--r--app/[lng]/partners/pq/page.tsx26
-rw-r--r--app/api/(S-ERP)/(ECC)/IF_ECC_EVCP_BIDDING_PROJECT/route.ts70
-rw-r--r--app/api/auth/[...nextauth]/route.ts44
-rw-r--r--app/api/basic-contract/status/route.ts141
-rw-r--r--app/api/cron/form-tags/start/route.ts136
-rw-r--r--app/api/cron/form-tags/status/route.ts46
-rw-r--r--app/api/cron/forms/route.ts57
-rw-r--r--app/api/cron/forms/start/route.ts100
-rw-r--r--app/api/cron/forms/status/route.ts46
-rw-r--r--app/api/cron/object-classes/route.ts4
-rw-r--r--app/api/cron/projects/route.ts5
-rw-r--r--app/api/cron/tag-types/route.ts2
-rw-r--r--app/api/cron/tags/start/route.ts133
-rw-r--r--app/api/cron/tags/status/route.ts46
-rw-r--r--app/api/upload/basicContract/chunk/route.ts71
-rw-r--r--app/api/upload/basicContract/complete/route.ts37
-rw-r--r--app/api/upload/signed-contract/route.ts57
-rw-r--r--app/api/vendors/attachments/download-all/route.ts108
-rw-r--r--app/api/vendors/attachments/download/route.ts93
-rw-r--r--app/api/vendors/erp/route.ts4
-rw-r--r--app/globals.css132
64 files changed, 2177 insertions, 223 deletions
diff --git a/app/[lng]/evcp/(evcp)/basic-contract-template/page.tsx b/app/[lng]/evcp/(evcp)/basic-contract-template/page.tsx
new file mode 100644
index 00000000..adc57ed9
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/basic-contract-template/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 { getBasicContractTemplates } from "@/lib/basic-contract/service"
+import { searchParamsTemplatesCache } from "@/lib/basic-contract/validations"
+import { BasicContractTemplateTable } from "@/lib/basic-contract/template/basic-contract-template"
+
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsTemplatesCache.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getBasicContractTemplates({
+ ...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">
+ 기본계약서 템플릿 관리
+ </h2>
+ <p className="text-muted-foreground">
+ 기본계약서를 비롯하여 초기 서명이 필요한 문서를 등록하고 편집할 수 있습니다. 활성화된 템플릿이 서명 요청의 리스트에 나타나게 됩니다..{" "}
+ {/* <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
+ />
+ }
+ >
+ <BasicContractTemplateTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+}
diff --git a/app/[lng]/evcp/(evcp)/basic-contract/page.tsx b/app/[lng]/evcp/(evcp)/basic-contract/page.tsx
new file mode 100644
index 00000000..a043e530
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/basic-contract/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 { getBasicContracts } from "@/lib/basic-contract/service"
+import { searchParamsCache } from "@/lib/basic-contract/validations"
+import { BasicContractsTable } from "@/lib/basic-contract/status/basic-contract-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([
+ getBasicContracts({
+ ...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">
+ 기본계약서 서명 현황
+ </h2>
+ <p className="text-muted-foreground">
+ 기본계약서를 비롯하여 초기 서명이 필요한 문서의 서명 현황을 확인할 수 있고 서명된 문서들을 다운로드할 수 있습니다. {" "}
+ {/* <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
+ />
+ }
+ >
+ <BasicContractsTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+}
diff --git a/app/[lng]/evcp/(evcp)/bid-projects/page.tsx b/app/[lng]/evcp/(evcp)/bid-projects/page.tsx
new file mode 100644
index 00000000..3390f4f3
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/bid-projects/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 { getBidProjectLists } from "@/lib/bidding-projects/service"
+import { searchParamsBidProjectsCache } from "@/lib/bidding-projects/validation"
+import { BidProjectsTable } from "@/lib/bidding-projects/table/projects-table"
+
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsBidProjectsCache.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getBidProjectLists({
+ ...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">
+ 견적 프로젝트 리스트
+ </h2>
+ <p className="text-muted-foreground">
+ SAP로부터 수신할 수 있습니다.{" "}
+ {/* <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
+ />
+ }
+ >
+ <BidProjectsTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+}
diff --git a/app/[lng]/evcp/(evcp)/bqcbe/page.tsx b/app/[lng]/evcp/(evcp)/bqcbe/page.tsx
new file mode 100644
index 00000000..ae503feb
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/bqcbe/page.tsx
@@ -0,0 +1,74 @@
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { getAllCBE } from "@/lib/rfqs/service"
+import { searchParamsCBECache } from "@/lib/rfqs/validations"
+
+import { AllCbeTable } from "@/lib/cbe/table/cbe-table"
+
+import { RfqType } from "@/lib/rfqs/validations"
+import * as React from "react"
+import { Shell } from "@/components/shell"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+
+interface IndexPageProps {
+ // Next.js 13 App Router에서 기본으로 주어지는 객체들
+ params: {
+ lng: string
+ }
+ searchParams: Promise<SearchParams>
+ rfqType: RfqType
+}
+
+export default async function RfqCBEPage(props: IndexPageProps) {
+ const resolvedParams = await props.params
+ const lng = resolvedParams.lng
+
+ const rfqType = props.rfqType ?? RfqType.BUDGETARY // rfqType이 없으면 BUDGETARY로 설정
+
+ // 2) SearchParams 파싱 (Zod)
+ // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
+ const searchParams = await props.searchParams
+ const search = searchParamsCBECache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getAllCBE({
+ ...search,
+ filters: validFilters,
+ rfqType
+ }
+ )
+ ])
+
+ // 4) 렌더링
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ Commercial Bid Evaluation
+ </h2>
+ <p className="text-muted-foreground">
+ 초대된 협력업체에게 CBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 CBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <AllCbeTable promises={promises}/>
+ </React.Suspense>
+ </Shell>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/bqtbe/page.tsx b/app/[lng]/evcp/(evcp)/bqtbe/page.tsx
index 655bd30a..4989c235 100644
--- a/app/[lng]/evcp/(evcp)/bqtbe/page.tsx
+++ b/app/[lng]/evcp/(evcp)/bqtbe/page.tsx
@@ -48,7 +48,7 @@ export default async function RfqTBEPage(props: IndexPageProps) {
Technical Bid Evaluation
</h2>
<p className="text-muted-foreground">
- 초대된 벤더에게 TBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 벤더가 입력할 수 있게 자동 생성됩니다.
+ 초대된 협력업체에게 TBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
</p>
</div>
</div>
diff --git a/app/[lng]/evcp/(evcp)/budgetary-rfq/[id]/cbe/page.tsx b/app/[lng]/evcp/(evcp)/budgetary-rfq/[id]/cbe/page.tsx
index 9a4ae7eb..956facd3 100644
--- a/app/[lng]/evcp/(evcp)/budgetary-rfq/[id]/cbe/page.tsx
+++ b/app/[lng]/evcp/(evcp)/budgetary-rfq/[id]/cbe/page.tsx
@@ -44,7 +44,7 @@ export default async function RfqTBEPage(props: IndexPageProps) {
Commercial Bid Evaluation
</h3>
<p className="text-sm text-muted-foreground">
- 초대된 벤더에게 CBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 벤더가 입력할 수 있게 자동 생성됩니다.
+ 초대된 협력업체에게 CBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 CBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
</p>
</div>
<Separator />
diff --git a/app/[lng]/evcp/(evcp)/budgetary-rfq/[id]/layout.tsx b/app/[lng]/evcp/(evcp)/budgetary-rfq/[id]/layout.tsx
index 39f045e5..ba7c071c 100644
--- a/app/[lng]/evcp/(evcp)/budgetary-rfq/[id]/layout.tsx
+++ b/app/[lng]/evcp/(evcp)/budgetary-rfq/[id]/layout.tsx
@@ -1,11 +1,13 @@
import { Metadata } from "next"
+import Link from "next/link"
+import { ArrowLeft } from "lucide-react"
import { Separator } from "@/components/ui/separator"
import { SidebarNav } from "@/components/layout/sidebar-nav"
-import { findVendorById } from "@/lib/vendors/service" // 가정: 여기에 findVendorById가 있다고 가정
-import { Rfq, RfqWithItems } from "@/db/schema/rfq"
+import { RfqViewWithItems } from "@/db/schema/rfq"
import { findRfqById } from "@/lib/rfqs/service"
import { formatDate } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
export const metadata: Metadata = {
title: "Vendor Detail",
@@ -25,8 +27,8 @@ export default async function RfqLayout({
const id = resolvedParams.id
const idAsNumber = Number(id)
- // 2) DB에서 해당 벤더 정보 조회
- const rfq: RfqWithItems | null = await findRfqById(idAsNumber)
+ // 2) DB에서 해당 협력업체 정보 조회
+ const rfq: RfqViewWithItems | null = await findRfqById(idAsNumber)
// 3) 사이드바 메뉴
const sidebarNavItems = [
@@ -50,27 +52,35 @@ export default async function RfqLayout({
<div className="container py-6">
<section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
<div className="hidden space-y-6 p-10 pb-16 md:block">
+ <div className="flex items-center justify-end mb-4">
+ <Link href={`/${lng}/evcp/budgetary-rfq`} passHref>
+ <Button variant="ghost" className="flex items-center text-primary hover:text-primary/80 transition-colors p-0 h-auto">
+ <ArrowLeft className="mr-1 h-4 w-4" />
+ <span>Budgetary RFQ 목록으로 돌아가기</span>
+ </Button>
+ </Link>
+ </div>
<div className="space-y-0.5">
- {/* 4) 벤더 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */}
+ {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */}
<h2 className="text-2xl font-bold tracking-tight">
{rfq
- ? `${rfq.rfqCode ?? ""} 관리`
+ ? `${rfq.projectCode ?? ""} ${rfq.rfqCode ?? ""} 관리`
: "Loading RFQ..."}
</h2>
-
+
<p className="text-muted-foreground">
{rfq
? `${rfq.description ?? ""} ${rfq.lines.map(line => line.itemCode).join(", ")}`
: ""}
</p>
- <h3>Due Date:{ rfq && <strong>{formatDate(rfq?.dueDate)}</strong>}</h3>
+ <h3>Due Date:{rfq && rfq?.dueDate && <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">
+ <aside className="lg:w-64 flex-shrink-0">
<SidebarNav items={sidebarNavItems} />
</aside>
- <div className="flex-1">{children}</div>
+ <div className="lg:w-[calc(100%-16rem)] overflow-auto">{children}</div>
</div>
</div>
</section>
diff --git a/app/[lng]/evcp/(evcp)/budgetary-rfq/[id]/page.tsx b/app/[lng]/evcp/(evcp)/budgetary-rfq/[id]/page.tsx
index f6160574..dd9df563 100644
--- a/app/[lng]/evcp/(evcp)/budgetary-rfq/[id]/page.tsx
+++ b/app/[lng]/evcp/(evcp)/budgetary-rfq/[id]/page.tsx
@@ -45,7 +45,7 @@ export default async function RfqPage(props: IndexPageProps) {
Vendors
</h3>
<p className="text-sm text-muted-foreground">
- 등록된 벤더 중에서 이 RFQ 아이템에 매칭되는 업체를 보여줍니다. <br/>"발행하기" 버튼을 통해 RFQ를 전송하면 첨부파일과 함께 RFQ 내용이 메일로 전달되고 eVCP에도 벤더가 입력할 수 있게 자동 생성됩니다.
+ 등록된 협력업체 중에서 이 RFQ 아이템에 매칭되는 업체를 보여줍니다. <br/>"발행하기" 버튼을 통해 RFQ를 전송하면 첨부파일과 함께 RFQ 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
</p>
</div>
<Separator />
diff --git a/app/[lng]/evcp/(evcp)/budgetary-rfq/[id]/tbe/page.tsx b/app/[lng]/evcp/(evcp)/budgetary-rfq/[id]/tbe/page.tsx
index a6259696..ec894e1c 100644
--- a/app/[lng]/evcp/(evcp)/budgetary-rfq/[id]/tbe/page.tsx
+++ b/app/[lng]/evcp/(evcp)/budgetary-rfq/[id]/tbe/page.tsx
@@ -43,7 +43,7 @@ export default async function RfqTBEPage(props: IndexPageProps) {
Technical Bid Evaluation
</h3>
<p className="text-sm text-muted-foreground">
- 초대된 벤더에게 TBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 벤더가 입력할 수 있게 자동 생성됩니다.
+ 초대된 협력업체에게 TBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
</p>
</div>
<Separator />
diff --git a/app/[lng]/evcp/(evcp)/budgetary/[id]/cbe/page.tsx b/app/[lng]/evcp/(evcp)/budgetary/[id]/cbe/page.tsx
index 9a4ae7eb..956facd3 100644
--- a/app/[lng]/evcp/(evcp)/budgetary/[id]/cbe/page.tsx
+++ b/app/[lng]/evcp/(evcp)/budgetary/[id]/cbe/page.tsx
@@ -44,7 +44,7 @@ export default async function RfqTBEPage(props: IndexPageProps) {
Commercial Bid Evaluation
</h3>
<p className="text-sm text-muted-foreground">
- 초대된 벤더에게 CBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 벤더가 입력할 수 있게 자동 생성됩니다.
+ 초대된 협력업체에게 CBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 CBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
</p>
</div>
<Separator />
diff --git a/app/[lng]/evcp/(evcp)/budgetary/[id]/layout.tsx b/app/[lng]/evcp/(evcp)/budgetary/[id]/layout.tsx
index 39f045e5..b0711c66 100644
--- a/app/[lng]/evcp/(evcp)/budgetary/[id]/layout.tsx
+++ b/app/[lng]/evcp/(evcp)/budgetary/[id]/layout.tsx
@@ -1,11 +1,12 @@
import { Metadata } from "next"
-
+import Link from "next/link"
+import { ArrowLeft } from "lucide-react"
import { Separator } from "@/components/ui/separator"
import { SidebarNav } from "@/components/layout/sidebar-nav"
-import { findVendorById } from "@/lib/vendors/service" // 가정: 여기에 findVendorById가 있다고 가정
-import { Rfq, RfqWithItems } from "@/db/schema/rfq"
+import { RfqViewWithItems } from "@/db/schema/rfq"
import { findRfqById } from "@/lib/rfqs/service"
import { formatDate } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
export const metadata: Metadata = {
title: "Vendor Detail",
@@ -18,16 +19,16 @@ export default async function RfqLayout({
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)
-
+ // 2) DB에서 해당 협력업체 정보 조회
+ const rfq: RfqViewWithItems | null = await findRfqById(idAsNumber)
+
// 3) 사이드바 메뉴
const sidebarNavItems = [
{
@@ -42,35 +43,44 @@ export default async function RfqLayout({
title: "CBE",
href: `/${lng}/evcp/budgetary/${id}/cbe`,
},
-
]
-
+
return (
<>
<div className="container py-6">
<section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
<div className="hidden space-y-6 p-10 pb-16 md:block">
+ {/* RFQ 목록으로 돌아가는 링크 추가 */}
+ <div className="flex items-center justify-end mb-4">
+ <Link href={`/${lng}/evcp/budgetary`} passHref>
+ <Button variant="ghost" className="flex items-center text-primary hover:text-primary/80 transition-colors p-0 h-auto">
+ <ArrowLeft className="mr-1 h-4 w-4" />
+ <span>Budgetary Quote 목록으로 돌아가기</span>
+ </Button>
+ </Link>
+ </div>
+
<div className="space-y-0.5">
- {/* 4) 벤더 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */}
+ {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */}
<h2 className="text-2xl font-bold tracking-tight">
{rfq
- ? `${rfq.rfqCode ?? ""} 관리`
+ ? `${rfq.projectCode ?? ""} ${rfq.rfqCode ?? ""} 관리`
: "Loading RFQ..."}
</h2>
-
+
<p className="text-muted-foreground">
{rfq
? `${rfq.description ?? ""} ${rfq.lines.map(line => line.itemCode).join(", ")}`
: ""}
</p>
- <h3>Due Date:{ rfq && <strong>{formatDate(rfq?.dueDate)}</strong>}</h3>
+ <h3>Due Date:{rfq && rfq?.dueDate && <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">
+ <aside className="lg:w-64 flex-shrink-0">
<SidebarNav items={sidebarNavItems} />
</aside>
- <div className="flex-1">{children}</div>
+ <div className="lg:w-[calc(100%-16rem)] overflow-auto">{children}</div>
</div>
</div>
</section>
diff --git a/app/[lng]/evcp/(evcp)/budgetary/[id]/page.tsx b/app/[lng]/evcp/(evcp)/budgetary/[id]/page.tsx
index f6160574..dd9df563 100644
--- a/app/[lng]/evcp/(evcp)/budgetary/[id]/page.tsx
+++ b/app/[lng]/evcp/(evcp)/budgetary/[id]/page.tsx
@@ -45,7 +45,7 @@ export default async function RfqPage(props: IndexPageProps) {
Vendors
</h3>
<p className="text-sm text-muted-foreground">
- 등록된 벤더 중에서 이 RFQ 아이템에 매칭되는 업체를 보여줍니다. <br/>"발행하기" 버튼을 통해 RFQ를 전송하면 첨부파일과 함께 RFQ 내용이 메일로 전달되고 eVCP에도 벤더가 입력할 수 있게 자동 생성됩니다.
+ 등록된 협력업체 중에서 이 RFQ 아이템에 매칭되는 업체를 보여줍니다. <br/>"발행하기" 버튼을 통해 RFQ를 전송하면 첨부파일과 함께 RFQ 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
</p>
</div>
<Separator />
diff --git a/app/[lng]/evcp/(evcp)/budgetary/[id]/tbe/page.tsx b/app/[lng]/evcp/(evcp)/budgetary/[id]/tbe/page.tsx
index a6259696..ec894e1c 100644
--- a/app/[lng]/evcp/(evcp)/budgetary/[id]/tbe/page.tsx
+++ b/app/[lng]/evcp/(evcp)/budgetary/[id]/tbe/page.tsx
@@ -43,7 +43,7 @@ export default async function RfqTBEPage(props: IndexPageProps) {
Technical Bid Evaluation
</h3>
<p className="text-sm text-muted-foreground">
- 초대된 벤더에게 TBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 벤더가 입력할 수 있게 자동 생성됩니다.
+ 초대된 협력업체에게 TBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
</p>
</div>
<Separator />
diff --git a/app/[lng]/evcp/(evcp)/dashboard/page.tsx b/app/[lng]/evcp/(evcp)/dashboard/page.tsx
new file mode 100644
index 00000000..1d61dc16
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/dashboard/page.tsx
@@ -0,0 +1,17 @@
+// app/invalid-access/page.tsx
+
+export default function InvalidAccessPage() {
+ return (
+ <main style={{ padding: '40px', textAlign: 'center' }}>
+ <h1>부적절한 접근입니다</h1>
+ <p>
+ 협력업체(Vendor)가 EVCP 화면에 접속하거나 <br />
+ SHI 계정이 협력업체 화면에 접속하려고 시도하는 경우입니다.
+ </p>
+ <p>
+ <strong>접근 권한이 없으므로, 다른 화면으로 이동해 주세요.</strong>
+ </p>
+ </main>
+ );
+ }
+ \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/equip-class/page.tsx b/app/[lng]/evcp/(evcp)/equip-class/page.tsx
index 375eb69e..cfa8f133 100644
--- a/app/[lng]/evcp/(evcp)/equip-class/page.tsx
+++ b/app/[lng]/evcp/(evcp)/equip-class/page.tsx
@@ -35,10 +35,10 @@ export default async function IndexPage(props: IndexPageProps) {
<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
+ 객체 클래스 목록 from S-EDP
</h2>
<p className="text-muted-foreground">
- Object Class List를 확인할 수 있습니다.{" "}
+ 객체 클래스 목록을 확인할 수 있습니다.{" "}
{/* <span className="inline-flex items-center whitespace-nowrap">
<Ellipsis className="size-3" />
<span className="ml-1">버튼</span>
diff --git a/app/[lng]/evcp/(evcp)/form-list/page.tsx b/app/[lng]/evcp/(evcp)/form-list/page.tsx
index f96917d6..a6cf7d9e 100644
--- a/app/[lng]/evcp/(evcp)/form-list/page.tsx
+++ b/app/[lng]/evcp/(evcp)/form-list/page.tsx
@@ -35,10 +35,10 @@ export default async function IndexPage(props: IndexPageProps) {
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">
- Form List from S-EDP
+ 레지스터 목록 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>
diff --git a/app/[lng]/evcp/(evcp)/po/page.tsx b/app/[lng]/evcp/(evcp)/po/page.tsx
index fa528df0..7868e231 100644
--- a/app/[lng]/evcp/(evcp)/po/page.tsx
+++ b/app/[lng]/evcp/(evcp)/po/page.tsx
@@ -37,7 +37,7 @@ export default async function IndexPage(props: IndexPageProps) {
PO 확인 및 전자서명
</h2>
<p className="text-muted-foreground">
- 기간계 시스템으로부터 PO를 확인하고 벤더에게 전자서명을 요청할 수 있습니다. 요쳥된 전자서명의 이력 또한 확인할 수 있습니다.
+ 기간계 시스템으로부터 PO를 확인하고 협력업체에게 전자서명을 요청할 수 있습니다. 요쳥된 전자서명의 이력 또한 확인할 수 있습니다.
</p>
</div>
diff --git a/app/[lng]/evcp/(evcp)/pq-criteria/[id]/page.tsx b/app/[lng]/evcp/(evcp)/pq-criteria/[id]/page.tsx
index f040a0ca..55b1e9df 100644
--- a/app/[lng]/evcp/(evcp)/pq-criteria/[id]/page.tsx
+++ b/app/[lng]/evcp/(evcp)/pq-criteria/[id]/page.tsx
@@ -48,7 +48,7 @@ export default async function ProjectPage(props: ProjectPageProps) {
Pre-Qualification Check Sheet
</h2>
<p className="text-muted-foreground">
- 벤더 등록을 위한, 벤더가 제출할 PQ 항목을: 프로젝트별로 관리할 수 있습니다.
+ 협력업체 등록을 위한, 협력업체가 제출할 PQ 항목을: 프로젝트별로 관리할 수 있습니다.
</p>
</div>
<ProjectSelectorWrapper selectedProjectId={projectId} />
diff --git a/app/[lng]/evcp/(evcp)/pq-criteria/page.tsx b/app/[lng]/evcp/(evcp)/pq-criteria/page.tsx
index 778baa93..7785b541 100644
--- a/app/[lng]/evcp/(evcp)/pq-criteria/page.tsx
+++ b/app/[lng]/evcp/(evcp)/pq-criteria/page.tsx
@@ -37,7 +37,7 @@ export default async function IndexPage(props: IndexPageProps) {
Pre-Qualification Check Sheet
</h2>
<p className="text-muted-foreground">
- 벤더 등록을 위한, 벤더가 제출할 PQ 항목을 관리할 수 있습니다.
+ 협력업체 등록을 위한, 협력업체가 제출할 PQ 항목을 관리할 수 있습니다.
</p>
</div>
<ProjectSelectorWrapper />
diff --git a/app/[lng]/evcp/(evcp)/pq/[vendorId]/page.tsx b/app/[lng]/evcp/(evcp)/pq/[vendorId]/page.tsx
index 4c2555a3..76bcfe59 100644
--- a/app/[lng]/evcp/(evcp)/pq/[vendorId]/page.tsx
+++ b/app/[lng]/evcp/(evcp)/pq/[vendorId]/page.tsx
@@ -1,7 +1,7 @@
import * as React from "react"
import { Shell } from "@/components/shell"
import { type SearchParams } from "@/types/table"
-import { getPQDataByVendorId, getVendorPQsList, loadGeneralPQData, loadProjectPQData } from "@/lib/pq/service"
+import { getPQDataByVendorId, getVendorPQsList, loadGeneralPQData, loadProjectPQAction, loadProjectPQData } from "@/lib/pq/service"
import { Vendor } from "@/db/schema/vendors"
import { findVendorById } from "@/lib/vendors/service"
import VendorPQAdminReview from "@/components/pq/pq-review-detail"
@@ -92,7 +92,8 @@ export default async function PQReviewPage(props: IndexPageProps) {
projectId={project.projectId}
projectName={project.projectName}
projectStatus={project.status}
- loadData={(vendorId, _projectId) => loadProjectPQData(vendorId, project.projectId)} pqType="project"
+ loadData={loadProjectPQAction}
+ pqType="project"
/>
</TabsContent>
))}
diff --git a/app/[lng]/evcp/(evcp)/project-vendors/page.tsx b/app/[lng]/evcp/(evcp)/project-vendors/page.tsx
new file mode 100644
index 00000000..dcc66071
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/project-vendors/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 { ProjectAVLTable } from "@/lib/project-avl/table/proejctAVL-table"
+import { getProjecTAVL } from "@/lib/project-avl/service"
+import { searchProjectAVLParamsCache } from "@/lib/project-avl/validations"
+
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchProjectAVLParamsCache.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getProjecTAVL({
+ ...search,
+ filters: validFilters,
+ }),
+
+ ])
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ 프로젝트 AVL 리스트
+ </h2>
+ <p className="text-muted-foreground">
+ 프로젝트 PQ를 통과한 벤더의 리스트를 보여줍니다.{" "}
+ {/* <span className="inline-flex items-center whitespace-nowrap">
+ <Ellipsis className="size-3" />
+ <span className="ml-1">버튼</span>
+ </span>
+ 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. */}
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ {/* <DateRangePicker
+ triggerSize="sm"
+ triggerClassName="ml-auto w-56 sm:w-60"
+ align="end"
+ shallow={false}
+ /> */}
+ </React.Suspense>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <ProjectAVLTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+}
diff --git a/app/[lng]/evcp/(evcp)/report/page.tsx b/app/[lng]/evcp/(evcp)/report/page.tsx
index a1e9f8be..3efaa7c3 100644
--- a/app/[lng]/evcp/(evcp)/report/page.tsx
+++ b/app/[lng]/evcp/(evcp)/report/page.tsx
@@ -1,8 +1,47 @@
+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"
-export default function Pages() {
- return (
- <>
- test
- </>
- )
- } \ No newline at end of file
+
+
+export default async function IndexPage() {
+
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ Dashboard
+ </h2>
+ <p className="text-muted-foreground">
+ 각종 지표 등을 대시보드로 표현하거나 리포트를 출력할 수 있습니다.
+ </p>
+ </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
+ />
+ }
+ >
+ </React.Suspense>
+ </Shell>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/rfq/[id]/cbe/page.tsx b/app/[lng]/evcp/(evcp)/rfq/[id]/cbe/page.tsx
index bc32641f..fb288a98 100644
--- a/app/[lng]/evcp/(evcp)/rfq/[id]/cbe/page.tsx
+++ b/app/[lng]/evcp/(evcp)/rfq/[id]/cbe/page.tsx
@@ -1,7 +1,9 @@
import { Separator } from "@/components/ui/separator"
import { type SearchParams } from "@/types/table"
import { getValidFilters } from "@/lib/data-table"
-import { searchParamsTBECache } from "@/lib/rfqs/validations"
+import { searchParamsCBECache } from "@/lib/rfqs/validations"
+import { getCBE } from "@/lib/rfqs/service"
+import { CbeTable } from "@/lib/rfqs/cbe-table/cbe-table"
interface IndexPageProps {
// Next.js 13 App Router에서 기본으로 주어지는 객체들
@@ -22,31 +24,31 @@ export default async function RfqCBEPage(props: IndexPageProps) {
// 2) SearchParams 파싱 (Zod)
// - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
const searchParams = await props.searchParams
- const search = searchParamsTBECache.parse(searchParams)
+ const search = searchParamsCBECache.parse(searchParams)
const validFilters = getValidFilters(search.filters)
- // const promises = Promise.all([
- // getCBE({
- // ...search,
- // filters: validFilters,
- // },
- // idAsNumber)
- // ])
+ 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
+ Commercial Bid Evaluation
</h3>
<p className="text-sm text-muted-foreground">
- 초대된 벤더에게 CBE를 보낼 수 있습니다. <br/>"발행하기" 버튼을 통해 CBE를 전송하면 CBE 내용이 메일로 전달되고 eVCP에도 벤더가 입력할 수 있게 자동 생성됩니다.
+ 초대된 협력업체에게 CBE를 보낼 수 있습니다. <br />"발행하기" 버튼을 통해 CBE를 전송하면 CBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
</p>
</div>
<Separator />
<div>
-
+ <CbeTable promises={promises} rfqId={idAsNumber} />
</div>
</div>
)
diff --git a/app/[lng]/evcp/(evcp)/rfq/[id]/layout.tsx b/app/[lng]/evcp/(evcp)/rfq/[id]/layout.tsx
index 2aac90eb..9a03efa4 100644
--- a/app/[lng]/evcp/(evcp)/rfq/[id]/layout.tsx
+++ b/app/[lng]/evcp/(evcp)/rfq/[id]/layout.tsx
@@ -1,11 +1,12 @@
import { Metadata } from "next"
-
+import Link from "next/link"
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 { RfqViewWithItems } from "@/db/schema/rfq"
import { findRfqById } from "@/lib/rfqs/service"
import { formatDate } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { ArrowLeft } from "lucide-react"
export const metadata: Metadata = {
title: "Vendor Detail",
@@ -25,8 +26,8 @@ export default async function RfqLayout({
const id = resolvedParams.id
const idAsNumber = Number(id)
- // 2) DB에서 해당 벤더 정보 조회
- const rfq: RfqWithItems | null = await findRfqById(idAsNumber)
+ // 2) DB에서 해당 협력업체 정보 조회
+ const rfq: RfqViewWithItems | null = await findRfqById(idAsNumber)
// 3) 사이드바 메뉴
const sidebarNavItems = [
@@ -50,11 +51,19 @@ export default async function RfqLayout({
<div className="container py-6">
<section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
<div className="hidden space-y-6 p-10 pb-16 md:block">
+ <div className="flex items-center justify-end mb-4">
+ <Link href={`/${lng}/evcp/rfq`} passHref>
+ <Button variant="ghost" className="flex items-center text-primary hover:text-primary/80 transition-colors p-0 h-auto">
+ <ArrowLeft className="mr-1 h-4 w-4" />
+ <span>RFQ 목록으로 돌아가기</span>
+ </Button>
+ </Link>
+ </div>
<div className="space-y-0.5">
- {/* 4) 벤더 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */}
+ {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */}
<h2 className="text-2xl font-bold tracking-tight">
{rfq
- ? `${rfq.rfqCode ?? ""} 관리`
+ ? `${rfq.projectCode ?? ""} ${rfq.rfqCode ?? ""} 관리`
: "Loading RFQ..."}
</h2>
@@ -63,15 +72,15 @@ export default async function RfqLayout({
? `${rfq.description ?? ""} ${rfq.lines.map(line => line.itemCode).join(", ")}`
: ""}
</p>
- <h3>Due Date:{ rfq && <strong>{formatDate(rfq?.dueDate)}</strong>}</h3>
+ <h3>Due Date:{rfq && rfq?.dueDate && <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 className="lg:w-64 flex-shrink-0">
+ <SidebarNav items={sidebarNavItems} />
</aside>
- <div className="flex-1">{children}</div>
- </div>
+ <div className="lg:w-[calc(100%-16rem)] overflow-auto">{children}</div>
+ </div>
</div>
</section>
</div>
diff --git a/app/[lng]/evcp/(evcp)/rfq/[id]/page.tsx b/app/[lng]/evcp/(evcp)/rfq/[id]/page.tsx
index 026ca5ac..1a9f4b18 100644
--- a/app/[lng]/evcp/(evcp)/rfq/[id]/page.tsx
+++ b/app/[lng]/evcp/(evcp)/rfq/[id]/page.tsx
@@ -43,7 +43,7 @@ export default async function RfqPage(props: IndexPageProps) {
Vendors
</h3>
<p className="text-sm text-muted-foreground">
- 등록된 벤더 중에서 이 RFQ 아이템에 매칭되는 업체를 보여줍니다. <br/>"발행하기" 버튼을 통해 RFQ를 전송하면 첨부파일과 함께 RFQ 내용이 메일로 전달되고 eVCP에도 벤더가 입력할 수 있게 자동 생성됩니다.
+ 등록된 협력업체 중에서 이 RFQ 아이템에 매칭되는 업체를 보여줍니다. <br/>"발행하기" 버튼을 통해 RFQ를 전송하면 첨부파일과 함께 RFQ 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
</p>
</div>
<Separator />
diff --git a/app/[lng]/evcp/(evcp)/rfq/[id]/tbe/page.tsx b/app/[lng]/evcp/(evcp)/rfq/[id]/tbe/page.tsx
index 15c5d93c..76eea302 100644
--- a/app/[lng]/evcp/(evcp)/rfq/[id]/tbe/page.tsx
+++ b/app/[lng]/evcp/(evcp)/rfq/[id]/tbe/page.tsx
@@ -43,7 +43,7 @@ export default async function RfqTBEPage(props: IndexPageProps) {
Technical Bid Evaluation
</h3>
<p className="text-sm text-muted-foreground">
- 초대된 벤더에게 TBE를 보낼 수 있습니다. <br/>"발행하기" 버튼을 통해 TBE를 전송하면 첨부파일과 함께 TBE 내용이 메일로 전달되고 eVCP에도 벤더가 입력할 수 있게 자동 생성됩니다.
+ 초대된 협력업체에게 TBE를 보낼 수 있습니다. <br/>"발행하기" 버튼을 통해 TBE를 전송하면 첨부파일과 함께 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
</p>
</div>
<Separator />
diff --git a/app/[lng]/evcp/(evcp)/tag-numbering/page.tsx b/app/[lng]/evcp/(evcp)/tag-numbering/page.tsx
index 9d5b903a..44695259 100644
--- a/app/[lng]/evcp/(evcp)/tag-numbering/page.tsx
+++ b/app/[lng]/evcp/(evcp)/tag-numbering/page.tsx
@@ -34,7 +34,7 @@ export default async function IndexPage(props: IndexPageProps) {
<div className="flex items-center justify-between space-y-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">
- Tag Numbering from S-EDP
+ 태그 타입 목록 from S-EDP
</h2>
<p className="text-muted-foreground">
태그 넘버링을 위한 룰셋을 S-EDP로부터 가져오고 확인할 수 있습니다{" "}
diff --git a/app/[lng]/evcp/(evcp)/tasks/page.tsx b/app/[lng]/evcp/(evcp)/tasks/page.tsx
index f14cc757..91b946fb 100644
--- a/app/[lng]/evcp/(evcp)/tasks/page.tsx
+++ b/app/[lng]/evcp/(evcp)/tasks/page.tsx
@@ -38,12 +38,12 @@ export default async function IndexPage(props: IndexPageProps) {
return (
<Shell className="gap-2">
<React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- {/* <DateRangePicker
+ <DateRangePicker
triggerSize="sm"
triggerClassName="ml-auto w-56 sm:w-60"
align="end"
shallow={false}
- /> */}
+ />
</React.Suspense>
<React.Suspense
fallback={
diff --git a/app/[lng]/evcp/(evcp)/tbe/page.tsx b/app/[lng]/evcp/(evcp)/tbe/page.tsx
index 2461ed42..1a7fdf86 100644
--- a/app/[lng]/evcp/(evcp)/tbe/page.tsx
+++ b/app/[lng]/evcp/(evcp)/tbe/page.tsx
@@ -70,8 +70,8 @@ export default async function RfqTBEPage(props: IndexPageProps) {
Technical Bid Evaluation
</h2>
<p className="text-muted-foreground">
- 초대된 벤더에게 TBE를 보낼 수 있습니다. <br/>
- 체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 벤더가 입력할 수 있게 자동 생성됩니다.
+ 초대된 협력업체에게 TBE를 보낼 수 있습니다. <br/>
+ 체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
</p>
</div>
</div>
diff --git a/app/[lng]/evcp/(evcp)/vendor-candidates/page.tsx b/app/[lng]/evcp/(evcp)/vendor-candidates/page.tsx
index 668c0dc6..a6e00b1b 100644
--- a/app/[lng]/evcp/(evcp)/vendor-candidates/page.tsx
+++ b/app/[lng]/evcp/(evcp)/vendor-candidates/page.tsx
@@ -9,6 +9,7 @@ import { Shell } from "@/components/shell"
import { getVendorCandidateCounts, getVendorCandidates } from "@/lib/vendor-candidates/service"
import { searchParamsCandidateCache } from "@/lib/vendor-candidates/validations"
import { VendorCandidateTable } from "@/lib/vendor-candidates/table/candidates-table"
+import { DateRangePicker } from "@/components/date-range-picker"
interface IndexPageProps {
searchParams: Promise<SearchParams>
@@ -30,24 +31,35 @@ export default async function IndexPage(props: IndexPageProps) {
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 Candidates Management
- </h2>
+ Vendor Candidates Management
+ </h2>
<p className="text-muted-foreground">
- 수집한 벤더 후보를 등록하고 초대 메일을 송부할 수 있습니다.
-
+ 수집한 협력업체 후보를 등록하고 초대 메일을 송부할 수 있습니다.
</p>
</div>
</div>
</div>
+
+ {/* 수집일 라벨과 DateRangePicker를 함께 배치 */}
+ <div className="flex items-center justify-start gap-2">
+ {/* <span className="text-sm font-medium">수집일 기간 설정: </span> */}
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ <DateRangePicker
+ triggerSize="sm"
+ triggerClassName="w-56 sm:w-60"
+ align="end"
+ shallow={false}
+ showClearButton={true}
+ placeholder="수집일 날짜 범위를 고르세요"
+ />
+ </React.Suspense>
+ </div>
-
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- </React.Suspense>
<React.Suspense
fallback={
<DataTableSkeleton
@@ -63,4 +75,4 @@ export default async function IndexPage(props: IndexPageProps) {
</React.Suspense>
</Shell>
)
-}
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/vendor-type/page.tsx b/app/[lng]/evcp/(evcp)/vendor-type/page.tsx
new file mode 100644
index 00000000..997c0f82
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/vendor-type/page.tsx
@@ -0,0 +1,70 @@
+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/vendor-type/validations"
+import { VendorTypesTable } from "@/lib/vendor-type/table/vendorTypes-table"
+import { getVendorTypes } from "@/lib/vendor-type/service"
+
+
+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([
+ getVendorTypes({
+ ...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">
+ 업체 유형
+ </h2>
+ <p className="text-muted-foreground">
+ 업체 유형을 등록하고 관리할 수 있습니다.{" "}
+
+ </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
+ />
+ }
+ >
+ <VendorTypesTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+}
diff --git a/app/[lng]/evcp/(evcp)/vendors/[id]/info/layout.tsx b/app/[lng]/evcp/(evcp)/vendors/[id]/info/layout.tsx
index 39e0bac0..4da5af74 100644
--- a/app/[lng]/evcp/(evcp)/vendors/[id]/info/layout.tsx
+++ b/app/[lng]/evcp/(evcp)/vendors/[id]/info/layout.tsx
@@ -23,29 +23,29 @@ export default async function SettingsLayout({
const id = resolvedParams.id
const idAsNumber = Number(id)
- // 2) DB에서 해당 벤더 정보 조회
+ // 2) DB에서 해당 협력업체 정보 조회
const vendor: Vendor | null = await findVendorById(idAsNumber)
// 3) 사이드바 메뉴
const sidebarNavItems = [
{
- title: "Contacts",
+ title: "연락처",
href: `/${lng}/evcp/vendors/${id}/info`,
},
{
- title: "Items",
+ title: "공급품목",
href: `/${lng}/evcp/vendors/${id}/info/items`,
},
{
- title: "RFQ History",
+ title: "견적 히스토리",
href: `/${lng}/evcp/vendors/${id}/info/rfq-history`,
},
{
- title: "Bidding History",
+ title: "입찰 히스토리",
href: `/${lng}/evcp/vendors/${id}/info/bid-history`,
},
{
- title: "Contract History",
+ title: "계약 히스토리",
href: `/${lng}/evcp/vendors/${id}/info/contract-history`,
},
]
@@ -56,13 +56,13 @@ export default async function SettingsLayout({
<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) 벤더 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */}
+ {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */}
<h2 className="text-2xl font-bold tracking-tight">
{vendor
? `${vendor.vendorCode ?? ""} - ${vendor.vendorName} 상세 정보`
: "Loading Vendor..."}
</h2>
- <p className="text-muted-foreground">벤더 관련 상세사항을 확인하세요.</p>
+ <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">
diff --git a/app/[lng]/evcp/(evcp)/vendors/[id]/info/rfq-history/page.tsx b/app/[lng]/evcp/(evcp)/vendors/[id]/info/rfq-history/page.tsx
index 1d2f618c..c7f8f8b6 100644
--- a/app/[lng]/evcp/(evcp)/vendors/[id]/info/rfq-history/page.tsx
+++ b/app/[lng]/evcp/(evcp)/vendors/[id]/info/rfq-history/page.tsx
@@ -43,7 +43,7 @@ export default async function RfqHistoryPage(props: IndexPageProps) {
RFQ History
</h3>
<p className="text-sm text-muted-foreground">
- 벤더의 RFQ 참여 이력을 확인할 수 있습니다.
+ 협력업체의 RFQ 참여 이력을 확인할 수 있습니다.
</p>
</div>
<Separator />
diff --git a/app/[lng]/evcp/(evcp)/vendors/page.tsx b/app/[lng]/evcp/(evcp)/vendors/page.tsx
index e3cc7fdc..52af0709 100644
--- a/app/[lng]/evcp/(evcp)/vendors/page.tsx
+++ b/app/[lng]/evcp/(evcp)/vendors/page.tsx
@@ -37,15 +37,15 @@ export default async function IndexPage(props: IndexPageProps) {
<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 요청을 할 수 있고 검토가 완료된 벤더를 기간계 시스템에 전송하여 벤더 코드를 따올 수 있습니다.
+ 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. <br/>벤더의 상태에 따라 가입을 승인해주거나 PQ 요청을 할 수 있고 검토가 완료된 벤더를 기간계 시스템에 전송하여 협력업체 코드를 따올 수 있습니다.
</p>
</div>
</div>
diff --git a/app/[lng]/partners/(partners)/basic-contract/page.tsx b/app/[lng]/partners/(partners)/basic-contract/page.tsx
new file mode 100644
index 00000000..e63e6a17
--- /dev/null
+++ b/app/[lng]/partners/(partners)/basic-contract/page.tsx
@@ -0,0 +1,77 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+import { getBasicContractsByVendorId } from "@/lib/basic-contract/service"
+import { searchParamsCache } from "@/lib/basic-contract/validations"
+import { redirect } from "next/navigation"
+import { BasicContractsVendorTable } from "@/lib/basic-contract/vendor-table/basic-contract-table"
+import { getServerSession } from "next-auth"
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+
+
+ const session = await getServerSession(authOptions)
+ const vendorId = session?.user.companyId
+
+ const searchParams = await props.searchParams
+ const search = searchParamsCache.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getBasicContractsByVendorId(
+ {
+ ...search,
+ filters: validFilters,
+ },
+ Number(vendorId)
+ ),
+ ])
+
+ 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">
+ 기본계약서 서명 요청현황
+ </h2>
+ <p className="text-muted-foreground">
+ 기본계약서를 비롯하여 초기 서명이 필요한 문서의 서명 현황을 확인할 수 있고 서명을 진행할 수 있습니다. {" "}
+ </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
+ />
+ }
+ >
+ <BasicContractsVendorTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/partners/(partners)/cbe/page.tsx b/app/[lng]/partners/(partners)/cbe/page.tsx
new file mode 100644
index 00000000..8d03e5f6
--- /dev/null
+++ b/app/[lng]/partners/(partners)/cbe/page.tsx
@@ -0,0 +1,86 @@
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { getCBEbyVendorId, } from "@/lib/rfqs/service"
+import { searchParamsCBECache } 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"
+import { CbeVendorTable } from "@/lib/vendor-rfq-response/vendor-cbe-table/cbe-table"
+
+interface IndexPageProps {
+ // Next.js 13 App Router에서 기본으로 주어지는 객체들
+ params: {
+ lng: string
+ id: string
+ }
+ searchParams: Promise<SearchParams>
+}
+
+export default async function CBEPage(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 = searchParamsCBECache.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([
+ getCBEbyVendorId({
+ ...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">
+ Commercial Bid Evaluation
+ </h2>
+ <p className="text-sm text-muted-foreground">
+ CBE에 응답하고 커뮤니케이션을 할 수 있습니다.{" "}
+ </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
+ />
+ }
+ >
+ <CbeVendorTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+}
diff --git a/app/[lng]/partners/(partners)/dashboard/page.tsx b/app/[lng]/partners/(partners)/dashboard/page.tsx
index a1e9f8be..3efaa7c3 100644
--- a/app/[lng]/partners/(partners)/dashboard/page.tsx
+++ b/app/[lng]/partners/(partners)/dashboard/page.tsx
@@ -1,8 +1,47 @@
+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"
-export default function Pages() {
- return (
- <>
- test
- </>
- )
- } \ No newline at end of file
+
+
+export default async function IndexPage() {
+
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ Dashboard
+ </h2>
+ <p className="text-muted-foreground">
+ 각종 지표 등을 대시보드로 표현하거나 리포트를 출력할 수 있습니다.
+ </p>
+ </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
+ />
+ }
+ >
+ </React.Suspense>
+ </Shell>
+ )
+} \ 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
index a75cdf7d..0eb9d27b 100644
--- a/app/[lng]/partners/(partners)/document-list/layout.tsx
+++ b/app/[lng]/partners/(partners)/document-list/layout.tsx
@@ -6,6 +6,8 @@ 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"
+import { authOptions } from "@/app/api/auth/[...nextauth]/route";
+import { getServerSession } from "next-auth";
@@ -15,9 +17,9 @@ export default async function VendorDocuments({
}: {
children: React.ReactNode
}) {
- // const session = await getServerSession(authOptions)
- // const vendorId = session?.user.companyId
- const vendorId = "17"
+ const session = await getServerSession(authOptions)
+ const vendorId = session?.user.companyId
+ // const vendorId = "17"
const idAsNumber = Number(vendorId)
const projects = await getVendorProjectsAndContracts(idAsNumber)
diff --git a/app/[lng]/partners/(partners)/documents/layout.tsx b/app/[lng]/partners/(partners)/documents/layout.tsx
index 3ac0c573..dcc2c271 100644
--- a/app/[lng]/partners/(partners)/documents/layout.tsx
+++ b/app/[lng]/partners/(partners)/documents/layout.tsx
@@ -5,6 +5,8 @@ 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 { authOptions } from "@/app/api/auth/[...nextauth]/route";
+import { getServerSession } from "next-auth";
@@ -14,9 +16,9 @@ export default async function VendorDocuments({
}: {
children: React.ReactNode
}) {
- // const session = await getServerSession(authOptions)
- // const vendorId = session?.user.companyId
- const vendorId = "17"
+ const session = await getServerSession(authOptions)
+ const vendorId = session?.user.companyId
+ // const vendorId = "17"
const idAsNumber = Number(vendorId)
const projects = await getVendorProjectsAndContracts(idAsNumber)
diff --git a/app/[lng]/partners/(partners)/report/page.tsx b/app/[lng]/partners/(partners)/report/page.tsx
new file mode 100644
index 00000000..1d61dc16
--- /dev/null
+++ b/app/[lng]/partners/(partners)/report/page.tsx
@@ -0,0 +1,17 @@
+// app/invalid-access/page.tsx
+
+export default function InvalidAccessPage() {
+ return (
+ <main style={{ padding: '40px', textAlign: 'center' }}>
+ <h1>부적절한 접근입니다</h1>
+ <p>
+ 협력업체(Vendor)가 EVCP 화면에 접속하거나 <br />
+ SHI 계정이 협력업체 화면에 접속하려고 시도하는 경우입니다.
+ </p>
+ <p>
+ <strong>접근 권한이 없으므로, 다른 화면으로 이동해 주세요.</strong>
+ </p>
+ </main>
+ );
+ }
+ \ No newline at end of file
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
index 01f5b501..dc8df262 100644
--- a/app/[lng]/partners/(partners)/vendor-data/form/[packageId]/[formId]/page.tsx
+++ b/app/[lng]/partners/(partners)/vendor-data/form/[packageId]/[formId]/page.tsx
@@ -7,32 +7,41 @@ interface IndexPageProps {
packageId: string;
formId: string;
};
+ searchParams?: {
+ mode?: string;
+ };
}
-export default async function FormPage({ params }: IndexPageProps) {
+export default async function FormPage({ params, searchParams }: IndexPageProps) {
// 1) 구조 분해 할당
const resolvedParams = await params;
-
- // 2) 구조 분해 할당
+
+ // 2) searchParams도 await 필요
+ const resolvedSearchParams = await searchParams;
+
+ // 3) 구조 분해 할당
const { lng, packageId, formId: formCode } = resolvedParams;
-
- // 2) 변환
+
+ // URL 쿼리 파라미터에서 mode 가져오기 (await 해서 사용)
+ const mode = resolvedSearchParams?.mode === "ENG" ? "ENG" : "IM"; // 기본값은 IM
+
+ // 4) 변환
const packageIdAsNumber = Number(packageId);
-
- // 3) DB 조회
- const { columns, data } = await getFormData(formCode, packageIdAsNumber);
-
- // 4) formId 및 report temp file 조회
+
+ // 5) DB 조회
+ const { columns, data, projectId } = await getFormData(formCode, packageIdAsNumber);
+
+ // 6) formId 및 report temp file 조회
const { formId } = await getFormId(packageId, formCode);
-
- // 5) 예외 처리
+
+ // 7) 예외 처리
if (!columns) {
return (
<p className="text-red-500">해당 폼의 메타 정보를 불러올 수 없습니다.</p>
);
}
-
- // 5) 렌더링
+
+ // 8) 렌더링
return (
<div className="space-y-6">
<DynamicTable
@@ -41,7 +50,9 @@ export default async function FormPage({ params }: IndexPageProps) {
formId={formId}
columnsJSON={columns}
dataJSON={data}
+ projectId={projectId}
+ mode={mode} // 모드 전달
/>
</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
index a8b51c52..29a720de 100644
--- a/app/[lng]/partners/(partners)/vendor-data/layout.tsx
+++ b/app/[lng]/partners/(partners)/vendor-data/layout.tsx
@@ -4,6 +4,8 @@ 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"
+import { authOptions } from "@/app/api/auth/[...nextauth]/route";
+import { getServerSession } from "next-auth";
// Layout 컴포넌트는 서버 컴포넌트입니다
export default async function VendorDataLayout({
@@ -11,9 +13,9 @@ export default async function VendorDataLayout({
}: {
children: React.ReactNode
}) {
- // const session = await getServerSession(authOptions)
- // const vendorId = session?.user.companyId
- const vendorId = "17"
+ const session = await getServerSession(authOptions)
+ const vendorId = session?.user.companyId
+ // const vendorId = "17"
const idAsNumber = Number(vendorId)
// 프로젝트 데이터 가져오기
diff --git a/app/[lng]/partners/(partners)/vendor-data/page.tsx b/app/[lng]/partners/(partners)/vendor-data/page.tsx
index 3eead226..afc3932c 100644
--- a/app/[lng]/partners/(partners)/vendor-data/page.tsx
+++ b/app/[lng]/partners/(partners)/vendor-data/page.tsx
@@ -6,7 +6,7 @@ export default async function IndexPage() {
return (
<div className="space-y-6">
<div>
- <h3 className="text-lg font-medium">벤더 데이터 대시보드</h3>
+ <h3 className="text-lg font-medium">협력업체 데이터 대시보드</h3>
<p className="text-sm text-muted-foreground">
왼쪽 사이드바에서 패키지를 선택하여 태그를 관리하세요.
</p>
diff --git a/app/[lng]/partners/pq/page.tsx b/app/[lng]/partners/pq/page.tsx
index 08faeebb..71741c6c 100644
--- a/app/[lng]/partners/pq/page.tsx
+++ b/app/[lng]/partners/pq/page.tsx
@@ -14,28 +14,30 @@ export default async function PQInputPage({
}) {
// Opt out of caching for this route
noStore()
-
+
// 세션
const session = await getServerSession(authOptions)
- // 예: 세션에서 vendorId 가져오기
- // const vendorId = session?.user.companyId
- const vendorId = 17 // 임시
+ // 세션에서 vendorId 가져오기
+ const vendorId = session?.user.companyId
+ // const vendorId = 17 // 임시
const idAsNumber = Number(vendorId)
- // 서버에서는 모든 데이터를 가져오고, 프로젝트 필터링은 클라이언트에서 진행
+ // 프로젝트 목록 가져오기
const projectPQs = await getPQProjectsByVendorId(idAsNumber)
- // 두 가지 방법으로 수정할 수 있습니다:
-
- // 방법 1: 먼저 allPQData 데이터를 projectId 없이 가져오기
- const allPQData = await getPQDataByVendorId(idAsNumber, undefined)
+ // searchParams에서 projectId 파싱
+ const projectIdParam = searchParams.projectId
+ const projectId = projectIdParam ? parseInt(projectIdParam, 10) : undefined
- // 방법 2: rawProjectId를 클라이언트로 전달하고, 클라이언트가 필터링을 처리
+ // 현재 선택된 프로젝트를 위한 PQ 데이터 가져오기
+ const selectedProjectPQData = projectId
+ ? await getPQDataByVendorId(idAsNumber, projectId)
+ : await getPQDataByVendorId(idAsNumber, undefined)
- // 클라이언트 컴포넌트로 데이터와 원시 searchParams 전달
+ // 클라이언트 컴포넌트로 데이터 전달
return (
<ClientPQWrapper
- allPQData={allPQData}
+ pqData={selectedProjectPQData}
projectPQs={projectPQs}
vendorId={idAsNumber}
rawSearchParams={searchParams}
diff --git a/app/api/(S-ERP)/(ECC)/IF_ECC_EVCP_BIDDING_PROJECT/route.ts b/app/api/(S-ERP)/(ECC)/IF_ECC_EVCP_BIDDING_PROJECT/route.ts
new file mode 100644
index 00000000..e942cbc5
--- /dev/null
+++ b/app/api/(S-ERP)/(ECC)/IF_ECC_EVCP_BIDDING_PROJECT/route.ts
@@ -0,0 +1,70 @@
+// /app/api/soap/route.js
+import { NextRequest, NextResponse } from 'next/server';
+import { headers } from 'next/headers';
+
+export async function POST(request: NextRequest) {
+ try {
+ // SOAP 요청 본문 가져오기
+ const body = await request.text();
+ const headersList = headers();
+
+ // 요청 로깅
+ console.log('SOAP Request:', body);
+ console.log('Headers:', headersList);
+
+ // 요청 처리 로직
+ // SAP에서 보낸 데이터를 파싱하고 DB에 저장
+ const data = parseSoapMessage(body);
+ await saveToDatabase(data);
+
+ // SOAP 응답 생성
+ const soapResponse = `<?xml version="1.0" encoding="UTF-8"?>
+<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
+ <soap:Body>
+ <ns1:receiveDataResponse xmlns:ns1="http://60.101.108.100/soap">
+ <result>success</result>
+ </ns1:receiveDataResponse>
+ </soap:Body>
+</soap:Envelope>`;
+
+ return new NextResponse(soapResponse, {
+ headers: {
+ 'Content-Type': 'application/xml',
+ },
+ });
+ } catch (error) {
+ console.error('SOAP Error:', error);
+
+ // 에러 응답
+ const errorResponse = `<?xml version="1.0" encoding="UTF-8"?>
+<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
+ <soap:Body>
+ <soap:Fault>
+ <faultcode>soap:Server</faultcode>
+ <faultstring>${error.message}</faultstring>
+ </soap:Fault>
+ </soap:Body>
+</soap:Envelope>`;
+
+ return new NextResponse(errorResponse, {
+ status: 500,
+ headers: {
+ 'Content-Type': 'application/xml',
+ },
+ });
+ }
+}
+
+// SOAP 메시지 파싱 함수
+function parseSoapMessage(soapMessage) {
+ // XML 파싱 로직 구현
+ // 라이브러리 사용 예: fast-xml-parser, xml2js 등
+ // 실제 구현은 SAP 메시지 형식에 따라 달라짐
+ return { /* 파싱된 데이터 */ };
+}
+
+// DB 저장 함수
+async function saveToDatabase(data) {
+ // 데이터베이스 저장 로직
+ // Prisma, Mongoose 등 사용
+} \ No newline at end of file
diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts
index cd91774c..5e4da7ed 100644
--- a/app/api/auth/[...nextauth]/route.ts
+++ b/app/api/auth/[...nextauth]/route.ts
@@ -8,7 +8,7 @@ import { JWT } from "next-auth/jwt"
import CredentialsProvider from 'next-auth/providers/credentials'
-import { verifyExternalCredentials, verifyOtp } from '@/lib/users/verifyOtp'
+import { verifyExternalCredentials, verifyOtp, verifyOtpTemp } from '@/lib/users/verifyOtp'
// 1) 모듈 보강 선언
declare module "next-auth" {
@@ -55,7 +55,7 @@ export const authOptions: NextAuthOptions = {
const { email, code } = credentials ?? {}
// OTP 검증
- const user = await verifyOtp(email ?? '', code ?? '')
+ const user = await verifyOtpTemp(email ?? '')
if (!user) {
return null
}
@@ -70,6 +70,31 @@ export const authOptions: NextAuthOptions = {
}
},
}),
+ // 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에서 가져온 실제 이름
+ // }
+ // },
+ // }),
// 새로 추가할 ID/비밀번호 provider
CredentialsProvider({
id: 'credentials-password',
@@ -115,6 +140,7 @@ export const authOptions: NextAuthOptions = {
session: {
strategy: 'jwt',
},
+
callbacks: {
// (4) 콜백에서 token, user, session 등의 타입을 좀 더 명시해주고 싶다면 아래처럼 destructuring에 제네릭/타입 지정
async jwt({ token, user }: { token: JWT; user?: User }) {
@@ -141,6 +167,20 @@ export const authOptions: NextAuthOptions = {
}
return session
},
+ // redirect 콜백 추가
+ async redirect({ url, baseUrl }) {
+ // 상대 경로인 경우 baseUrl을 기준으로 함
+ if (url.startsWith("/")) {
+ return `${baseUrl}${url}`;
+ }
+ // 같은 도메인인 경우 그대로 사용
+ else if (new URL(url).origin === baseUrl) {
+ return url;
+ }
+ // 그 외에는 baseUrl로 리다이렉트
+ return baseUrl;
+ }
+
},
}
diff --git a/app/api/basic-contract/status/route.ts b/app/api/basic-contract/status/route.ts
new file mode 100644
index 00000000..f543accd
--- /dev/null
+++ b/app/api/basic-contract/status/route.ts
@@ -0,0 +1,141 @@
+// /app/api/basic-contract/status/route.ts
+
+import { NextRequest, NextResponse } from "next/server";
+import db from "@/db/db";
+import { basicContract, vendors, basicContractTemplates } from "@/db/schema";
+import { eq, and, inArray, desc } from "drizzle-orm";
+import { differenceInDays } from "date-fns";
+
+/**
+ * 계약 요청 상태 확인 API
+ */
+export async function POST(request: NextRequest) {
+ try {
+ // 요청 본문 파싱
+ const body = await request.json();
+ const { vendorIds, templateIds } = body;
+
+ // 필수 파라미터 확인
+ if (!vendorIds || !templateIds || !Array.isArray(vendorIds) || !Array.isArray(templateIds)) {
+ return NextResponse.json(
+ { success: false, error: "유효하지 않은 요청 형식입니다." },
+ { status: 400 }
+ );
+ }
+
+ // 각 협력업체-템플릿 조합에 대한 최신 계약 요청 상태 확인
+ const requests = await db
+ .select({
+ vendorId: basicContract.vendorId,
+ templateId: basicContract.templateId,
+ status: basicContract.status,
+ createdAt: basicContract.createdAt,
+ updatedAt: basicContract.updatedAt,
+ })
+ .from(basicContract)
+ .where(
+ and(
+ inArray(basicContract.vendorId, vendorIds),
+ inArray(basicContract.templateId, templateIds)
+ )
+ )
+ .orderBy(desc(basicContract.createdAt));
+
+ // 협력업체 정보 가져오기
+ const vendorData = await db
+ .select({
+ id: vendors.id,
+ vendorName: vendors.vendorName,
+ })
+ .from(vendors)
+ .where(inArray(vendors.id, vendorIds));
+
+ // 템플릿 정보 가져오기
+ const templateData = await db
+ .select({
+ id: basicContractTemplates.id,
+ templateName: basicContractTemplates.templateName,
+ updatedAt: basicContractTemplates.updatedAt,
+ })
+ .from(basicContractTemplates)
+ .where(inArray(basicContractTemplates.id, templateIds));
+
+ // 데이터 가공 - 협력업체별, 템플릿별로 상태 매핑
+ const vendorMap = new Map(vendorData.map(v => [v.id, v]));
+ const templateMap = new Map(templateData.map(t => [t.id, t]));
+
+ const uniqueRequests = new Map();
+
+ // 각 협력업체-템플릿 조합에 대해 가장 최근 요청만 사용
+ requests.forEach(req => {
+ const key = `${req.vendorId}-${req.templateId}`;
+ if (!uniqueRequests.has(key)) {
+ uniqueRequests.set(key, req);
+ }
+ });
+
+ // 상태 정보 생성
+ const statusData = [];
+
+ // 요청 만료 기준 - 30일
+ const EXPIRATION_DAYS = 30;
+
+ // 모든 협력업체-템플릿 조합에 대해 상태 확인
+ vendorIds.forEach(vendorId => {
+ templateIds.forEach(templateId => {
+ const key = `${vendorId}-${templateId}`;
+ const request = uniqueRequests.get(key);
+ const vendor = vendorMap.get(vendorId);
+ const template = templateMap.get(templateId);
+
+ if (!vendor || !template) return;
+
+ let status = "NONE"; // 기본 상태: 요청 없음
+ let createdAt = new Date();
+ let isExpired = false;
+ let isUpdated = false;
+
+ if (request) {
+ status = request.status;
+ createdAt = request.createdAt;
+
+ // 요청이 오래되었는지 확인 (PENDING 상태일 때만 적용)
+ if (status === "PENDING") {
+ isExpired = differenceInDays(new Date(), createdAt) > EXPIRATION_DAYS;
+ }
+
+ // 요청 이후 템플릿이 업데이트되었는지 확인
+ if (template.updatedAt && request.createdAt) {
+ isUpdated = template.updatedAt > request.createdAt;
+ }
+ }
+
+ statusData.push({
+ vendorId,
+ vendorName: vendor.vendorName,
+ templateId,
+ templateName: template.templateName,
+ status,
+ createdAt,
+ isExpired,
+ isUpdated,
+ });
+ });
+ });
+
+ // 성공 응답 반환
+ return NextResponse.json({ success: true, data: statusData });
+
+ } catch (error) {
+ console.error("계약 상태 확인 중 오류:", error);
+
+ // 오류 응답 반환
+ return NextResponse.json(
+ {
+ success: false,
+ error: "계약 상태 확인 중 오류가 발생했습니다."
+ },
+ { status: 500 }
+ );
+ }
+} \ No newline at end of file
diff --git a/app/api/cron/form-tags/start/route.ts b/app/api/cron/form-tags/start/route.ts
new file mode 100644
index 00000000..6a029c4c
--- /dev/null
+++ b/app/api/cron/form-tags/start/route.ts
@@ -0,0 +1,136 @@
+// app/api/cron/tags/start/route.ts
+import { NextRequest } from 'next/server';
+import { v4 as uuidv4 } from 'uuid';
+import { revalidateTag } from 'next/cache';
+
+// 동기화 작업의 상태를 저장할 Map
+// 실제 프로덕션에서는 Redis 또는 DB에 저장하는 것이 좋습니다
+const syncJobs = new Map<string, {
+ status: 'queued' | 'processing' | 'completed' | 'failed';
+ startTime: Date;
+ endTime?: Date;
+ result?: any;
+ error?: string;
+ progress?: number;
+ projectCode?: string;
+ formCode?: string;
+ packageId?: number;
+}>();
+
+export async function POST(request: NextRequest) {
+ try {
+ // 요청 데이터 가져오기
+ let projectCode: string | undefined;
+ let formCode: string | undefined;
+ let packageId: number | undefined;
+
+
+ const body = await request.json();
+ projectCode = body.projectCode;
+ formCode = body.formCode;
+ packageId = body.contractItemId;
+
+
+ // 고유 ID 생성
+ const syncId = uuidv4();
+
+ // 작업 상태 초기화
+ syncJobs.set(syncId, {
+ status: 'queued',
+ startTime: new Date(),
+ formCode,
+ projectCode,
+ packageId
+
+ });
+
+ // 비동기 작업 시작 (백그라운드에서 실행)
+ processTagImport(syncId).catch(error => {
+ console.error('Background tag import job failed:', error);
+ syncJobs.set(syncId, {
+ ...syncJobs.get(syncId)!,
+ status: 'failed',
+ endTime: new Date(),
+ error: error.message || 'Unknown error occurred'
+ });
+ });
+
+ // 즉시 응답 반환 (작업 ID 포함)
+ return Response.json({
+ success: true,
+ message: 'Tag import job started',
+ syncId
+ }, { status: 200 });
+
+ } catch (error: any) {
+ console.error('Failed to start tag import job:', error);
+ return Response.json({
+ success: false,
+ error: error.message || 'Failed to start tag import job'
+ }, { status: 500 });
+ }
+}
+
+// 백그라운드에서 실행되는 태그 가져오기 작업
+async function processTagImport(syncId: string) {
+ try {
+ const jobInfo = syncJobs.get(syncId)!;
+ const formCode = jobInfo.formCode;
+ const projectCode = jobInfo.projectCode;
+ const packageId = jobInfo.packageId || 0;
+
+ // 상태 업데이트: 처리 중
+ syncJobs.set(syncId, {
+ ...jobInfo,
+ status: 'processing',
+ progress: 0,
+ });
+
+ if (!formCode || !projectCode ) {
+ throw new Error('formCode,projectCode is required');
+ }
+
+ // 여기서 실제 태그 가져오기 로직 import
+ const { importTagsFromSEDP } = await import('@/lib/sedp/get-form-tags');
+
+ // 진행 상황 업데이트를 위한 콜백 함수
+ const updateProgress = (progress: number) => {
+ syncJobs.set(syncId, {
+ ...syncJobs.get(syncId)!,
+ progress
+ });
+ };
+
+ // 실제 태그 가져오기 실행
+ const result = await importTagsFromSEDP(formCode, projectCode, packageId, updateProgress);
+
+ // 명시적으로 캐시 무효화
+ revalidateTag(`forms-${packageId}`);
+
+ // 상태 업데이트: 완료
+ syncJobs.set(syncId, {
+ ...syncJobs.get(syncId)!,
+ status: 'completed',
+ endTime: new Date(),
+ result,
+ progress: 100,
+ });
+
+ return result;
+ } catch (error: any) {
+ // 에러 발생 시 상태 업데이트
+ syncJobs.set(syncId, {
+ ...syncJobs.get(syncId)!,
+ status: 'failed',
+ endTime: new Date(),
+ error: error.message || 'Unknown error occurred',
+ });
+
+ throw error; // 에러 다시 던지기
+ }
+}
+
+// 서버 메모리에 저장된 작업 상태 접근 함수 (다른 API에서 사용)
+export function getSyncJobStatus(id: string) {
+ return syncJobs.get(id);
+} \ No newline at end of file
diff --git a/app/api/cron/form-tags/status/route.ts b/app/api/cron/form-tags/status/route.ts
new file mode 100644
index 00000000..9d288f52
--- /dev/null
+++ b/app/api/cron/form-tags/status/route.ts
@@ -0,0 +1,46 @@
+// app/api/cron/tags/status/route.ts
+import { NextRequest } from 'next/server';
+import { getSyncJobStatus } from '../start/route';
+
+export async function GET(request: NextRequest) {
+ try {
+ // URL에서 작업 ID 가져오기
+ const searchParams = request.nextUrl.searchParams;
+ const syncId = searchParams.get('id');
+
+ if (!syncId) {
+ return Response.json({
+ success: false,
+ error: 'Missing sync ID parameter'
+ }, { status: 400 });
+ }
+
+ // 작업 상태 조회
+ const jobStatus = getSyncJobStatus(syncId);
+
+ if (!jobStatus) {
+ return Response.json({
+ success: false,
+ error: 'Sync job not found'
+ }, { status: 404 });
+ }
+
+ // 작업 상태 반환
+ return Response.json({
+ success: true,
+ status: jobStatus.status,
+ startTime: jobStatus.startTime,
+ endTime: jobStatus.endTime,
+ progress: jobStatus.progress,
+ result: jobStatus.result,
+ error: jobStatus.error
+ }, { status: 200 });
+
+ } catch (error: any) {
+ console.error('Error retrieving tag import status:', error);
+ return Response.json({
+ success: false,
+ error: error.message || 'Failed to retrieve tag import status'
+ }, { status: 500 });
+ }
+} \ No newline at end of file
diff --git a/app/api/cron/forms/route.ts b/app/api/cron/forms/route.ts
index f58c146b..abe6753a 100644
--- a/app/api/cron/forms/route.ts
+++ b/app/api/cron/forms/route.ts
@@ -1,20 +1,65 @@
-// src/app/api/cron/tag-form-mappings/route.ts
+// app/api/cron/forms/route.ts
import { syncTagFormMappings } from '@/lib/sedp/sync-form';
import { NextRequest } from 'next/server';
+import { revalidateTag } from 'next/cache';
+
+// TypeScript에서 global 객체를 확장하기 위한 type 선언
+declare global {
+ var pendingTasks: Set<Promise<any>>;
+}
+
+// 글로벌 태스크 집합 초기화 (서버가 시작될 때만 한 번 실행됨)
+if (!global.pendingTasks) {
+ global.pendingTasks = new Set<Promise<any>>();
+}
+
+// 이 함수는 비동기 작업을 더 안전하게 처리하기 위한 도우미 함수입니다
+function runBackgroundTask<T>(task: Promise<T>, taskName: string): Promise<T> {
+ // 작업을 추적 세트에 추가
+ global.pendingTasks.add(task);
+
+ // finally 블록을 사용하여 작업이 완료될 때 세트에서 제거
+ task
+ .then(result => {
+ console.log(`Background task '${taskName}' completed successfully`);
+ return result;
+ })
+ .catch(error => {
+ console.error(`Background task '${taskName}' failed:`, error);
+ })
+ .finally(() => {
+ global.pendingTasks.delete(task);
+ });
+
+ return task;
+}
export async function GET(request: NextRequest) {
try {
console.log('태그 폼 매핑 동기화 API 호출됨:', new Date().toISOString());
- // syncTagFormMappings 함수 호출
- const result = await syncTagFormMappings();
+ // 비동기 작업을 생성하고 전역 객체에 저장
+ const syncTask = syncTagFormMappings()
+ .then(result => {
+ // 작업이 완료되면 캐시 무효화
+ revalidateTag('form-lists');
+ return result;
+ });
+
+ // 백그라운드에서 작업이 계속 실행되도록 보장
+ runBackgroundTask(syncTask, 'form-sync');
- // 성공 시 결과와 함께 200 OK 반환
- return Response.json({ success: true, result }, { status: 200 });
+ // 먼저 상태를 반환하고, 그 동안 백그라운드에서 작업 계속
+ return new Response(
+ JSON.stringify({
+ success: true,
+ message: 'Form sync started in background. This may take a while.'
+ }),
+ { status: 202, headers: { 'Content-Type': 'application/json' } }
+ );
} catch (error: any) {
console.error('태그 폼 매핑 동기화 API 에러:', error);
- // 에러 시에는 message를 담아 500 반환
const message = error.message || 'Something went wrong';
return Response.json({ success: false, error: message }, { status: 500 });
}
diff --git a/app/api/cron/forms/start/route.ts b/app/api/cron/forms/start/route.ts
new file mode 100644
index 00000000..a99c4677
--- /dev/null
+++ b/app/api/cron/forms/start/route.ts
@@ -0,0 +1,100 @@
+// app/api/cron/forms/start/route.ts
+import { NextRequest } from 'next/server';
+import { v4 as uuidv4 } from 'uuid';
+import { revalidateTag } from 'next/cache';
+
+// 동기화 작업의 상태를 저장할 Map
+// 실제 프로덕션에서는 Redis 또는 DB에 저장하는 것이 좋습니다
+const syncJobs = new Map<string, {
+ status: 'queued' | 'processing' | 'completed' | 'failed';
+ startTime: Date;
+ endTime?: Date;
+ result?: any;
+ error?: string;
+ progress?: number;
+}>();
+
+export async function POST(request: NextRequest) {
+ try {
+ // 고유 ID 생성
+ const syncId = uuidv4();
+
+ // 작업 상태 초기화
+ syncJobs.set(syncId, {
+ status: 'queued',
+ startTime: new Date(),
+ });
+
+ // 비동기 작업 시작 (백그라운드에서 실행)
+ processSyncJob(syncId).catch(error => {
+ console.error('Background sync job failed:', error);
+ syncJobs.set(syncId, {
+ ...syncJobs.get(syncId)!,
+ status: 'failed',
+ endTime: new Date(),
+ error: error.message || 'Unknown error occurred'
+ });
+ });
+
+ // 즉시 응답 반환 (작업 ID 포함)
+ return Response.json({
+ success: true,
+ message: 'Form sync job started',
+ syncId
+ }, { status: 200 });
+
+ } catch (error: any) {
+ console.error('Failed to start sync job:', error);
+ return Response.json({
+ success: false,
+ error: error.message || 'Failed to start sync job'
+ }, { status: 500 });
+ }
+}
+
+// 백그라운드에서 실행되는 동기화 작업
+async function processSyncJob(syncId: string) {
+ try {
+ // 상태 업데이트: 처리 중
+ syncJobs.set(syncId, {
+ ...syncJobs.get(syncId)!,
+ status: 'processing',
+ progress: 0,
+ });
+
+ // 여기서 실제 동기화 로직 가져오기
+ const { syncTagFormMappings } = await import('@/lib/sedp/sync-form');
+
+ // 실제 동기화 작업 실행
+ const result = await syncTagFormMappings();
+
+ // 명시적으로 캐시 무효화 (동적 import 대신 상단에서 import)
+ revalidateTag('form-lists');
+
+ // 상태 업데이트: 완료
+ syncJobs.set(syncId, {
+ ...syncJobs.get(syncId)!,
+ status: 'completed',
+ endTime: new Date(),
+ result,
+ progress: 100,
+ });
+
+ return result;
+ } catch (error: any) {
+ // 에러 발생 시 상태 업데이트
+ syncJobs.set(syncId, {
+ ...syncJobs.get(syncId)!,
+ status: 'failed',
+ endTime: new Date(),
+ error: error.message || 'Unknown error occurred',
+ });
+
+ throw error; // 에러 다시 던지기
+ }
+}
+
+// 서버 메모리에 저장된 작업 상태 접근 함수 (다른 API에서 사용)
+export function getSyncJobStatus(id: string) {
+ return syncJobs.get(id);
+} \ No newline at end of file
diff --git a/app/api/cron/forms/status/route.ts b/app/api/cron/forms/status/route.ts
new file mode 100644
index 00000000..c0e27b2e
--- /dev/null
+++ b/app/api/cron/forms/status/route.ts
@@ -0,0 +1,46 @@
+// app/api/cron/forms/status/route.ts
+import { NextRequest } from 'next/server';
+import { getSyncJobStatus } from '../start/route';
+
+export async function GET(request: NextRequest) {
+ try {
+ // URL에서 작업 ID 가져오기
+ const searchParams = request.nextUrl.searchParams;
+ const syncId = searchParams.get('id');
+
+ if (!syncId) {
+ return Response.json({
+ success: false,
+ error: 'Missing sync ID parameter'
+ }, { status: 400 });
+ }
+
+ // 작업 상태 조회
+ const jobStatus = getSyncJobStatus(syncId);
+
+ if (!jobStatus) {
+ return Response.json({
+ success: false,
+ error: 'Sync job not found'
+ }, { status: 404 });
+ }
+
+ // 작업 상태 반환
+ return Response.json({
+ success: true,
+ status: jobStatus.status,
+ startTime: jobStatus.startTime,
+ endTime: jobStatus.endTime,
+ progress: jobStatus.progress,
+ result: jobStatus.result,
+ error: jobStatus.error
+ }, { status: 200 });
+
+ } catch (error: any) {
+ console.error('Error retrieving sync status:', error);
+ return Response.json({
+ success: false,
+ error: error.message || 'Failed to retrieve sync status'
+ }, { status: 500 });
+ }
+} \ No newline at end of file
diff --git a/app/api/cron/object-classes/route.ts b/app/api/cron/object-classes/route.ts
index 9a574b1b..6743da70 100644
--- a/app/api/cron/object-classes/route.ts
+++ b/app/api/cron/object-classes/route.ts
@@ -1,6 +1,7 @@
// src/app/api/cron/object-classes/route.ts
import { syncObjectClasses } from '@/lib/sedp/sync-object-class';
import { NextRequest } from 'next/server';
+import { revalidateTag } from 'next/cache';
export async function GET(request: NextRequest) {
try {
@@ -8,7 +9,8 @@ export async function GET(request: NextRequest) {
// syncObjectClasses 함수 호출
const result = await syncObjectClasses();
-
+ revalidateTag("equip-class")
+
// 성공 시 결과와 함께 200 OK 반환
return Response.json({ success: true, result }, { status: 200 });
} catch (error: any) {
diff --git a/app/api/cron/projects/route.ts b/app/api/cron/projects/route.ts
index d8e6af51..12c89bdb 100644
--- a/app/api/cron/projects/route.ts
+++ b/app/api/cron/projects/route.ts
@@ -1,6 +1,7 @@
// src/app/api/cron/projects/route.ts
import { syncProjects } from '@/lib/sedp/sync-projects';
import { NextRequest } from 'next/server';
+import { revalidateTag } from 'next/cache';
export async function GET(request: NextRequest) {
try {
@@ -8,7 +9,9 @@ export async function GET(request: NextRequest) {
// syncProjects 함수 호출
const result = await syncProjects();
-
+
+ revalidateTag('project-lists')
+
// 성공 시 결과와 함께 200 OK 반환
return Response.json({ success: true, result }, { status: 200 });
} catch (error: any) {
diff --git a/app/api/cron/tag-types/route.ts b/app/api/cron/tag-types/route.ts
index 35145984..43644833 100644
--- a/app/api/cron/tag-types/route.ts
+++ b/app/api/cron/tag-types/route.ts
@@ -1,5 +1,6 @@
import { syncTagSubfields } from '@/lib/sedp/sync-tag-types';
import { NextRequest } from 'next/server';
+import { revalidateTag } from 'next/cache';
export async function GET(request: NextRequest) {
try {
@@ -7,6 +8,7 @@ export async function GET(request: NextRequest) {
// syncTagSubfields 함수 호출
const result = await syncTagSubfields();
+ revalidateTag('tag-numbering')
// 성공 시 결과와 함께 200 OK 반환
return Response.json({ success: true, result }, { status: 200 });
diff --git a/app/api/cron/tags/start/route.ts b/app/api/cron/tags/start/route.ts
new file mode 100644
index 00000000..b506b9a3
--- /dev/null
+++ b/app/api/cron/tags/start/route.ts
@@ -0,0 +1,133 @@
+// app/api/cron/tags/start/route.ts
+import { NextRequest } from 'next/server';
+import { v4 as uuidv4 } from 'uuid';
+import { revalidateTag } from 'next/cache';
+
+// 동기화 작업의 상태를 저장할 Map
+// 실제 프로덕션에서는 Redis 또는 DB에 저장하는 것이 좋습니다
+const syncJobs = new Map<string, {
+ status: 'queued' | 'processing' | 'completed' | 'failed';
+ startTime: Date;
+ endTime?: Date;
+ result?: any;
+ error?: string;
+ progress?: number;
+ packageId?: number;
+}>();
+
+export async function POST(request: NextRequest) {
+ try {
+ // 요청 데이터 가져오기
+ let packageId: number | undefined;
+
+ try {
+ const body = await request.json();
+ packageId = body.packageId;
+ } catch (error) {
+ // 요청 본문이 없거나 JSON이 아닌 경우, URL 파라미터 확인
+ const searchParams = request.nextUrl.searchParams;
+ const packageIdParam = searchParams.get('packageId');
+ if (packageIdParam) {
+ packageId = parseInt(packageIdParam, 10);
+ }
+ }
+
+ // 고유 ID 생성
+ const syncId = uuidv4();
+
+ // 작업 상태 초기화
+ syncJobs.set(syncId, {
+ status: 'queued',
+ startTime: new Date(),
+ packageId
+ });
+
+ // 비동기 작업 시작 (백그라운드에서 실행)
+ processTagImport(syncId).catch(error => {
+ console.error('Background tag import job failed:', error);
+ syncJobs.set(syncId, {
+ ...syncJobs.get(syncId)!,
+ status: 'failed',
+ endTime: new Date(),
+ error: error.message || 'Unknown error occurred'
+ });
+ });
+
+ // 즉시 응답 반환 (작업 ID 포함)
+ return Response.json({
+ success: true,
+ message: 'Tag import job started',
+ syncId
+ }, { status: 200 });
+
+ } catch (error: any) {
+ console.error('Failed to start tag import job:', error);
+ return Response.json({
+ success: false,
+ error: error.message || 'Failed to start tag import job'
+ }, { status: 500 });
+ }
+}
+
+// 백그라운드에서 실행되는 태그 가져오기 작업
+async function processTagImport(syncId: string) {
+ try {
+ const jobInfo = syncJobs.get(syncId)!;
+ const packageId = jobInfo.packageId;
+
+ // 상태 업데이트: 처리 중
+ syncJobs.set(syncId, {
+ ...jobInfo,
+ status: 'processing',
+ progress: 0,
+ });
+
+ if (!packageId) {
+ throw new Error('Package ID is required');
+ }
+
+ // 여기서 실제 태그 가져오기 로직 import
+ const { importTagsFromSEDP } = await import('@/lib/sedp/get-tags');
+
+ // 진행 상황 업데이트를 위한 콜백 함수
+ const updateProgress = (progress: number) => {
+ syncJobs.set(syncId, {
+ ...syncJobs.get(syncId)!,
+ progress
+ });
+ };
+
+ // 실제 태그 가져오기 실행
+ const result = await importTagsFromSEDP(packageId, updateProgress);
+
+ // 명시적으로 캐시 무효화
+ revalidateTag(`tags-${packageId}`);
+ revalidateTag(`forms-${packageId}`);
+
+ // 상태 업데이트: 완료
+ syncJobs.set(syncId, {
+ ...syncJobs.get(syncId)!,
+ status: 'completed',
+ endTime: new Date(),
+ result,
+ progress: 100,
+ });
+
+ return result;
+ } catch (error: any) {
+ // 에러 발생 시 상태 업데이트
+ syncJobs.set(syncId, {
+ ...syncJobs.get(syncId)!,
+ status: 'failed',
+ endTime: new Date(),
+ error: error.message || 'Unknown error occurred',
+ });
+
+ throw error; // 에러 다시 던지기
+ }
+}
+
+// 서버 메모리에 저장된 작업 상태 접근 함수 (다른 API에서 사용)
+export function getSyncJobStatus(id: string) {
+ return syncJobs.get(id);
+} \ No newline at end of file
diff --git a/app/api/cron/tags/status/route.ts b/app/api/cron/tags/status/route.ts
new file mode 100644
index 00000000..9d288f52
--- /dev/null
+++ b/app/api/cron/tags/status/route.ts
@@ -0,0 +1,46 @@
+// app/api/cron/tags/status/route.ts
+import { NextRequest } from 'next/server';
+import { getSyncJobStatus } from '../start/route';
+
+export async function GET(request: NextRequest) {
+ try {
+ // URL에서 작업 ID 가져오기
+ const searchParams = request.nextUrl.searchParams;
+ const syncId = searchParams.get('id');
+
+ if (!syncId) {
+ return Response.json({
+ success: false,
+ error: 'Missing sync ID parameter'
+ }, { status: 400 });
+ }
+
+ // 작업 상태 조회
+ const jobStatus = getSyncJobStatus(syncId);
+
+ if (!jobStatus) {
+ return Response.json({
+ success: false,
+ error: 'Sync job not found'
+ }, { status: 404 });
+ }
+
+ // 작업 상태 반환
+ return Response.json({
+ success: true,
+ status: jobStatus.status,
+ startTime: jobStatus.startTime,
+ endTime: jobStatus.endTime,
+ progress: jobStatus.progress,
+ result: jobStatus.result,
+ error: jobStatus.error
+ }, { status: 200 });
+
+ } catch (error: any) {
+ console.error('Error retrieving tag import status:', error);
+ return Response.json({
+ success: false,
+ error: error.message || 'Failed to retrieve tag import status'
+ }, { status: 500 });
+ }
+} \ No newline at end of file
diff --git a/app/api/upload/basicContract/chunk/route.ts b/app/api/upload/basicContract/chunk/route.ts
new file mode 100644
index 00000000..7100988b
--- /dev/null
+++ b/app/api/upload/basicContract/chunk/route.ts
@@ -0,0 +1,71 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { mkdir, writeFile, appendFile } from 'fs/promises';
+import path from 'path';
+import crypto from 'crypto';
+
+export async function POST(request: NextRequest) {
+ try {
+ const formData = await request.formData();
+
+ const chunk = formData.get('chunk') as File;
+ const filename = formData.get('filename') as string;
+ const chunkIndex = parseInt(formData.get('chunkIndex') as string);
+ const totalChunks = parseInt(formData.get('totalChunks') as string);
+ const fileId = formData.get('fileId') as string;
+
+ if (!chunk || !filename || isNaN(chunkIndex) || isNaN(totalChunks) || !fileId) {
+ return NextResponse.json({ success: false, error: '필수 매개변수가 누락되었습니다' }, { status: 400 });
+ }
+
+ // 임시 디렉토리 생성
+ const tempDir = path.join(process.cwd(), 'temp', fileId);
+ await mkdir(tempDir, { recursive: true });
+
+ // 청크 파일 저장
+ const chunkPath = path.join(tempDir, `chunk-${chunkIndex}`);
+ const buffer = Buffer.from(await chunk.arrayBuffer());
+ await writeFile(chunkPath, buffer);
+
+ // 마지막 청크인 경우 모든 청크를 합쳐 최종 파일 생성
+ if (chunkIndex === totalChunks - 1) {
+ const uploadDir = path.join(process.cwd(), "public", "basicContract", "template");
+ await mkdir(uploadDir, { recursive: true });
+
+ // 파일명 생성
+ const timestamp = Date.now();
+ const randomHash = crypto.createHash('md5')
+ .update(`${filename}-${timestamp}`)
+ .digest('hex')
+ .substring(0, 8);
+ const hashedFileName = `${timestamp}-${randomHash}${path.extname(filename)}`;
+ const finalPath = path.join(uploadDir, hashedFileName);
+
+ // 모든 청크 병합
+ await writeFile(finalPath, Buffer.alloc(0)); // 빈 파일 생성
+ for (let i = 0; i < totalChunks; i++) {
+ const chunkData = await require('fs/promises').readFile(path.join(tempDir, `chunk-${i}`));
+ await appendFile(finalPath, chunkData);
+ }
+
+ // 임시 파일 정리 (비동기로 처리)
+ require('fs/promises').rm(tempDir, { recursive: true, force: true })
+ .catch((e: unknown) => console.error('청크 정리 오류:', e));
+
+ return NextResponse.json({
+ success: true,
+ fileName: filename,
+ filePath: `/basicContract/template/${hashedFileName}`
+ });
+ }
+
+ return NextResponse.json({
+ success: true,
+ chunkIndex,
+ message: `청크 ${chunkIndex + 1}/${totalChunks} 업로드 완료`
+ });
+
+ } catch (error) {
+ console.error('청크 업로드 오류:', error);
+ return NextResponse.json({ success: false, error: '서버 오류' }, { status: 500 });
+ }
+} \ No newline at end of file
diff --git a/app/api/upload/basicContract/complete/route.ts b/app/api/upload/basicContract/complete/route.ts
new file mode 100644
index 00000000..6398c5eb
--- /dev/null
+++ b/app/api/upload/basicContract/complete/route.ts
@@ -0,0 +1,37 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { createBasicContractTemplate } from '@/lib/basic-contract/service';
+import { revalidatePath ,revalidateTag} from 'next/cache';
+
+export async function POST(request: NextRequest) {
+ try {
+ const { templateName,validityPeriod, status, fileName, filePath } = await request.json();
+
+ if (!templateName || !fileName || !filePath) {
+ return NextResponse.json({ success: false, error: '필수 정보가 누락되었습니다' }, { status: 400 });
+ }
+
+ // DB에 저장
+ const { data, error } = await createBasicContractTemplate({
+ templateName,
+ validityPeriod,
+ status,
+ fileName,
+ filePath
+ });
+
+
+ revalidatePath('/evcp/basic-contract-templates');
+ revalidatePath('/'); // 루트 경로 무효화도 시도
+ revalidateTag("basic-contract-templates");
+
+ if (error) {
+ throw new Error(error);
+ }
+
+ return NextResponse.json({ success: true, data });
+
+ } catch (error) {
+ console.error('템플릿 저장 오류:', error);
+ return NextResponse.json({ success: false, error: '서버 오류' }, { status: 500 });
+ }
+} \ No newline at end of file
diff --git a/app/api/upload/signed-contract/route.ts b/app/api/upload/signed-contract/route.ts
new file mode 100644
index 00000000..f26e20ba
--- /dev/null
+++ b/app/api/upload/signed-contract/route.ts
@@ -0,0 +1,57 @@
+// app/api/upload/signed-contract/route.ts
+import { NextRequest, NextResponse } from 'next/server';
+import fs from 'fs/promises';
+import path from 'path';
+import { v4 as uuidv4 } from 'uuid';
+import db from "@/db/db";
+import { basicContract } from '@/db/schema';
+import { eq } from 'drizzle-orm';
+import { revalidateTag } from 'next/cache';
+
+export async function POST(request: NextRequest) {
+ try {
+ const formData = await request.formData();
+ const file = formData.get('file') as File;
+ const tableRowId = parseInt(formData.get('tableRowId') as string);
+ const templateName = formData.get('templateName') as string;
+
+ if (!file || !tableRowId || !templateName) {
+ return NextResponse.json({ result: false, error: '필수 파라미터가 누락되었습니다.' }, { status: 400 });
+ }
+
+ const originalName = `${tableRowId}_${templateName}`;
+ const ext = path.extname(originalName);
+ const uniqueName = uuidv4() + ext;
+
+ const publicDir = path.join(process.cwd(), "public", "basicContract");
+ const relativePath = `/basicContract/${uniqueName}`;
+ const absolutePath = path.join(publicDir, uniqueName);
+ const buffer = Buffer.from(await file.arrayBuffer());
+
+ await fs.mkdir(publicDir, { recursive: true });
+ await fs.writeFile(absolutePath, buffer);
+
+ await db.transaction(async (tx) => {
+ await tx
+ .update(basicContract)
+ .set({
+ status: "COMPLETED",
+ fileName: originalName,
+ filePath: relativePath,
+ updatedAt: new Date(),
+ completedAt: new Date()
+ })
+ .where(eq(basicContract.id, tableRowId));
+ });
+
+ // 캐시 무효화
+ revalidateTag("basic-contract-requests");
+ revalidateTag("basicContractView-vendor");
+
+ return NextResponse.json({ result: true });
+ } catch (error) {
+ console.error('서명된 계약서 저장 오류:', error);
+ const errorMessage = error instanceof Error ? error.message : "알 수 없는 오류";
+ return NextResponse.json({ result: false, error: errorMessage }, { status: 500 });
+ }
+} \ No newline at end of file
diff --git a/app/api/vendors/attachments/download-all/route.ts b/app/api/vendors/attachments/download-all/route.ts
new file mode 100644
index 00000000..23f85786
--- /dev/null
+++ b/app/api/vendors/attachments/download-all/route.ts
@@ -0,0 +1,108 @@
+// /app/api/vendors/attachments/download-all/route.js
+import { NextResponse,NextRequest } from 'next/server';
+import fs from 'fs';
+import path from 'path';
+import JSZip from 'jszip';
+import db from '@/db/db';
+
+import { eq } from 'drizzle-orm';
+import { vendorAttachments, vendors } from '@/db/schema';
+
+export async function GET(request: NextRequest) {
+ try {
+ const { searchParams } = new URL(request.url);
+ const vendorId = searchParams.get('vendorId');
+
+ if (!vendorId) {
+ return NextResponse.json(
+ { error: "필수 파라미터가 누락되었습니다." },
+ { status: 400 }
+ );
+ }
+
+ // 협력업체 정보 조회
+ const vendor = await db.query.vendors.findFirst({
+ where: eq(vendors.id, parseInt(vendorId, 10))
+ });
+
+ if (!vendor) {
+ return NextResponse.json(
+ { error: `협력업체 정보를 찾을 수 없습니다. (ID: ${vendorId})` },
+ { status: 404 }
+ );
+ }
+
+ // 첨부파일 조회
+ const attachments = await db.select()
+ .from(vendorAttachments)
+ .where(eq(vendorAttachments.vendorId, parseInt(vendorId, 10)));
+
+ if (!attachments.length) {
+ return NextResponse.json(
+ { error: '다운로드할 첨부파일이 없습니다.' },
+ { status: 404 }
+ );
+ }
+
+ // 업로드 기본 경로
+ const basePath = process.env.UPLOAD_DIR || path.join(process.cwd(), 'public');
+
+ // ZIP 생성
+ const zip = new JSZip();
+
+ // 파일 읽기 및 ZIP에 추가
+ await Promise.all(
+ attachments.map(async (attachment) => {
+ const filePath = path.join(basePath, attachment.filePath);
+
+ try {
+ // 파일 존재 확인
+ try {
+ await fs.promises.access(filePath, fs.constants.F_OK);
+ } catch (e) {
+ console.warn(`파일이 존재하지 않습니다: ${filePath}`);
+ return; // 파일이 없으면 건너뜀
+ }
+
+ // 파일 읽기
+ const fileData = await fs.promises.readFile(filePath);
+
+ // ZIP에 파일 추가
+ zip.file(attachment.fileName, fileData);
+ } catch (error) {
+ console.warn(`파일을 처리할 수 없습니다: ${filePath}`, error);
+ // 오류가 있더라도 계속 진행
+ }
+ })
+ );
+
+ // ZIP 생성
+ const zipContent = await zip.generateAsync({
+ type: 'nodebuffer',
+ compression: 'DEFLATE',
+ compressionOptions: { level: 9 }
+ });
+
+ // 파일명 생성
+ const fileName = `${vendor.vendorName || `vendor-${vendorId}`}-attachments.zip`;
+
+ // 응답 헤더 설정
+ const headers = new Headers();
+ headers.set('Content-Disposition', `attachment; filename="${fileName}"`);
+ headers.set('Content-Type', 'application/zip');
+ headers.set('Content-Length', zipContent.length.toString());
+
+ // ZIP 파일 데이터와 함께 응답
+ return new Response(zipContent, {
+ status: 200,
+ headers
+ });
+
+ } catch (error) {
+ console.error('첨부파일 다운로드 오류:', error);
+ return NextResponse.json(
+ { error: "첨부파일 다운로드 준비 중 오류가 발생했습니다." },
+ { status: 500 }
+ );
+ }
+} \ No newline at end of file
diff --git a/app/api/vendors/attachments/download/route.ts b/app/api/vendors/attachments/download/route.ts
new file mode 100644
index 00000000..0151a699
--- /dev/null
+++ b/app/api/vendors/attachments/download/route.ts
@@ -0,0 +1,93 @@
+// /app/api/vendors/attachments/download/route.js (Next.js App Router 기준)
+import { NextRequest, NextResponse } from 'next/server';
+import fs from 'fs';
+import path from 'path';
+import { eq } from 'drizzle-orm'; // 쿼리 빌더
+import { vendorAttachments } from '@/db/schema';
+import db from '@/db/db';
+
+export async function GET(request: NextRequest) {
+ try {
+ const { searchParams } = new URL(request.url);
+ const fileId = searchParams.get('id');
+ const vendorId = searchParams.get('vendorId');
+
+ if (!fileId || !vendorId) {
+ return NextResponse.json(
+ { error: "필수 파라미터가 누락되었습니다." },
+ { status: 400 }
+ );
+ }
+
+ // 첨부파일 정보 조회
+ const attachment = await db.query.vendorAttachments.findFirst({
+ where: eq(vendorAttachments.id, parseInt(fileId, 10))
+ });
+
+ if (!attachment) {
+ return NextResponse.json(
+ { error: "파일을 찾을 수 없습니다." },
+ { status: 404 }
+ );
+ }
+
+ // 파일 경로 구성
+ const basePath = process.env.UPLOAD_DIR || path.join(process.cwd(), 'public');
+ const filePath = path.join(basePath, attachment.filePath);
+
+ // 파일 존재 확인
+ try {
+ await fs.promises.access(filePath, fs.constants.F_OK);
+ } catch (e) {
+ return NextResponse.json(
+ { error: "파일이 서버에 존재하지 않습니다." },
+ { status: 404 }
+ );
+ }
+
+ // 파일 데이터 읽기
+ const fileBuffer = await fs.promises.readFile(filePath);
+
+
+
+ // 파일 MIME 타입 추정
+ let contentType = 'application/octet-stream';
+ if (attachment.fileName) {
+ const ext = path.extname(attachment.fileName).toLowerCase();
+ switch (ext) {
+ case '.pdf': contentType = 'application/pdf'; break;
+ case '.jpg':
+ case '.jpeg': contentType = 'image/jpeg'; break;
+ case '.png': contentType = 'image/png'; break;
+ case '.doc': contentType = 'application/msword'; break;
+ case '.docx': contentType = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; break;
+ // 필요에 따라 더 많은 타입 추가
+ }
+ }
+
+ // 응답 헤더 설정
+ const headers = new Headers();
+
+ // 파일명에 non-ASCII 문자가 포함될 수 있으므로 인코딩 처리
+ const encodedFileName = encodeURIComponent(attachment.fileName)
+ .replace(/['()]/g, escape) // 추가 이스케이프 필요한 문자들
+ .replace(/\*/g, '%2A');
+
+ // RFC 5987에 따른 인코딩 방식 적용
+ headers.set('Content-Disposition', `attachment; filename*=UTF-8''${encodedFileName}`);
+ headers.set('Content-Type', contentType);
+ headers.set('Content-Length', fileBuffer.length.toString());
+ // 파일 데이터와 함께 응답
+ return new Response(fileBuffer, {
+ status: 200,
+ headers
+ });
+
+ } catch (error) {
+ console.error('파일 다운로드 오류:', error);
+ return NextResponse.json(
+ { 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
index 0724eeeb..70573592 100644
--- a/app/api/vendors/erp/route.ts
+++ b/app/api/vendors/erp/route.ts
@@ -3,7 +3,7 @@ import { headers } from 'next/headers';
import { getErrorMessage } from '@/lib/handle-error';
/**
- * 기간계 시스템에 벤더 정보를 전송하는 API 엔드포인트
+ * 기간계 시스템에 협력업체 정보를 전송하는 API 엔드포인트
* 서버 액션 내부에서 호출됨
*/
export async function POST(request: NextRequest) {
@@ -78,7 +78,7 @@ export async function POST(request: NextRequest) {
const result = await response.json();
- // 벤더 코드 검증
+ // 협력업체 코드 검증
if (!result.vendor_code) {
return NextResponse.json(
{ success: false, message: 'Vendor code not provided in ERP response' },
diff --git a/app/globals.css b/app/globals.css
index c427b92f..9cd22397 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -8,74 +8,74 @@ body {
@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%;
+ --background: 0 0% 100% !important;
+ --foreground: 222.2 84% 4.9% !important;
+ --card: 0 0% 100% !important;
+ --card-foreground: 222.2 84% 4.9% !important;
+ --popover: 0 0% 100% !important;
+ --popover-foreground: 222.2 84% 4.9% !important;
+ --primary: 222.2 47.4% 11.2% !important;
+ --samsung: 222.2 47.4% 11.2% !important;
+ --primary-foreground: 210 40% 98% !important;
+ --secondary: 210 40% 96.1% !important;
+ --secondary-foreground: 222.2 47.4% 11.2% !important;
+ --muted: 210 40% 96.1% !important;
+ --muted-foreground: 215.4 16.3% 46.9% !important;
+ --accent: 210 40% 96.1% !important;
+ --accent-foreground: 222.2 47.4% 11.2% !important;
+ --destructive: 0 84.2% 60.2% !important;
+ --destructive-foreground: 210 40% 98% !important;
+ --border: 214.3 31.8% 91.4% !important;
+ --input: 214.3 31.8% 91.4% !important;
+ --ring: 222.2 84% 4.9% !important;
+ --chart-1: 12 76% 61% !important;
+ --chart-2: 173 58% 39% !important;
+ --chart-3: 197 37% 24% !important;
+ --chart-4: 43 74% 66% !important;
+ --chart-5: 27 87% 67% !important;
+ --radius: 0.5rem !important;
+ --sidebar-background: 0 0% 98% !important;
+ --sidebar-foreground: 240 5.3% 26.1% !important;
+ --sidebar-primary: 240 5.9% 10% !important;
+ --sidebar-primary-foreground: 0 0% 98% !important;
+ --sidebar-accent: 240 4.8% 95.9% !important;
+ --sidebar-accent-foreground: 240 5.9% 10% !important;
+ --sidebar-border: 220 13% 91% !important;
+ --sidebar-ring: 217.2 91.2% 59.8% !important;
}
.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%;
+ --background: 222.2 84% 4.9% !important;
+ --foreground: 210 40% 98% !important;
+ --card: 222.2 84% 4.9% !important;
+ --card-foreground: 210 40% 98% !important;
+ --popover: 222.2 84% 4.9% !important;
+ --popover-foreground: 210 40% 98% !important;
+ --primary: 210 40% 98% !important;
+ --primary-foreground: 222.2 47.4% 11.2% !important;
+ --secondary: 217.2 32.6% 17.5% !important;
+ --secondary-foreground: 210 40% 98% !important;
+ --muted: 217.2 32.6% 17.5% !important;
+ --muted-foreground: 215 20.2% 65.1% !important;
+ --accent: 217.2 32.6% 17.5% !important;
+ --accent-foreground: 210 40% 98% !important;
+ --destructive: 0 62.8% 30.6% !important;
+ --destructive-foreground: 210 40% 98% !important;
+ --border: 217.2 32.6% 17.5% !important;
+ --input: 217.2 32.6% 17.5% !important;
+ --ring: 212.7 26.8% 83.9% !important;
+ --chart-1: 220 70% 50% !important;
+ --chart-2: 160 60% 45% !important;
+ --chart-3: 30 80% 55% !important;
+ --chart-4: 280 65% 60% !important;
+ --chart-5: 340 75% 55% !important;
+ --sidebar-background: 240 5.9% 10% !important;
+ --sidebar-foreground: 240 4.8% 95.9% !important;
+ --sidebar-primary: 224.3 76.3% 48% !important;
+ --sidebar-primary-foreground: 0 0% 100% !important;
+ --sidebar-accent: 240 3.7% 15.9% !important;
+ --sidebar-accent-foreground: 240 4.8% 95.9% !important;
+ --sidebar-border: 240 3.7% 15.9% !important;
+ --sidebar-ring: 217.2 91.2% 59.8% !important;
}
}