diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-04-28 02:13:30 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-04-28 02:13:30 +0000 |
| commit | ef4c533ebacc2cdc97e518f30e9a9350004fcdfb (patch) | |
| tree | 345251a3ed0f4429716fa5edaa31024d8f4cb560 /app | |
| parent | 9ceed79cf32c896f8a998399bf1b296506b2cd4a (diff) | |
~20250428 작업사항
Diffstat (limited to 'app')
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; } } |
