diff options
170 files changed, 3417 insertions, 10040 deletions
diff --git a/app/[lng]/engineering/(engineering)/b-rfq/[id]/final/page.tsx b/app/[lng]/engineering/(engineering)/b-rfq/[id]/final/page.tsx deleted file mode 100644 index e69de29b..00000000 --- a/app/[lng]/engineering/(engineering)/b-rfq/[id]/final/page.tsx +++ /dev/null diff --git a/app/[lng]/engineering/(engineering)/b-rfq/[id]/initial/page.tsx b/app/[lng]/engineering/(engineering)/b-rfq/[id]/initial/page.tsx deleted file mode 100644 index 1af65fbc..00000000 --- a/app/[lng]/engineering/(engineering)/b-rfq/[id]/initial/page.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { InitialRfqDetailTable } from "@/lib/b-rfq/initial/initial-rfq-detail-table" -import { getInitialRfqDetail } from "@/lib/b-rfq/service" -import { searchParamsInitialRfqDetailCache } from "@/lib/b-rfq/validations" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> -} - -export default async function RfqPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsInitialRfqDetailCache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - const promises = getInitialRfqDetail({ - ...search, - filters: validFilters, - }, idAsNumber) - - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - Initial RFQ List - </h3> - <p className="text-sm text-muted-foreground"> - 설계로부터 받은 RFQ 문서와 구매 RFQ 문서 및 사전 계약자료를 Vendor에 발송하기 위한 RFQ 생성 및 관리하는 화면입니다. - </p> - </div> - <Separator /> - <div> - <InitialRfqDetailTable promises={promises} rfqId={idAsNumber}/> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/b-rfq/[id]/layout.tsx b/app/[lng]/engineering/(engineering)/b-rfq/[id]/layout.tsx deleted file mode 100644 index 8dad7676..00000000 --- a/app/[lng]/engineering/(engineering)/b-rfq/[id]/layout.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { Metadata } from "next" -import Link from "next/link" -import { Separator } from "@/components/ui/separator" -import { SidebarNav } from "@/components/layout/sidebar-nav" -import { formatDate } from "@/lib/utils" -import { Button } from "@/components/ui/button" -import { ArrowLeft } from "lucide-react" -import { RfqDashboardView } from "@/db/schema" -import { findBRfqById } from "@/lib/b-rfq/service" - -export const metadata: Metadata = { - title: "견적 RFQ 상세", -} - -export default async function RfqLayout({ - children, - params, -}: { - children: React.ReactNode - params: { lng: string, id: string } -}) { - - // 1) URL 파라미터에서 id 추출, Number로 변환 - const resolvedParams = await params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - // 2) DB에서 해당 협력업체 정보 조회 - const rfq: RfqDashboardView | null = await findBRfqById(idAsNumber) - - // 3) 사이드바 메뉴 - const sidebarNavItems = [ - { - title: "견적/입찰 문서관리", - href: `/${lng}/evcp/b-rfq/${id}`, - }, - { - title: "Initial RFQ 발송", - href: `/${lng}/evcp/b-rfq/${id}/initial`, - }, - { - title: "Final RFQ 발송", - href: `/${lng}/evcp/b-rfq/${id}/final`, - }, - - ] - - return ( - <> - <div className="container py-6"> - <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow"> - <div className="hidden space-y-6 p-10 pb-16 md:block"> - <div className="flex items-center justify-end mb-4"> - <Link href={`/${lng}/evcp/b-rfq`} passHref> - <Button variant="ghost" className="flex items-center text-primary hover:text-primary/80 transition-colors p-0 h-auto"> - <ArrowLeft className="mr-1 h-4 w-4" /> - <span>RFQ 목록으로 돌아가기</span> - </Button> - </Link> - </div> - <div className="space-y-0.5"> - {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */} - <h2 className="text-2xl font-bold tracking-tight"> - {rfq - ? `${rfq.rfqCode ?? ""} | ${rfq.packageNo ?? ""} | ${rfq.packageName ?? ""}` - : "Loading RFQ..."} - </h2> - - <p className="text-muted-foreground"> - PR발행 전 RFQ를 생성하여 관리하는 화면입니다. - </p> - <h3>Due Date:{rfq && rfq?.dueDate && <strong>{formatDate(rfq?.dueDate)}</strong>}</h3> - </div> - <Separator className="my-6" /> - <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0"> - <aside className="lg:w-64 flex-shrink-0"> - <SidebarNav items={sidebarNavItems} /> - </aside> - <div className="lg:w-[calc(100%-16rem)] overflow-auto">{children}</div> - </div> - </div> - </section> - </div> - </> - ) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/b-rfq/[id]/page.tsx b/app/[lng]/engineering/(engineering)/b-rfq/[id]/page.tsx deleted file mode 100644 index 26dc45fb..00000000 --- a/app/[lng]/engineering/(engineering)/b-rfq/[id]/page.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { searchParamsRfqAttachmentsCache } from "@/lib/b-rfq/validations" -import { getRfqAttachments } from "@/lib/b-rfq/service" -import { RfqAttachmentsTable } from "@/lib/b-rfq/attachment/attachment-table" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> -} - -export default async function RfqPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsRfqAttachmentsCache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - const promises = getRfqAttachments({ - ...search, - filters: validFilters, - }, idAsNumber) - - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - 견적 RFQ 문서관리 - </h3> - <p className="text-sm text-muted-foreground"> - 설계로부터 받은 RFQ 문서와 구매 RFQ 문서를 관리하고 Vendor 회신을 점검/관리하는 화면입니다. - </p> - </div> - <Separator /> - <div> - <RfqAttachmentsTable promises={promises} rfqId={idAsNumber} /> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/b-rfq/page.tsx b/app/[lng]/engineering/(engineering)/b-rfq/page.tsx deleted file mode 100644 index a66d7b58..00000000 --- a/app/[lng]/engineering/(engineering)/b-rfq/page.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import * as React from "react" -import { Metadata } from "next" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { Shell } from "@/components/shell" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { searchParamsRFQDashboardCache } from "@/lib/b-rfq/validations" -import { getRFQDashboard } from "@/lib/b-rfq/service" -import { RFQDashboardTable } from "@/lib/b-rfq/summary-table/summary-rfq-table" - -export const metadata: Metadata = { - title: "견적 RFQ", - description: "", -} - -interface PQReviewPageProps { - searchParams: Promise<SearchParams> -} - -export default async function PQReviewPage(props: PQReviewPageProps) { - const searchParams = await props.searchParams - const search = searchParamsRFQDashboardCache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - // 기본 필터 처리 (통일된 이름 사용) - let basicFilters = [] - if (search.basicFilters && search.basicFilters.length > 0) { - basicFilters = search.basicFilters - console.log("Using search.basicFilters:", basicFilters); - } else { - console.log("No basic filters found"); - } - - // 모든 필터를 합쳐서 처리 - const allFilters = [...validFilters, ...basicFilters] - - // 조인 연산자도 통일된 이름 사용 - const joinOperator = search.basicJoinOperator || search.joinOperator || 'and'; - - // Promise.all로 감싸서 전달 - const promises = Promise.all([ - getRFQDashboard({ - ...search, - filters: allFilters, - joinOperator, - }) - ]) - - console.log(search, "견적") - - return ( - <Shell className="gap-4"> - <div className="flex items-center justify-between space-y-2"> - <div className="flex items-center justify-between space-y-2"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - 견적 RFQ - </h2> - </div> - </div> - </div> - - {/* Items처럼 직접 테이블 렌더링 */} - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={8} - searchableColumnCount={2} - filterableColumnCount={3} - cellWidths={["10rem", "15rem", "12rem", "12rem", "8rem", "8rem", "10rem", "8rem"]} - shrinkZero - /> - } - > - <RFQDashboardTable promises={promises} /> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/basic-contract-template/page.tsx b/app/[lng]/engineering/(engineering)/basic-contract-template/page.tsx deleted file mode 100644 index adc57ed9..00000000 --- a/app/[lng]/engineering/(engineering)/basic-contract-template/page.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import * as React from "react" -import { type SearchParams } from "@/types/table" - -import { getValidFilters } from "@/lib/data-table" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" -import { 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]/engineering/(engineering)/basic-contract/page.tsx b/app/[lng]/engineering/(engineering)/basic-contract/page.tsx deleted file mode 100644 index a043e530..00000000 --- a/app/[lng]/engineering/(engineering)/basic-contract/page.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import * as React from "react" -import { type SearchParams } from "@/types/table" - -import { getValidFilters } from "@/lib/data-table" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" -import { 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]/engineering/(engineering)/bid-projects/page.tsx b/app/[lng]/engineering/(engineering)/bid-projects/page.tsx deleted file mode 100644 index 2039e5b2..00000000 --- a/app/[lng]/engineering/(engineering)/bid-projects/page.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import * as React from "react" -import { type SearchParams } from "@/types/table" - -import { getValidFilters } from "@/lib/data-table" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" -import { 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(S-ERP)로부터 수신한 견적 프로젝트 데이터입니다. 기술영업의 Budgetary RFQ에서 사용됩니다. - {/* <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]/engineering/(engineering)/bqcbe/page.tsx b/app/[lng]/engineering/(engineering)/bqcbe/page.tsx deleted file mode 100644 index ae503feb..00000000 --- a/app/[lng]/engineering/(engineering)/bqcbe/page.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { getAllCBE } from "@/lib/rfqs/service" -import { searchParamsCBECache } from "@/lib/rfqs/validations" - -import { AllCbeTable } from "@/lib/cbe/table/cbe-table" - -import { RfqType } from "@/lib/rfqs/validations" -import * as React from "react" -import { Shell } from "@/components/shell" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - } - searchParams: Promise<SearchParams> - rfqType: RfqType -} - -export default async function RfqCBEPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - - const rfqType = props.rfqType ?? RfqType.BUDGETARY // rfqType이 없으면 BUDGETARY로 설정 - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsCBECache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getAllCBE({ - ...search, - filters: validFilters, - rfqType - } - ) - ]) - - // 4) 렌더링 - return ( - <Shell className="gap-2"> - <div className="flex items-center justify-between space-y-2"> - <div className="flex items-center justify-between space-y-2"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - 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]/engineering/(engineering)/bqtbe/page.tsx b/app/[lng]/engineering/(engineering)/bqtbe/page.tsx deleted file mode 100644 index 4989c235..00000000 --- a/app/[lng]/engineering/(engineering)/bqtbe/page.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { getAllTBE } from "@/lib/rfqs/service" -import { searchParamsTBECache } from "@/lib/rfqs/validations" -import { AllTbeTable } from "@/lib/tbe/table/tbe-table" -import { RfqType } from "@/lib/rfqs/validations" -import * as React from "react" -import { Shell } from "@/components/shell" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - } - searchParams: Promise<SearchParams> - rfqType: RfqType -} - -export default async function RfqTBEPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - - const rfqType = props.rfqType ?? RfqType.BUDGETARY // rfqType이 없으면 BUDGETARY로 설정 - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsTBECache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getAllTBE({ - ...search, - filters: validFilters, - rfqType - } - ) - ]) - - // 4) 렌더링 - return ( - <Shell className="gap-2"> - <div className="flex items-center justify-between space-y-2"> - <div className="flex items-center justify-between space-y-2"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - Technical Bid Evaluation - </h2> - <p className="text-muted-foreground"> - 초대된 협력업체에게 TBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. - </p> - </div> - </div> - </div> - - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <AllTbeTable promises={promises}/> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/budgetary-rfq/[id]/cbe/page.tsx b/app/[lng]/engineering/(engineering)/budgetary-rfq/[id]/cbe/page.tsx deleted file mode 100644 index 956facd3..00000000 --- a/app/[lng]/engineering/(engineering)/budgetary-rfq/[id]/cbe/page.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { getCBE, getTBE } from "@/lib/rfqs/service" -import { searchParamsCBECache, } from "@/lib/rfqs/validations" -import { TbeTable } from "@/lib/rfqs/tbe-table/tbe-table" -import { CbeTable } from "@/lib/rfqs/cbe-table/cbe-table" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> -} - -export default async function RfqTBEPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsCBECache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getCBE({ - ...search, - filters: validFilters, - }, - idAsNumber) - ]) - - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - Commercial Bid Evaluation - </h3> - <p className="text-sm text-muted-foreground"> - 초대된 협력업체에게 CBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 CBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. - </p> - </div> - <Separator /> - <div> - <CbeTable promises={promises} rfqId={idAsNumber}/> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/budgetary-rfq/[id]/layout.tsx b/app/[lng]/engineering/(engineering)/budgetary-rfq/[id]/layout.tsx deleted file mode 100644 index ba7c071c..00000000 --- a/app/[lng]/engineering/(engineering)/budgetary-rfq/[id]/layout.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { Metadata } from "next" -import Link from "next/link" -import { ArrowLeft } from "lucide-react" - -import { Separator } from "@/components/ui/separator" -import { SidebarNav } from "@/components/layout/sidebar-nav" -import { RfqViewWithItems } from "@/db/schema/rfq" -import { findRfqById } from "@/lib/rfqs/service" -import { formatDate } from "@/lib/utils" -import { Button } from "@/components/ui/button" - -export const metadata: Metadata = { - title: "Vendor Detail", -} - -export default async function RfqLayout({ - children, - params, -}: { - children: React.ReactNode - params: { lng: string, id: string } -}) { - - // 1) URL 파라미터에서 id 추출, Number로 변환 - const resolvedParams = await params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - // 2) DB에서 해당 협력업체 정보 조회 - const rfq: RfqViewWithItems | null = await findRfqById(idAsNumber) - - // 3) 사이드바 메뉴 - const sidebarNavItems = [ - { - title: "Matched Vendors", - href: `/${lng}/evcp/budgetary/${id}`, - }, - { - title: "TBE", - href: `/${lng}/evcp/budgetary/${id}/tbe`, - }, - { - title: "CBE", - href: `/${lng}/evcp/budgetary/${id}/cbe`, - }, - - ] - - return ( - <> - <div className="container py-6"> - <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow"> - <div className="hidden space-y-6 p-10 pb-16 md:block"> - <div className="flex items-center justify-end mb-4"> - <Link href={`/${lng}/evcp/budgetary-rfq`} passHref> - <Button variant="ghost" className="flex items-center text-primary hover:text-primary/80 transition-colors p-0 h-auto"> - <ArrowLeft className="mr-1 h-4 w-4" /> - <span>Budgetary RFQ 목록으로 돌아가기</span> - </Button> - </Link> - </div> - <div className="space-y-0.5"> - {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */} - <h2 className="text-2xl font-bold tracking-tight"> - {rfq - ? `${rfq.projectCode ?? ""} ${rfq.rfqCode ?? ""} 관리` - : "Loading RFQ..."} - </h2> - - <p className="text-muted-foreground"> - {rfq - ? `${rfq.description ?? ""} ${rfq.lines.map(line => line.itemCode).join(", ")}` - : ""} - </p> - <h3>Due Date:{rfq && rfq?.dueDate && <strong>{formatDate(rfq?.dueDate)}</strong>}</h3> - </div> - <Separator className="my-6" /> - <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0"> - <aside className="lg:w-64 flex-shrink-0"> - <SidebarNav items={sidebarNavItems} /> - </aside> - <div className="lg:w-[calc(100%-16rem)] overflow-auto">{children}</div> - </div> - </div> - </section> - </div> - </> - ) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/budgetary-rfq/[id]/page.tsx b/app/[lng]/engineering/(engineering)/budgetary-rfq/[id]/page.tsx deleted file mode 100644 index dd9df563..00000000 --- a/app/[lng]/engineering/(engineering)/budgetary-rfq/[id]/page.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { getMatchedVendors } from "@/lib/rfqs/service" -import { searchParamsMatchedVCache } from "@/lib/rfqs/validations" -import { MatchedVendorsTable } from "@/lib/rfqs/vendor-table/vendors-table" -import { RfqType } from "@/lib/rfqs/validations" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> - rfqType: RfqType -} - -export default async function RfqPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - const id = resolvedParams.id - const rfqType = props.rfqType ?? RfqType.BUDGETARY // rfqType이 없으면 BUDGETARY로 설정 - - const idAsNumber = Number(id) - - // 2) SearchParams 파싱 (Zod) - const searchParams = await props.searchParams - const search = searchParamsMatchedVCache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getMatchedVendors({ - ...search, - filters: validFilters, - }, - idAsNumber) - ]) - - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - Vendors - </h3> - <p className="text-sm text-muted-foreground"> - 등록된 협력업체 중에서 이 RFQ 아이템에 매칭되는 업체를 보여줍니다. <br/>"발행하기" 버튼을 통해 RFQ를 전송하면 첨부파일과 함께 RFQ 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. - </p> - </div> - <Separator /> - <div> - <MatchedVendorsTable promises={promises} rfqId={idAsNumber} rfqType={rfqType}/> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/budgetary-rfq/[id]/tbe/page.tsx b/app/[lng]/engineering/(engineering)/budgetary-rfq/[id]/tbe/page.tsx deleted file mode 100644 index ec894e1c..00000000 --- a/app/[lng]/engineering/(engineering)/budgetary-rfq/[id]/tbe/page.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { getTBE } from "@/lib/rfqs/service" -import { searchParamsTBECache } from "@/lib/rfqs/validations" -import { TbeTable } from "@/lib/rfqs/tbe-table/tbe-table" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> -} - -export default async function RfqTBEPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsTBECache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getTBE({ - ...search, - filters: validFilters, - }, - idAsNumber) - ]) - - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - Technical Bid Evaluation - </h3> - <p className="text-sm text-muted-foreground"> - 초대된 협력업체에게 TBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. - </p> - </div> - <Separator /> - <div> - <TbeTable promises={promises} rfqId={idAsNumber}/> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/budgetary-rfq/page.tsx b/app/[lng]/engineering/(engineering)/budgetary-rfq/page.tsx deleted file mode 100644 index dc2a4a2b..00000000 --- a/app/[lng]/engineering/(engineering)/budgetary-rfq/page.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import * as React from "react" -import { type SearchParams } from "@/types/table" - -import { getValidFilters } from "@/lib/data-table" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" - -import { searchParamsCache } from "@/lib/rfqs/validations" -import { getRfqs, getRfqStatusCounts } from "@/lib/rfqs/service" -import { RfqsTable } from "@/lib/rfqs/table/rfqs-table" -import { getAllItems } from "@/lib/items/service" -import { RfqType } from "@/lib/rfqs/validations" -import { Ellipsis } from "lucide-react" - -interface RfqPageProps { - searchParams: Promise<SearchParams>; - rfqType: RfqType; - title: string; - description: string; -} - -export default async function RfqPage({ - searchParams, - rfqType = RfqType.PURCHASE_BUDGETARY, - title = "Budgetary Quote", - description = "Budgetary Quote를 등록하여 요청 및 응답을 관리할 수 있습니다." -}: RfqPageProps) { - const search = searchParamsCache.parse(await searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getRfqs({ - ...search, - filters: validFilters, - rfqType // 전달받은 rfqType 사용 - }), - getRfqStatusCounts(rfqType), // rfqType 전달 - getAllItems() - ]) - - return ( - <Shell className="gap-2"> - <div className="flex items-center justify-between space-y-2"> - <div className="flex items-center justify-between space-y-2"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - {title} - </h2> - <p className="text-muted-foreground"> - {description} - 기본적인 정보와 RFQ를 위한 아이템 등록 및 첨부를 한 후, - <span className="inline-flex items-center whitespace-nowrap"> - <Ellipsis className="size-3" /> - <span className="ml-1">버튼</span> - </span> 을 클릭하면 "Proceed"를 통해 상세화면으로 이동하여 진행할 수 있습니다. - </p> - </div> - </div> - </div> - - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> - {/* <DateRangePicker - triggerSize="sm" - triggerClassName="ml-auto w-56 sm:w-60" - align="end" - shallow={false} - /> */} - </React.Suspense> - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <RfqsTable promises={promises} rfqType={rfqType} /> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/budgetary-tech-sales-hull/page.tsx b/app/[lng]/engineering/(engineering)/budgetary-tech-sales-hull/page.tsx deleted file mode 100644 index b1be29db..00000000 --- a/app/[lng]/engineering/(engineering)/budgetary-tech-sales-hull/page.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { searchParamsHullCache } from "@/lib/techsales-rfq/validations" -import { getTechSalesHullRfqsWithJoin } from "@/lib/techsales-rfq/service" -import { getValidFilters } from "@/lib/data-table" -import { Shell } from "@/components/shell" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { RFQListTable } from "@/lib/techsales-rfq/table/rfq-table" -import { type SearchParams } from "@/types/table" -import * as React from "react" - -interface HullRfqPageProps { - searchParams: Promise<SearchParams> -} - -export default async function HullRfqPage(props: HullRfqPageProps) { - // searchParams를 await하여 resolve - const searchParams = await props.searchParams - - // 해양 HULL용 파라미터 파싱 - const search = searchParamsHullCache.parse(searchParams); - const validFilters = getValidFilters(search.filters); - - // 기술영업 해양 Hull RFQ 데이터를 Promise.all로 감싸서 전달 - const promises = Promise.all([ - getTechSalesHullRfqsWithJoin({ - ...search, // 모든 파라미터 전달 (page, perPage, sort, basicFilters, filters 등) - filters: validFilters, // 고급 필터를 명시적으로 오버라이드 (파싱된 버전) - }) - ]) - - return ( - <Shell variant="fullscreen" className="h-full"> {/* fullscreen variant 사용 */} - {/* 고정 헤더 영역 */} - <div className="flex-shrink-0"> - <div className="flex items-center justify-between"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - 기술영업-해양 Hull RFQ - </h2> - </div> - </div> - </div> - - {/* 테이블 영역 - 남은 공간 모두 차지 */} - <div className="flex-1 min-h-0"> - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={8} - searchableColumnCount={2} - filterableColumnCount={3} - cellWidths={["10rem", "15rem", "12rem", "12rem", "8rem", "8rem", "10rem", "8rem"]} - shrinkZero - /> - } - > - <RFQListTable promises={promises} className="h-full" rfqType="HULL" /> - </React.Suspense> - </div> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/budgetary-tech-sales-ship/page.tsx b/app/[lng]/engineering/(engineering)/budgetary-tech-sales-ship/page.tsx deleted file mode 100644 index b7bf9d15..00000000 --- a/app/[lng]/engineering/(engineering)/budgetary-tech-sales-ship/page.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { searchParamsShipCache } from "@/lib/techsales-rfq/validations" -import { getTechSalesShipRfqsWithJoin } from "@/lib/techsales-rfq/service" -import { getValidFilters } from "@/lib/data-table" -import { Shell } from "@/components/shell" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { RFQListTable } from "@/lib/techsales-rfq/table/rfq-table" -import { type SearchParams } from "@/types/table" -import * as React from "react" - -interface RfqPageProps { - searchParams: Promise<SearchParams> -} - -export default async function RfqPage(props: RfqPageProps) { - // searchParams를 await하여 resolve - const searchParams = await props.searchParams - - // 조선용 파라미터 파싱 - const search = searchParamsShipCache.parse(searchParams); - const validFilters = getValidFilters(search.filters); - - // 기술영업 조선 RFQ 데이터를 Promise.all로 감싸서 전달 - const promises = Promise.all([ - getTechSalesShipRfqsWithJoin({ - ...search, // 모든 파라미터 전달 (page, perPage, sort, basicFilters, filters 등) - filters: validFilters, // 고급 필터를 명시적으로 오버라이드 (파싱된 버전) - }) - ]) - - return ( - <Shell variant="fullscreen" className="h-full"> {/* fullscreen variant 사용 */} - {/* 고정 헤더 영역 */} - <div className="flex-shrink-0"> - <div className="flex items-center justify-between"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - 기술영업-조선 RFQ - </h2> - </div> - </div> - </div> - - {/* 테이블 영역 - 남은 공간 모두 차지 */} - <div className="flex-1 min-h-0"> - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={8} - searchableColumnCount={2} - filterableColumnCount={3} - cellWidths={["10rem", "15rem", "12rem", "12rem", "8rem", "8rem", "10rem", "8rem"]} - shrinkZero - /> - } - > - <RFQListTable promises={promises} className="h-full" rfqType="SHIP" /> - </React.Suspense> - </div> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/budgetary-tech-sales-top/page.tsx b/app/[lng]/engineering/(engineering)/budgetary-tech-sales-top/page.tsx deleted file mode 100644 index f84a9794..00000000 --- a/app/[lng]/engineering/(engineering)/budgetary-tech-sales-top/page.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { searchParamsTopCache } from "@/lib/techsales-rfq/validations" -import { getTechSalesTopRfqsWithJoin } from "@/lib/techsales-rfq/service" -import { getValidFilters } from "@/lib/data-table" -import { Shell } from "@/components/shell" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { RFQListTable } from "@/lib/techsales-rfq/table/rfq-table" -import { type SearchParams } from "@/types/table" -import * as React from "react" - -interface HullRfqPageProps { - searchParams: Promise<SearchParams> -} - -export default async function HullRfqPage(props: HullRfqPageProps) { - // searchParams를 await하여 resolve - const searchParams = await props.searchParams - - // 해양 TOP용 파라미터 파싱 - const search = searchParamsTopCache.parse(searchParams); - const validFilters = getValidFilters(search.filters); - - // 기술영업 해양 TOP RFQ 데이터를 Promise.all로 감싸서 전달 - const promises = Promise.all([ - getTechSalesTopRfqsWithJoin({ - ...search, // 모든 파라미터 전달 (page, perPage, sort, basicFilters, filters 등) - filters: validFilters, // 고급 필터를 명시적으로 오버라이드 (파싱된 버전) - }) - ]) - - return ( - <Shell variant="fullscreen" className="h-full"> {/* fullscreen variant 사용 */} - {/* 고정 헤더 영역 */} - <div className="flex-shrink-0"> - <div className="flex items-center justify-between"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - 기술영업-해양 TOP RFQ - </h2> - </div> - </div> - </div> - - {/* 테이블 영역 - 남은 공간 모두 차지 */} - <div className="flex-1 min-h-0"> - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={8} - searchableColumnCount={2} - filterableColumnCount={3} - cellWidths={["10rem", "15rem", "12rem", "12rem", "8rem", "8rem", "10rem", "8rem"]} - shrinkZero - /> - } - > - <RFQListTable promises={promises} className="h-full" rfqType="TOP" /> - </React.Suspense> - </div> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/budgetary/[id]/cbe/page.tsx b/app/[lng]/engineering/(engineering)/budgetary/[id]/cbe/page.tsx deleted file mode 100644 index 956facd3..00000000 --- a/app/[lng]/engineering/(engineering)/budgetary/[id]/cbe/page.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { getCBE, getTBE } from "@/lib/rfqs/service" -import { searchParamsCBECache, } from "@/lib/rfqs/validations" -import { TbeTable } from "@/lib/rfqs/tbe-table/tbe-table" -import { CbeTable } from "@/lib/rfqs/cbe-table/cbe-table" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> -} - -export default async function RfqTBEPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsCBECache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getCBE({ - ...search, - filters: validFilters, - }, - idAsNumber) - ]) - - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - Commercial Bid Evaluation - </h3> - <p className="text-sm text-muted-foreground"> - 초대된 협력업체에게 CBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 CBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. - </p> - </div> - <Separator /> - <div> - <CbeTable promises={promises} rfqId={idAsNumber}/> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/budgetary/[id]/layout.tsx b/app/[lng]/engineering/(engineering)/budgetary/[id]/layout.tsx deleted file mode 100644 index b0711c66..00000000 --- a/app/[lng]/engineering/(engineering)/budgetary/[id]/layout.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { Metadata } from "next" -import Link from "next/link" -import { ArrowLeft } from "lucide-react" -import { Separator } from "@/components/ui/separator" -import { SidebarNav } from "@/components/layout/sidebar-nav" -import { RfqViewWithItems } from "@/db/schema/rfq" -import { findRfqById } from "@/lib/rfqs/service" -import { formatDate } from "@/lib/utils" -import { Button } from "@/components/ui/button" - -export const metadata: Metadata = { - title: "Vendor Detail", -} - -export default async function RfqLayout({ - children, - params, -}: { - children: React.ReactNode - params: { lng: string, id: string } -}) { - - // 1) URL 파라미터에서 id 추출, Number로 변환 - const resolvedParams = await params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - // 2) DB에서 해당 협력업체 정보 조회 - const rfq: RfqViewWithItems | null = await findRfqById(idAsNumber) - - // 3) 사이드바 메뉴 - const sidebarNavItems = [ - { - title: "Matched Vendors", - href: `/${lng}/evcp/budgetary/${id}`, - }, - { - title: "TBE", - href: `/${lng}/evcp/budgetary/${id}/tbe`, - }, - { - title: "CBE", - href: `/${lng}/evcp/budgetary/${id}/cbe`, - }, - ] - - return ( - <> - <div className="container py-6"> - <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow"> - <div className="hidden space-y-6 p-10 pb-16 md:block"> - {/* RFQ 목록으로 돌아가는 링크 추가 */} - <div className="flex items-center justify-end mb-4"> - <Link href={`/${lng}/evcp/budgetary`} passHref> - <Button variant="ghost" className="flex items-center text-primary hover:text-primary/80 transition-colors p-0 h-auto"> - <ArrowLeft className="mr-1 h-4 w-4" /> - <span>Budgetary Quote 목록으로 돌아가기</span> - </Button> - </Link> - </div> - - <div className="space-y-0.5"> - {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */} - <h2 className="text-2xl font-bold tracking-tight"> - {rfq - ? `${rfq.projectCode ?? ""} ${rfq.rfqCode ?? ""} 관리` - : "Loading RFQ..."} - </h2> - - <p className="text-muted-foreground"> - {rfq - ? `${rfq.description ?? ""} ${rfq.lines.map(line => line.itemCode).join(", ")}` - : ""} - </p> - <h3>Due Date:{rfq && rfq?.dueDate && <strong>{formatDate(rfq?.dueDate)}</strong>}</h3> - </div> - <Separator className="my-6" /> - <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0"> - <aside className="lg:w-64 flex-shrink-0"> - <SidebarNav items={sidebarNavItems} /> - </aside> - <div className="lg:w-[calc(100%-16rem)] overflow-auto">{children}</div> - </div> - </div> - </section> - </div> - </> - ) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/budgetary/[id]/page.tsx b/app/[lng]/engineering/(engineering)/budgetary/[id]/page.tsx deleted file mode 100644 index dd9df563..00000000 --- a/app/[lng]/engineering/(engineering)/budgetary/[id]/page.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { getMatchedVendors } from "@/lib/rfqs/service" -import { searchParamsMatchedVCache } from "@/lib/rfqs/validations" -import { MatchedVendorsTable } from "@/lib/rfqs/vendor-table/vendors-table" -import { RfqType } from "@/lib/rfqs/validations" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> - rfqType: RfqType -} - -export default async function RfqPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - const id = resolvedParams.id - const rfqType = props.rfqType ?? RfqType.BUDGETARY // rfqType이 없으면 BUDGETARY로 설정 - - const idAsNumber = Number(id) - - // 2) SearchParams 파싱 (Zod) - const searchParams = await props.searchParams - const search = searchParamsMatchedVCache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getMatchedVendors({ - ...search, - filters: validFilters, - }, - idAsNumber) - ]) - - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - Vendors - </h3> - <p className="text-sm text-muted-foreground"> - 등록된 협력업체 중에서 이 RFQ 아이템에 매칭되는 업체를 보여줍니다. <br/>"발행하기" 버튼을 통해 RFQ를 전송하면 첨부파일과 함께 RFQ 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. - </p> - </div> - <Separator /> - <div> - <MatchedVendorsTable promises={promises} rfqId={idAsNumber} rfqType={rfqType}/> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/budgetary/[id]/tbe/page.tsx b/app/[lng]/engineering/(engineering)/budgetary/[id]/tbe/page.tsx deleted file mode 100644 index ec894e1c..00000000 --- a/app/[lng]/engineering/(engineering)/budgetary/[id]/tbe/page.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { getTBE } from "@/lib/rfqs/service" -import { searchParamsTBECache } from "@/lib/rfqs/validations" -import { TbeTable } from "@/lib/rfqs/tbe-table/tbe-table" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> -} - -export default async function RfqTBEPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsTBECache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getTBE({ - ...search, - filters: validFilters, - }, - idAsNumber) - ]) - - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - Technical Bid Evaluation - </h3> - <p className="text-sm text-muted-foreground"> - 초대된 협력업체에게 TBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. - </p> - </div> - <Separator /> - <div> - <TbeTable promises={promises} rfqId={idAsNumber}/> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/budgetary/page.tsx b/app/[lng]/engineering/(engineering)/budgetary/page.tsx deleted file mode 100644 index 04550353..00000000 --- a/app/[lng]/engineering/(engineering)/budgetary/page.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import * as React from "react" -import { type SearchParams } from "@/types/table" - -import { getValidFilters } from "@/lib/data-table" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" - -import { searchParamsCache } from "@/lib/rfqs/validations" -import { getRfqs, getRfqStatusCounts } from "@/lib/rfqs/service" -import { RfqsTable } from "@/lib/rfqs/table/rfqs-table" -import { getAllItems } from "@/lib/items/service" -import { RfqType } from "@/lib/rfqs/validations" -import { Ellipsis } from "lucide-react" - -interface RfqPageProps { - searchParams: Promise<SearchParams>; - rfqType: RfqType; - title: string; - description: string; -} - -export default async function RfqPage({ - searchParams, - rfqType = RfqType.BUDGETARY, - title = "Budgetary Quote", - description = "Budgetary Quote를 등록하여 요청 및 응답을 관리할 수 있습니다." -}: RfqPageProps) { - const search = searchParamsCache.parse(await searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getRfqs({ - ...search, - filters: validFilters, - rfqType // 전달받은 rfqType 사용 - }), - getRfqStatusCounts(rfqType), // rfqType 전달 - getAllItems() - ]) - - return ( - <Shell className="gap-2"> - <div className="flex items-center justify-between space-y-2"> - <div className="flex items-center justify-between space-y-2"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - {title} - </h2> - <p className="text-muted-foreground"> - {description} - 기본적인 정보와 RFQ를 위한 아이템 등록 및 첨부를 한 후, - <span className="inline-flex items-center whitespace-nowrap"> - <Ellipsis className="size-3" /> - <span className="ml-1">버튼</span> - </span> 을 클릭하면 "Proceed"를 통해 상세화면으로 이동하여 진행할 수 있습니다. - </p> - </div> - </div> - </div> - - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> - {/* <DateRangePicker - triggerSize="sm" - triggerClassName="ml-auto w-56 sm:w-60" - align="end" - shallow={false} - /> */} - </React.Suspense> - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <RfqsTable promises={promises} rfqType={rfqType} /> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/cbe-tech/page.tsx b/app/[lng]/engineering/(engineering)/cbe-tech/page.tsx deleted file mode 100644 index 4dadc58f..00000000 --- a/app/[lng]/engineering/(engineering)/cbe-tech/page.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { getAllCBE } from "@/lib/rfqs-tech/service" -import { searchParamsCBECache } from "@/lib/rfqs-tech/validations" -import { AllCbeTable } from "@/lib/cbe-tech/table/cbe-table" -import * as React from "react" -import { Shell } from "@/components/shell" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" - -interface IndexPageProps { - params: { - lng: string - } - searchParams: Promise<SearchParams> -} - -export default async function RfqCBEPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - - // URL 쿼리 파라미터에서 타입 추출 - const searchParams = await props.searchParams - - // SearchParams 파싱 (Zod) - const search = searchParamsCBECache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - // 현재 선택된 타입의 데이터 로드 - const promises = Promise.all([ - getAllCBE({ - ...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"> - 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]/engineering/(engineering)/dashboard/page.tsx b/app/[lng]/engineering/(engineering)/dashboard/page.tsx deleted file mode 100644 index 1d61dc16..00000000 --- a/app/[lng]/engineering/(engineering)/dashboard/page.tsx +++ /dev/null @@ -1,17 +0,0 @@ -// 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]/engineering/(engineering)/email-template/[name]/page.tsx b/app/[lng]/engineering/(engineering)/email-template/[name]/page.tsx deleted file mode 100644 index cccc10fc..00000000 --- a/app/[lng]/engineering/(engineering)/email-template/[name]/page.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { getTemplateAction } from '@/lib/mail/service';
-import MailTemplateEditorClient from '@/components/mail/mail-template-editor-client';
-
-interface EditMailTemplatePageProps {
- params: {
- name: string;
- lng: string;
- };
-}
-
-export default async function EditMailTemplatePage({ params }: EditMailTemplatePageProps) {
- const { name: templateName } = await params;
-
- // 서버에서 초기 템플릿 데이터 가져오기
- const result = await getTemplateAction(templateName);
- const initialTemplate = result.success ? result.data : null;
-
- return (
- <div className="container mx-auto p-6">
- <MailTemplateEditorClient
- templateName={templateName}
- initialTemplate={initialTemplate}
- />
- </div>
- );
-}
diff --git a/app/[lng]/engineering/(engineering)/email-template/page.tsx b/app/[lng]/engineering/(engineering)/email-template/page.tsx deleted file mode 100644 index 1ef3de6c..00000000 --- a/app/[lng]/engineering/(engineering)/email-template/page.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { getTemplatesAction } from '@/lib/mail/service';
-import MailTemplatesClient from '@/components/mail/mail-templates-client';
-
-export default async function MailTemplatesPage() {
- // 서버에서 초기 데이터 가져오기
- const result = await getTemplatesAction();
- const initialData = result.success ? result.data : [];
-
- return (
- <div className="container mx-auto p-6">
- <div className="mb-8">
- <h1 className="text-3xl font-bold text-gray-900 mb-2">메일 템플릿 관리</h1>
- <p className="text-gray-600">이메일 템플릿을 관리할 수 있습니다.</p>
- </div>
-
- <MailTemplatesClient initialData={initialData} />
- </div>
- );
-}
diff --git a/app/[lng]/engineering/(engineering)/equip-class/page.tsx b/app/[lng]/engineering/(engineering)/equip-class/page.tsx deleted file mode 100644 index cfa8f133..00000000 --- a/app/[lng]/engineering/(engineering)/equip-class/page.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import * as React from "react" -import { type SearchParams } from "@/types/table" - -import { getValidFilters } from "@/lib/data-table" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" -import { searchParamsCache } from "@/lib/equip-class/validation" -import { FormListsTable } from "@/lib/form-list/table/formLists-table" -import { getTagClassists } from "@/lib/equip-class/service" -import { EquipClassTable } from "@/lib/equip-class/table/equipClass-table" - - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsCache.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getTagClassists({ - ...search, - filters: validFilters, - }), - - ]) - - return ( - <Shell className="gap-2"> - <div className="flex items-center justify-between space-y-2"> - <div className="flex items-center justify-between space-y-2"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - 객체 클래스 목록 from S-EDP - </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 - /> - } - > - <EquipClassTable promises={promises} /> - </React.Suspense> - </Shell> - ) -} diff --git a/app/[lng]/engineering/(engineering)/esg-check-list/page.tsx b/app/[lng]/engineering/(engineering)/esg-check-list/page.tsx deleted file mode 100644 index 515751d5..00000000 --- a/app/[lng]/engineering/(engineering)/esg-check-list/page.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import * as React from "react" -import { type SearchParams } from "@/types/table" - -import { getValidFilters } from "@/lib/data-table" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" -import { getEsgEvaluations } from "@/lib/esg-check-list/service" -import { getEsgEvaluationsSchema } from "@/lib/esg-check-list/validation" -import { EsgEvaluationsTable } from "@/lib/esg-check-list/table/esg-table" - - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = getEsgEvaluationsSchema.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getEsgEvaluations({ - ...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"> - ESG 자가진단표 - </h2> - <p className="text-muted-foreground"> - 협력업체 평가에 사용되는 ESG 자가진단표를 관리{" "} - {/* <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 - /> - } - > - <EsgEvaluationsTable promises={promises} /> - </React.Suspense> - </Shell> - ) -} diff --git a/app/[lng]/engineering/(engineering)/evaluation-check-list/page.tsx b/app/[lng]/engineering/(engineering)/evaluation-check-list/page.tsx deleted file mode 100644 index a660c492..00000000 --- a/app/[lng]/engineering/(engineering)/evaluation-check-list/page.tsx +++ /dev/null @@ -1,81 +0,0 @@ -/* IMPORT */
-import { DataTableSkeleton } from '@/components/data-table/data-table-skeleton';
-import { getRegEvalCriteria } from '@/lib/evaluation-criteria/service';
-import { getValidFilters } from '@/lib/data-table';
-import RegEvalCriteriaTable from '@/lib/evaluation-criteria/table/reg-eval-criteria-table';
-import { searchParamsCache } from '@/lib/evaluation-criteria/validations';
-import { Shell } from '@/components/shell';
-import { Skeleton } from '@/components/ui/skeleton';
-import { Suspense } from 'react';
-import { type SearchParams } from '@/types/table';
-
-// ----------------------------------------------------------------------------------------------------
-
-/* TYPES */
-interface EvaluationCriteriaPageProps {
- searchParams: Promise<SearchParams>
-}
-
-// ----------------------------------------------------------------------------------------------------
-
-/* REGULAR EVALUATION CRITERIA PAGE */
-async function EvaluationCriteriaPage(props: EvaluationCriteriaPageProps) {
- const searchParams = await props.searchParams;
- const search = searchParamsCache.parse(searchParams);
- const validFilters = getValidFilters(search.filters);
- const promises = Promise.all([
- getRegEvalCriteria({
- ...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>
-
- <Suspense fallback={<Skeleton className="h-7 w-52" />}>
- {/* <DateRangePicker
- triggerSize="sm"
- triggerClassName="ml-auto w-56 sm:w-60"
- align="end"
- shallow={false}
- /> */}
- </Suspense>
- <Suspense
- fallback={
- <DataTableSkeleton
- columnCount={11}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <RegEvalCriteriaTable promises={promises} />
- </Suspense>
- </Shell>
- )
-}
-
-// ----------------------------------------------------------------------------------------------------
-
-/* EXPORT */
-export default EvaluationCriteriaPage;
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/evaluation-target-list/page.tsx b/app/[lng]/engineering/(engineering)/evaluation-target-list/page.tsx deleted file mode 100644 index 088ae75b..00000000 --- a/app/[lng]/engineering/(engineering)/evaluation-target-list/page.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import * as React from "react" -import { Metadata } from "next" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { Shell } from "@/components/shell" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { HelpCircle } from "lucide-react" -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover" -import { Button } from "@/components/ui/button" -import { Badge } from "@/components/ui/badge" - -import { getDefaultEvaluationYear, searchParamsEvaluationTargetsCache } from "@/lib/evaluation-target-list/validation" -import { getEvaluationTargets } from "@/lib/evaluation-target-list/service" -import { EvaluationTargetsTable } from "@/lib/evaluation-target-list/table/evaluation-target-table" - -export const metadata: Metadata = { - title: "협력업체 평가 대상 확정", - description: "협력업체 평가 대상을 확정하고 담당자를 지정합니다.", -} - -interface EvaluationTargetsPageProps { - searchParams: Promise<SearchParams> -} - - - -export default async function EvaluationTargetsPage(props: EvaluationTargetsPageProps) { - const searchParams = await props.searchParams - const search = searchParamsEvaluationTargetsCache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - // 기본 필터 처리 (통일된 이름 사용) - let basicFilters = [] - if (search.basicFilters && search.basicFilters.length > 0) { - basicFilters = search.basicFilters - console.log("Using search.basicFilters:", basicFilters); - } else { - console.log("No basic filters found"); - } - - // 모든 필터를 합쳐서 처리 - const allFilters = [...validFilters, ...basicFilters] - - // 조인 연산자도 통일된 이름 사용 - const joinOperator = search.basicJoinOperator || search.joinOperator || 'and'; - - // 현재 평가년도 (필터에서 가져오거나 기본값 사용) - const currentEvaluationYear = search.evaluationYear || getDefaultEvaluationYear() - - // Promise.all로 감싸서 전달 - const promises = Promise.all([ - getEvaluationTargets({ - ...search, - filters: allFilters, - joinOperator, - }) - ]) - - return ( - <Shell className="gap-4"> - {/* 간소화된 헤더 */} - <div className="flex items-center justify-between space-y-2"> - <div className="flex items-center justify-between space-y-2"> - <div className="flex items-center gap-2"> - <h2 className="text-2xl font-bold tracking-tight"> - 협력업체 평가 대상 확정 - </h2> - <Badge variant="outline" className="text-sm"> - {currentEvaluationYear}년도 - </Badge> - - </div> - </div> - </div> - - {/* 메인 테이블 (통계는 테이블 내부로 이동) */} - <React.Suspense - key={JSON.stringify(searchParams)} // URL 파라미터가 변경될 때마다 강제 리렌더링 - fallback={ - <DataTableSkeleton - columnCount={12} - searchableColumnCount={2} - filterableColumnCount={6} - cellWidths={[ - "3rem", // checkbox - "5rem", // 평가년도 - "4rem", // 구분 - "8rem", // 벤더코드 - "12rem", // 벤더명 - "4rem", // 내외자 - "6rem", // 자재구분 - "5rem", // 상태 - "5rem", // 의견일치 - "8rem", // 담당자현황 - "10rem", // 관리자의견 - "8rem" // actions - ]} - shrinkZero - /> - } - > - {currentEvaluationYear && - <EvaluationTargetsTable - promises={promises} - evaluationYear={currentEvaluationYear} - /> -} - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/evaluation/page.tsx b/app/[lng]/engineering/(engineering)/evaluation/page.tsx deleted file mode 100644 index ead61077..00000000 --- a/app/[lng]/engineering/(engineering)/evaluation/page.tsx +++ /dev/null @@ -1,181 +0,0 @@ -// ================================================================ -// 4. PERIODIC EVALUATIONS PAGE -// ================================================================ - -import * as React from "react" -import { Metadata } from "next" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { Shell } from "@/components/shell" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { HelpCircle } from "lucide-react" -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover" -import { Button } from "@/components/ui/button" -import { Badge } from "@/components/ui/badge" -import { PeriodicEvaluationsTable } from "@/lib/evaluation/table/evaluation-table" -import { getPeriodicEvaluations } from "@/lib/evaluation/service" -import { searchParamsEvaluationsCache } from "@/lib/evaluation/validation" - -export const metadata: Metadata = { - title: "협력업체 정기평가", - description: "협력업체 정기평가 진행 현황을 관리합니다.", -} - -interface PeriodicEvaluationsPageProps { - searchParams: Promise<SearchParams> -} - -// 프로세스 안내 팝오버 컴포넌트 -function ProcessGuidePopover() { - return ( - <Popover> - <PopoverTrigger asChild> - <Button variant="ghost" size="icon" className="h-6 w-6"> - <HelpCircle className="h-4 w-4 text-muted-foreground" /> - </Button> - </PopoverTrigger> - <PopoverContent className="w-96" align="start"> - <div className="space-y-3"> - <div className="space-y-1"> - <h4 className="font-medium">정기평가 프로세스</h4> - <p className="text-sm text-muted-foreground"> - 확정된 평가 대상 업체들에 대한 정기평가 절차입니다. - </p> - </div> - <div className="space-y-3 text-sm"> - <div className="flex gap-3"> - <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600"> - 1 - </div> - <div> - <p className="font-medium">평가 대상 확정</p> - <p className="text-muted-foreground">평가 대상으로 확정된 업체들의 정기평가가 자동 생성됩니다.</p> - </div> - </div> - <div className="flex gap-3"> - <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600"> - 2 - </div> - <div> - <p className="font-medium">업체 자료 제출</p> - <p className="text-muted-foreground">각 업체는 평가에 필요한 자료를 제출 마감일까지 제출해야 합니다.</p> - </div> - </div> - <div className="flex gap-3"> - <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600"> - 3 - </div> - <div> - <p className="font-medium">평가자 검토</p> - <p className="text-muted-foreground">지정된 평가자들이 평가표를 기반으로 점수를 매기고 검토합니다.</p> - </div> - </div> - <div className="flex gap-3"> - <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600"> - 4 - </div> - <div> - <p className="font-medium">최종 확정</p> - <p className="text-muted-foreground">모든 평가가 완료되면 최종 점수와 등급이 확정됩니다.</p> - </div> - </div> - </div> - </div> - </PopoverContent> - </Popover> - ) -} - -// TODO: 이 함수들은 실제 서비스 파일에서 구현해야 함 -function getDefaultEvaluationYear() { - return new Date().getFullYear() -} - - - -export default async function PeriodicEvaluationsPage(props: PeriodicEvaluationsPageProps) { - const searchParams = await props.searchParams - const search = searchParamsEvaluationsCache.parse(searchParams) - const validFilters = getValidFilters(search.filters || []) - - // 기본 필터 처리 - let basicFilters = [] - if (search.basicFilters && search.basicFilters.length > 0) { - basicFilters = search.basicFilters - } - - // 모든 필터를 합쳐서 처리 - const allFilters = [...validFilters, ...basicFilters] - - // 조인 연산자 - const joinOperator = search.basicJoinOperator || search.joinOperator || 'and'; - - // 현재 평가년도 - const currentEvaluationYear = search.evaluationYear || getDefaultEvaluationYear() - - // Promise.all로 감싸서 전달 - const promises = Promise.all([ - getPeriodicEvaluations({ - ...search, - filters: allFilters, - joinOperator, - }) - ]) - - return ( - <Shell className="gap-4"> - {/* 헤더 */} - <div className="flex items-center justify-between space-y-2"> - <div className="flex items-center justify-between space-y-2"> - <div className="flex items-center gap-2"> - <h2 className="text-2xl font-bold tracking-tight"> - 협력업체 정기평가 - </h2> - <Badge variant="outline" className="text-sm"> - {currentEvaluationYear}년도 - </Badge> - </div> - </div> - </div> - - {/* 메인 테이블 */} - <React.Suspense - key={JSON.stringify(searchParams)} - fallback={ - <DataTableSkeleton - columnCount={15} - searchableColumnCount={2} - filterableColumnCount={8} - cellWidths={[ - "3rem", // checkbox - "5rem", // 평가년도 - "5rem", // 평가기간 - "4rem", // 구분 - "8rem", // 벤더코드 - "12rem", // 벤더명 - "4rem", // 내외자 - "6rem", // 자재구분 - "5rem", // 문서제출 - "4rem", // 제출일 - "4rem", // 마감일 - "4rem", // 총점 - "4rem", // 등급 - "5rem", // 진행상태 - "8rem" // actions - ]} - shrinkZero - /> - } - > - <PeriodicEvaluationsTable - promises={promises} - evaluationYear={currentEvaluationYear} - /> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/incoterms/page.tsx b/app/[lng]/engineering/(engineering)/incoterms/page.tsx deleted file mode 100644 index 57a19009..00000000 --- a/app/[lng]/engineering/(engineering)/incoterms/page.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import * as React from "react"; -import { type SearchParams } from "@/types/table"; -import { getValidFilters } from "@/lib/data-table"; -import { Shell } from "@/components/shell"; -import { Skeleton } from "@/components/ui/skeleton"; -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"; -import { SearchParamsCache } from "@/lib/incoterms/validations"; -import { getIncoterms } from "@/lib/incoterms/service"; -import { IncotermsTable } from "@/lib/incoterms/table/incoterms-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([ - getIncoterms({ - ...search, - filters: validFilters, - }), - ]); - - return ( - <Shell className="gap-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"> - 인코텀즈(Incoterms)를 등록, 수정, 삭제할 수 있습니다. - </p> - </div> - </div> - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}></React.Suspense> - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={4} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "8rem"]} - shrinkZero - /> - } - > - <IncotermsTable promises={promises} /> - </React.Suspense> - </Shell> - ); -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/items-tech/layout.tsx b/app/[lng]/engineering/(engineering)/items-tech/layout.tsx deleted file mode 100644 index d375059b..00000000 --- a/app/[lng]/engineering/(engineering)/items-tech/layout.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import * as React from "react"
-import { ItemTechContainer } from "@/components/items-tech/item-tech-container"
-import { Shell } from "@/components/shell"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-
-// Layout 컴포넌트는 서버 컴포넌트입니다
-export default function ItemsShipLayout({
- children,
-}: {
- children: React.ReactNode
-}) {
- // 아이템 타입 정의
- const itemTypes = [
- { id: "ship", name: "조선 아이템" },
- { id: "top", name: "해양 TOP" },
- { id: "hull", name: "해양 HULL" },
- ]
-
- return (
- <Shell className="gap-4">
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <ItemTechContainer itemTypes={itemTypes}>
- {children}
- </ItemTechContainer>
- </React.Suspense>
- </Shell>
- )
-}
diff --git a/app/[lng]/engineering/(engineering)/items-tech/page.tsx b/app/[lng]/engineering/(engineering)/items-tech/page.tsx deleted file mode 100644 index 55ac9c63..00000000 --- a/app/[lng]/engineering/(engineering)/items-tech/page.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import * as React from "react"
-import { type SearchParams } from "@/types/table"
-
-import { getValidFilters } from "@/lib/data-table"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-import { shipbuildingSearchParamsCache, offshoreTopSearchParamsCache, offshoreHullSearchParamsCache } from "@/lib/items-tech/validations"
-import { getShipbuildingItems, getOffshoreTopItems, getOffshoreHullItems } from "@/lib/items-tech/service"
-import { OffshoreTopTable } from "@/lib/items-tech/table/top/offshore-top-table"
-import { OffshoreHullTable } from "@/lib/items-tech/table/hull/offshore-hull-table"
-
-// 대소문자 문제 해결 - 실제 파일명에 맞게 import
-import { ItemsShipTable } from "@/lib/items-tech/table/ship/Items-ship-table"
-
-interface IndexPageProps {
- searchParams: Promise<SearchParams>
-}
-
-export default async function IndexPage({ searchParams }: IndexPageProps) {
- const params = await searchParams
- const shipbuildingSearch = shipbuildingSearchParamsCache.parse(params)
- const offshoreTopSearch = offshoreTopSearchParamsCache.parse(params)
- const offshoreHullSearch = offshoreHullSearchParamsCache.parse(params)
- const validShipbuildingFilters = getValidFilters(shipbuildingSearch.filters || [])
- const validOffshoreTopFilters = getValidFilters(offshoreTopSearch.filters || [])
- const validOffshoreHullFilters = getValidFilters(offshoreHullSearch.filters || [])
-
-
- // URL에서 아이템 타입 가져오기
- const itemType = params.type || "ship"
-
- return (
- <div>
- {itemType === "ship" && (
- <ItemsShipTable
- promises={Promise.all([
- getShipbuildingItems({
- ...shipbuildingSearch,
- filters: validShipbuildingFilters,
- }),
- ]).then(([result]) => result)}
- />
- )}
-
- {itemType === "top" && (
- <OffshoreTopTable
- promises={Promise.all([
- getOffshoreTopItems({
- ...offshoreTopSearch,
- filters: validOffshoreTopFilters,
- }),
- ]).then(([result]) => result)}
- />
- )}
-
- {itemType === "hull" && (
- <OffshoreHullTable
- promises={Promise.all([
- getOffshoreHullItems({
- ...offshoreHullSearch,
- filters: validOffshoreHullFilters,
- }),
- ]).then(([result]) => result)}
- />
- )}
- </div>
- )
-}
diff --git a/app/[lng]/engineering/(engineering)/menu-list/page.tsx b/app/[lng]/engineering/(engineering)/menu-list/page.tsx deleted file mode 100644 index 84138320..00000000 --- a/app/[lng]/engineering/(engineering)/menu-list/page.tsx +++ /dev/null @@ -1,70 +0,0 @@ -// app/evcp/menu-list/page.tsx - -import { Suspense } from "react"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { RefreshCw, Settings } from "lucide-react"; -import { getActiveUsers, getMenuAssignments } from "@/lib/menu-list/servcie"; -import { InitializeButton } from "@/lib/menu-list/table/initialize-button"; -import { MenuListTable } from "@/lib/menu-list/table/menu-list-table"; -import { Shell } from "@/components/shell" -import * as React from "react" - -export default async function MenuListPage() { - // 초기 데이터 로드 - const [menusResult, usersResult] = await Promise.all([ - getMenuAssignments(), - getActiveUsers() - ]); - - 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={ - "" - } - > - <Card> - <CardHeader> - <CardTitle className="flex items-center gap-2"> - <Settings className="h-5 w-5" /> - 메뉴 리스트 - </CardTitle> - <CardDescription> - 시스템의 모든 메뉴와 담당자 정보를 확인할 수 있습니다. - {menusResult.data?.length > 0 && ( - <span className="ml-2 text-sm"> - 총 {menusResult.data.length}개의 메뉴 - </span> - )} - </CardDescription> - </CardHeader> - <CardContent> - <Suspense fallback={<div className="text-center py-8">로딩 중...</div>}> - <MenuListTable - initialMenus={menusResult.data || []} - initialUsers={usersResult.data || []} - /> - </Suspense> - </CardContent> - </Card> - </React.Suspense> - </Shell> - - ); -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/payment-conditions/page.tsx b/app/[lng]/engineering/(engineering)/payment-conditions/page.tsx deleted file mode 100644 index b9aedfbb..00000000 --- a/app/[lng]/engineering/(engineering)/payment-conditions/page.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import * as React from "react"; -import { type SearchParams } from "@/types/table"; -import { getValidFilters } from "@/lib/data-table"; -import { Shell } from "@/components/shell"; -import { Skeleton } from "@/components/ui/skeleton"; -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"; -import { SearchParamsCache } from "@/lib/payment-terms/validations"; -import { getPaymentTerms } from "@/lib/payment-terms/service"; -import { PaymentTermsTable } from "@/lib/payment-terms/table/payment-terms-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([ - getPaymentTerms({ - ...search, - filters: validFilters, - }), - ]); - - return ( - <Shell className="gap-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"> - 결제 조건(Payment Terms)을 등록, 수정, 삭제할 수 있습니다. - </p> - </div> - </div> - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}></React.Suspense> - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={4} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "8rem"]} - shrinkZero - /> - } - > - <PaymentTermsTable promises={promises} /> - </React.Suspense> - </Shell> - ); -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/po-rfq/page.tsx b/app/[lng]/engineering/(engineering)/po-rfq/page.tsx deleted file mode 100644 index bdeae25e..00000000 --- a/app/[lng]/engineering/(engineering)/po-rfq/page.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { getPORfqs } from "@/lib/procurement-rfqs/services" -import { searchParamsCache } from "@/lib/procurement-rfqs/validations" -import { getValidFilters } from "@/lib/data-table" -import { Shell } from "@/components/shell" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { RFQListTable } from "@/lib/procurement-rfqs/table/rfq-table" -import { type SearchParams } from "@/types/table" -import * as React from "react" - -interface RfqPageProps { - searchParams: Promise<SearchParams> -} - -export default async function RfqPage(props: RfqPageProps) { - // searchParams를 await하여 resolve - const searchParams = await props.searchParams - - // 파라미터 파싱 - const search = searchParamsCache.parse(searchParams); - const validFilters = getValidFilters(search.filters); - - // RFQ 서버는 기본필터와 고급필터를 분리해서 받으므로 그대로 전달 - const promises = Promise.all([ - getPORfqs({ - ...search, // 모든 파라미터 전달 (page, perPage, sort, basicFilters, filters 등) - filters: validFilters, // 고급 필터를 명시적으로 오버라이드 (파싱된 버전) - }) - ]) - - return ( - <Shell variant="fullscreen" className="h-full"> {/* fullscreen variant 사용 */} - {/* 고정 헤더 영역 */} - <div className="flex-shrink-0"> - <div className="flex items-center justify-between"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - 발주용 견적 - </h2> - </div> - </div> - </div> - - {/* 테이블 영역 - 남은 공간 모두 차지 */} - <div className="flex-1 min-h-0"> - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={8} - searchableColumnCount={2} - filterableColumnCount={3} - cellWidths={["10rem", "15rem", "12rem", "12rem", "8rem", "8rem", "10rem", "8rem"]} - shrinkZero - /> - } - > - <RFQListTable promises={promises} className="h-full" /> - </React.Suspense> - </div> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/po/page.tsx b/app/[lng]/engineering/(engineering)/po/page.tsx deleted file mode 100644 index 7868e231..00000000 --- a/app/[lng]/engineering/(engineering)/po/page.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import * as React from "react" -import { type SearchParams } from "@/types/table" - -import { getValidFilters } from "@/lib/data-table" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" -import { getPOs } from "@/lib/po/service" -import { searchParamsCache } from "@/lib/po/validations" -import { PoListsTable } from "@/lib/po/table/po-table" - - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsCache.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getPOs({ - ...search, - filters: validFilters, - }), - ]) - - return ( - <Shell className="gap-2"> - - <div className="flex items-center justify-between space-y-2"> - <div className="flex items-center justify-between space-y-2"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - PO 확인 및 전자서명 - </h2> - <p className="text-muted-foreground"> - 기간계 시스템으로부터 PO를 확인하고 협력업체에게 전자서명을 요청할 수 있습니다. 요쳥된 전자서명의 이력 또한 확인할 수 있습니다. - - </p> - </div> - </div> - </div> - - - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> - </React.Suspense> - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <PoListsTable promises={promises} /> - </React.Suspense> - </Shell> - ) -} diff --git a/app/[lng]/engineering/(engineering)/poa/page.tsx b/app/[lng]/engineering/(engineering)/poa/page.tsx deleted file mode 100644 index dec5e05b..00000000 --- a/app/[lng]/engineering/(engineering)/poa/page.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import * as React from "react" -import { type SearchParams } from "@/types/table" - -import { getValidFilters } from "@/lib/data-table" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" -import { getChangeOrders } from "@/lib/poa/service" -import { searchParamsCache } from "@/lib/poa/validations" -import { ChangeOrderListsTable } from "@/lib/poa/table/poa-table" - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsCache.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getChangeOrders({ - ...search, - filters: validFilters, - }), - ]) - - return ( - <Shell className="gap-2"> - <div className="flex items-center justify-between space-y-2"> - <div className="flex items-center justify-between space-y-2"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - 변경 PO 확인 및 전자서명 - </h2> - <p className="text-muted-foreground"> - 발행된 PO의 변경 내역을 확인하고 관리할 수 있습니다. - </p> - </div> - </div> - </div> - - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> - </React.Suspense> - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <ChangeOrderListsTable promises={promises} /> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/pq-criteria/[id]/page.tsx b/app/[lng]/engineering/(engineering)/pq-criteria/[id]/page.tsx deleted file mode 100644 index 55b1e9df..00000000 --- a/app/[lng]/engineering/(engineering)/pq-criteria/[id]/page.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import * as React from "react" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" -import { searchParamsCache } from "@/lib/pq/validations" -import { getPQs } from "@/lib/pq/service" -import { PqsTable } from "@/lib/pq/table/pq-table" -import { ProjectSelectorWrapper } from "@/components/pq/project-select-wrapper" -import { notFound } from "next/navigation" - -interface ProjectPageProps { - params: { id: string } - searchParams: Promise<SearchParams> -} - -export default async function ProjectPage(props: ProjectPageProps) { - const resolvedParams = await props.params - const id = resolvedParams.id - - const projectId = parseInt(id, 10) - - // 유효하지 않은 projectId 확인 - if (isNaN(projectId)) { - notFound() - } - - const searchParams = await props.searchParams - const search = searchParamsCache.parse(searchParams) - - // filters가 없는 경우를 처리 - const validFilters = getValidFilters(search.filters) - - // 프로젝트별 PQ 데이터 가져오기 - const promises = Promise.all([ - getPQs({ - ...search, - filters: validFilters, - }, projectId, false) - ]) - - return ( - <Shell className="gap-2"> - <div className="flex items-center justify-between"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - Pre-Qualification Check Sheet - </h2> - <p className="text-muted-foreground"> - 협력업체 등록을 위한, 협력업체가 제출할 PQ 항목을: 프로젝트별로 관리할 수 있습니다. - </p> - </div> - <ProjectSelectorWrapper selectedProjectId={projectId} /> - </div> - - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> - {/* <DateRangePicker - triggerSize="sm" - triggerClassName="ml-auto w-56 sm:w-60" - align="end" - shallow={false} - /> */} - </React.Suspense> - - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <PqsTable promises={promises} currentProjectId={projectId}/> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/pq-criteria/page.tsx b/app/[lng]/engineering/(engineering)/pq-criteria/page.tsx deleted file mode 100644 index 7785b541..00000000 --- a/app/[lng]/engineering/(engineering)/pq-criteria/page.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import * as React from "react" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" -import { searchParamsCache } from "@/lib/pq/validations" -import { getPQs } from "@/lib/pq/service" -import { PqsTable } from "@/lib/pq/table/pq-table" -import { ProjectSelectorWrapper } from "@/components/pq/project-select-wrapper" - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsCache.parse(searchParams) - - // filters가 없는 경우를 처리 - - const validFilters = getValidFilters(search.filters) - - // onlyGeneral: true로 설정하여 일반 PQ 항목만 가져옴 - const promises = Promise.all([ - getPQs({ - ...search, - filters: validFilters, - }, null, true) - ]) - - return ( - <Shell className="gap-2"> - <div className="flex items-center justify-between"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - Pre-Qualification Check Sheet - </h2> - <p className="text-muted-foreground"> - 협력업체 등록을 위한, 협력업체가 제출할 PQ 항목을 관리할 수 있습니다. - </p> - </div> - <ProjectSelectorWrapper /> - </div> - - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> - {/* <DateRangePicker - triggerSize="sm" - triggerClassName="ml-auto w-56 sm:w-60" - align="end" - shallow={false} - /> */} - </React.Suspense> - - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <PqsTable promises={promises}/> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/pq/[vendorId]/page.tsx b/app/[lng]/engineering/(engineering)/pq/[vendorId]/page.tsx deleted file mode 100644 index 76bcfe59..00000000 --- a/app/[lng]/engineering/(engineering)/pq/[vendorId]/page.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import * as React from "react" -import { Shell } from "@/components/shell" -import { type SearchParams } from "@/types/table" -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" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" -import { Badge } from "@/components/ui/badge" - -interface IndexPageProps { - params: { - vendorId: string - } - searchParams: Promise<SearchParams> -} - -export default async function PQReviewPage(props: IndexPageProps) { - const resolvedParams = await props.params - const vendorId = Number(resolvedParams.vendorId) - - // Fetch the vendor data - const vendor: Vendor | null = await findVendorById(vendorId) - if (!vendor) return <div>Vendor not found</div> - - // Get list of all PQs (general + project-specific) for this vendor - const pqsList = await getVendorPQsList(vendorId) - - // Determine default active PQ to display - // If query param projectId exists, use that, otherwise use general PQ if available - const searchParams = await props.searchParams - const activeProjectId = searchParams.projectId ? Number(searchParams.projectId) : undefined - - // If no projectId query param, default to general PQ or first project PQ - const defaultTabId = activeProjectId ? - `project-${activeProjectId}` : - (pqsList.hasGeneralPq ? 'general' : `project-${pqsList.projectPQs[0]?.projectId}`) - - // Fetch PQ data for the active tab - let pqData; - if (activeProjectId) { - // Get project-specific PQ data - pqData = await getPQDataByVendorId(vendorId, activeProjectId) - } else { - // Get general PQ data - pqData = await getPQDataByVendorId(vendorId) - } - - return ( - <Shell className="gap-2"> - {pqsList.hasGeneralPq || pqsList.projectPQs.length > 0 ? ( - <Tabs defaultValue={defaultTabId} className="space-y-4"> - <div className="flex justify-between items-center"> - <h1 className="text-2xl font-bold"> - {vendor.vendorName} PQ Review - </h1> - - <TabsList> - {pqsList.hasGeneralPq && ( - <TabsTrigger value="general"> - General PQ <Badge variant="outline" className="ml-2">Standard</Badge> - </TabsTrigger> - )} - - {pqsList.projectPQs.map((project) => ( - <TabsTrigger key={project.projectId} value={`project-${project.projectId}`}> - {project.projectName} <Badge variant="outline" className="ml-2">{project.status}</Badge> - </TabsTrigger> - ))} - </TabsList> - </div> - - {/* Tab content for General PQ */} - {pqsList.hasGeneralPq && ( - <TabsContent value="general" className="mt-0"> - <VendorPQAdminReview - data={activeProjectId ? [] : pqData} - vendor={vendor} - projectId={undefined} - loadData={loadGeneralPQData} - pqType="general" - /> - </TabsContent> - )} - - {/* Tab content for each Project PQ */} - {pqsList.projectPQs.map((project) => ( - <TabsContent key={project.projectId} value={`project-${project.projectId}`} className="mt-0"> - <VendorPQAdminReview - data={activeProjectId === project.projectId ? pqData : []} - vendor={vendor} - projectId={project.projectId} - projectName={project.projectName} - projectStatus={project.status} - loadData={loadProjectPQAction} - pqType="project" - /> - </TabsContent> - ))} - </Tabs> - ) : ( - <div className="text-center py-10"> - <h2 className="text-xl font-medium">No PQ submissions found for this vendor</h2> - </div> - )} - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/pq/page.tsx b/app/[lng]/engineering/(engineering)/pq/page.tsx deleted file mode 100644 index 46b22b12..00000000 --- a/app/[lng]/engineering/(engineering)/pq/page.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import * as React from "react" -import { type SearchParams } from "@/types/table" - -import { getValidFilters } from "@/lib/data-table" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" - -import { getVendorsInPQ } from "@/lib/pq/service" -import { searchParamsCache } from "@/lib/vendors/validations" -import { VendorsPQReviewTable } from "@/lib/pq/pq-review-table/vendors-table" - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsCache.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getVendorsInPQ({ - ...search, - filters: validFilters, - }), - ]) - - return ( - <Shell className="gap-2"> - - <div className="flex items-center justify-between space-y-2"> - <div className="flex items-center justify-between space-y-2"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - Pre-Qualification Review - </h2> - <p className="text-muted-foreground"> - 벤더가 제출한 PQ를 확인하고 수정 요청 등을 할 수 있으며 PQ 종료 후에는 통과 여부를 결정할 수 있습니다. - - </p> - </div> - </div> - </div> - - - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> - {/* <DateRangePicker - triggerSize="sm" - triggerClassName="ml-auto w-56 sm:w-60" - align="end" - shallow={false} - /> */} - </React.Suspense> - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <VendorsPQReviewTable promises={promises} /> - </React.Suspense> - </Shell> - ) -} diff --git a/app/[lng]/engineering/(engineering)/pq_new/[vendorId]/[submissionId]/page.tsx b/app/[lng]/engineering/(engineering)/pq_new/[vendorId]/[submissionId]/page.tsx deleted file mode 100644 index 28ce3128..00000000 --- a/app/[lng]/engineering/(engineering)/pq_new/[vendorId]/[submissionId]/page.tsx +++ /dev/null @@ -1,215 +0,0 @@ -import * as React from "react" -import { Metadata } from "next" -import Link from "next/link" -import { notFound } from "next/navigation" -import { ArrowLeft } from "lucide-react" -import { Shell } from "@/components/shell" -import { Button } from "@/components/ui/button" -import { Badge } from "@/components/ui/badge" -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" -import { Separator } from "@/components/ui/separator" -import { getPQById, getPQDataByVendorId } from "@/lib/pq/service" -import { unstable_noStore as noStore } from 'next/cache' -import { PQReviewWrapper } from "@/components/pq-input/pq-review-wrapper" - -export const metadata: Metadata = { - title: "PQ 검토", - description: "협력업체의 Pre-Qualification 답변을 검토합니다.", -} - -// 페이지가 기본적으로 동적임을 나타냄 -export const dynamic = "force-dynamic" - -interface PQReviewPageProps { - params: Promise<{ - vendorId: string; - submissionId: string; - }> -} - -export default async function PQReviewPage(props: PQReviewPageProps) { - // 캐시 비활성화 - noStore() - - const params = await props.params - const vendorId = parseInt(params.vendorId, 10) - const submissionId = parseInt(params.submissionId, 10) - - try { - // PQ Submission 정보 조회 - const pqSubmission = await getPQById(submissionId, vendorId) - - // PQ 데이터 조회 (질문과 답변) - const pqData = await getPQDataByVendorId(vendorId, pqSubmission.projectId || undefined) - - // 프로젝트 정보 (프로젝트 PQ인 경우) - const projectInfo = pqSubmission.projectId ? { - id: pqSubmission.projectId, - projectCode: pqSubmission.projectCode || '', - projectName: pqSubmission.projectName || '', - status: pqSubmission.status, - submittedAt: pqSubmission.submittedAt, - } : null - - // PQ 유형 및 상태 레이블 - const typeLabel = pqSubmission.type === "GENERAL" ? "일반 PQ" : "프로젝트 PQ" - const statusLabel = getStatusLabel(pqSubmission.status) - const statusVariant = getStatusVariant(pqSubmission.status) - - // 수정 가능 여부 (SUBMITTED 상태일 때만 가능) - const canReview = pqSubmission.status === "SUBMITTED" - - return ( - <Shell className="gap-6 max-w-5xl"> - <div className="flex items-center justify-between"> - <div className="flex items-center gap-4"> - <Button variant="outline" size="sm" asChild> - <Link href="/evcp/pq_new"> - <ArrowLeft className="w-4 h-4 mr-2" /> - 목록으로 - </Link> - </Button> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - {pqSubmission.vendorName} - {typeLabel} - </h2> - <div className="flex items-center gap-2 mt-1"> - <Badge variant={statusVariant}>{statusLabel}</Badge> - {projectInfo && ( - <span className="text-muted-foreground"> - {projectInfo.projectName} ({projectInfo.projectCode}) - </span> - )} - </div> - </div> - </div> - </div> - - {/* 상태별 알림 */} - {pqSubmission.status === "SUBMITTED" && ( - <Alert> - <AlertTitle>제출 완료</AlertTitle> - <AlertDescription> - 협력업체가 {formatDate(pqSubmission.submittedAt)}에 PQ를 제출했습니다. 검토 후 승인 또는 거부할 수 있습니다. - </AlertDescription> - </Alert> - )} - - {pqSubmission.status === "APPROVED" && ( - <Alert variant="success"> - <AlertTitle>승인됨</AlertTitle> - <AlertDescription> - {formatDate(pqSubmission.approvedAt)}에 승인되었습니다. - </AlertDescription> - </Alert> - )} - - {pqSubmission.status === "REJECTED" && ( - <Alert variant="destructive"> - <AlertTitle>거부됨</AlertTitle> - <AlertDescription> - {formatDate(pqSubmission.rejectedAt)}에 거부되었습니다. - {pqSubmission.rejectReason && ( - <div className="mt-2"> - <strong>사유:</strong> {pqSubmission.rejectReason} - </div> - )} - </AlertDescription> - </Alert> - )} - - <Separator /> - - {/* PQ 검토 컴포넌트 */} - <Tabs defaultValue="review" className="w-full"> - <TabsList> - <TabsTrigger value="review">PQ 검토</TabsTrigger> - <TabsTrigger value="vendor-info">협력업체 정보</TabsTrigger> - </TabsList> - - <TabsContent value="review" className="mt-4"> - <PQReviewWrapper - pqData={pqData} - vendorId={vendorId} - pqSubmission={pqSubmission} - canReview={canReview} - /> - </TabsContent> - - <TabsContent value="vendor-info" className="mt-4"> - <div className="rounded-md border p-4"> - <h3 className="text-lg font-medium mb-4">협력업체 정보</h3> - <div className="grid grid-cols-2 gap-4"> - <div> - <p className="text-sm font-medium text-muted-foreground">업체명</p> - <p>{pqSubmission.vendorName}</p> - </div> - <div> - <p className="text-sm font-medium text-muted-foreground">업체 코드</p> - <p>{pqSubmission.vendorCode}</p> - </div> - <div> - <p className="text-sm font-medium text-muted-foreground">상태</p> - <p>{pqSubmission.vendorStatus}</p> - </div> - {/* 필요시 추가 정보 표시 */} - </div> - </div> - </TabsContent> - </Tabs> - </Shell> - ) - } catch (error) { - console.error("Error loading PQ:", error) - notFound() - } -} - -// 상태 레이블 함수 -function getStatusLabel(status: string): string { - switch (status) { - case "REQUESTED": - return "요청됨"; - case "IN_PROGRESS": - return "진행 중"; - case "SUBMITTED": - return "제출됨"; - case "APPROVED": - return "승인됨"; - case "REJECTED": - return "거부됨"; - default: - return status; - } -} - -// 상태별 Badge 스타일 -function getStatusVariant(status: string): "default" | "outline" | "secondary" | "destructive" | "success" { - switch (status) { - case "REQUESTED": - return "outline"; - case "IN_PROGRESS": - return "secondary"; - case "SUBMITTED": - return "default"; - case "APPROVED": - return "success"; - case "REJECTED": - return "destructive"; - default: - return "outline"; - } -} - -// 날짜 형식화 함수 -function formatDate(date: Date | null) { - if (!date) return "날짜 없음"; - return new Date(date).toLocaleDateString("ko-KR", { - year: "numeric", - month: "long", - day: "numeric", - hour: "2-digit", - minute: "2-digit" - }); -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/pq_new/page.tsx b/app/[lng]/engineering/(engineering)/pq_new/page.tsx deleted file mode 100644 index 6598349b..00000000 --- a/app/[lng]/engineering/(engineering)/pq_new/page.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import * as React from "react" -import { Metadata } from "next" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { Shell } from "@/components/shell" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { searchParamsPQReviewCache } from "@/lib/pq/validations" -import { getPQSubmissions } from "@/lib/pq/service" -import { PQSubmissionsTable } from "@/lib/pq/pq-review-table-new/vendors-table" - -export const metadata: Metadata = { - title: "PQ 검토/실사 의뢰", - description: "", -} - -interface PQReviewPageProps { - searchParams: Promise<SearchParams> -} - -export default async function PQReviewPage(props: PQReviewPageProps) { - const searchParams = await props.searchParams - const search = searchParamsPQReviewCache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - // 디버깅 로그 추가 - console.log("=== PQ Page Debug ==="); - console.log("Raw searchParams:", searchParams); - console.log("Raw basicFilters param:", searchParams.basicFilters); - console.log("Raw pqBasicFilters param:", searchParams.pqBasicFilters); - console.log("Parsed search:", search); - console.log("search.filters:", search.filters); - console.log("search.basicFilters:", search.basicFilters); - console.log("search.pqBasicFilters:", search.pqBasicFilters); - console.log("validFilters:", validFilters); - - // 기본 필터 처리 (통일된 이름 사용) - let basicFilters = [] - if (search.basicFilters && search.basicFilters.length > 0) { - basicFilters = search.basicFilters - console.log("Using search.basicFilters:", basicFilters); - } else if (search.pqBasicFilters && search.pqBasicFilters.length > 0) { - // 하위 호환성을 위해 기존 이름도 지원 - basicFilters = search.pqBasicFilters - console.log("Using search.pqBasicFilters:", basicFilters); - } else { - console.log("No basic filters found"); - } - - // 모든 필터를 합쳐서 처리 - const allFilters = [...validFilters, ...basicFilters] - - console.log("Final allFilters:", allFilters); - - // 조인 연산자도 통일된 이름 사용 - const joinOperator = search.basicJoinOperator || search.pqBasicJoinOperator || search.joinOperator || 'and'; - console.log("Final joinOperator:", joinOperator); - - // Promise.all로 감싸서 전달 - const promises = Promise.all([ - getPQSubmissions({ - ...search, - filters: allFilters, - joinOperator, - }) - ]) - - return ( - <Shell className="gap-4"> - <div className="flex items-center justify-between space-y-2"> - <div className="flex items-center justify-between space-y-2"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - PQ 검토/실사 의뢰 - </h2> - </div> - </div> - </div> - - {/* Items처럼 직접 테이블 렌더링 */} - <React.Suspense - key={JSON.stringify(searchParams)} // URL 파라미터가 변경될 때마다 강제 리렌더링 - fallback={ - <DataTableSkeleton - columnCount={8} - searchableColumnCount={2} - filterableColumnCount={3} - cellWidths={["10rem", "15rem", "12rem", "12rem", "8rem", "8rem", "10rem", "8rem"]} - shrinkZero - /> - } - > - <PQSubmissionsTable promises={promises} /> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/project-gtc/page.tsx b/app/[lng]/engineering/(engineering)/project-gtc/page.tsx deleted file mode 100644 index 8e12a489..00000000 --- a/app/[lng]/engineering/(engineering)/project-gtc/page.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import * as React from "react" -import { type SearchParams } from "@/types/table" - -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" -import { getProjectGtcList } from "@/lib/project-gtc/service" -import { projectGtcSearchParamsSchema } from "@/lib/project-gtc/validations" -import { ProjectGtcTable } from "@/lib/project-gtc/table/project-gtc-table" - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = projectGtcSearchParamsSchema.parse(searchParams) - - const promises = Promise.all([ - getProjectGtcList({ - page: search.page, - perPage: search.perPage, - search: search.search, - sort: search.sort, - }), - ]) - - return ( - <Shell className="gap-2"> - <div className="flex items-center justify-between space-y-2"> - <div className="flex items-center justify-between space-y-2"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - Project GTC - </h2> - <p className="text-muted-foreground"> - 프로젝트별 GTC(General Terms and Conditions) 파일을 관리할 수 있습니다. - 각 프로젝트마다 하나의 GTC 파일을 업로드할 수 있으며, 파일 업로드 시 기존 파일은 자동으로 교체됩니다. - </p> - </div> - </div> - </div> - - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> - {/* 추가 기능이 필요하면 여기에 추가 */} - </React.Suspense> - - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={8} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["3rem", "3rem", "12rem", "20rem", "10rem", "20rem", "15rem", "12rem", "3rem"]} - shrinkZero - /> - } - > - <ProjectGtcTable promises={promises} /> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/project-vendors/page.tsx b/app/[lng]/engineering/(engineering)/project-vendors/page.tsx deleted file mode 100644 index dcc66071..00000000 --- a/app/[lng]/engineering/(engineering)/project-vendors/page.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import * as React from "react" -import { type SearchParams } from "@/types/table" - -import { getValidFilters } from "@/lib/data-table" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" -import { ProjectAVLTable } from "@/lib/project-avl/table/proejctAVL-table" -import { getProjecTAVL } from "@/lib/project-avl/service" -import { searchProjectAVLParamsCache } from "@/lib/project-avl/validations" - - -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]/engineering/(engineering)/report/page.tsx b/app/[lng]/engineering/(engineering)/report/page.tsx index eb932e0f..c54d8a5e 100644 --- a/app/[lng]/engineering/(engineering)/report/page.tsx +++ b/app/[lng]/engineering/(engineering)/report/page.tsx @@ -1,5 +1,3 @@ - -// app/procurement/dashboard/page.tsx import * as React from "react"; import { Skeleton } from "@/components/ui/skeleton"; import { Shell } from "@/components/shell"; @@ -7,29 +5,34 @@ import { ErrorBoundary } from "@/components/error-boundary"; import { getDashboardData } from "@/lib/dashboard/service"; import { DashboardClient } from "@/lib/dashboard/dashboard-client"; -// 대시보드 데이터 로딩 컴포넌트 -async function DashboardContent() { +export default async function IndexPage() { + // domain을 명시적으로 전달 + const domain = "engineering"; + try { - const data = await getDashboardData("engineering"); + // 서버에서 직접 데이터 fetch + const dashboardData = await getDashboardData(domain); - const handleRefresh = async () => { - "use server"; - return await getDashboardData("engineering"); - }; - return ( - <DashboardClient - initialData={data} - onRefresh={handleRefresh} - /> + <Shell className="gap-2"> + <DashboardClient initialData={dashboardData} /> + </Shell> ); } catch (error) { - console.error("Dashboard data loading error:", error); - throw error; + console.error("Dashboard data fetch error:", error); + return ( + <Shell className="gap-2"> + <div className="flex items-center justify-center py-12"> + <div className="text-center space-y-2"> + <p className="text-destructive">데이터를 불러오는데 실패했습니다.</p> + <p className="text-muted-foreground text-sm">{error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다."}</p> + </div> + </div> + </Shell> + ); } } -// 대시보드 로딩 스켈레톤 function DashboardSkeleton() { return ( <div className="space-y-6"> @@ -95,35 +98,3 @@ function DashboardSkeleton() { </div> ); } - -// 에러 표시 컴포넌트 -function DashboardError({ error, reset }: { error: Error; reset: () => void }) { - return ( - <div className="flex flex-col items-center justify-center py-12 space-y-4"> - <div className="text-center space-y-2"> - <h3 className="text-lg font-semibold">대시보드를 불러올 수 없습니다</h3> - <p className="text-muted-foreground"> - {error.message || "알 수 없는 오류가 발생했습니다."} - </p> - </div> - <button - onClick={reset} - className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90" - > - 다시 시도 - </button> - </div> - ); -} - -export default async function DashboardPage() { - return ( - <Shell className="gap-6"> - <ErrorBoundary fallback={DashboardError}> - <React.Suspense fallback={<DashboardSkeleton />}> - <DashboardContent /> - </React.Suspense> - </ErrorBoundary> - </Shell> - ); -} diff --git a/app/[lng]/engineering/(engineering)/rfq/[id]/cbe/page.tsx b/app/[lng]/engineering/(engineering)/rfq/[id]/cbe/page.tsx deleted file mode 100644 index fb288a98..00000000 --- a/app/[lng]/engineering/(engineering)/rfq/[id]/cbe/page.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { searchParamsCBECache } from "@/lib/rfqs/validations" -import { getCBE } from "@/lib/rfqs/service" -import { CbeTable } from "@/lib/rfqs/cbe-table/cbe-table" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> -} - -export default async function RfqCBEPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsCBECache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getCBE({ - ...search, - filters: validFilters, - }, - idAsNumber) - ]) - - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - Commercial Bid Evaluation - </h3> - <p className="text-sm text-muted-foreground"> - 초대된 협력업체에게 CBE를 보낼 수 있습니다. <br />"발행하기" 버튼을 통해 CBE를 전송하면 CBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. - </p> - </div> - <Separator /> - <div> - <CbeTable promises={promises} rfqId={idAsNumber} /> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/rfq/[id]/layout.tsx b/app/[lng]/engineering/(engineering)/rfq/[id]/layout.tsx deleted file mode 100644 index 9a03efa4..00000000 --- a/app/[lng]/engineering/(engineering)/rfq/[id]/layout.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { Metadata } from "next" -import Link from "next/link" -import { Separator } from "@/components/ui/separator" -import { SidebarNav } from "@/components/layout/sidebar-nav" -import { RfqViewWithItems } from "@/db/schema/rfq" -import { findRfqById } from "@/lib/rfqs/service" -import { formatDate } from "@/lib/utils" -import { Button } from "@/components/ui/button" -import { ArrowLeft } from "lucide-react" - -export const metadata: Metadata = { - title: "Vendor Detail", -} - -export default async function RfqLayout({ - children, - params, -}: { - children: React.ReactNode - params: { lng: string, id: string } -}) { - - // 1) URL 파라미터에서 id 추출, Number로 변환 - const resolvedParams = await params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - // 2) DB에서 해당 협력업체 정보 조회 - const rfq: RfqViewWithItems | null = await findRfqById(idAsNumber) - - // 3) 사이드바 메뉴 - const sidebarNavItems = [ - { - title: "Matched Vendors", - href: `/${lng}/evcp/rfq/${id}`, - }, - { - title: "TBE", - href: `/${lng}/evcp/rfq/${id}/tbe`, - }, - { - title: "CBE", - href: `/${lng}/evcp/rfq/${id}/cbe`, - }, - - ] - - return ( - <> - <div className="container py-6"> - <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow"> - <div className="hidden space-y-6 p-10 pb-16 md:block"> - <div className="flex items-center justify-end mb-4"> - <Link href={`/${lng}/evcp/rfq`} passHref> - <Button variant="ghost" className="flex items-center text-primary hover:text-primary/80 transition-colors p-0 h-auto"> - <ArrowLeft className="mr-1 h-4 w-4" /> - <span>RFQ 목록으로 돌아가기</span> - </Button> - </Link> - </div> - <div className="space-y-0.5"> - {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */} - <h2 className="text-2xl font-bold tracking-tight"> - {rfq - ? `${rfq.projectCode ?? ""} ${rfq.rfqCode ?? ""} 관리` - : "Loading RFQ..."} - </h2> - - <p className="text-muted-foreground"> - {rfq - ? `${rfq.description ?? ""} ${rfq.lines.map(line => line.itemCode).join(", ")}` - : ""} - </p> - <h3>Due Date:{rfq && rfq?.dueDate && <strong>{formatDate(rfq?.dueDate)}</strong>}</h3> - </div> - <Separator className="my-6" /> - <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0"> - <aside className="lg:w-64 flex-shrink-0"> - <SidebarNav items={sidebarNavItems} /> - </aside> - <div className="lg:w-[calc(100%-16rem)] overflow-auto">{children}</div> - </div> - </div> - </section> - </div> - </> - ) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/rfq/[id]/page.tsx b/app/[lng]/engineering/(engineering)/rfq/[id]/page.tsx deleted file mode 100644 index 1a9f4b18..00000000 --- a/app/[lng]/engineering/(engineering)/rfq/[id]/page.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { getMatchedVendors } from "@/lib/rfqs/service" -import { searchParamsMatchedVCache } from "@/lib/rfqs/validations" -import { MatchedVendorsTable } from "@/lib/rfqs/vendor-table/vendors-table" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> -} - -export default async function RfqPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsMatchedVCache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getMatchedVendors({ - ...search, - filters: validFilters, - }, - idAsNumber) - ]) - - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - Vendors - </h3> - <p className="text-sm text-muted-foreground"> - 등록된 협력업체 중에서 이 RFQ 아이템에 매칭되는 업체를 보여줍니다. <br/>"발행하기" 버튼을 통해 RFQ를 전송하면 첨부파일과 함께 RFQ 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. - </p> - </div> - <Separator /> - <div> - <MatchedVendorsTable promises={promises} rfqId={idAsNumber}/> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/rfq/[id]/tbe/page.tsx b/app/[lng]/engineering/(engineering)/rfq/[id]/tbe/page.tsx deleted file mode 100644 index 76eea302..00000000 --- a/app/[lng]/engineering/(engineering)/rfq/[id]/tbe/page.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { getTBE } from "@/lib/rfqs/service" -import { searchParamsTBECache } from "@/lib/rfqs/validations" -import { TbeTable } from "@/lib/rfqs/tbe-table/tbe-table" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> -} - -export default async function RfqTBEPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsTBECache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getTBE({ - ...search, - filters: validFilters, - }, - idAsNumber) - ]) - - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - Technical Bid Evaluation - </h3> - <p className="text-sm text-muted-foreground"> - 초대된 협력업체에게 TBE를 보낼 수 있습니다. <br/>"발행하기" 버튼을 통해 TBE를 전송하면 첨부파일과 함께 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. - </p> - </div> - <Separator /> - <div> - <TbeTable promises={promises} rfqId={idAsNumber}/> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/rfq/page.tsx b/app/[lng]/engineering/(engineering)/rfq/page.tsx deleted file mode 100644 index 3417b0bf..00000000 --- a/app/[lng]/engineering/(engineering)/rfq/page.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import * as React from "react" -import { type SearchParams } from "@/types/table" - -import { getValidFilters } from "@/lib/data-table" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" - -import { searchParamsCache } from "@/lib/rfqs/validations" -import { getRfqs, getRfqStatusCounts } from "@/lib/rfqs/service" -import { RfqsTable } from "@/lib/rfqs/table/rfqs-table" -import { getAllItems } from "@/lib/items/service" -import { RfqType } from "@/lib/rfqs/validations" - -interface RfqPageProps { - searchParams: Promise<SearchParams>; - rfqType: RfqType; - title: string; - description: string; -} - -export default async function RfqPage({ - searchParams, - rfqType = RfqType.PURCHASE, - title = "RFQ", - description = "RFQ를 등록하고 관리할 수 있습니다." -}: RfqPageProps) { - const search = searchParamsCache.parse(await searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getRfqs({ - ...search, - filters: validFilters, - rfqType // 전달받은 rfqType 사용 - }), - getRfqStatusCounts(rfqType), // rfqType 전달 - getAllItems() - ]) - - return ( - <Shell className="gap-2"> - <div className="flex items-center justify-between space-y-2"> - <div className="flex items-center justify-between space-y-2"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - {title} - </h2> - <p className="text-muted-foreground"> - {description} - </p> - </div> - </div> - </div> - - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> - {/* <DateRangePicker - triggerSize="sm" - triggerClassName="ml-auto w-56 sm:w-60" - align="end" - shallow={false} - /> */} - </React.Suspense> - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <RfqsTable promises={promises} rfqType={rfqType} /> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/settings/layout.tsx b/app/[lng]/engineering/(engineering)/settings/layout.tsx deleted file mode 100644 index 6f373567..00000000 --- a/app/[lng]/engineering/(engineering)/settings/layout.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { Metadata } from "next" - -import { Separator } from "@/components/ui/separator" -import { SidebarNav } from "@/components/layout/sidebar-nav" - -export const metadata: Metadata = { - title: "Settings", - // description: "Advanced form example using react-hook-form and Zod.", -} - - -interface SettingsLayoutProps { - children: React.ReactNode - params: { lng: string } -} - -export default async function SettingsLayout({ - children, - params, -}: { - children: React.ReactNode - params: { lng: string } -}) { - const resolvedParams = await params - const lng = resolvedParams.lng - - - const sidebarNavItems = [ - - { - title: "Account", - href: `/${lng}/evcp/settings`, - }, - { - title: "Preferences", - href: `/${lng}/evcp/settings/preferences`, - } - - - ] - - - return ( - <> - <div className="container py-6"> - <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow"> - <div className="hidden space-y-6 p-10 pb-16 md:block"> - <div className="space-y-0.5"> - <h2 className="text-2xl font-bold tracking-tight">Settings</h2> - <p className="text-muted-foreground"> - Manage your account settings and preferences. - </p> - </div> - <Separator className="my-6" /> - <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0"> - <aside className="-mx-4 lg:w-1/5"> - <SidebarNav items={sidebarNavItems} /> - </aside> - <div className="flex-1 ">{children}</div> - </div> - </div> - </section> - </div> - - - </> - ) -} diff --git a/app/[lng]/engineering/(engineering)/settings/page.tsx b/app/[lng]/engineering/(engineering)/settings/page.tsx deleted file mode 100644 index a6eaac90..00000000 --- a/app/[lng]/engineering/(engineering)/settings/page.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { AccountForm } from "@/components/settings/account-form" - -export default function SettingsAccountPage() { - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium">Account</h3> - <p className="text-sm text-muted-foreground"> - Update your account settings. Set your preferred language and - timezone. - </p> - </div> - <Separator /> - <AccountForm /> - </div> - ) -} diff --git a/app/[lng]/engineering/(engineering)/settings/preferences/page.tsx b/app/[lng]/engineering/(engineering)/settings/preferences/page.tsx deleted file mode 100644 index e2a88021..00000000 --- a/app/[lng]/engineering/(engineering)/settings/preferences/page.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { AppearanceForm } from "@/components/settings/appearance-form" - -export default function SettingsAppearancePage() { - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium">Preference</h3> - <p className="text-sm text-muted-foreground"> - Customize the preference of the app. - </p> - </div> - <Separator /> - <AppearanceForm /> - </div> - ) -} diff --git a/app/[lng]/engineering/(engineering)/system/admin-users/page.tsx b/app/[lng]/engineering/(engineering)/system/admin-users/page.tsx deleted file mode 100644 index 11a9e9fb..00000000 --- a/app/[lng]/engineering/(engineering)/system/admin-users/page.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import * as React from "react" -import { type SearchParams } from "@/types/table" - -import { getValidFilters } from "@/lib/data-table" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { DateRangePicker } from "@/components/date-range-picker" -import { Separator } from "@/components/ui/separator" - -import { searchParamsCache } from "@/lib/admin-users/validations" -import { getAllCompanies, getAllRoles, getUserCountGroupByCompany, getUserCountGroupByRole, getUsers } from "@/lib/admin-users/service" -import { AdmUserTable } from "@/lib/admin-users/table/ausers-table" - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function UserTable(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsCache.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getUsers({ - ...search, - filters: validFilters, - }), - getUserCountGroupByCompany(), - getUserCountGroupByRole(), - getAllCompanies(), - getAllRoles() - ]) - - return ( - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium">Vendor Admin User Management</h3> - <p className="text-sm text-muted-foreground"> - 협력업체의 유저 전체를 조회하고 어드민 유저를 생성할 수 있는 페이지입니다. 이곳에서 초기 유저를 생성시킬 수 있습니다. <br />생성 후에는 생성된 사용자의 이메일로 생성 통보 이메일이 발송되며 사용자는 이메일과 OTP로 로그인이 가능합니다. - </p> - </div> - <Separator /> - <AdmUserTable promises={promises} /> - </div> - </React.Suspense> - - ) -} diff --git a/app/[lng]/engineering/(engineering)/system/layout.tsx b/app/[lng]/engineering/(engineering)/system/layout.tsx deleted file mode 100644 index 7e8f69d0..00000000 --- a/app/[lng]/engineering/(engineering)/system/layout.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { Metadata } from "next" - -import { Separator } from "@/components/ui/separator" -import { SidebarNav } from "@/components/layout/sidebar-nav" - -export const metadata: Metadata = { - title: "System Setting", - // description: "Advanced form example using react-hook-form and Zod.", -} - - -interface SettingsLayoutProps { - children: React.ReactNode - params: { lng: string } -} - -export default async function SettingsLayout({ - children, - params, -}: { - children: React.ReactNode - params: { lng: string } -}) { - const resolvedParams = await params - const lng = resolvedParams.lng - - - const sidebarNavItems = [ - - { - title: "삼성중공업 사용자", - href: `/${lng}/evcp/system`, - }, - { - title: "Roles", - href: `/${lng}/evcp/system/roles`, - }, - { - title: "권한 통제", - href: `/${lng}/evcp/system/permissions`, - }, - { - title: "협력업체 사용자", - href: `/${lng}/evcp/system/admin-users`, - }, - - { - title: "비밀번호 정책", - href: `/${lng}/evcp/system/password-policy`, - }, - - ] - - - return ( - <> - <div className="container py-6"> - <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow"> - <div className="hidden space-y-6 p-10 pb-16 md:block"> - <div className="space-y-0.5"> - <h2 className="text-2xl font-bold tracking-tight">시스템 설정</h2> - <p className="text-muted-foreground"> - 사용자, 롤, 접근 권한을 관리하세요. - </p> - </div> - <Separator className="my-6" /> - <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0"> - <aside className="-mx-4 lg:w-1/5"> - <SidebarNav items={sidebarNavItems} /> - </aside> - <div className="flex-1 ">{children}</div> - </div> - </div> - </section> - </div> - - - </> - ) -} diff --git a/app/[lng]/engineering/(engineering)/system/page.tsx b/app/[lng]/engineering/(engineering)/system/page.tsx deleted file mode 100644 index fe0a262c..00000000 --- a/app/[lng]/engineering/(engineering)/system/page.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { type SearchParams } from "@/types/table" -import * as React from "react" -import { getValidFilters } from "@/lib/data-table" -import { searchParamsCache } from "@/lib/admin-users/validations" -import { getAllRoles, getUsersEVCP } from "@/lib/users/service" -import { getUserCountGroupByRole } from "@/lib/admin-users/service" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { UserTable } from "@/lib/users/table/users-table" - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function SystemUserPage(props: IndexPageProps) { - - const searchParams = await props.searchParams - const search = searchParamsCache.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getUsersEVCP({ - ...search, - filters: validFilters, - }), - getUserCountGroupByRole(), - getAllRoles() - ]) - - return ( - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "12rem", "12rem", "12rem"]} - shrinkZero - /> - } - > - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium">SHI Users</h3> - <p className="text-sm text-muted-foreground"> - 시스템 전체 사용자들을 조회하고 관리할 수 있는 페이지입니다. 사용자에게 롤을 할당하는 것으로 메뉴별 권한을 관리할 수 있습니다. - </p> - </div> - <Separator /> - <UserTable promises={promises} /> - </div> - </React.Suspense> - - ) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/system/password-policy/page.tsx b/app/[lng]/engineering/(engineering)/system/password-policy/page.tsx deleted file mode 100644 index 0f14fefe..00000000 --- a/app/[lng]/engineering/(engineering)/system/password-policy/page.tsx +++ /dev/null @@ -1,63 +0,0 @@ -// app/admin/password-policy/page.tsx - -import * as React from "react" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Separator } from "@/components/ui/separator" -import { Alert, AlertDescription } from "@/components/ui/alert" -import { AlertTriangle } from "lucide-react" -import SecuritySettingsTable from "@/components/system/passwordPolicy" -import { getSecuritySettings } from "@/lib/password-policy/service" - - -export default async function PasswordPolicyPage() { - try { - // 보안 설정 데이터 로드 - const securitySettings = await getSecuritySettings() - - return ( - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={4} - searchableColumnCount={0} - filterableColumnCount={0} - cellWidths={["20rem", "30rem", "15rem", "10rem"]} - shrinkZero - /> - } - > - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium">협력업체 사용자 비밀번호 정책 설정</h3> - <p className="text-sm text-muted-foreground"> - 협력업체 사용자들을 위한 비밀번호 정책과 보안 설정을 관리할 수 있습니다. - </p> - </div> - <Separator /> - <SecuritySettingsTable initialSettings={securitySettings} /> - </div> - </React.Suspense> - ) - } catch (error) { - console.error('Failed to load security settings:', error) - - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium">협력업체 사용자 비밀번호 정책 설정</h3> - <p className="text-sm text-muted-foreground"> - 협력업체 사용자들을 위한 비밀번호 정책과 보안 설정을 관리할 수 있습니다. - </p> - </div> - <Separator /> - <Alert variant="destructive"> - <AlertTriangle className="h-4 w-4" /> - <AlertDescription> - 보안 설정을 불러오는 중 오류가 발생했습니다. 페이지를 새로고침하거나 관리자에게 문의하세요. - </AlertDescription> - </Alert> - </div> - ) - } -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/system/permissions/page.tsx b/app/[lng]/engineering/(engineering)/system/permissions/page.tsx deleted file mode 100644 index 6aa2b693..00000000 --- a/app/[lng]/engineering/(engineering)/system/permissions/page.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import PermissionsTree from "@/components/system/permissionsTree" -import { Separator } from "@/components/ui/separator" - -export default function PermissionsPage() { - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium">Permissions</h3> - <p className="text-sm text-muted-foreground"> - Set permissions to the menu by Role - </p> - </div> - <Separator /> - <PermissionsTree/> - </div> - ) -} diff --git a/app/[lng]/engineering/(engineering)/system/roles/page.tsx b/app/[lng]/engineering/(engineering)/system/roles/page.tsx deleted file mode 100644 index fe074600..00000000 --- a/app/[lng]/engineering/(engineering)/system/roles/page.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import * as React from "react" -import { type SearchParams } from "@/types/table" - -import { getValidFilters } from "@/lib/data-table" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Separator } from "@/components/ui/separator" - -import { searchParamsCache } from "@/lib/roles/validations" -import { searchParamsCache as searchParamsCache2 } from "@/lib/admin-users/validations" -import { RolesTable } from "@/lib/roles/table/roles-table" -import { getRolesWithCount } from "@/lib/roles/services" -import { getUsersAll } from "@/lib/users/service" - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function UserTable(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsCache.parse(searchParams) - const search2 = searchParamsCache2.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getRolesWithCount({ - ...search, - filters: validFilters, - }), - - - ]) - - - const promises2 = Promise.all([ - getUsersAll({ - ...search2, - filters: validFilters, - }, "evcp"), - ]) - - - return ( - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium">Role Management</h3> - <p className="text-sm text-muted-foreground"> - 역할을 생성하고 역할에 유저를 할당할 수 있는 페이지입니다. 역할에 메뉴의 접근 권한 역시 할당할 수 있습니다. - </p> - </div> - <Separator /> - <RolesTable promises={promises} promises2={promises2} /> - </div> - </React.Suspense> - - ) -} diff --git a/app/[lng]/engineering/(engineering)/tech-vendor-candidates/page.tsx b/app/[lng]/engineering/(engineering)/tech-vendor-candidates/page.tsx deleted file mode 100644 index 3923863a..00000000 --- a/app/[lng]/engineering/(engineering)/tech-vendor-candidates/page.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import * as React from "react" -import { type SearchParams } from "@/types/table" - -import { getValidFilters } from "@/lib/data-table" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" - -import { getVendorCandidateCounts, getVendorCandidates } from "@/lib/tech-vendor-candidates/service" -import { searchParamsTechCandidateCache } from "@/lib/tech-vendor-candidates/validations" -import { VendorCandidateTable as TechVendorCandidateTable } from "@/lib/tech-vendor-candidates/table/candidates-table" -import { DateRangePicker } from "@/components/date-range-picker" - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsTechCandidateCache.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getVendorCandidates({ - ...search, - filters: validFilters, - }), - getVendorCandidateCounts() - ]) - - 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> - <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={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <TechVendorCandidateTable promises={promises}/> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/tech-vendors/[id]/info/items/page.tsx b/app/[lng]/engineering/(engineering)/tech-vendors/[id]/info/items/page.tsx deleted file mode 100644 index 69c36576..00000000 --- a/app/[lng]/engineering/(engineering)/tech-vendors/[id]/info/items/page.tsx +++ /dev/null @@ -1,48 +0,0 @@ -// import { Separator } from "@/components/ui/separator" -// import { getTechVendorById, getVendorItemsByType } from "@/lib/tech-vendors/service" -// import { type SearchParams } from "@/types/table" -// import { TechVendorItemsTable } from "@/lib/tech-vendors/items-table/item-table" - -// interface IndexPageProps { -// // Next.js 13 App Router에서 기본으로 주어지는 객체들 -// params: { -// lng: string -// id: string -// } -// searchParams: Promise<SearchParams> -// } - -// export default async function TechVendorItemsPage(props: IndexPageProps) { -// const resolvedParams = await props.params -// const id = resolvedParams.id - -// const idAsNumber = Number(id) - -// // 벤더 정보 가져오기 (벤더 타입 필요) -// const vendorInfo = await getTechVendorById(idAsNumber) -// const vendorType = vendorInfo.data?.techVendorType || "조선" - -// const promises = getVendorItemsByType(idAsNumber, vendorType) - -// // 4) 렌더링 -// return ( -// <div className="space-y-6"> -// <div> -// <h3 className="text-lg font-medium"> -// 공급품목 -// </h3> -// <p className="text-sm text-muted-foreground"> -// 기술영업 벤더의 공급 가능한 품목을 확인하세요. -// </p> -// </div> -// <Separator /> -// <div> -// <TechVendorItemsTable -// promises={promises} -// vendorId={idAsNumber} -// vendorType={vendorType} -// /> -// </div> -// </div> -// ) -// }
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/tech-vendors/[id]/info/layout.tsx b/app/[lng]/engineering/(engineering)/tech-vendors/[id]/info/layout.tsx deleted file mode 100644 index 7c389720..00000000 --- a/app/[lng]/engineering/(engineering)/tech-vendors/[id]/info/layout.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { Metadata } from "next" - -import { Separator } from "@/components/ui/separator" -import { SidebarNav } from "@/components/layout/sidebar-nav" -import { findTechVendorById } from "@/lib/tech-vendors/service" -import { TechVendor } from "@/db/schema/techVendors" -import { Button } from "@/components/ui/button" -import { ArrowLeft } from "lucide-react" -import Link from "next/link" -export const metadata: Metadata = { - title: "Tech Vendor Detail", -} - -export default async function SettingsLayout({ - children, - params, -}: { - children: React.ReactNode - params: { lng: string , id: string} -}) { - - // 1) URL 파라미터에서 id 추출, Number로 변환 - const resolvedParams = await params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - // 2) DB에서 해당 협력업체 정보 조회 - const vendor: TechVendor | null = await findTechVendorById(idAsNumber) - - // 3) 사이드바 메뉴 - const sidebarNavItems = [ - { - title: "연락처", - href: `/${lng}/evcp/tech-vendors/${id}/info`, - }, - // { - // title: "자재 리스트", - // href: `/${lng}/evcp/tech-vendors/${id}/info/items`, - // }, - // { - // title: "견적 히스토리", - // href: `/${lng}/evcp/tech-vendors/${id}/info/rfq-history`, - // }, - ] - - return ( - <> - <div className="container py-6"> - <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow"> - <div className="hidden space-y-6 p-10 pb-16 md:block"> - {/* RFQ 목록으로 돌아가는 링크 추가 */} - <div className="flex items-center justify-end mb-4"> - <Link href={`/${lng}/evcp/tech-vendors`} 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>기술영업 벤더 목록으로 돌아가기</span> - </Button> - </Link> - </div> - <div className="space-y-0.5"> - {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */} - <h2 className="text-2xl font-bold tracking-tight"> - {vendor - ? `${vendor.vendorCode ?? ""} - ${vendor.vendorName} 상세 정보` - : "Loading Vendor..."} - </h2> - <p className="text-muted-foreground">기술영업 벤더 관련 상세사항을 확인하세요.</p> - </div> - <Separator className="my-6" /> - <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0"> - <aside className="-mx-4 lg:w-1/5"> - <SidebarNav items={sidebarNavItems} /> - </aside> - <div className="flex-1">{children}</div> - </div> - </div> - </section> - </div> - </> - ) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/tech-vendors/[id]/info/page.tsx b/app/[lng]/engineering/(engineering)/tech-vendors/[id]/info/page.tsx deleted file mode 100644 index a57d6df7..00000000 --- a/app/[lng]/engineering/(engineering)/tech-vendors/[id]/info/page.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { getTechVendorContacts } from "@/lib/tech-vendors/service" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { searchParamsContactCache } from "@/lib/tech-vendors/validations" -import { TechVendorContactsTable } from "@/lib/tech-vendors/contacts-table/contact-table" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> -} - -export default async function SettingsAccountPage(props: IndexPageProps) { - const resolvedParams = await props.params - const id = resolvedParams.id - - const idAsNumber = Number(id) - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsContactCache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - - - const promises = Promise.all([ - getTechVendorContacts({ - ...search, - filters: validFilters, - }, - idAsNumber) - ]) - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - Contacts - </h3> - <p className="text-sm text-muted-foreground"> - 업무별 담당자 정보를 확인하세요. - </p> - </div> - <Separator /> - <div> - <TechVendorContactsTable promises={promises} vendorId={idAsNumber}/> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/tech-vendors/[id]/info/rfq-history/page.tsx b/app/[lng]/engineering/(engineering)/tech-vendors/[id]/info/rfq-history/page.tsx deleted file mode 100644 index 4ed2b39f..00000000 --- a/app/[lng]/engineering/(engineering)/tech-vendors/[id]/info/rfq-history/page.tsx +++ /dev/null @@ -1,55 +0,0 @@ -// import { Separator } from "@/components/ui/separator"
-// import { getRfqHistory } from "@/lib/vendors/service"
-// import { type SearchParams } from "@/types/table"
-// import { getValidFilters } from "@/lib/data-table"
-// import { searchParamsRfqHistoryCache } from "@/lib/vendors/validations"
-// import { TechVendorRfqHistoryTable } from "@/lib/tech-vendors/rfq-history-table/rfq-history-table"
-
-// interface IndexPageProps {
-// // Next.js 13 App Router에서 기본으로 주어지는 객체들
-// params: {
-// lng: string
-// id: string
-// }
-// searchParams: Promise<SearchParams>
-// }
-
-// export default async function RfqHistoryPage(props: IndexPageProps) {
-// const resolvedParams = await props.params
-// const lng = resolvedParams.lng
-// const id = resolvedParams.id
-
-// const idAsNumber = Number(id)
-
-// // 2) SearchParams 파싱 (Zod)
-// // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
-// const searchParams = await props.searchParams
-// const search = searchParamsRfqHistoryCache.parse(searchParams)
-// const validFilters = getValidFilters(search.filters)
-
-// const promises = Promise.all([
-// getRfqHistory({
-// ...search,
-// filters: validFilters,
-// },
-// idAsNumber)
-// ])
-
-// // 4) 렌더링
-// return (
-// <div className="space-y-6">
-// <div>
-// <h3 className="text-lg font-medium">
-// RFQ History
-// </h3>
-// <p className="text-sm text-muted-foreground">
-// 협력업체의 RFQ 참여 이력을 확인할 수 있습니다.
-// </p>
-// </div>
-// <Separator />
-// <div>
-// <TechVendorRfqHistoryTable promises={promises} vendorId={idAsNumber} />
-// </div>
-// </div>
-// )
-// }
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/tech-vendors/page.tsx b/app/[lng]/engineering/(engineering)/tech-vendors/page.tsx deleted file mode 100644 index 8f542f59..00000000 --- a/app/[lng]/engineering/(engineering)/tech-vendors/page.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import * as React from "react"
-import { type SearchParams } from "@/types/table"
-
-import { getValidFilters } from "@/lib/data-table"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-import { Shell } from "@/components/shell"
-
-import { searchParamsCache } from "@/lib/tech-vendors/validations"
-import { getTechVendors, getTechVendorStatusCounts } from "@/lib/tech-vendors/service"
-import { TechVendorsTable } from "@/lib/tech-vendors/table/tech-vendors-table"
-import { TechVendorContainer } from "@/components/tech-vendors/tech-vendor-container"
-
-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 vendorTypes = [
- { id: "all", name: "전체", value: "" },
- { id: "ship", name: "조선", value: "조선" },
- { id: "top", name: "해양TOP", value: "해양TOP" },
- { id: "hull", name: "해양HULL", value: "해양HULL" },
- ]
-
- const promises = Promise.all([
- getTechVendors({
- ...search,
- filters: validFilters,
- }),
- getTechVendorStatusCounts(),
- ])
-
- return (
- <Shell className="gap-4">
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <TechVendorContainer vendorTypes={vendorTypes}>
- <TechVendorsTable promises={promises} />
- </TechVendorContainer>
- </React.Suspense>
- </Shell>
- )
-}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/vendor-candidates/page.tsx b/app/[lng]/engineering/(engineering)/vendor-candidates/page.tsx deleted file mode 100644 index a6e00b1b..00000000 --- a/app/[lng]/engineering/(engineering)/vendor-candidates/page.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import * as React from "react" -import { type SearchParams } from "@/types/table" - -import { getValidFilters } from "@/lib/data-table" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" - -import { 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> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsCandidateCache.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getVendorCandidates({ - ...search, - filters: validFilters, - }), - getVendorCandidateCounts() - ]) - - 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> - <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={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <VendorCandidateTable promises={promises}/> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/vendors/[id]/info/items/page.tsx b/app/[lng]/engineering/(engineering)/vendors/[id]/info/items/page.tsx deleted file mode 100644 index 5d5838c6..00000000 --- a/app/[lng]/engineering/(engineering)/vendors/[id]/info/items/page.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { getVendorItems } from "@/lib/vendors/service" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { searchParamsItemCache } from "@/lib/vendors/validations" -import { VendorItemsTable } from "@/lib/vendors/items-table/item-table" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> -} - -export default async function SettingsAccountPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsItemCache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - - - const promises = Promise.all([ - getVendorItems({ - ...search, - filters: validFilters, - }, - idAsNumber) - ]) - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - 공급품목(패키지) - </h3> - <p className="text-sm text-muted-foreground"> - {/* 딜리버리가 가능한 아이템 리스트를 확인할 수 있습니다. */} - </p> - </div> - <Separator /> - <div> - <VendorItemsTable promises={promises} vendorId={idAsNumber}/> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/vendors/[id]/info/layout.tsx b/app/[lng]/engineering/(engineering)/vendors/[id]/info/layout.tsx deleted file mode 100644 index 7e2cd4f6..00000000 --- a/app/[lng]/engineering/(engineering)/vendors/[id]/info/layout.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { Metadata } from "next" - -import { Separator } from "@/components/ui/separator" -import { SidebarNav } from "@/components/layout/sidebar-nav" -import { findVendorById } from "@/lib/vendors/service" // 가정: 여기에 findVendorById가 있다고 가정 -import { Vendor } from "@/db/schema/vendors" -import { Button } from "@/components/ui/button" -import { ArrowLeft } from "lucide-react" -import Link from "next/link" -export const metadata: Metadata = { - title: "Vendor Detail", -} - -export default async function SettingsLayout({ - children, - params, -}: { - children: React.ReactNode - params: { lng: string , id: string} -}) { - - // 1) URL 파라미터에서 id 추출, Number로 변환 - const resolvedParams = await params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - // 2) DB에서 해당 협력업체 정보 조회 - const vendor: Vendor | null = await findVendorById(idAsNumber) - - // 3) 사이드바 메뉴 - const sidebarNavItems = [ - { - title: "연락처", - href: `/${lng}/evcp/vendors/${id}/info`, - }, - { - title: "공급품목(패키지)", - href: `/${lng}/evcp/vendors/${id}/info/items`, - }, - { - title: "공급품목(자재그룹)", - href: `/${lng}/evcp/vendors/${id}/info/materials`, - }, - { - title: "견적 히스토리", - href: `/${lng}/evcp/vendors/${id}/info/rfq-history`, - }, - { - title: "입찰 히스토리", - href: `/${lng}/evcp/vendors/${id}/info/bid-history`, - }, - { - title: "계약 히스토리", - href: `/${lng}/evcp/vendors/${id}/info/contract-history`, - }, - ] - - return ( - <> - <div className="container py-6"> - <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow"> - <div className="hidden space-y-6 p-10 pb-16 md:block"> - {/* RFQ 목록으로 돌아가는 링크 추가 */} - <div className="flex items-center justify-end mb-4"> - <Link href={`/${lng}/evcp/vendors`} 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>협력업체 목록으로 돌아가기</span> - </Button> - </Link> - </div> - <div className="space-y-0.5"> - {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */} - <h2 className="text-2xl font-bold tracking-tight"> - {vendor - ? `${vendor.vendorCode ?? ""} - ${vendor.vendorName} 상세 정보` - : "Loading Vendor..."} - </h2> - <p className="text-muted-foreground">협력업체 관련 상세사항을 확인하세요.</p> - </div> - <Separator className="my-6" /> - <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0"> - <aside className="-mx-4 lg:w-1/5"> - <SidebarNav items={sidebarNavItems} /> - </aside> - <div className="flex-1">{children}</div> - </div> - </div> - </section> - </div> - </> - ) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/vendors/[id]/info/materials/page.tsx b/app/[lng]/engineering/(engineering)/vendors/[id]/info/materials/page.tsx deleted file mode 100644 index 0ebb66ba..00000000 --- a/app/[lng]/engineering/(engineering)/vendors/[id]/info/materials/page.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { searchParamsMaterialCache } from "@/lib/vendors/validations" -import { getVendorMaterials } from "@/lib/vendors/service" -import { VendorMaterialsTable } from "@/lib/vendors/materials-table/item-table" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> -} - -export default async function SettingsAccountPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsMaterialCache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - - - const promises = Promise.all([ - getVendorMaterials({ - ...search, - filters: validFilters, - }, - idAsNumber) - ]) - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - 공급품목(자재 그룹) - </h3> - <p className="text-sm text-muted-foreground"> - {/* 딜리버리가 가능한 공급품목(자재 그룹)을 확인할 수 있습니다. */} - </p> - </div> - <Separator /> - <div> - <VendorMaterialsTable promises={promises} vendorId={idAsNumber}/> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/vendors/[id]/info/page.tsx b/app/[lng]/engineering/(engineering)/vendors/[id]/info/page.tsx deleted file mode 100644 index 6279e924..00000000 --- a/app/[lng]/engineering/(engineering)/vendors/[id]/info/page.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { getVendorContacts } from "@/lib/vendors/service" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { searchParamsContactCache } from "@/lib/vendors/validations" -import { VendorContactsTable } from "@/lib/vendors/contacts-table/contact-table" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> -} - -export default async function SettingsAccountPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsContactCache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - - - const promises = Promise.all([ - getVendorContacts({ - ...search, - filters: validFilters, - }, - idAsNumber) - ]) - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - Contacts - </h3> - <p className="text-sm text-muted-foreground"> - 업무별 담당자 정보를 확인하세요. - </p> - </div> - <Separator /> - <div> - <VendorContactsTable promises={promises} vendorId={idAsNumber}/> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/vendors/[id]/info/rfq-history/page.tsx b/app/[lng]/engineering/(engineering)/vendors/[id]/info/rfq-history/page.tsx deleted file mode 100644 index c7f8f8b6..00000000 --- a/app/[lng]/engineering/(engineering)/vendors/[id]/info/rfq-history/page.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Separator } from "@/components/ui/separator"
-import { getRfqHistory } from "@/lib/vendors/service"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { searchParamsRfqHistoryCache } from "@/lib/vendors/validations"
-import { VendorRfqHistoryTable } from "@/lib/vendors/rfq-history-table/rfq-history-table"
-
-interface IndexPageProps {
- // Next.js 13 App Router에서 기본으로 주어지는 객체들
- params: {
- lng: string
- id: string
- }
- searchParams: Promise<SearchParams>
-}
-
-export default async function RfqHistoryPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
- const id = resolvedParams.id
-
- const idAsNumber = Number(id)
-
- // 2) SearchParams 파싱 (Zod)
- // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
- const searchParams = await props.searchParams
- const search = searchParamsRfqHistoryCache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getRfqHistory({
- ...search,
- filters: validFilters,
- },
- idAsNumber)
- ])
-
- // 4) 렌더링
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">
- RFQ History
- </h3>
- <p className="text-sm text-muted-foreground">
- 협력업체의 RFQ 참여 이력을 확인할 수 있습니다.
- </p>
- </div>
- <Separator />
- <div>
- <VendorRfqHistoryTable promises={promises} />
- </div>
- </div>
- )
-}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/vendors/page.tsx b/app/[lng]/engineering/(engineering)/vendors/page.tsx deleted file mode 100644 index 52af0709..00000000 --- a/app/[lng]/engineering/(engineering)/vendors/page.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import * as React from "react" -import { type SearchParams } from "@/types/table" - -import { getValidFilters } from "@/lib/data-table" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" - - -import { searchParamsCache } from "@/lib/vendors/validations" -import { getVendors, getVendorStatusCounts } from "@/lib/vendors/service" -import { VendorsTable } from "@/lib/vendors/table/vendors-table" -import { Ellipsis } from "lucide-react" - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsCache.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getVendors({ - ...search, - filters: validFilters, - }), - getVendorStatusCounts(), - ]) - - return ( - <Shell className="gap-2"> - - <div className="flex items-center justify-between space-y-2"> - <div className="flex items-center justify-between space-y-2"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - 협력업체 리스트 - </h2> - <p className="text-muted-foreground"> - 협력업체에 대한 요약 정보를 확인하고{" "} - <span className="inline-flex items-center whitespace-nowrap"> - <Ellipsis className="size-3" /> - <span className="ml-1">버튼</span> - </span> - 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. <br/>벤더의 상태에 따라 가입을 승인해주거나 PQ 요청을 할 수 있고 검토가 완료된 벤더를 기간계 시스템에 전송하여 협력업체 코드를 따올 수 있습니다. - </p> - </div> - </div> - </div> - - - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> - {/* <DateRangePicker - triggerSize="sm" - triggerClassName="ml-auto w-56 sm:w-60" - align="end" - shallow={false} - /> */} - </React.Suspense> - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <VendorsTable promises={promises} /> - </React.Suspense> - </Shell> - ) -} diff --git a/app/[lng]/evcp/(evcp)/evaluation-input/[id]/page.tsx b/app/[lng]/evcp/(evcp)/evaluation-input/[id]/page.tsx new file mode 100644 index 00000000..3a403620 --- /dev/null +++ b/app/[lng]/evcp/(evcp)/evaluation-input/[id]/page.tsx @@ -0,0 +1,22 @@ +import { EvaluationPage } from "@/lib/evaluation-submit/evaluation-page" +import { Metadata } from "next" + +export const metadata: Metadata = { + title: "평가 작성", + description: "협력업체 평가를 작성합니다", +} + +interface PageProps { + params: { + id: string + } +} + +export default function Page({ params }: PageProps) { + return <EvaluationPage /> +} + +export async function generateStaticParams() { + // 동적 경로이므로 빈 배열 반환 + return [] +}
\ No newline at end of file diff --git a/app/[lng]/evcp/(evcp)/evaluation-input/page.tsx b/app/[lng]/evcp/(evcp)/evaluation-input/page.tsx new file mode 100644 index 00000000..2cf5449f --- /dev/null +++ b/app/[lng]/evcp/(evcp)/evaluation-input/page.tsx @@ -0,0 +1,135 @@ +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 { getServerSession } from "next-auth" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import Link from "next/link" +import { Button } from "@/components/ui/button" +import { LogIn } from "lucide-react" +import { getSHIEvaluationSubmissions } from "@/lib/evaluation-submit/service" +import { getSHIEvaluationsSubmitSchema } from "@/lib/evaluation-submit/validation" +import { SHIEvaluationSubmissionsTable } from "@/lib/evaluation-submit/table/submit-table" + +interface IndexPageProps { + searchParams: Promise<SearchParams> +} + +export default async function IndexPage(props: IndexPageProps) { + const searchParams = await props.searchParams + const search = getSHIEvaluationsSubmitSchema.parse(searchParams) + const validFilters = getValidFilters(search.filters) + + // Get session + const session = await getServerSession(authOptions) + + // Check if user is logged in + if (!session || !session.user) { + // Return login required UI instead of redirecting + return ( + <Shell className="gap-6"> + <div className="flex items-center justify-between"> + <div> + <div className="flex items-center gap-2"> + <h2 className="text-2xl font-bold tracking-tight"> + 정기평가 + </h2> + </div> + <p className="text-muted-foreground"> + 요청된 정기평가를 입력하고 제출할 수 있습니다. + </p> + </div> + </div> + + <div className="flex flex-col items-center justify-center py-12 text-center"> + <div className="rounded-lg border border-dashed p-10 shadow-sm"> + <h3 className="mb-2 text-xl font-semibold">로그인이 필요합니다</h3> + <p className="mb-6 text-muted-foreground"> + 정기평가를 확인하려면 먼저 로그인하세요. + </p> + <Button size="lg" asChild> + <Link href="/partners"> + <LogIn className="mr-2 h-4 w-4" /> + 로그인하기 + </Link> + </Button> + </div> + </div> + </Shell> + ) + } + + const userId = session.user.id + + // Validate vendorId (should be a number) + const idAsNumber = Number(userId) + + + if (isNaN(idAsNumber)) { + // Handle invalid vendor ID (this shouldn't happen if authentication is working properly) + return ( + <Shell className="gap-6"> + <div className="flex items-center justify-between"> + <div> + <h2 className="text-2xl font-bold tracking-tight"> + 정기평가 + </h2> + </div> + </div> + <div className="flex flex-col items-center justify-center py-12 text-center"> + <div className="rounded-lg border border-dashed p-10 shadow-sm"> + <h3 className="mb-2 text-xl font-semibold">계정 오류</h3> + <p className="mb-6 text-muted-foreground"> + 관리자에게 문의하세요. + </p> + </div> + </div> + </Shell> + ) + } + + // If we got here, we have a valid vendor ID + const promises = Promise.all([ + getSHIEvaluationSubmissions({ + ...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"> + 정기평가 + </h2> + <p className="text-muted-foreground"> + 요청된 정기평가를 입력하고 제출할 수 있습니다. + </p> + </div> + </div> + </div> + + <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> + {/* DateRangePicker can go here */} + </React.Suspense> + + <React.Suspense + fallback={ + <DataTableSkeleton + columnCount={6} + searchableColumnCount={1} + filterableColumnCount={2} + cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} + shrinkZero + /> + } + > + <SHIEvaluationSubmissionsTable promises={promises} /> + </React.Suspense> + </Shell> + ) +}
\ No newline at end of file diff --git a/app/[lng]/engineering/(engineering)/vendor-type/page.tsx b/app/[lng]/evcp/(evcp)/login-history/page.tsx index 997c0f82..af9c94f2 100644 --- a/app/[lng]/engineering/(engineering)/vendor-type/page.tsx +++ b/app/[lng]/evcp/(evcp)/login-history/page.tsx @@ -5,27 +5,27 @@ 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" +import { InformationButton } from "@/components/information/information-button" +import { getLoginSessions } from "@/lib/login-session/service" +import { searchParamsCache } from "@/lib/login-session/validation" +import { LoginSessionsTable } from "@/lib/login-session/table/login-sessions-table" -interface IndexPageProps { +interface LoginHistoryPageProps { searchParams: Promise<SearchParams> } -export default async function IndexPage(props: IndexPageProps) { +export default async function LoginHistoryPage(props: LoginHistoryPageProps) { const searchParams = await props.searchParams const search = searchParamsCache.parse(searchParams) const validFilters = getValidFilters(search.filters) const promises = Promise.all([ - getVendorTypes({ + getLoginSessions({ ...search, filters: validFilters, }), - ]) return ( @@ -33,38 +33,36 @@ export default async function IndexPage(props: IndexPageProps) { <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> + <div className="flex items-center gap-2"> + <h2 className="text-2xl font-bold tracking-tight"> + 로그인 세션 이력 + </h2> + <InformationButton pagePath="admin/sessions/login-history" /> + </div> <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"]} + columnCount={8} + searchableColumnCount={2} + filterableColumnCount={3} + cellWidths={["12rem", "16rem", "12rem", "10rem", "12rem", "10rem", "8rem", "8rem"]} shrinkZero /> } > - <VendorTypesTable promises={promises} /> + <LoginSessionsTable promises={promises} /> </React.Suspense> </Shell> ) -} +}
\ No newline at end of file diff --git a/app/[lng]/evcp/(evcp)/report/page.tsx b/app/[lng]/evcp/(evcp)/report/page.tsx index 95566b05..f84ebe52 100644 --- a/app/[lng]/evcp/(evcp)/report/page.tsx +++ b/app/[lng]/evcp/(evcp)/report/page.tsx @@ -1,26 +1,22 @@ - // app/procurement/dashboard/page.tsx import * as React from "react"; import { Skeleton } from "@/components/ui/skeleton"; import { Shell } from "@/components/shell"; import { ErrorBoundary } from "@/components/error-boundary"; -import { getDashboardData } from "@/lib/dashboard/service"; +import { getDashboardData, refreshDashboardData } from "@/lib/dashboard/service"; import { DashboardClient } from "@/lib/dashboard/dashboard-client"; +export const dynamic = 'force-dynamic'; // ① 동적 페이지 선언 + // 대시보드 데이터 로딩 컴포넌트 async function DashboardContent() { try { const data = await getDashboardData("evcp"); - - const handleRefresh = async () => { - "use server"; - return await getDashboardData("evcp"); - }; return ( <DashboardClient initialData={data} - onRefresh={handleRefresh} + onRefresh={refreshDashboardData} /> ); } catch (error) { @@ -119,9 +115,11 @@ function DashboardError({ error, reset }: { error: Error; reset: () => void }) { export default async function DashboardPage() { return ( <Shell className="gap-6"> + <ErrorBoundary fallback={DashboardError}> <React.Suspense fallback={<DashboardSkeleton />}> <DashboardContent /> </React.Suspense> + </ErrorBoundary> </Shell> ); -} +}
\ No newline at end of file diff --git a/app/[lng]/partners/(partners)/dashboard/page.tsx b/app/[lng]/partners/(partners)/dashboard/page.tsx index 71b70abc..09589cb5 100644 --- a/app/[lng]/partners/(partners)/dashboard/page.tsx +++ b/app/[lng]/partners/(partners)/dashboard/page.tsx @@ -1,34 +1,39 @@ - -// app/procurement/dashboard/page.tsx import * as React from "react"; import { Skeleton } from "@/components/ui/skeleton"; import { Shell } from "@/components/shell"; +import { ErrorBoundary } from "@/components/error-boundary"; +import { getDashboardData } from "@/lib/dashboard/service"; import { DashboardClient } from "@/lib/dashboard/dashboard-client"; import { getPartnersDashboardData } from "@/lib/dashboard/partners-service"; -// 대시보드 데이터 로딩 컴포넌트 -async function DashboardContent() { +export default async function IndexPage() { + // domain을 명시적으로 전달 + const domain = "partners"; + try { - const data = await getPartnersDashboardData("partners"); + // 서버에서 직접 데이터 fetch + const dashboardData = await getPartnersDashboardData(domain); - const handleRefresh = async () => { - "use server"; - return await getPartnersDashboardData("partners"); - }; - return ( - <DashboardClient - initialData={data} - onRefresh={handleRefresh} - /> + <Shell className="gap-2"> + <DashboardClient initialData={dashboardData} /> + </Shell> ); } catch (error) { - console.error("Dashboard data loading error:", error); - throw error; + console.error("Dashboard data fetch error:", error); + return ( + <Shell className="gap-2"> + <div className="flex items-center justify-center py-12"> + <div className="text-center space-y-2"> + <p className="text-destructive">데이터를 불러오는데 실패했습니다.</p> + <p className="text-muted-foreground text-sm">{error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다."}</p> + </div> + </div> + </Shell> + ); } } -// 대시보드 로딩 스켈레톤 function DashboardSkeleton() { return ( <div className="space-y-6"> @@ -94,33 +99,3 @@ function DashboardSkeleton() { </div> ); } - -// 에러 표시 컴포넌트 -function DashboardError({ error, reset }: { error: Error; reset: () => void }) { - return ( - <div className="flex flex-col items-center justify-center py-12 space-y-4"> - <div className="text-center space-y-2"> - <h3 className="text-lg font-semibold">대시보드를 불러올 수 없습니다</h3> - <p className="text-muted-foreground"> - {error.message || "알 수 없는 오류가 발생했습니다."} - </p> - </div> - <button - onClick={reset} - className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90" - > - 다시 시도 - </button> - </div> - ); -} - -export default async function DashboardPage() { - return ( - <Shell className="gap-6"> - <React.Suspense fallback={<DashboardSkeleton />}> - <DashboardContent /> - </React.Suspense> - </Shell> - ); -} diff --git a/app/[lng]/procurement/(procurement)/bid-projects/page.tsx b/app/[lng]/procurement/(procurement)/bid-projects/page.tsx deleted file mode 100644 index 2039e5b2..00000000 --- a/app/[lng]/procurement/(procurement)/bid-projects/page.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import * as React from "react" -import { type SearchParams } from "@/types/table" - -import { getValidFilters } from "@/lib/data-table" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" -import { 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(S-ERP)로부터 수신한 견적 프로젝트 데이터입니다. 기술영업의 Budgetary RFQ에서 사용됩니다. - {/* <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]/procurement/(procurement)/budgetary-tech-sales-hull/page.tsx b/app/[lng]/procurement/(procurement)/budgetary-tech-sales-hull/page.tsx deleted file mode 100644 index b1be29db..00000000 --- a/app/[lng]/procurement/(procurement)/budgetary-tech-sales-hull/page.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { searchParamsHullCache } from "@/lib/techsales-rfq/validations" -import { getTechSalesHullRfqsWithJoin } from "@/lib/techsales-rfq/service" -import { getValidFilters } from "@/lib/data-table" -import { Shell } from "@/components/shell" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { RFQListTable } from "@/lib/techsales-rfq/table/rfq-table" -import { type SearchParams } from "@/types/table" -import * as React from "react" - -interface HullRfqPageProps { - searchParams: Promise<SearchParams> -} - -export default async function HullRfqPage(props: HullRfqPageProps) { - // searchParams를 await하여 resolve - const searchParams = await props.searchParams - - // 해양 HULL용 파라미터 파싱 - const search = searchParamsHullCache.parse(searchParams); - const validFilters = getValidFilters(search.filters); - - // 기술영업 해양 Hull RFQ 데이터를 Promise.all로 감싸서 전달 - const promises = Promise.all([ - getTechSalesHullRfqsWithJoin({ - ...search, // 모든 파라미터 전달 (page, perPage, sort, basicFilters, filters 등) - filters: validFilters, // 고급 필터를 명시적으로 오버라이드 (파싱된 버전) - }) - ]) - - return ( - <Shell variant="fullscreen" className="h-full"> {/* fullscreen variant 사용 */} - {/* 고정 헤더 영역 */} - <div className="flex-shrink-0"> - <div className="flex items-center justify-between"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - 기술영업-해양 Hull RFQ - </h2> - </div> - </div> - </div> - - {/* 테이블 영역 - 남은 공간 모두 차지 */} - <div className="flex-1 min-h-0"> - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={8} - searchableColumnCount={2} - filterableColumnCount={3} - cellWidths={["10rem", "15rem", "12rem", "12rem", "8rem", "8rem", "10rem", "8rem"]} - shrinkZero - /> - } - > - <RFQListTable promises={promises} className="h-full" rfqType="HULL" /> - </React.Suspense> - </div> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/budgetary-tech-sales-ship/page.tsx b/app/[lng]/procurement/(procurement)/budgetary-tech-sales-ship/page.tsx deleted file mode 100644 index b7bf9d15..00000000 --- a/app/[lng]/procurement/(procurement)/budgetary-tech-sales-ship/page.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { searchParamsShipCache } from "@/lib/techsales-rfq/validations" -import { getTechSalesShipRfqsWithJoin } from "@/lib/techsales-rfq/service" -import { getValidFilters } from "@/lib/data-table" -import { Shell } from "@/components/shell" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { RFQListTable } from "@/lib/techsales-rfq/table/rfq-table" -import { type SearchParams } from "@/types/table" -import * as React from "react" - -interface RfqPageProps { - searchParams: Promise<SearchParams> -} - -export default async function RfqPage(props: RfqPageProps) { - // searchParams를 await하여 resolve - const searchParams = await props.searchParams - - // 조선용 파라미터 파싱 - const search = searchParamsShipCache.parse(searchParams); - const validFilters = getValidFilters(search.filters); - - // 기술영업 조선 RFQ 데이터를 Promise.all로 감싸서 전달 - const promises = Promise.all([ - getTechSalesShipRfqsWithJoin({ - ...search, // 모든 파라미터 전달 (page, perPage, sort, basicFilters, filters 등) - filters: validFilters, // 고급 필터를 명시적으로 오버라이드 (파싱된 버전) - }) - ]) - - return ( - <Shell variant="fullscreen" className="h-full"> {/* fullscreen variant 사용 */} - {/* 고정 헤더 영역 */} - <div className="flex-shrink-0"> - <div className="flex items-center justify-between"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - 기술영업-조선 RFQ - </h2> - </div> - </div> - </div> - - {/* 테이블 영역 - 남은 공간 모두 차지 */} - <div className="flex-1 min-h-0"> - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={8} - searchableColumnCount={2} - filterableColumnCount={3} - cellWidths={["10rem", "15rem", "12rem", "12rem", "8rem", "8rem", "10rem", "8rem"]} - shrinkZero - /> - } - > - <RFQListTable promises={promises} className="h-full" rfqType="SHIP" /> - </React.Suspense> - </div> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/budgetary-tech-sales-top/page.tsx b/app/[lng]/procurement/(procurement)/budgetary-tech-sales-top/page.tsx deleted file mode 100644 index f84a9794..00000000 --- a/app/[lng]/procurement/(procurement)/budgetary-tech-sales-top/page.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { searchParamsTopCache } from "@/lib/techsales-rfq/validations" -import { getTechSalesTopRfqsWithJoin } from "@/lib/techsales-rfq/service" -import { getValidFilters } from "@/lib/data-table" -import { Shell } from "@/components/shell" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { RFQListTable } from "@/lib/techsales-rfq/table/rfq-table" -import { type SearchParams } from "@/types/table" -import * as React from "react" - -interface HullRfqPageProps { - searchParams: Promise<SearchParams> -} - -export default async function HullRfqPage(props: HullRfqPageProps) { - // searchParams를 await하여 resolve - const searchParams = await props.searchParams - - // 해양 TOP용 파라미터 파싱 - const search = searchParamsTopCache.parse(searchParams); - const validFilters = getValidFilters(search.filters); - - // 기술영업 해양 TOP RFQ 데이터를 Promise.all로 감싸서 전달 - const promises = Promise.all([ - getTechSalesTopRfqsWithJoin({ - ...search, // 모든 파라미터 전달 (page, perPage, sort, basicFilters, filters 등) - filters: validFilters, // 고급 필터를 명시적으로 오버라이드 (파싱된 버전) - }) - ]) - - return ( - <Shell variant="fullscreen" className="h-full"> {/* fullscreen variant 사용 */} - {/* 고정 헤더 영역 */} - <div className="flex-shrink-0"> - <div className="flex items-center justify-between"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - 기술영업-해양 TOP RFQ - </h2> - </div> - </div> - </div> - - {/* 테이블 영역 - 남은 공간 모두 차지 */} - <div className="flex-1 min-h-0"> - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={8} - searchableColumnCount={2} - filterableColumnCount={3} - cellWidths={["10rem", "15rem", "12rem", "12rem", "8rem", "8rem", "10rem", "8rem"]} - shrinkZero - /> - } - > - <RFQListTable promises={promises} className="h-full" rfqType="TOP" /> - </React.Suspense> - </div> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/cbe-tech/page.tsx b/app/[lng]/procurement/(procurement)/cbe-tech/page.tsx deleted file mode 100644 index 4dadc58f..00000000 --- a/app/[lng]/procurement/(procurement)/cbe-tech/page.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { getAllCBE } from "@/lib/rfqs-tech/service" -import { searchParamsCBECache } from "@/lib/rfqs-tech/validations" -import { AllCbeTable } from "@/lib/cbe-tech/table/cbe-table" -import * as React from "react" -import { Shell } from "@/components/shell" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" - -interface IndexPageProps { - params: { - lng: string - } - searchParams: Promise<SearchParams> -} - -export default async function RfqCBEPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - - // URL 쿼리 파라미터에서 타입 추출 - const searchParams = await props.searchParams - - // SearchParams 파싱 (Zod) - const search = searchParamsCBECache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - // 현재 선택된 타입의 데이터 로드 - const promises = Promise.all([ - getAllCBE({ - ...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"> - 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]/procurement/(procurement)/evaluation-input/[id]/page.tsx b/app/[lng]/procurement/(procurement)/evaluation-input/[id]/page.tsx new file mode 100644 index 00000000..3a403620 --- /dev/null +++ b/app/[lng]/procurement/(procurement)/evaluation-input/[id]/page.tsx @@ -0,0 +1,22 @@ +import { EvaluationPage } from "@/lib/evaluation-submit/evaluation-page" +import { Metadata } from "next" + +export const metadata: Metadata = { + title: "평가 작성", + description: "협력업체 평가를 작성합니다", +} + +interface PageProps { + params: { + id: string + } +} + +export default function Page({ params }: PageProps) { + return <EvaluationPage /> +} + +export async function generateStaticParams() { + // 동적 경로이므로 빈 배열 반환 + return [] +}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/evaluation-input/page.tsx b/app/[lng]/procurement/(procurement)/evaluation-input/page.tsx new file mode 100644 index 00000000..2cf5449f --- /dev/null +++ b/app/[lng]/procurement/(procurement)/evaluation-input/page.tsx @@ -0,0 +1,135 @@ +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 { getServerSession } from "next-auth" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import Link from "next/link" +import { Button } from "@/components/ui/button" +import { LogIn } from "lucide-react" +import { getSHIEvaluationSubmissions } from "@/lib/evaluation-submit/service" +import { getSHIEvaluationsSubmitSchema } from "@/lib/evaluation-submit/validation" +import { SHIEvaluationSubmissionsTable } from "@/lib/evaluation-submit/table/submit-table" + +interface IndexPageProps { + searchParams: Promise<SearchParams> +} + +export default async function IndexPage(props: IndexPageProps) { + const searchParams = await props.searchParams + const search = getSHIEvaluationsSubmitSchema.parse(searchParams) + const validFilters = getValidFilters(search.filters) + + // Get session + const session = await getServerSession(authOptions) + + // Check if user is logged in + if (!session || !session.user) { + // Return login required UI instead of redirecting + return ( + <Shell className="gap-6"> + <div className="flex items-center justify-between"> + <div> + <div className="flex items-center gap-2"> + <h2 className="text-2xl font-bold tracking-tight"> + 정기평가 + </h2> + </div> + <p className="text-muted-foreground"> + 요청된 정기평가를 입력하고 제출할 수 있습니다. + </p> + </div> + </div> + + <div className="flex flex-col items-center justify-center py-12 text-center"> + <div className="rounded-lg border border-dashed p-10 shadow-sm"> + <h3 className="mb-2 text-xl font-semibold">로그인이 필요합니다</h3> + <p className="mb-6 text-muted-foreground"> + 정기평가를 확인하려면 먼저 로그인하세요. + </p> + <Button size="lg" asChild> + <Link href="/partners"> + <LogIn className="mr-2 h-4 w-4" /> + 로그인하기 + </Link> + </Button> + </div> + </div> + </Shell> + ) + } + + const userId = session.user.id + + // Validate vendorId (should be a number) + const idAsNumber = Number(userId) + + + if (isNaN(idAsNumber)) { + // Handle invalid vendor ID (this shouldn't happen if authentication is working properly) + return ( + <Shell className="gap-6"> + <div className="flex items-center justify-between"> + <div> + <h2 className="text-2xl font-bold tracking-tight"> + 정기평가 + </h2> + </div> + </div> + <div className="flex flex-col items-center justify-center py-12 text-center"> + <div className="rounded-lg border border-dashed p-10 shadow-sm"> + <h3 className="mb-2 text-xl font-semibold">계정 오류</h3> + <p className="mb-6 text-muted-foreground"> + 관리자에게 문의하세요. + </p> + </div> + </div> + </Shell> + ) + } + + // If we got here, we have a valid vendor ID + const promises = Promise.all([ + getSHIEvaluationSubmissions({ + ...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"> + 정기평가 + </h2> + <p className="text-muted-foreground"> + 요청된 정기평가를 입력하고 제출할 수 있습니다. + </p> + </div> + </div> + </div> + + <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> + {/* DateRangePicker can go here */} + </React.Suspense> + + <React.Suspense + fallback={ + <DataTableSkeleton + columnCount={6} + searchableColumnCount={1} + filterableColumnCount={2} + cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} + shrinkZero + /> + } + > + <SHIEvaluationSubmissionsTable promises={promises} /> + </React.Suspense> + </Shell> + ) +}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/evaluation-target-list/page.tsx b/app/[lng]/procurement/(procurement)/evaluation-target-list/page.tsx index 088ae75b..9ec30b66 100644 --- a/app/[lng]/procurement/(procurement)/evaluation-target-list/page.tsx +++ b/app/[lng]/procurement/(procurement)/evaluation-target-list/page.tsx @@ -16,7 +16,7 @@ import { Badge } from "@/components/ui/badge" import { getDefaultEvaluationYear, searchParamsEvaluationTargetsCache } from "@/lib/evaluation-target-list/validation" import { getEvaluationTargets } from "@/lib/evaluation-target-list/service" import { EvaluationTargetsTable } from "@/lib/evaluation-target-list/table/evaluation-target-table" - +import { InformationButton } from "@/components/information/information-button" export const metadata: Metadata = { title: "협력업체 평가 대상 확정", description: "협력업체 평가 대상을 확정하고 담당자를 지정합니다.", @@ -66,9 +66,12 @@ export default async function EvaluationTargetsPage(props: EvaluationTargetsPage <div className="flex items-center justify-between space-y-2"> <div className="flex items-center justify-between space-y-2"> <div className="flex items-center gap-2"> - <h2 className="text-2xl font-bold tracking-tight"> - 협력업체 평가 대상 확정 - </h2> + <div className="flex items-center gap-2"> + <h2 className="text-2xl font-bold tracking-tight"> + 협력업체 평가 대상 확정 + </h2> + <InformationButton pagePath="evcp/evaluation-target-list" /> + </div> <Badge variant="outline" className="text-sm"> {currentEvaluationYear}년도 </Badge> diff --git a/app/[lng]/procurement/(procurement)/form-list/page.tsx b/app/[lng]/procurement/(procurement)/form-list/page.tsx deleted file mode 100644 index a6cf7d9e..00000000 --- a/app/[lng]/procurement/(procurement)/form-list/page.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import * as React from "react" -import { type SearchParams } from "@/types/table" - -import { getValidFilters } from "@/lib/data-table" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" -import { searchParamsCache } from "@/lib/form-list/validation" -import { ItemsTable } from "@/lib/items/table/items-table" -import { getFormLists } from "@/lib/form-list/service" -import { FormListsTable } from "@/lib/form-list/table/formLists-table" - - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsCache.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getFormLists({ - ...search, - filters: validFilters, - }), - - ]) - - return ( - <Shell className="gap-2"> - <div className="flex items-center justify-between space-y-2"> - <div className="flex items-center justify-between space-y-2"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - 레지스터 목록 from S-EDP - </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 - /> - } - > - <FormListsTable promises={promises} /> - </React.Suspense> - </Shell> - ) -} diff --git a/app/[lng]/procurement/(procurement)/report/page.tsx b/app/[lng]/procurement/(procurement)/report/page.tsx index 800fbd8b..adeb31aa 100644 --- a/app/[lng]/procurement/(procurement)/report/page.tsx +++ b/app/[lng]/procurement/(procurement)/report/page.tsx @@ -1,5 +1,3 @@ - -// app/procurement/dashboard/page.tsx import * as React from "react"; import { Skeleton } from "@/components/ui/skeleton"; import { Shell } from "@/components/shell"; @@ -7,29 +5,34 @@ import { ErrorBoundary } from "@/components/error-boundary"; import { getDashboardData } from "@/lib/dashboard/service"; import { DashboardClient } from "@/lib/dashboard/dashboard-client"; -// 대시보드 데이터 로딩 컴포넌트 -async function DashboardContent() { +export default async function IndexPage() { + // domain을 명시적으로 전달 + const domain = "procurement"; + try { - const data = await getDashboardData("procurement"); + // 서버에서 직접 데이터 fetch + const dashboardData = await getDashboardData(domain); - const handleRefresh = async () => { - "use server"; - return await getDashboardData("procurement"); - }; - return ( - <DashboardClient - initialData={data} - onRefresh={handleRefresh} - /> + <Shell className="gap-2"> + <DashboardClient initialData={dashboardData} /> + </Shell> ); } catch (error) { - console.error("Dashboard data loading error:", error); - throw error; + console.error("Dashboard data fetch error:", error); + return ( + <Shell className="gap-2"> + <div className="flex items-center justify-center py-12"> + <div className="text-center space-y-2"> + <p className="text-destructive">데이터를 불러오는데 실패했습니다.</p> + <p className="text-muted-foregroucdnd text-sm">{error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다."}</p> + </div> + </div> + </Shell> + ); } } -// 대시보드 로딩 스켈레톤 function DashboardSkeleton() { return ( <div className="space-y-6"> @@ -95,35 +98,3 @@ function DashboardSkeleton() { </div> ); } - -// 에러 표시 컴포넌트 -function DashboardError({ error, reset }: { error: Error; reset: () => void }) { - return ( - <div className="flex flex-col items-center justify-center py-12 space-y-4"> - <div className="text-center space-y-2"> - <h3 className="text-lg font-semibold">대시보드를 불러올 수 없습니다</h3> - <p className="text-muted-foreground"> - {error.message || "알 수 없는 오류가 발생했습니다."} - </p> - </div> - <button - onClick={reset} - className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90" - > - 다시 시도 - </button> - </div> - ); -} - -export default async function DashboardPage() { - return ( - <Shell className="gap-6"> - <ErrorBoundary fallback={DashboardError}> - <React.Suspense fallback={<DashboardSkeleton />}> - <DashboardContent /> - </React.Suspense> - </ErrorBoundary> - </Shell> - ); -} diff --git a/app/[lng]/procurement/(procurement)/rfq-tech/[id]/cbe/page.tsx b/app/[lng]/procurement/(procurement)/rfq-tech/[id]/cbe/page.tsx deleted file mode 100644 index 84379caf..00000000 --- a/app/[lng]/procurement/(procurement)/rfq-tech/[id]/cbe/page.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { searchParamsCBECache } from "@/lib/rfqs-tech/validations" -import { getCBE } from "@/lib/rfqs-tech/service" -import { CbeTable } from "@/lib/rfqs-tech/cbe-table/cbe-table" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> -} - -export default async function RfqCBEPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsCBECache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getCBE({ - ...search, - filters: validFilters, - }, - idAsNumber) - ]) - - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - Commercial Bid Evaluation - </h3> - <p className="text-sm text-muted-foreground"> - 초대된 협력업체에게 CBE를 보낼 수 있습니다. <br />"발행하기" 버튼을 통해 CBE를 전송하면 CBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. - </p> - </div> - <Separator /> - <div> - <CbeTable promises={promises} rfqId={idAsNumber} /> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/rfq-tech/[id]/layout.tsx b/app/[lng]/procurement/(procurement)/rfq-tech/[id]/layout.tsx deleted file mode 100644 index 0bb62fe0..00000000 --- a/app/[lng]/procurement/(procurement)/rfq-tech/[id]/layout.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { Metadata } from "next" -import Link from "next/link" -import { Separator } from "@/components/ui/separator" -import { SidebarNav } from "@/components/layout/sidebar-nav" -import { RfqViewWithItems } from "@/db/schema/rfq" -import { findRfqById } from "@/lib/rfqs-tech/service" -import { formatDate } from "@/lib/utils" -import { Button } from "@/components/ui/button" -import { ArrowLeft } from "lucide-react" - -export const metadata: Metadata = { - title: "Vendor Detail", -} - -export default async function RfqLayout({ - children, - params, -}: { - children: React.ReactNode - params: { lng: string, id: string } -}) { - - // 1) URL 파라미터에서 id 추출, Number로 변환 - const resolvedParams = await params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - // 2) DB에서 해당 협력업체 정보 조회 - const rfq: RfqViewWithItems | null = await findRfqById(idAsNumber) - - // 3) 사이드바 메뉴 - const sidebarNavItems = [ - { - title: "Matched Vendors", - href: `/${lng}/evcp/rfq-tech/${id}`, - }, - { - title: "TBE", - href: `/${lng}/evcp/rfq-tech/${id}/tbe`, - }, - { - title: "CBE", - href: `/${lng}/evcp/rfq-tech/${id}/cbe`, - }, - - ] - - return ( - <> - <div className="container py-6"> - <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow"> - <div className="hidden space-y-6 p-10 pb-16 md:block"> - <div className="flex items-center justify-end mb-4"> - <Link href={`/${lng}/evcp/rfq`} passHref> - <Button variant="ghost" className="flex items-center text-primary hover:text-primary/80 transition-colors p-0 h-auto"> - <ArrowLeft className="mr-1 h-4 w-4" /> - <span>RFQ 목록으로 돌아가기</span> - </Button> - </Link> - </div> - <div className="space-y-0.5"> - {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */} - <h2 className="text-2xl font-bold tracking-tight"> - {rfq - ? `${rfq.projectCode ?? ""} ${rfq.rfqCode ?? ""} 관리` - : "Loading RFQ..."} - </h2> - - <p className="text-muted-foreground"> - {rfq - ? `${rfq.description ?? ""} ${rfq.lines.map(line => line.itemCode).join(", ")}` - : ""} - </p> - <h3>Due Date:{rfq && rfq?.dueDate && <strong>{formatDate(rfq?.dueDate)}</strong>}</h3> - </div> - <Separator className="my-6" /> - <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0"> - <aside className="lg:w-64 flex-shrink-0"> - <SidebarNav items={sidebarNavItems} /> - </aside> - <div className="lg:w-[calc(100%-16rem)] overflow-auto">{children}</div> - </div> - </div> - </section> - </div> - </> - ) -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/rfq-tech/[id]/page.tsx b/app/[lng]/procurement/(procurement)/rfq-tech/[id]/page.tsx deleted file mode 100644 index 007270a1..00000000 --- a/app/[lng]/procurement/(procurement)/rfq-tech/[id]/page.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { getMatchedVendors } from "@/lib/rfqs-tech/service" -import { searchParamsMatchedVCache } from "@/lib/rfqs-tech/validations" -import { MatchedVendorsTable } from "@/lib/rfqs-tech/vendor-table/vendors-table" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> -} - -export default async function RfqPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsMatchedVCache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getMatchedVendors({ - ...search, - filters: validFilters, - }, - idAsNumber) - ]) - - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - Vendors - </h3> - <p className="text-sm text-muted-foreground"> - 등록된 협력업체 중에서 이 RFQ 아이템에 매칭되는 업체를 보여줍니다. <br/>"발행하기" 버튼을 통해 RFQ를 전송하면 첨부파일과 함께 RFQ 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. - </p> - </div> - <Separator /> - <div> - <MatchedVendorsTable promises={promises} rfqId={idAsNumber}/> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/rfq-tech/[id]/tbe/page.tsx b/app/[lng]/procurement/(procurement)/rfq-tech/[id]/tbe/page.tsx deleted file mode 100644 index 4b226cdc..00000000 --- a/app/[lng]/procurement/(procurement)/rfq-tech/[id]/tbe/page.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { getTBE } from "@/lib/rfqs-tech/service" -import { searchParamsTBECache } from "@/lib/rfqs-tech/validations" -import { TbeTable } from "@/lib/rfqs-tech/tbe-table/tbe-table" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> -} - -export default async function RfqTBEPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsTBECache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getTBE({ - ...search, - filters: validFilters, - }, - idAsNumber) - ]) - - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - Technical Bid Evaluation - </h3> - <p className="text-sm text-muted-foreground"> - 초대된 협력업체에게 TBE를 보낼 수 있습니다. <br/>"발행하기" 버튼을 통해 TBE를 전송하면 첨부파일과 함께 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. - </p> - </div> - <Separator /> - <div> - <TbeTable promises={promises} rfqId={idAsNumber}/> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/rfq-tech/page.tsx b/app/[lng]/procurement/(procurement)/rfq-tech/page.tsx deleted file mode 100644 index f35b3632..00000000 --- a/app/[lng]/procurement/(procurement)/rfq-tech/page.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import * as React from "react" -import { type SearchParams } from "@/types/table" - -import { getValidFilters } from "@/lib/data-table" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" - -import { searchParamsCache } from "@/lib/rfqs-tech/validations" -import { getRfqs, getRfqStatusCounts } from "@/lib/rfqs-tech/service" -import { RfqsTable } from "@/lib/rfqs-tech/table/rfqs-table" -import { getAllOffshoreItems } from "@/lib/items-tech/service" - -interface RfqPageProps { - searchParams: Promise<SearchParams>; - title: string; - description: string; -} - -export default async function RfqPage({ - searchParams, - title = "기술영업 해양 RFQ", - description = "기술영업 해양 RFQ를 등록하고 관리할 수 있습니다." -}: RfqPageProps) { - const search = searchParamsCache.parse(await searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getRfqs({ - ...search, - filters: validFilters, - }), - getRfqStatusCounts(), - getAllOffshoreItems() - ]) - - return ( - <Shell className="gap-2"> - <div className="flex items-center justify-between space-y-2"> - <div className="flex items-center justify-between space-y-2"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - {title} - </h2> - <p className="text-muted-foreground"> - {description} - </p> - </div> - </div> - </div> - - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> - {/* <DateRangePicker - triggerSize="sm" - triggerClassName="ml-auto w-56 sm:w-60" - align="end" - shallow={false} - /> */} - </React.Suspense> - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <RfqsTable promises={promises} /> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/tag-numbering/page.tsx b/app/[lng]/procurement/(procurement)/tag-numbering/page.tsx deleted file mode 100644 index 44695259..00000000 --- a/app/[lng]/procurement/(procurement)/tag-numbering/page.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import * as React from "react" -import { type SearchParams } from "@/types/table" - -import { getValidFilters } from "@/lib/data-table" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" -import { searchParamsCache } from "@/lib/tag-numbering/validation" -import { getTagNumbering } from "@/lib/tag-numbering/service" -import { TagNumberingTable } from "@/lib/tag-numbering/table/tagNumbering-table" - - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsCache.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getTagNumbering({ - ...search, - filters: validFilters, - }), - - ]) - - return ( - <Shell className="gap-2"> - <div className="flex items-center justify-between space-y-2"> - <div className="flex items-center justify-between space-y-2"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - 태그 타입 목록 from S-EDP - </h2> - <p className="text-muted-foreground"> - 태그 넘버링을 위한 룰셋을 S-EDP로부터 가져오고 확인할 수 있습니다{" "} - {/* <span className="inline-flex items-center whitespace-nowrap"> - <Ellipsis className="size-3" /> - <span className="ml-1">버튼</span> - </span> - 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. */} - </p> - </div> - </div> - </div> - - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> - {/* <DateRangePicker - triggerSize="sm" - triggerClassName="ml-auto w-56 sm:w-60" - align="end" - shallow={false} - /> */} - </React.Suspense> - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <TagNumberingTable promises={promises} /> - </React.Suspense> - </Shell> - ) -} diff --git a/app/[lng]/procurement/(procurement)/tasks/page.tsx b/app/[lng]/procurement/(procurement)/tasks/page.tsx deleted file mode 100644 index 91b946fb..00000000 --- a/app/[lng]/procurement/(procurement)/tasks/page.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import * as React from "react" -import { type SearchParams } from "@/types/table" - -import { getValidFilters } from "@/lib/data-table" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { DateRangePicker } from "@/components/date-range-picker" -import { Shell } from "@/components/shell" - -import { FeatureFlagsProvider } from "@/lib/tasks/table/feature-flags-provider" -import { TasksTable } from "@/lib/tasks/table/tasks-table" -import { - getTaskPriorityCounts, - getTasks, - getTaskStatusCounts, -} from "@/lib/tasks/service" -import { searchParamsCache } from "@/lib/tasks/validations" - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsCache.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getTasks({ - ...search, - filters: validFilters, - }), - getTaskStatusCounts(), - getTaskPriorityCounts(), - ]) - - return ( - <Shell className="gap-2"> - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> - <DateRangePicker - triggerSize="sm" - triggerClassName="ml-auto w-56 sm:w-60" - align="end" - shallow={false} - /> - </React.Suspense> - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <TasksTable promises={promises} /> - </React.Suspense> - </Shell> - ) -} diff --git a/app/[lng]/procurement/(procurement)/tbe-tech/page.tsx b/app/[lng]/procurement/(procurement)/tbe-tech/page.tsx deleted file mode 100644 index 17b01ce2..00000000 --- a/app/[lng]/procurement/(procurement)/tbe-tech/page.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { getAllTBE } from "@/lib/rfqs-tech/service" -import { searchParamsTBECache } from "@/lib/rfqs-tech/validations" -import { AllTbeTable } from "@/lib/tbe-tech/table/tbe-table" -import * as React from "react" -import { Shell } from "@/components/shell" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" - -interface IndexPageProps { - params: { - lng: string - } - searchParams: Promise<SearchParams> -} - -export default async function RfqTBEPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - - // URL 쿼리 파라미터에서 타입 추출 - const searchParams = await props.searchParams - - // SearchParams 파싱 (Zod) - const search = searchParamsTBECache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - // 현재 선택된 타입의 데이터 로드 - const promises = Promise.all([ - getAllTBE({ - ...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"> - Technical Bid Evaluation - </h2> - <p className="text-muted-foreground"> - 초대된 협력업체에게 TBE를 보낼 수 있습니다. <br/> - 체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. - </p> - </div> - </div> - </div> - - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <AllTbeTable promises={promises}/> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/tech-project-avl/page.tsx b/app/[lng]/procurement/(procurement)/tech-project-avl/page.tsx deleted file mode 100644 index d942c5c5..00000000 --- a/app/[lng]/procurement/(procurement)/tech-project-avl/page.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import * as React from "react"
-import { redirect } from "next/navigation"
-import { getServerSession } from "next-auth/next"
-import { authOptions } from "@/app/api/auth/[...nextauth]/route"
-import { SearchParams } from "@/types/table"
-import { searchParamsCache } from "@/lib/tech-project-avl/validations"
-import { Skeleton } from "@/components/ui/skeleton"
-import { Shell } from "@/components/shell"
-import { AcceptedQuotationsTable } from "@/lib/tech-project-avl/table/accepted-quotations-table"
-import { getAcceptedTechSalesVendorQuotations } from "@/lib/techsales-rfq/service"
-import { getValidFilters } from "@/lib/data-table"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-import { Ellipsis } from "lucide-react"
-
-export interface PageProps {
- params: Promise<{ lng: string }>
- searchParams: Promise<SearchParams>
-}
-
-export default async function AcceptedQuotationsPage({
- params,
- searchParams,
-}: PageProps) {
- const { lng } = await params
-
- const session = await getServerSession(authOptions)
- if (!session) {
- redirect(`/${lng}/auth/signin`)
- }
-
- const search = await searchParams
- const { page, perPage, sort, filters, search: searchText } = searchParamsCache.parse(search)
- const validFilters = getValidFilters(filters ?? [])
-
- const { data, pageCount } = await getAcceptedTechSalesVendorQuotations({
- page,
- perPage: perPage ?? 10,
- sort,
- search: searchText,
- 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">
- 승인된 견적서(해양TOP,HULL)
- </h2>
- <p className="text-muted-foreground">
- 기술영업 승인 견적서에 대한 요약 정보를 확인하고{" "}
- <span className="inline-flex items-center whitespace-nowrap">
- <Ellipsis className="size-3" />
- <span className="ml-1">버튼</span>
- </span>
- 을 통해 RFQ 코드, 설명, 업체명, 업체 코드 등의 상세 정보를 확인할 수 있습니다.
- </p>
- </div>
- </div>
- </div>
-
- <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
- {/* Date range picker can be added here if needed */}
- </React.Suspense>
-
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={12}
- searchableColumnCount={2}
- filterableColumnCount={4}
- cellWidths={["10rem", "15rem", "12rem", "10rem", "10rem", "12rem", "8rem", "12rem", "10rem", "8rem", "10rem", "10rem"]}
- shrinkZero
- />
- }
- >
- <AcceptedQuotationsTable
- data={data}
- pageCount={pageCount}
- />
- </React.Suspense>
- </Shell>
- )
-}
diff --git a/app/[lng]/procurement/(procurement)/tech-vendor-candidates/page.tsx b/app/[lng]/procurement/(procurement)/tech-vendor-candidates/page.tsx deleted file mode 100644 index 3923863a..00000000 --- a/app/[lng]/procurement/(procurement)/tech-vendor-candidates/page.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import * as React from "react" -import { type SearchParams } from "@/types/table" - -import { getValidFilters } from "@/lib/data-table" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" - -import { getVendorCandidateCounts, getVendorCandidates } from "@/lib/tech-vendor-candidates/service" -import { searchParamsTechCandidateCache } from "@/lib/tech-vendor-candidates/validations" -import { VendorCandidateTable as TechVendorCandidateTable } from "@/lib/tech-vendor-candidates/table/candidates-table" -import { DateRangePicker } from "@/components/date-range-picker" - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsTechCandidateCache.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getVendorCandidates({ - ...search, - filters: validFilters, - }), - getVendorCandidateCounts() - ]) - - 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> - <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={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <TechVendorCandidateTable promises={promises}/> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/tech-vendors/[id]/info/items/page.tsx b/app/[lng]/procurement/(procurement)/tech-vendors/[id]/info/items/page.tsx deleted file mode 100644 index 69c36576..00000000 --- a/app/[lng]/procurement/(procurement)/tech-vendors/[id]/info/items/page.tsx +++ /dev/null @@ -1,48 +0,0 @@ -// import { Separator } from "@/components/ui/separator" -// import { getTechVendorById, getVendorItemsByType } from "@/lib/tech-vendors/service" -// import { type SearchParams } from "@/types/table" -// import { TechVendorItemsTable } from "@/lib/tech-vendors/items-table/item-table" - -// interface IndexPageProps { -// // Next.js 13 App Router에서 기본으로 주어지는 객체들 -// params: { -// lng: string -// id: string -// } -// searchParams: Promise<SearchParams> -// } - -// export default async function TechVendorItemsPage(props: IndexPageProps) { -// const resolvedParams = await props.params -// const id = resolvedParams.id - -// const idAsNumber = Number(id) - -// // 벤더 정보 가져오기 (벤더 타입 필요) -// const vendorInfo = await getTechVendorById(idAsNumber) -// const vendorType = vendorInfo.data?.techVendorType || "조선" - -// const promises = getVendorItemsByType(idAsNumber, vendorType) - -// // 4) 렌더링 -// return ( -// <div className="space-y-6"> -// <div> -// <h3 className="text-lg font-medium"> -// 공급품목 -// </h3> -// <p className="text-sm text-muted-foreground"> -// 기술영업 벤더의 공급 가능한 품목을 확인하세요. -// </p> -// </div> -// <Separator /> -// <div> -// <TechVendorItemsTable -// promises={promises} -// vendorId={idAsNumber} -// vendorType={vendorType} -// /> -// </div> -// </div> -// ) -// }
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/tech-vendors/[id]/info/layout.tsx b/app/[lng]/procurement/(procurement)/tech-vendors/[id]/info/layout.tsx deleted file mode 100644 index 7c389720..00000000 --- a/app/[lng]/procurement/(procurement)/tech-vendors/[id]/info/layout.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { Metadata } from "next" - -import { Separator } from "@/components/ui/separator" -import { SidebarNav } from "@/components/layout/sidebar-nav" -import { findTechVendorById } from "@/lib/tech-vendors/service" -import { TechVendor } from "@/db/schema/techVendors" -import { Button } from "@/components/ui/button" -import { ArrowLeft } from "lucide-react" -import Link from "next/link" -export const metadata: Metadata = { - title: "Tech Vendor Detail", -} - -export default async function SettingsLayout({ - children, - params, -}: { - children: React.ReactNode - params: { lng: string , id: string} -}) { - - // 1) URL 파라미터에서 id 추출, Number로 변환 - const resolvedParams = await params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - // 2) DB에서 해당 협력업체 정보 조회 - const vendor: TechVendor | null = await findTechVendorById(idAsNumber) - - // 3) 사이드바 메뉴 - const sidebarNavItems = [ - { - title: "연락처", - href: `/${lng}/evcp/tech-vendors/${id}/info`, - }, - // { - // title: "자재 리스트", - // href: `/${lng}/evcp/tech-vendors/${id}/info/items`, - // }, - // { - // title: "견적 히스토리", - // href: `/${lng}/evcp/tech-vendors/${id}/info/rfq-history`, - // }, - ] - - return ( - <> - <div className="container py-6"> - <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow"> - <div className="hidden space-y-6 p-10 pb-16 md:block"> - {/* RFQ 목록으로 돌아가는 링크 추가 */} - <div className="flex items-center justify-end mb-4"> - <Link href={`/${lng}/evcp/tech-vendors`} 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>기술영업 벤더 목록으로 돌아가기</span> - </Button> - </Link> - </div> - <div className="space-y-0.5"> - {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */} - <h2 className="text-2xl font-bold tracking-tight"> - {vendor - ? `${vendor.vendorCode ?? ""} - ${vendor.vendorName} 상세 정보` - : "Loading Vendor..."} - </h2> - <p className="text-muted-foreground">기술영업 벤더 관련 상세사항을 확인하세요.</p> - </div> - <Separator className="my-6" /> - <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0"> - <aside className="-mx-4 lg:w-1/5"> - <SidebarNav items={sidebarNavItems} /> - </aside> - <div className="flex-1">{children}</div> - </div> - </div> - </section> - </div> - </> - ) -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/tech-vendors/[id]/info/page.tsx b/app/[lng]/procurement/(procurement)/tech-vendors/[id]/info/page.tsx deleted file mode 100644 index a57d6df7..00000000 --- a/app/[lng]/procurement/(procurement)/tech-vendors/[id]/info/page.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { getTechVendorContacts } from "@/lib/tech-vendors/service" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { searchParamsContactCache } from "@/lib/tech-vendors/validations" -import { TechVendorContactsTable } from "@/lib/tech-vendors/contacts-table/contact-table" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> -} - -export default async function SettingsAccountPage(props: IndexPageProps) { - const resolvedParams = await props.params - const id = resolvedParams.id - - const idAsNumber = Number(id) - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsContactCache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - - - const promises = Promise.all([ - getTechVendorContacts({ - ...search, - filters: validFilters, - }, - idAsNumber) - ]) - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - Contacts - </h3> - <p className="text-sm text-muted-foreground"> - 업무별 담당자 정보를 확인하세요. - </p> - </div> - <Separator /> - <div> - <TechVendorContactsTable promises={promises} vendorId={idAsNumber}/> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/tech-vendors/[id]/info/rfq-history/page.tsx b/app/[lng]/procurement/(procurement)/tech-vendors/[id]/info/rfq-history/page.tsx deleted file mode 100644 index 4ed2b39f..00000000 --- a/app/[lng]/procurement/(procurement)/tech-vendors/[id]/info/rfq-history/page.tsx +++ /dev/null @@ -1,55 +0,0 @@ -// import { Separator } from "@/components/ui/separator"
-// import { getRfqHistory } from "@/lib/vendors/service"
-// import { type SearchParams } from "@/types/table"
-// import { getValidFilters } from "@/lib/data-table"
-// import { searchParamsRfqHistoryCache } from "@/lib/vendors/validations"
-// import { TechVendorRfqHistoryTable } from "@/lib/tech-vendors/rfq-history-table/rfq-history-table"
-
-// interface IndexPageProps {
-// // Next.js 13 App Router에서 기본으로 주어지는 객체들
-// params: {
-// lng: string
-// id: string
-// }
-// searchParams: Promise<SearchParams>
-// }
-
-// export default async function RfqHistoryPage(props: IndexPageProps) {
-// const resolvedParams = await props.params
-// const lng = resolvedParams.lng
-// const id = resolvedParams.id
-
-// const idAsNumber = Number(id)
-
-// // 2) SearchParams 파싱 (Zod)
-// // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
-// const searchParams = await props.searchParams
-// const search = searchParamsRfqHistoryCache.parse(searchParams)
-// const validFilters = getValidFilters(search.filters)
-
-// const promises = Promise.all([
-// getRfqHistory({
-// ...search,
-// filters: validFilters,
-// },
-// idAsNumber)
-// ])
-
-// // 4) 렌더링
-// return (
-// <div className="space-y-6">
-// <div>
-// <h3 className="text-lg font-medium">
-// RFQ History
-// </h3>
-// <p className="text-sm text-muted-foreground">
-// 협력업체의 RFQ 참여 이력을 확인할 수 있습니다.
-// </p>
-// </div>
-// <Separator />
-// <div>
-// <TechVendorRfqHistoryTable promises={promises} vendorId={idAsNumber} />
-// </div>
-// </div>
-// )
-// }
\ No newline at end of file diff --git a/app/[lng]/procurement/(procurement)/tech-vendors/page.tsx b/app/[lng]/procurement/(procurement)/tech-vendors/page.tsx deleted file mode 100644 index 8f542f59..00000000 --- a/app/[lng]/procurement/(procurement)/tech-vendors/page.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import * as React from "react"
-import { type SearchParams } from "@/types/table"
-
-import { getValidFilters } from "@/lib/data-table"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-import { Shell } from "@/components/shell"
-
-import { searchParamsCache } from "@/lib/tech-vendors/validations"
-import { getTechVendors, getTechVendorStatusCounts } from "@/lib/tech-vendors/service"
-import { TechVendorsTable } from "@/lib/tech-vendors/table/tech-vendors-table"
-import { TechVendorContainer } from "@/components/tech-vendors/tech-vendor-container"
-
-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 vendorTypes = [
- { id: "all", name: "전체", value: "" },
- { id: "ship", name: "조선", value: "조선" },
- { id: "top", name: "해양TOP", value: "해양TOP" },
- { id: "hull", name: "해양HULL", value: "해양HULL" },
- ]
-
- const promises = Promise.all([
- getTechVendors({
- ...search,
- filters: validFilters,
- }),
- getTechVendorStatusCounts(),
- ])
-
- return (
- <Shell className="gap-4">
- <React.Suspense
- fallback={
- <DataTableSkeleton
- columnCount={6}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <TechVendorContainer vendorTypes={vendorTypes}>
- <TechVendorsTable promises={promises} />
- </TechVendorContainer>
- </React.Suspense>
- </Shell>
- )
-}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/b-rfq/[id]/final/page.tsx b/app/[lng]/sales/(sales)/b-rfq/[id]/final/page.tsx deleted file mode 100644 index e69de29b..00000000 --- a/app/[lng]/sales/(sales)/b-rfq/[id]/final/page.tsx +++ /dev/null diff --git a/app/[lng]/sales/(sales)/b-rfq/[id]/initial/page.tsx b/app/[lng]/sales/(sales)/b-rfq/[id]/initial/page.tsx deleted file mode 100644 index 1af65fbc..00000000 --- a/app/[lng]/sales/(sales)/b-rfq/[id]/initial/page.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { InitialRfqDetailTable } from "@/lib/b-rfq/initial/initial-rfq-detail-table" -import { getInitialRfqDetail } from "@/lib/b-rfq/service" -import { searchParamsInitialRfqDetailCache } from "@/lib/b-rfq/validations" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> -} - -export default async function RfqPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsInitialRfqDetailCache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - const promises = getInitialRfqDetail({ - ...search, - filters: validFilters, - }, idAsNumber) - - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - Initial RFQ List - </h3> - <p className="text-sm text-muted-foreground"> - 설계로부터 받은 RFQ 문서와 구매 RFQ 문서 및 사전 계약자료를 Vendor에 발송하기 위한 RFQ 생성 및 관리하는 화면입니다. - </p> - </div> - <Separator /> - <div> - <InitialRfqDetailTable promises={promises} rfqId={idAsNumber}/> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/b-rfq/[id]/layout.tsx b/app/[lng]/sales/(sales)/b-rfq/[id]/layout.tsx deleted file mode 100644 index 8dad7676..00000000 --- a/app/[lng]/sales/(sales)/b-rfq/[id]/layout.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { Metadata } from "next" -import Link from "next/link" -import { Separator } from "@/components/ui/separator" -import { SidebarNav } from "@/components/layout/sidebar-nav" -import { formatDate } from "@/lib/utils" -import { Button } from "@/components/ui/button" -import { ArrowLeft } from "lucide-react" -import { RfqDashboardView } from "@/db/schema" -import { findBRfqById } from "@/lib/b-rfq/service" - -export const metadata: Metadata = { - title: "견적 RFQ 상세", -} - -export default async function RfqLayout({ - children, - params, -}: { - children: React.ReactNode - params: { lng: string, id: string } -}) { - - // 1) URL 파라미터에서 id 추출, Number로 변환 - const resolvedParams = await params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - // 2) DB에서 해당 협력업체 정보 조회 - const rfq: RfqDashboardView | null = await findBRfqById(idAsNumber) - - // 3) 사이드바 메뉴 - const sidebarNavItems = [ - { - title: "견적/입찰 문서관리", - href: `/${lng}/evcp/b-rfq/${id}`, - }, - { - title: "Initial RFQ 발송", - href: `/${lng}/evcp/b-rfq/${id}/initial`, - }, - { - title: "Final RFQ 발송", - href: `/${lng}/evcp/b-rfq/${id}/final`, - }, - - ] - - return ( - <> - <div className="container py-6"> - <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow"> - <div className="hidden space-y-6 p-10 pb-16 md:block"> - <div className="flex items-center justify-end mb-4"> - <Link href={`/${lng}/evcp/b-rfq`} passHref> - <Button variant="ghost" className="flex items-center text-primary hover:text-primary/80 transition-colors p-0 h-auto"> - <ArrowLeft className="mr-1 h-4 w-4" /> - <span>RFQ 목록으로 돌아가기</span> - </Button> - </Link> - </div> - <div className="space-y-0.5"> - {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */} - <h2 className="text-2xl font-bold tracking-tight"> - {rfq - ? `${rfq.rfqCode ?? ""} | ${rfq.packageNo ?? ""} | ${rfq.packageName ?? ""}` - : "Loading RFQ..."} - </h2> - - <p className="text-muted-foreground"> - PR발행 전 RFQ를 생성하여 관리하는 화면입니다. - </p> - <h3>Due Date:{rfq && rfq?.dueDate && <strong>{formatDate(rfq?.dueDate)}</strong>}</h3> - </div> - <Separator className="my-6" /> - <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0"> - <aside className="lg:w-64 flex-shrink-0"> - <SidebarNav items={sidebarNavItems} /> - </aside> - <div className="lg:w-[calc(100%-16rem)] overflow-auto">{children}</div> - </div> - </div> - </section> - </div> - </> - ) -}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/b-rfq/[id]/page.tsx b/app/[lng]/sales/(sales)/b-rfq/[id]/page.tsx deleted file mode 100644 index 26dc45fb..00000000 --- a/app/[lng]/sales/(sales)/b-rfq/[id]/page.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { searchParamsRfqAttachmentsCache } from "@/lib/b-rfq/validations" -import { getRfqAttachments } from "@/lib/b-rfq/service" -import { RfqAttachmentsTable } from "@/lib/b-rfq/attachment/attachment-table" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> -} - -export default async function RfqPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsRfqAttachmentsCache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - const promises = getRfqAttachments({ - ...search, - filters: validFilters, - }, idAsNumber) - - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - 견적 RFQ 문서관리 - </h3> - <p className="text-sm text-muted-foreground"> - 설계로부터 받은 RFQ 문서와 구매 RFQ 문서를 관리하고 Vendor 회신을 점검/관리하는 화면입니다. - </p> - </div> - <Separator /> - <div> - <RfqAttachmentsTable promises={promises} rfqId={idAsNumber} /> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/b-rfq/page.tsx b/app/[lng]/sales/(sales)/b-rfq/page.tsx deleted file mode 100644 index a66d7b58..00000000 --- a/app/[lng]/sales/(sales)/b-rfq/page.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import * as React from "react" -import { Metadata } from "next" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { Shell } from "@/components/shell" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { searchParamsRFQDashboardCache } from "@/lib/b-rfq/validations" -import { getRFQDashboard } from "@/lib/b-rfq/service" -import { RFQDashboardTable } from "@/lib/b-rfq/summary-table/summary-rfq-table" - -export const metadata: Metadata = { - title: "견적 RFQ", - description: "", -} - -interface PQReviewPageProps { - searchParams: Promise<SearchParams> -} - -export default async function PQReviewPage(props: PQReviewPageProps) { - const searchParams = await props.searchParams - const search = searchParamsRFQDashboardCache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - // 기본 필터 처리 (통일된 이름 사용) - let basicFilters = [] - if (search.basicFilters && search.basicFilters.length > 0) { - basicFilters = search.basicFilters - console.log("Using search.basicFilters:", basicFilters); - } else { - console.log("No basic filters found"); - } - - // 모든 필터를 합쳐서 처리 - const allFilters = [...validFilters, ...basicFilters] - - // 조인 연산자도 통일된 이름 사용 - const joinOperator = search.basicJoinOperator || search.joinOperator || 'and'; - - // Promise.all로 감싸서 전달 - const promises = Promise.all([ - getRFQDashboard({ - ...search, - filters: allFilters, - joinOperator, - }) - ]) - - console.log(search, "견적") - - return ( - <Shell className="gap-4"> - <div className="flex items-center justify-between space-y-2"> - <div className="flex items-center justify-between space-y-2"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - 견적 RFQ - </h2> - </div> - </div> - </div> - - {/* Items처럼 직접 테이블 렌더링 */} - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={8} - searchableColumnCount={2} - filterableColumnCount={3} - cellWidths={["10rem", "15rem", "12rem", "12rem", "8rem", "8rem", "10rem", "8rem"]} - shrinkZero - /> - } - > - <RFQDashboardTable promises={promises} /> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/basic-contract-template/page.tsx b/app/[lng]/sales/(sales)/basic-contract-template/page.tsx deleted file mode 100644 index adc57ed9..00000000 --- a/app/[lng]/sales/(sales)/basic-contract-template/page.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import * as React from "react" -import { type SearchParams } from "@/types/table" - -import { getValidFilters } from "@/lib/data-table" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" -import { 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]/sales/(sales)/basic-contract/page.tsx b/app/[lng]/sales/(sales)/basic-contract/page.tsx deleted file mode 100644 index a043e530..00000000 --- a/app/[lng]/sales/(sales)/basic-contract/page.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import * as React from "react" -import { type SearchParams } from "@/types/table" - -import { getValidFilters } from "@/lib/data-table" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" -import { 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]/sales/(sales)/email-template/[name]/page.tsx b/app/[lng]/sales/(sales)/email-template/[name]/page.tsx deleted file mode 100644 index cccc10fc..00000000 --- a/app/[lng]/sales/(sales)/email-template/[name]/page.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { getTemplateAction } from '@/lib/mail/service';
-import MailTemplateEditorClient from '@/components/mail/mail-template-editor-client';
-
-interface EditMailTemplatePageProps {
- params: {
- name: string;
- lng: string;
- };
-}
-
-export default async function EditMailTemplatePage({ params }: EditMailTemplatePageProps) {
- const { name: templateName } = await params;
-
- // 서버에서 초기 템플릿 데이터 가져오기
- const result = await getTemplateAction(templateName);
- const initialTemplate = result.success ? result.data : null;
-
- return (
- <div className="container mx-auto p-6">
- <MailTemplateEditorClient
- templateName={templateName}
- initialTemplate={initialTemplate}
- />
- </div>
- );
-}
diff --git a/app/[lng]/sales/(sales)/email-template/page.tsx b/app/[lng]/sales/(sales)/email-template/page.tsx deleted file mode 100644 index 1ef3de6c..00000000 --- a/app/[lng]/sales/(sales)/email-template/page.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { getTemplatesAction } from '@/lib/mail/service';
-import MailTemplatesClient from '@/components/mail/mail-templates-client';
-
-export default async function MailTemplatesPage() {
- // 서버에서 초기 데이터 가져오기
- const result = await getTemplatesAction();
- const initialData = result.success ? result.data : [];
-
- return (
- <div className="container mx-auto p-6">
- <div className="mb-8">
- <h1 className="text-3xl font-bold text-gray-900 mb-2">메일 템플릿 관리</h1>
- <p className="text-gray-600">이메일 템플릿을 관리할 수 있습니다.</p>
- </div>
-
- <MailTemplatesClient initialData={initialData} />
- </div>
- );
-}
diff --git a/app/[lng]/sales/(sales)/equip-class/page.tsx b/app/[lng]/sales/(sales)/equip-class/page.tsx deleted file mode 100644 index cfa8f133..00000000 --- a/app/[lng]/sales/(sales)/equip-class/page.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import * as React from "react" -import { type SearchParams } from "@/types/table" - -import { getValidFilters } from "@/lib/data-table" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" -import { searchParamsCache } from "@/lib/equip-class/validation" -import { FormListsTable } from "@/lib/form-list/table/formLists-table" -import { getTagClassists } from "@/lib/equip-class/service" -import { EquipClassTable } from "@/lib/equip-class/table/equipClass-table" - - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsCache.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getTagClassists({ - ...search, - filters: validFilters, - }), - - ]) - - return ( - <Shell className="gap-2"> - <div className="flex items-center justify-between space-y-2"> - <div className="flex items-center justify-between space-y-2"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - 객체 클래스 목록 from S-EDP - </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 - /> - } - > - <EquipClassTable promises={promises} /> - </React.Suspense> - </Shell> - ) -} diff --git a/app/[lng]/sales/(sales)/form-list/page.tsx b/app/[lng]/sales/(sales)/form-list/page.tsx deleted file mode 100644 index a6cf7d9e..00000000 --- a/app/[lng]/sales/(sales)/form-list/page.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import * as React from "react" -import { type SearchParams } from "@/types/table" - -import { getValidFilters } from "@/lib/data-table" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" -import { searchParamsCache } from "@/lib/form-list/validation" -import { ItemsTable } from "@/lib/items/table/items-table" -import { getFormLists } from "@/lib/form-list/service" -import { FormListsTable } from "@/lib/form-list/table/formLists-table" - - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsCache.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getFormLists({ - ...search, - filters: validFilters, - }), - - ]) - - return ( - <Shell className="gap-2"> - <div className="flex items-center justify-between space-y-2"> - <div className="flex items-center justify-between space-y-2"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - 레지스터 목록 from S-EDP - </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 - /> - } - > - <FormListsTable promises={promises} /> - </React.Suspense> - </Shell> - ) -} diff --git a/app/[lng]/sales/(sales)/incoterms/page.tsx b/app/[lng]/sales/(sales)/incoterms/page.tsx deleted file mode 100644 index 57a19009..00000000 --- a/app/[lng]/sales/(sales)/incoterms/page.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import * as React from "react"; -import { type SearchParams } from "@/types/table"; -import { getValidFilters } from "@/lib/data-table"; -import { Shell } from "@/components/shell"; -import { Skeleton } from "@/components/ui/skeleton"; -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"; -import { SearchParamsCache } from "@/lib/incoterms/validations"; -import { getIncoterms } from "@/lib/incoterms/service"; -import { IncotermsTable } from "@/lib/incoterms/table/incoterms-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([ - getIncoterms({ - ...search, - filters: validFilters, - }), - ]); - - return ( - <Shell className="gap-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"> - 인코텀즈(Incoterms)를 등록, 수정, 삭제할 수 있습니다. - </p> - </div> - </div> - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}></React.Suspense> - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={4} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "8rem"]} - shrinkZero - /> - } - > - <IncotermsTable promises={promises} /> - </React.Suspense> - </Shell> - ); -}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/menu-list/page.tsx b/app/[lng]/sales/(sales)/menu-list/page.tsx deleted file mode 100644 index 84138320..00000000 --- a/app/[lng]/sales/(sales)/menu-list/page.tsx +++ /dev/null @@ -1,70 +0,0 @@ -// app/evcp/menu-list/page.tsx - -import { Suspense } from "react"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { RefreshCw, Settings } from "lucide-react"; -import { getActiveUsers, getMenuAssignments } from "@/lib/menu-list/servcie"; -import { InitializeButton } from "@/lib/menu-list/table/initialize-button"; -import { MenuListTable } from "@/lib/menu-list/table/menu-list-table"; -import { Shell } from "@/components/shell" -import * as React from "react" - -export default async function MenuListPage() { - // 초기 데이터 로드 - const [menusResult, usersResult] = await Promise.all([ - getMenuAssignments(), - getActiveUsers() - ]); - - 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={ - "" - } - > - <Card> - <CardHeader> - <CardTitle className="flex items-center gap-2"> - <Settings className="h-5 w-5" /> - 메뉴 리스트 - </CardTitle> - <CardDescription> - 시스템의 모든 메뉴와 담당자 정보를 확인할 수 있습니다. - {menusResult.data?.length > 0 && ( - <span className="ml-2 text-sm"> - 총 {menusResult.data.length}개의 메뉴 - </span> - )} - </CardDescription> - </CardHeader> - <CardContent> - <Suspense fallback={<div className="text-center py-8">로딩 중...</div>}> - <MenuListTable - initialMenus={menusResult.data || []} - initialUsers={usersResult.data || []} - /> - </Suspense> - </CardContent> - </Card> - </React.Suspense> - </Shell> - - ); -}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/payment-conditions/page.tsx b/app/[lng]/sales/(sales)/payment-conditions/page.tsx deleted file mode 100644 index b9aedfbb..00000000 --- a/app/[lng]/sales/(sales)/payment-conditions/page.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import * as React from "react"; -import { type SearchParams } from "@/types/table"; -import { getValidFilters } from "@/lib/data-table"; -import { Shell } from "@/components/shell"; -import { Skeleton } from "@/components/ui/skeleton"; -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"; -import { SearchParamsCache } from "@/lib/payment-terms/validations"; -import { getPaymentTerms } from "@/lib/payment-terms/service"; -import { PaymentTermsTable } from "@/lib/payment-terms/table/payment-terms-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([ - getPaymentTerms({ - ...search, - filters: validFilters, - }), - ]); - - return ( - <Shell className="gap-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"> - 결제 조건(Payment Terms)을 등록, 수정, 삭제할 수 있습니다. - </p> - </div> - </div> - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}></React.Suspense> - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={4} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "8rem"]} - shrinkZero - /> - } - > - <PaymentTermsTable promises={promises} /> - </React.Suspense> - </Shell> - ); -}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/po-rfq/page.tsx b/app/[lng]/sales/(sales)/po-rfq/page.tsx deleted file mode 100644 index bdeae25e..00000000 --- a/app/[lng]/sales/(sales)/po-rfq/page.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { getPORfqs } from "@/lib/procurement-rfqs/services" -import { searchParamsCache } from "@/lib/procurement-rfqs/validations" -import { getValidFilters } from "@/lib/data-table" -import { Shell } from "@/components/shell" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { RFQListTable } from "@/lib/procurement-rfqs/table/rfq-table" -import { type SearchParams } from "@/types/table" -import * as React from "react" - -interface RfqPageProps { - searchParams: Promise<SearchParams> -} - -export default async function RfqPage(props: RfqPageProps) { - // searchParams를 await하여 resolve - const searchParams = await props.searchParams - - // 파라미터 파싱 - const search = searchParamsCache.parse(searchParams); - const validFilters = getValidFilters(search.filters); - - // RFQ 서버는 기본필터와 고급필터를 분리해서 받으므로 그대로 전달 - const promises = Promise.all([ - getPORfqs({ - ...search, // 모든 파라미터 전달 (page, perPage, sort, basicFilters, filters 등) - filters: validFilters, // 고급 필터를 명시적으로 오버라이드 (파싱된 버전) - }) - ]) - - return ( - <Shell variant="fullscreen" className="h-full"> {/* fullscreen variant 사용 */} - {/* 고정 헤더 영역 */} - <div className="flex-shrink-0"> - <div className="flex items-center justify-between"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - 발주용 견적 - </h2> - </div> - </div> - </div> - - {/* 테이블 영역 - 남은 공간 모두 차지 */} - <div className="flex-1 min-h-0"> - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={8} - searchableColumnCount={2} - filterableColumnCount={3} - cellWidths={["10rem", "15rem", "12rem", "12rem", "8rem", "8rem", "10rem", "8rem"]} - shrinkZero - /> - } - > - <RFQListTable promises={promises} className="h-full" /> - </React.Suspense> - </div> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/po/page.tsx b/app/[lng]/sales/(sales)/po/page.tsx deleted file mode 100644 index 7868e231..00000000 --- a/app/[lng]/sales/(sales)/po/page.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import * as React from "react" -import { type SearchParams } from "@/types/table" - -import { getValidFilters } from "@/lib/data-table" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" -import { getPOs } from "@/lib/po/service" -import { searchParamsCache } from "@/lib/po/validations" -import { PoListsTable } from "@/lib/po/table/po-table" - - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsCache.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getPOs({ - ...search, - filters: validFilters, - }), - ]) - - return ( - <Shell className="gap-2"> - - <div className="flex items-center justify-between space-y-2"> - <div className="flex items-center justify-between space-y-2"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - PO 확인 및 전자서명 - </h2> - <p className="text-muted-foreground"> - 기간계 시스템으로부터 PO를 확인하고 협력업체에게 전자서명을 요청할 수 있습니다. 요쳥된 전자서명의 이력 또한 확인할 수 있습니다. - - </p> - </div> - </div> - </div> - - - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> - </React.Suspense> - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <PoListsTable promises={promises} /> - </React.Suspense> - </Shell> - ) -} diff --git a/app/[lng]/sales/(sales)/poa/page.tsx b/app/[lng]/sales/(sales)/poa/page.tsx deleted file mode 100644 index dec5e05b..00000000 --- a/app/[lng]/sales/(sales)/poa/page.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import * as React from "react" -import { type SearchParams } from "@/types/table" - -import { getValidFilters } from "@/lib/data-table" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" -import { getChangeOrders } from "@/lib/poa/service" -import { searchParamsCache } from "@/lib/poa/validations" -import { ChangeOrderListsTable } from "@/lib/poa/table/poa-table" - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsCache.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getChangeOrders({ - ...search, - filters: validFilters, - }), - ]) - - return ( - <Shell className="gap-2"> - <div className="flex items-center justify-between space-y-2"> - <div className="flex items-center justify-between space-y-2"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - 변경 PO 확인 및 전자서명 - </h2> - <p className="text-muted-foreground"> - 발행된 PO의 변경 내역을 확인하고 관리할 수 있습니다. - </p> - </div> - </div> - </div> - - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> - </React.Suspense> - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <ChangeOrderListsTable promises={promises} /> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/pq-criteria/[id]/page.tsx b/app/[lng]/sales/(sales)/pq-criteria/[id]/page.tsx deleted file mode 100644 index 55b1e9df..00000000 --- a/app/[lng]/sales/(sales)/pq-criteria/[id]/page.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import * as React from "react" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" -import { searchParamsCache } from "@/lib/pq/validations" -import { getPQs } from "@/lib/pq/service" -import { PqsTable } from "@/lib/pq/table/pq-table" -import { ProjectSelectorWrapper } from "@/components/pq/project-select-wrapper" -import { notFound } from "next/navigation" - -interface ProjectPageProps { - params: { id: string } - searchParams: Promise<SearchParams> -} - -export default async function ProjectPage(props: ProjectPageProps) { - const resolvedParams = await props.params - const id = resolvedParams.id - - const projectId = parseInt(id, 10) - - // 유효하지 않은 projectId 확인 - if (isNaN(projectId)) { - notFound() - } - - const searchParams = await props.searchParams - const search = searchParamsCache.parse(searchParams) - - // filters가 없는 경우를 처리 - const validFilters = getValidFilters(search.filters) - - // 프로젝트별 PQ 데이터 가져오기 - const promises = Promise.all([ - getPQs({ - ...search, - filters: validFilters, - }, projectId, false) - ]) - - return ( - <Shell className="gap-2"> - <div className="flex items-center justify-between"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - Pre-Qualification Check Sheet - </h2> - <p className="text-muted-foreground"> - 협력업체 등록을 위한, 협력업체가 제출할 PQ 항목을: 프로젝트별로 관리할 수 있습니다. - </p> - </div> - <ProjectSelectorWrapper selectedProjectId={projectId} /> - </div> - - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> - {/* <DateRangePicker - triggerSize="sm" - triggerClassName="ml-auto w-56 sm:w-60" - align="end" - shallow={false} - /> */} - </React.Suspense> - - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <PqsTable promises={promises} currentProjectId={projectId}/> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/pq-criteria/page.tsx b/app/[lng]/sales/(sales)/pq-criteria/page.tsx deleted file mode 100644 index 7785b541..00000000 --- a/app/[lng]/sales/(sales)/pq-criteria/page.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import * as React from "react" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" -import { searchParamsCache } from "@/lib/pq/validations" -import { getPQs } from "@/lib/pq/service" -import { PqsTable } from "@/lib/pq/table/pq-table" -import { ProjectSelectorWrapper } from "@/components/pq/project-select-wrapper" - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsCache.parse(searchParams) - - // filters가 없는 경우를 처리 - - const validFilters = getValidFilters(search.filters) - - // onlyGeneral: true로 설정하여 일반 PQ 항목만 가져옴 - const promises = Promise.all([ - getPQs({ - ...search, - filters: validFilters, - }, null, true) - ]) - - return ( - <Shell className="gap-2"> - <div className="flex items-center justify-between"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - Pre-Qualification Check Sheet - </h2> - <p className="text-muted-foreground"> - 협력업체 등록을 위한, 협력업체가 제출할 PQ 항목을 관리할 수 있습니다. - </p> - </div> - <ProjectSelectorWrapper /> - </div> - - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> - {/* <DateRangePicker - triggerSize="sm" - triggerClassName="ml-auto w-56 sm:w-60" - align="end" - shallow={false} - /> */} - </React.Suspense> - - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <PqsTable promises={promises}/> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/pq/[vendorId]/page.tsx b/app/[lng]/sales/(sales)/pq/[vendorId]/page.tsx deleted file mode 100644 index 76bcfe59..00000000 --- a/app/[lng]/sales/(sales)/pq/[vendorId]/page.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import * as React from "react" -import { Shell } from "@/components/shell" -import { type SearchParams } from "@/types/table" -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" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" -import { Badge } from "@/components/ui/badge" - -interface IndexPageProps { - params: { - vendorId: string - } - searchParams: Promise<SearchParams> -} - -export default async function PQReviewPage(props: IndexPageProps) { - const resolvedParams = await props.params - const vendorId = Number(resolvedParams.vendorId) - - // Fetch the vendor data - const vendor: Vendor | null = await findVendorById(vendorId) - if (!vendor) return <div>Vendor not found</div> - - // Get list of all PQs (general + project-specific) for this vendor - const pqsList = await getVendorPQsList(vendorId) - - // Determine default active PQ to display - // If query param projectId exists, use that, otherwise use general PQ if available - const searchParams = await props.searchParams - const activeProjectId = searchParams.projectId ? Number(searchParams.projectId) : undefined - - // If no projectId query param, default to general PQ or first project PQ - const defaultTabId = activeProjectId ? - `project-${activeProjectId}` : - (pqsList.hasGeneralPq ? 'general' : `project-${pqsList.projectPQs[0]?.projectId}`) - - // Fetch PQ data for the active tab - let pqData; - if (activeProjectId) { - // Get project-specific PQ data - pqData = await getPQDataByVendorId(vendorId, activeProjectId) - } else { - // Get general PQ data - pqData = await getPQDataByVendorId(vendorId) - } - - return ( - <Shell className="gap-2"> - {pqsList.hasGeneralPq || pqsList.projectPQs.length > 0 ? ( - <Tabs defaultValue={defaultTabId} className="space-y-4"> - <div className="flex justify-between items-center"> - <h1 className="text-2xl font-bold"> - {vendor.vendorName} PQ Review - </h1> - - <TabsList> - {pqsList.hasGeneralPq && ( - <TabsTrigger value="general"> - General PQ <Badge variant="outline" className="ml-2">Standard</Badge> - </TabsTrigger> - )} - - {pqsList.projectPQs.map((project) => ( - <TabsTrigger key={project.projectId} value={`project-${project.projectId}`}> - {project.projectName} <Badge variant="outline" className="ml-2">{project.status}</Badge> - </TabsTrigger> - ))} - </TabsList> - </div> - - {/* Tab content for General PQ */} - {pqsList.hasGeneralPq && ( - <TabsContent value="general" className="mt-0"> - <VendorPQAdminReview - data={activeProjectId ? [] : pqData} - vendor={vendor} - projectId={undefined} - loadData={loadGeneralPQData} - pqType="general" - /> - </TabsContent> - )} - - {/* Tab content for each Project PQ */} - {pqsList.projectPQs.map((project) => ( - <TabsContent key={project.projectId} value={`project-${project.projectId}`} className="mt-0"> - <VendorPQAdminReview - data={activeProjectId === project.projectId ? pqData : []} - vendor={vendor} - projectId={project.projectId} - projectName={project.projectName} - projectStatus={project.status} - loadData={loadProjectPQAction} - pqType="project" - /> - </TabsContent> - ))} - </Tabs> - ) : ( - <div className="text-center py-10"> - <h2 className="text-xl font-medium">No PQ submissions found for this vendor</h2> - </div> - )} - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/pq/page.tsx b/app/[lng]/sales/(sales)/pq/page.tsx deleted file mode 100644 index 46b22b12..00000000 --- a/app/[lng]/sales/(sales)/pq/page.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import * as React from "react" -import { type SearchParams } from "@/types/table" - -import { getValidFilters } from "@/lib/data-table" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" - -import { getVendorsInPQ } from "@/lib/pq/service" -import { searchParamsCache } from "@/lib/vendors/validations" -import { VendorsPQReviewTable } from "@/lib/pq/pq-review-table/vendors-table" - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsCache.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getVendorsInPQ({ - ...search, - filters: validFilters, - }), - ]) - - return ( - <Shell className="gap-2"> - - <div className="flex items-center justify-between space-y-2"> - <div className="flex items-center justify-between space-y-2"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - Pre-Qualification Review - </h2> - <p className="text-muted-foreground"> - 벤더가 제출한 PQ를 확인하고 수정 요청 등을 할 수 있으며 PQ 종료 후에는 통과 여부를 결정할 수 있습니다. - - </p> - </div> - </div> - </div> - - - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> - {/* <DateRangePicker - triggerSize="sm" - triggerClassName="ml-auto w-56 sm:w-60" - align="end" - shallow={false} - /> */} - </React.Suspense> - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <VendorsPQReviewTable promises={promises} /> - </React.Suspense> - </Shell> - ) -} diff --git a/app/[lng]/sales/(sales)/pq_new/[vendorId]/[submissionId]/page.tsx b/app/[lng]/sales/(sales)/pq_new/[vendorId]/[submissionId]/page.tsx deleted file mode 100644 index 28ce3128..00000000 --- a/app/[lng]/sales/(sales)/pq_new/[vendorId]/[submissionId]/page.tsx +++ /dev/null @@ -1,215 +0,0 @@ -import * as React from "react" -import { Metadata } from "next" -import Link from "next/link" -import { notFound } from "next/navigation" -import { ArrowLeft } from "lucide-react" -import { Shell } from "@/components/shell" -import { Button } from "@/components/ui/button" -import { Badge } from "@/components/ui/badge" -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" -import { Separator } from "@/components/ui/separator" -import { getPQById, getPQDataByVendorId } from "@/lib/pq/service" -import { unstable_noStore as noStore } from 'next/cache' -import { PQReviewWrapper } from "@/components/pq-input/pq-review-wrapper" - -export const metadata: Metadata = { - title: "PQ 검토", - description: "협력업체의 Pre-Qualification 답변을 검토합니다.", -} - -// 페이지가 기본적으로 동적임을 나타냄 -export const dynamic = "force-dynamic" - -interface PQReviewPageProps { - params: Promise<{ - vendorId: string; - submissionId: string; - }> -} - -export default async function PQReviewPage(props: PQReviewPageProps) { - // 캐시 비활성화 - noStore() - - const params = await props.params - const vendorId = parseInt(params.vendorId, 10) - const submissionId = parseInt(params.submissionId, 10) - - try { - // PQ Submission 정보 조회 - const pqSubmission = await getPQById(submissionId, vendorId) - - // PQ 데이터 조회 (질문과 답변) - const pqData = await getPQDataByVendorId(vendorId, pqSubmission.projectId || undefined) - - // 프로젝트 정보 (프로젝트 PQ인 경우) - const projectInfo = pqSubmission.projectId ? { - id: pqSubmission.projectId, - projectCode: pqSubmission.projectCode || '', - projectName: pqSubmission.projectName || '', - status: pqSubmission.status, - submittedAt: pqSubmission.submittedAt, - } : null - - // PQ 유형 및 상태 레이블 - const typeLabel = pqSubmission.type === "GENERAL" ? "일반 PQ" : "프로젝트 PQ" - const statusLabel = getStatusLabel(pqSubmission.status) - const statusVariant = getStatusVariant(pqSubmission.status) - - // 수정 가능 여부 (SUBMITTED 상태일 때만 가능) - const canReview = pqSubmission.status === "SUBMITTED" - - return ( - <Shell className="gap-6 max-w-5xl"> - <div className="flex items-center justify-between"> - <div className="flex items-center gap-4"> - <Button variant="outline" size="sm" asChild> - <Link href="/evcp/pq_new"> - <ArrowLeft className="w-4 h-4 mr-2" /> - 목록으로 - </Link> - </Button> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - {pqSubmission.vendorName} - {typeLabel} - </h2> - <div className="flex items-center gap-2 mt-1"> - <Badge variant={statusVariant}>{statusLabel}</Badge> - {projectInfo && ( - <span className="text-muted-foreground"> - {projectInfo.projectName} ({projectInfo.projectCode}) - </span> - )} - </div> - </div> - </div> - </div> - - {/* 상태별 알림 */} - {pqSubmission.status === "SUBMITTED" && ( - <Alert> - <AlertTitle>제출 완료</AlertTitle> - <AlertDescription> - 협력업체가 {formatDate(pqSubmission.submittedAt)}에 PQ를 제출했습니다. 검토 후 승인 또는 거부할 수 있습니다. - </AlertDescription> - </Alert> - )} - - {pqSubmission.status === "APPROVED" && ( - <Alert variant="success"> - <AlertTitle>승인됨</AlertTitle> - <AlertDescription> - {formatDate(pqSubmission.approvedAt)}에 승인되었습니다. - </AlertDescription> - </Alert> - )} - - {pqSubmission.status === "REJECTED" && ( - <Alert variant="destructive"> - <AlertTitle>거부됨</AlertTitle> - <AlertDescription> - {formatDate(pqSubmission.rejectedAt)}에 거부되었습니다. - {pqSubmission.rejectReason && ( - <div className="mt-2"> - <strong>사유:</strong> {pqSubmission.rejectReason} - </div> - )} - </AlertDescription> - </Alert> - )} - - <Separator /> - - {/* PQ 검토 컴포넌트 */} - <Tabs defaultValue="review" className="w-full"> - <TabsList> - <TabsTrigger value="review">PQ 검토</TabsTrigger> - <TabsTrigger value="vendor-info">협력업체 정보</TabsTrigger> - </TabsList> - - <TabsContent value="review" className="mt-4"> - <PQReviewWrapper - pqData={pqData} - vendorId={vendorId} - pqSubmission={pqSubmission} - canReview={canReview} - /> - </TabsContent> - - <TabsContent value="vendor-info" className="mt-4"> - <div className="rounded-md border p-4"> - <h3 className="text-lg font-medium mb-4">협력업체 정보</h3> - <div className="grid grid-cols-2 gap-4"> - <div> - <p className="text-sm font-medium text-muted-foreground">업체명</p> - <p>{pqSubmission.vendorName}</p> - </div> - <div> - <p className="text-sm font-medium text-muted-foreground">업체 코드</p> - <p>{pqSubmission.vendorCode}</p> - </div> - <div> - <p className="text-sm font-medium text-muted-foreground">상태</p> - <p>{pqSubmission.vendorStatus}</p> - </div> - {/* 필요시 추가 정보 표시 */} - </div> - </div> - </TabsContent> - </Tabs> - </Shell> - ) - } catch (error) { - console.error("Error loading PQ:", error) - notFound() - } -} - -// 상태 레이블 함수 -function getStatusLabel(status: string): string { - switch (status) { - case "REQUESTED": - return "요청됨"; - case "IN_PROGRESS": - return "진행 중"; - case "SUBMITTED": - return "제출됨"; - case "APPROVED": - return "승인됨"; - case "REJECTED": - return "거부됨"; - default: - return status; - } -} - -// 상태별 Badge 스타일 -function getStatusVariant(status: string): "default" | "outline" | "secondary" | "destructive" | "success" { - switch (status) { - case "REQUESTED": - return "outline"; - case "IN_PROGRESS": - return "secondary"; - case "SUBMITTED": - return "default"; - case "APPROVED": - return "success"; - case "REJECTED": - return "destructive"; - default: - return "outline"; - } -} - -// 날짜 형식화 함수 -function formatDate(date: Date | null) { - if (!date) return "날짜 없음"; - return new Date(date).toLocaleDateString("ko-KR", { - year: "numeric", - month: "long", - day: "numeric", - hour: "2-digit", - minute: "2-digit" - }); -}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/pq_new/page.tsx b/app/[lng]/sales/(sales)/pq_new/page.tsx deleted file mode 100644 index 6598349b..00000000 --- a/app/[lng]/sales/(sales)/pq_new/page.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import * as React from "react" -import { Metadata } from "next" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { Shell } from "@/components/shell" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { searchParamsPQReviewCache } from "@/lib/pq/validations" -import { getPQSubmissions } from "@/lib/pq/service" -import { PQSubmissionsTable } from "@/lib/pq/pq-review-table-new/vendors-table" - -export const metadata: Metadata = { - title: "PQ 검토/실사 의뢰", - description: "", -} - -interface PQReviewPageProps { - searchParams: Promise<SearchParams> -} - -export default async function PQReviewPage(props: PQReviewPageProps) { - const searchParams = await props.searchParams - const search = searchParamsPQReviewCache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - // 디버깅 로그 추가 - console.log("=== PQ Page Debug ==="); - console.log("Raw searchParams:", searchParams); - console.log("Raw basicFilters param:", searchParams.basicFilters); - console.log("Raw pqBasicFilters param:", searchParams.pqBasicFilters); - console.log("Parsed search:", search); - console.log("search.filters:", search.filters); - console.log("search.basicFilters:", search.basicFilters); - console.log("search.pqBasicFilters:", search.pqBasicFilters); - console.log("validFilters:", validFilters); - - // 기본 필터 처리 (통일된 이름 사용) - let basicFilters = [] - if (search.basicFilters && search.basicFilters.length > 0) { - basicFilters = search.basicFilters - console.log("Using search.basicFilters:", basicFilters); - } else if (search.pqBasicFilters && search.pqBasicFilters.length > 0) { - // 하위 호환성을 위해 기존 이름도 지원 - basicFilters = search.pqBasicFilters - console.log("Using search.pqBasicFilters:", basicFilters); - } else { - console.log("No basic filters found"); - } - - // 모든 필터를 합쳐서 처리 - const allFilters = [...validFilters, ...basicFilters] - - console.log("Final allFilters:", allFilters); - - // 조인 연산자도 통일된 이름 사용 - const joinOperator = search.basicJoinOperator || search.pqBasicJoinOperator || search.joinOperator || 'and'; - console.log("Final joinOperator:", joinOperator); - - // Promise.all로 감싸서 전달 - const promises = Promise.all([ - getPQSubmissions({ - ...search, - filters: allFilters, - joinOperator, - }) - ]) - - return ( - <Shell className="gap-4"> - <div className="flex items-center justify-between space-y-2"> - <div className="flex items-center justify-between space-y-2"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - PQ 검토/실사 의뢰 - </h2> - </div> - </div> - </div> - - {/* Items처럼 직접 테이블 렌더링 */} - <React.Suspense - key={JSON.stringify(searchParams)} // URL 파라미터가 변경될 때마다 강제 리렌더링 - fallback={ - <DataTableSkeleton - columnCount={8} - searchableColumnCount={2} - filterableColumnCount={3} - cellWidths={["10rem", "15rem", "12rem", "12rem", "8rem", "8rem", "10rem", "8rem"]} - shrinkZero - /> - } - > - <PQSubmissionsTable promises={promises} /> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/report/page.tsx b/app/[lng]/sales/(sales)/report/page.tsx index 33225e33..db1bb9d8 100644 --- a/app/[lng]/sales/(sales)/report/page.tsx +++ b/app/[lng]/sales/(sales)/report/page.tsx @@ -1,5 +1,3 @@ - -// app/procurement/dashboard/page.tsx import * as React from "react"; import { Skeleton } from "@/components/ui/skeleton"; import { Shell } from "@/components/shell"; @@ -7,29 +5,34 @@ import { ErrorBoundary } from "@/components/error-boundary"; import { getDashboardData } from "@/lib/dashboard/service"; import { DashboardClient } from "@/lib/dashboard/dashboard-client"; -// 대시보드 데이터 로딩 컴포넌트 -async function DashboardContent() { +export default async function IndexPage() { + // domain을 명시적으로 전달 + const domain = "sales"; + try { - const data = await getDashboardData("sales"); + // 서버에서 직접 데이터 fetch + const dashboardData = await getDashboardData(domain); - const handleRefresh = async () => { - "use server"; - return await getDashboardData("sales"); - }; - return ( - <DashboardClient - initialData={data} - onRefresh={handleRefresh} - /> + <Shell className="gap-2"> + <DashboardClient initialData={dashboardData} /> + </Shell> ); } catch (error) { - console.error("Dashboard data loading error:", error); - throw error; + console.error("Dashboard data fetch error:", error); + return ( + <Shell className="gap-2"> + <div className="flex items-center justify-center py-12"> + <div className="text-center space-y-2"> + <p className="text-destructive">데이터를 불러오는데 실패했습니다.</p> + <p className="text-muted-foreground text-sm">{error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다."}</p> + </div> + </div> + </Shell> + ); } } -// 대시보드 로딩 스켈레톤 function DashboardSkeleton() { return ( <div className="space-y-6"> @@ -95,35 +98,3 @@ function DashboardSkeleton() { </div> ); } - -// 에러 표시 컴포넌트 -function DashboardError({ error, reset }: { error: Error; reset: () => void }) { - return ( - <div className="flex flex-col items-center justify-center py-12 space-y-4"> - <div className="text-center space-y-2"> - <h3 className="text-lg font-semibold">대시보드를 불러올 수 없습니다</h3> - <p className="text-muted-foreground"> - {error.message || "알 수 없는 오류가 발생했습니다."} - </p> - </div> - <button - onClick={reset} - className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90" - > - 다시 시도 - </button> - </div> - ); -} - -export default async function DashboardPage() { - return ( - <Shell className="gap-6"> - <ErrorBoundary fallback={DashboardError}> - <React.Suspense fallback={<DashboardSkeleton />}> - <DashboardContent /> - </React.Suspense> - </ErrorBoundary> - </Shell> - ); -} diff --git a/app/[lng]/sales/(sales)/rfq/[id]/cbe/page.tsx b/app/[lng]/sales/(sales)/rfq/[id]/cbe/page.tsx deleted file mode 100644 index fb288a98..00000000 --- a/app/[lng]/sales/(sales)/rfq/[id]/cbe/page.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { searchParamsCBECache } from "@/lib/rfqs/validations" -import { getCBE } from "@/lib/rfqs/service" -import { CbeTable } from "@/lib/rfqs/cbe-table/cbe-table" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> -} - -export default async function RfqCBEPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsCBECache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getCBE({ - ...search, - filters: validFilters, - }, - idAsNumber) - ]) - - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - Commercial Bid Evaluation - </h3> - <p className="text-sm text-muted-foreground"> - 초대된 협력업체에게 CBE를 보낼 수 있습니다. <br />"발행하기" 버튼을 통해 CBE를 전송하면 CBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. - </p> - </div> - <Separator /> - <div> - <CbeTable promises={promises} rfqId={idAsNumber} /> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/rfq/[id]/layout.tsx b/app/[lng]/sales/(sales)/rfq/[id]/layout.tsx deleted file mode 100644 index 9a03efa4..00000000 --- a/app/[lng]/sales/(sales)/rfq/[id]/layout.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { Metadata } from "next" -import Link from "next/link" -import { Separator } from "@/components/ui/separator" -import { SidebarNav } from "@/components/layout/sidebar-nav" -import { RfqViewWithItems } from "@/db/schema/rfq" -import { findRfqById } from "@/lib/rfqs/service" -import { formatDate } from "@/lib/utils" -import { Button } from "@/components/ui/button" -import { ArrowLeft } from "lucide-react" - -export const metadata: Metadata = { - title: "Vendor Detail", -} - -export default async function RfqLayout({ - children, - params, -}: { - children: React.ReactNode - params: { lng: string, id: string } -}) { - - // 1) URL 파라미터에서 id 추출, Number로 변환 - const resolvedParams = await params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - // 2) DB에서 해당 협력업체 정보 조회 - const rfq: RfqViewWithItems | null = await findRfqById(idAsNumber) - - // 3) 사이드바 메뉴 - const sidebarNavItems = [ - { - title: "Matched Vendors", - href: `/${lng}/evcp/rfq/${id}`, - }, - { - title: "TBE", - href: `/${lng}/evcp/rfq/${id}/tbe`, - }, - { - title: "CBE", - href: `/${lng}/evcp/rfq/${id}/cbe`, - }, - - ] - - return ( - <> - <div className="container py-6"> - <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow"> - <div className="hidden space-y-6 p-10 pb-16 md:block"> - <div className="flex items-center justify-end mb-4"> - <Link href={`/${lng}/evcp/rfq`} passHref> - <Button variant="ghost" className="flex items-center text-primary hover:text-primary/80 transition-colors p-0 h-auto"> - <ArrowLeft className="mr-1 h-4 w-4" /> - <span>RFQ 목록으로 돌아가기</span> - </Button> - </Link> - </div> - <div className="space-y-0.5"> - {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */} - <h2 className="text-2xl font-bold tracking-tight"> - {rfq - ? `${rfq.projectCode ?? ""} ${rfq.rfqCode ?? ""} 관리` - : "Loading RFQ..."} - </h2> - - <p className="text-muted-foreground"> - {rfq - ? `${rfq.description ?? ""} ${rfq.lines.map(line => line.itemCode).join(", ")}` - : ""} - </p> - <h3>Due Date:{rfq && rfq?.dueDate && <strong>{formatDate(rfq?.dueDate)}</strong>}</h3> - </div> - <Separator className="my-6" /> - <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0"> - <aside className="lg:w-64 flex-shrink-0"> - <SidebarNav items={sidebarNavItems} /> - </aside> - <div className="lg:w-[calc(100%-16rem)] overflow-auto">{children}</div> - </div> - </div> - </section> - </div> - </> - ) -}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/rfq/[id]/page.tsx b/app/[lng]/sales/(sales)/rfq/[id]/page.tsx deleted file mode 100644 index 1a9f4b18..00000000 --- a/app/[lng]/sales/(sales)/rfq/[id]/page.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { getMatchedVendors } from "@/lib/rfqs/service" -import { searchParamsMatchedVCache } from "@/lib/rfqs/validations" -import { MatchedVendorsTable } from "@/lib/rfqs/vendor-table/vendors-table" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> -} - -export default async function RfqPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsMatchedVCache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getMatchedVendors({ - ...search, - filters: validFilters, - }, - idAsNumber) - ]) - - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - Vendors - </h3> - <p className="text-sm text-muted-foreground"> - 등록된 협력업체 중에서 이 RFQ 아이템에 매칭되는 업체를 보여줍니다. <br/>"발행하기" 버튼을 통해 RFQ를 전송하면 첨부파일과 함께 RFQ 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. - </p> - </div> - <Separator /> - <div> - <MatchedVendorsTable promises={promises} rfqId={idAsNumber}/> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/rfq/[id]/tbe/page.tsx b/app/[lng]/sales/(sales)/rfq/[id]/tbe/page.tsx deleted file mode 100644 index 76eea302..00000000 --- a/app/[lng]/sales/(sales)/rfq/[id]/tbe/page.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { getTBE } from "@/lib/rfqs/service" -import { searchParamsTBECache } from "@/lib/rfqs/validations" -import { TbeTable } from "@/lib/rfqs/tbe-table/tbe-table" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> -} - -export default async function RfqTBEPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsTBECache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getTBE({ - ...search, - filters: validFilters, - }, - idAsNumber) - ]) - - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - Technical Bid Evaluation - </h3> - <p className="text-sm text-muted-foreground"> - 초대된 협력업체에게 TBE를 보낼 수 있습니다. <br/>"발행하기" 버튼을 통해 TBE를 전송하면 첨부파일과 함께 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다. - </p> - </div> - <Separator /> - <div> - <TbeTable promises={promises} rfqId={idAsNumber}/> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/rfq/page.tsx b/app/[lng]/sales/(sales)/rfq/page.tsx deleted file mode 100644 index 3417b0bf..00000000 --- a/app/[lng]/sales/(sales)/rfq/page.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import * as React from "react" -import { type SearchParams } from "@/types/table" - -import { getValidFilters } from "@/lib/data-table" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" - -import { searchParamsCache } from "@/lib/rfqs/validations" -import { getRfqs, getRfqStatusCounts } from "@/lib/rfqs/service" -import { RfqsTable } from "@/lib/rfqs/table/rfqs-table" -import { getAllItems } from "@/lib/items/service" -import { RfqType } from "@/lib/rfqs/validations" - -interface RfqPageProps { - searchParams: Promise<SearchParams>; - rfqType: RfqType; - title: string; - description: string; -} - -export default async function RfqPage({ - searchParams, - rfqType = RfqType.PURCHASE, - title = "RFQ", - description = "RFQ를 등록하고 관리할 수 있습니다." -}: RfqPageProps) { - const search = searchParamsCache.parse(await searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getRfqs({ - ...search, - filters: validFilters, - rfqType // 전달받은 rfqType 사용 - }), - getRfqStatusCounts(rfqType), // rfqType 전달 - getAllItems() - ]) - - return ( - <Shell className="gap-2"> - <div className="flex items-center justify-between space-y-2"> - <div className="flex items-center justify-between space-y-2"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - {title} - </h2> - <p className="text-muted-foreground"> - {description} - </p> - </div> - </div> - </div> - - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> - {/* <DateRangePicker - triggerSize="sm" - triggerClassName="ml-auto w-56 sm:w-60" - align="end" - shallow={false} - /> */} - </React.Suspense> - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <RfqsTable promises={promises} rfqType={rfqType} /> - </React.Suspense> - </Shell> - ) -}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/tag-numbering/page.tsx b/app/[lng]/sales/(sales)/tag-numbering/page.tsx deleted file mode 100644 index 44695259..00000000 --- a/app/[lng]/sales/(sales)/tag-numbering/page.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import * as React from "react" -import { type SearchParams } from "@/types/table" - -import { getValidFilters } from "@/lib/data-table" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" -import { searchParamsCache } from "@/lib/tag-numbering/validation" -import { getTagNumbering } from "@/lib/tag-numbering/service" -import { TagNumberingTable } from "@/lib/tag-numbering/table/tagNumbering-table" - - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsCache.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getTagNumbering({ - ...search, - filters: validFilters, - }), - - ]) - - return ( - <Shell className="gap-2"> - <div className="flex items-center justify-between space-y-2"> - <div className="flex items-center justify-between space-y-2"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - 태그 타입 목록 from S-EDP - </h2> - <p className="text-muted-foreground"> - 태그 넘버링을 위한 룰셋을 S-EDP로부터 가져오고 확인할 수 있습니다{" "} - {/* <span className="inline-flex items-center whitespace-nowrap"> - <Ellipsis className="size-3" /> - <span className="ml-1">버튼</span> - </span> - 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. */} - </p> - </div> - </div> - </div> - - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> - {/* <DateRangePicker - triggerSize="sm" - triggerClassName="ml-auto w-56 sm:w-60" - align="end" - shallow={false} - /> */} - </React.Suspense> - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <TagNumberingTable promises={promises} /> - </React.Suspense> - </Shell> - ) -} diff --git a/app/[lng]/sales/(sales)/tasks/page.tsx b/app/[lng]/sales/(sales)/tasks/page.tsx deleted file mode 100644 index 91b946fb..00000000 --- a/app/[lng]/sales/(sales)/tasks/page.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import * as React from "react" -import { type SearchParams } from "@/types/table" - -import { getValidFilters } from "@/lib/data-table" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { DateRangePicker } from "@/components/date-range-picker" -import { Shell } from "@/components/shell" - -import { FeatureFlagsProvider } from "@/lib/tasks/table/feature-flags-provider" -import { TasksTable } from "@/lib/tasks/table/tasks-table" -import { - getTaskPriorityCounts, - getTasks, - getTaskStatusCounts, -} from "@/lib/tasks/service" -import { searchParamsCache } from "@/lib/tasks/validations" - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsCache.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getTasks({ - ...search, - filters: validFilters, - }), - getTaskStatusCounts(), - getTaskPriorityCounts(), - ]) - - return ( - <Shell className="gap-2"> - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> - <DateRangePicker - triggerSize="sm" - triggerClassName="ml-auto w-56 sm:w-60" - align="end" - shallow={false} - /> - </React.Suspense> - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <TasksTable promises={promises} /> - </React.Suspense> - </Shell> - ) -} diff --git a/app/[lng]/sales/(sales)/vendor-check-list/page.tsx b/app/[lng]/sales/(sales)/vendor-check-list/page.tsx deleted file mode 100644 index 3fd7e425..00000000 --- a/app/[lng]/sales/(sales)/vendor-check-list/page.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import * as React from "react" -import { type SearchParams } from "@/types/table" - -import { getValidFilters } from "@/lib/data-table" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" -import { getGenralEvaluationsSchema } from "@/lib/general-check-list/validation" -import { GeneralEvaluationsTable } from "@/lib/general-check-list/table/general-check-list-table" -import { getGeneralEvaluations } from "@/lib/general-check-list/service" - - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = getGenralEvaluationsSchema.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getGeneralEvaluations({ - ...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 - /> - } - > - <GeneralEvaluationsTable promises={promises} /> - </React.Suspense> - </Shell> - ) -} diff --git a/app/[lng]/sales/(sales)/vendor-investigation/page.tsx b/app/[lng]/sales/(sales)/vendor-investigation/page.tsx deleted file mode 100644 index c59de869..00000000 --- a/app/[lng]/sales/(sales)/vendor-investigation/page.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import * as React from "react" -import { type SearchParams } from "@/types/table" - -import { getValidFilters } from "@/lib/data-table" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" - -import { VendorsInvestigationTable } from "@/lib/vendor-investigation/table/investigation-table" -import { getVendorsInvestigation } from "@/lib/vendor-investigation/service" -import { searchParamsInvestigationCache } from "@/lib/vendor-investigation/validations" - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsInvestigationCache.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getVendorsInvestigation({ - ...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"> - Vendor Investigation Management - </h2> - <p className="text-muted-foreground"> - 요청된 Vendor 실사에 대한 스케줄 정보를 관리하고 결과를 입력할 수 있습니다. - - </p> - </div> - </div> - </div> - - - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> - </React.Suspense> - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <VendorsInvestigationTable promises={promises}/> - </React.Suspense> - </Shell> - ) -} diff --git a/app/[lng]/sales/(sales)/vendor-type/page.tsx b/app/[lng]/sales/(sales)/vendor-type/page.tsx deleted file mode 100644 index 997c0f82..00000000 --- a/app/[lng]/sales/(sales)/vendor-type/page.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import * as React from "react" -import { type SearchParams } from "@/types/table" - -import { getValidFilters } from "@/lib/data-table" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" -import { searchParamsCache } from "@/lib/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]/sales/(sales)/vendors/[id]/info/items/page.tsx b/app/[lng]/sales/(sales)/vendors/[id]/info/items/page.tsx deleted file mode 100644 index 5d5838c6..00000000 --- a/app/[lng]/sales/(sales)/vendors/[id]/info/items/page.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { getVendorItems } from "@/lib/vendors/service" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { searchParamsItemCache } from "@/lib/vendors/validations" -import { VendorItemsTable } from "@/lib/vendors/items-table/item-table" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> -} - -export default async function SettingsAccountPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsItemCache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - - - const promises = Promise.all([ - getVendorItems({ - ...search, - filters: validFilters, - }, - idAsNumber) - ]) - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - 공급품목(패키지) - </h3> - <p className="text-sm text-muted-foreground"> - {/* 딜리버리가 가능한 아이템 리스트를 확인할 수 있습니다. */} - </p> - </div> - <Separator /> - <div> - <VendorItemsTable promises={promises} vendorId={idAsNumber}/> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/vendors/[id]/info/layout.tsx b/app/[lng]/sales/(sales)/vendors/[id]/info/layout.tsx deleted file mode 100644 index 7e2cd4f6..00000000 --- a/app/[lng]/sales/(sales)/vendors/[id]/info/layout.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { Metadata } from "next" - -import { Separator } from "@/components/ui/separator" -import { SidebarNav } from "@/components/layout/sidebar-nav" -import { findVendorById } from "@/lib/vendors/service" // 가정: 여기에 findVendorById가 있다고 가정 -import { Vendor } from "@/db/schema/vendors" -import { Button } from "@/components/ui/button" -import { ArrowLeft } from "lucide-react" -import Link from "next/link" -export const metadata: Metadata = { - title: "Vendor Detail", -} - -export default async function SettingsLayout({ - children, - params, -}: { - children: React.ReactNode - params: { lng: string , id: string} -}) { - - // 1) URL 파라미터에서 id 추출, Number로 변환 - const resolvedParams = await params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - // 2) DB에서 해당 협력업체 정보 조회 - const vendor: Vendor | null = await findVendorById(idAsNumber) - - // 3) 사이드바 메뉴 - const sidebarNavItems = [ - { - title: "연락처", - href: `/${lng}/evcp/vendors/${id}/info`, - }, - { - title: "공급품목(패키지)", - href: `/${lng}/evcp/vendors/${id}/info/items`, - }, - { - title: "공급품목(자재그룹)", - href: `/${lng}/evcp/vendors/${id}/info/materials`, - }, - { - title: "견적 히스토리", - href: `/${lng}/evcp/vendors/${id}/info/rfq-history`, - }, - { - title: "입찰 히스토리", - href: `/${lng}/evcp/vendors/${id}/info/bid-history`, - }, - { - title: "계약 히스토리", - href: `/${lng}/evcp/vendors/${id}/info/contract-history`, - }, - ] - - return ( - <> - <div className="container py-6"> - <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow"> - <div className="hidden space-y-6 p-10 pb-16 md:block"> - {/* RFQ 목록으로 돌아가는 링크 추가 */} - <div className="flex items-center justify-end mb-4"> - <Link href={`/${lng}/evcp/vendors`} 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>협력업체 목록으로 돌아가기</span> - </Button> - </Link> - </div> - <div className="space-y-0.5"> - {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */} - <h2 className="text-2xl font-bold tracking-tight"> - {vendor - ? `${vendor.vendorCode ?? ""} - ${vendor.vendorName} 상세 정보` - : "Loading Vendor..."} - </h2> - <p className="text-muted-foreground">협력업체 관련 상세사항을 확인하세요.</p> - </div> - <Separator className="my-6" /> - <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0"> - <aside className="-mx-4 lg:w-1/5"> - <SidebarNav items={sidebarNavItems} /> - </aside> - <div className="flex-1">{children}</div> - </div> - </div> - </section> - </div> - </> - ) -}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/vendors/[id]/info/materials/page.tsx b/app/[lng]/sales/(sales)/vendors/[id]/info/materials/page.tsx deleted file mode 100644 index 0ebb66ba..00000000 --- a/app/[lng]/sales/(sales)/vendors/[id]/info/materials/page.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { searchParamsMaterialCache } from "@/lib/vendors/validations" -import { getVendorMaterials } from "@/lib/vendors/service" -import { VendorMaterialsTable } from "@/lib/vendors/materials-table/item-table" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> -} - -export default async function SettingsAccountPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsMaterialCache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - - - const promises = Promise.all([ - getVendorMaterials({ - ...search, - filters: validFilters, - }, - idAsNumber) - ]) - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - 공급품목(자재 그룹) - </h3> - <p className="text-sm text-muted-foreground"> - {/* 딜리버리가 가능한 공급품목(자재 그룹)을 확인할 수 있습니다. */} - </p> - </div> - <Separator /> - <div> - <VendorMaterialsTable promises={promises} vendorId={idAsNumber}/> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/vendors/[id]/info/page.tsx b/app/[lng]/sales/(sales)/vendors/[id]/info/page.tsx deleted file mode 100644 index 6279e924..00000000 --- a/app/[lng]/sales/(sales)/vendors/[id]/info/page.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { Separator } from "@/components/ui/separator" -import { getVendorContacts } from "@/lib/vendors/service" -import { type SearchParams } from "@/types/table" -import { getValidFilters } from "@/lib/data-table" -import { searchParamsContactCache } from "@/lib/vendors/validations" -import { VendorContactsTable } from "@/lib/vendors/contacts-table/contact-table" - -interface IndexPageProps { - // Next.js 13 App Router에서 기본으로 주어지는 객체들 - params: { - lng: string - id: string - } - searchParams: Promise<SearchParams> -} - -export default async function SettingsAccountPage(props: IndexPageProps) { - const resolvedParams = await props.params - const lng = resolvedParams.lng - const id = resolvedParams.id - - const idAsNumber = Number(id) - - // 2) SearchParams 파싱 (Zod) - // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼 - const searchParams = await props.searchParams - const search = searchParamsContactCache.parse(searchParams) - const validFilters = getValidFilters(search.filters) - - - - const promises = Promise.all([ - getVendorContacts({ - ...search, - filters: validFilters, - }, - idAsNumber) - ]) - // 4) 렌더링 - return ( - <div className="space-y-6"> - <div> - <h3 className="text-lg font-medium"> - Contacts - </h3> - <p className="text-sm text-muted-foreground"> - 업무별 담당자 정보를 확인하세요. - </p> - </div> - <Separator /> - <div> - <VendorContactsTable promises={promises} vendorId={idAsNumber}/> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/vendors/[id]/info/rfq-history/page.tsx b/app/[lng]/sales/(sales)/vendors/[id]/info/rfq-history/page.tsx deleted file mode 100644 index c7f8f8b6..00000000 --- a/app/[lng]/sales/(sales)/vendors/[id]/info/rfq-history/page.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Separator } from "@/components/ui/separator"
-import { getRfqHistory } from "@/lib/vendors/service"
-import { type SearchParams } from "@/types/table"
-import { getValidFilters } from "@/lib/data-table"
-import { searchParamsRfqHistoryCache } from "@/lib/vendors/validations"
-import { VendorRfqHistoryTable } from "@/lib/vendors/rfq-history-table/rfq-history-table"
-
-interface IndexPageProps {
- // Next.js 13 App Router에서 기본으로 주어지는 객체들
- params: {
- lng: string
- id: string
- }
- searchParams: Promise<SearchParams>
-}
-
-export default async function RfqHistoryPage(props: IndexPageProps) {
- const resolvedParams = await props.params
- const lng = resolvedParams.lng
- const id = resolvedParams.id
-
- const idAsNumber = Number(id)
-
- // 2) SearchParams 파싱 (Zod)
- // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
- const searchParams = await props.searchParams
- const search = searchParamsRfqHistoryCache.parse(searchParams)
- const validFilters = getValidFilters(search.filters)
-
- const promises = Promise.all([
- getRfqHistory({
- ...search,
- filters: validFilters,
- },
- idAsNumber)
- ])
-
- // 4) 렌더링
- return (
- <div className="space-y-6">
- <div>
- <h3 className="text-lg font-medium">
- RFQ History
- </h3>
- <p className="text-sm text-muted-foreground">
- 협력업체의 RFQ 참여 이력을 확인할 수 있습니다.
- </p>
- </div>
- <Separator />
- <div>
- <VendorRfqHistoryTable promises={promises} />
- </div>
- </div>
- )
-}
\ No newline at end of file diff --git a/app/[lng]/sales/(sales)/vendors/page.tsx b/app/[lng]/sales/(sales)/vendors/page.tsx deleted file mode 100644 index 52af0709..00000000 --- a/app/[lng]/sales/(sales)/vendors/page.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import * as React from "react" -import { type SearchParams } from "@/types/table" - -import { getValidFilters } from "@/lib/data-table" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { Shell } from "@/components/shell" - - -import { searchParamsCache } from "@/lib/vendors/validations" -import { getVendors, getVendorStatusCounts } from "@/lib/vendors/service" -import { VendorsTable } from "@/lib/vendors/table/vendors-table" -import { Ellipsis } from "lucide-react" - -interface IndexPageProps { - searchParams: Promise<SearchParams> -} - -export default async function IndexPage(props: IndexPageProps) { - const searchParams = await props.searchParams - const search = searchParamsCache.parse(searchParams) - - const validFilters = getValidFilters(search.filters) - - const promises = Promise.all([ - getVendors({ - ...search, - filters: validFilters, - }), - getVendorStatusCounts(), - ]) - - return ( - <Shell className="gap-2"> - - <div className="flex items-center justify-between space-y-2"> - <div className="flex items-center justify-between space-y-2"> - <div> - <h2 className="text-2xl font-bold tracking-tight"> - 협력업체 리스트 - </h2> - <p className="text-muted-foreground"> - 협력업체에 대한 요약 정보를 확인하고{" "} - <span className="inline-flex items-center whitespace-nowrap"> - <Ellipsis className="size-3" /> - <span className="ml-1">버튼</span> - </span> - 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. <br/>벤더의 상태에 따라 가입을 승인해주거나 PQ 요청을 할 수 있고 검토가 완료된 벤더를 기간계 시스템에 전송하여 협력업체 코드를 따올 수 있습니다. - </p> - </div> - </div> - </div> - - - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> - {/* <DateRangePicker - triggerSize="sm" - triggerClassName="ml-auto w-56 sm:w-60" - align="end" - shallow={false} - /> */} - </React.Suspense> - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={6} - searchableColumnCount={1} - filterableColumnCount={2} - cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} - shrinkZero - /> - } - > - <VendorsTable promises={promises} /> - </React.Suspense> - </Shell> - ) -} diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts index f5d49f77..2b168746 100644 --- a/app/api/auth/[...nextauth]/route.ts +++ b/app/api/auth/[...nextauth]/route.ts @@ -1,5 +1,4 @@ -// Updated NextAuth configuration with dynamic session timeout from database - +// auth/config.ts - 업데이트된 NextAuth 설정 import NextAuth, { NextAuthOptions, Session, @@ -9,15 +8,18 @@ import NextAuth, { import { JWT } from "next-auth/jwt" import CredentialsProvider from 'next-auth/providers/credentials' import { SAMLProvider } from './saml/provider' -import { getUserById } from '@/lib/users/repository' +import { getUserByEmail, getUserById } from '@/lib/users/repository' import { authenticateWithSGips, verifyExternalCredentials } from '@/lib/users/auth/verifyCredentails' import { verifyOtpTemp } from '@/lib/users/verifyOtp' import { getSecuritySettings } from '@/lib/password-policy/service' +import { verifySmsToken } from '@/lib/users/auth/passwordUtil' +import { SessionRepository } from '@/lib/users/session/repository' +import { loginSessions } from '@/db/schema' // 인증 방식 타입 정의 type AuthMethod = 'otp' | 'email' | 'sgips' | 'saml' -// 모듈 보강 선언 (인증 방식 추가) +// 모듈 보강 선언 (기존과 동일) declare module "next-auth" { interface Session { user: { @@ -30,7 +32,8 @@ declare module "next-auth" { domain?: string | null reAuthTime?: number | null authMethod?: AuthMethod - sessionExpiredAt?: number | null // 세션 만료 시간 추가 + sessionExpiredAt?: number | null + dbSessionId?: string | null // DB 세션 ID 추가 } } @@ -42,6 +45,7 @@ declare module "next-auth" { domain?: string | null reAuthTime?: number | null authMethod?: AuthMethod + dbSessionId?: string | null } } @@ -54,11 +58,12 @@ declare module "next-auth/jwt" { domain?: string | null reAuthTime?: number | null authMethod?: AuthMethod - sessionExpiredAt?: number | null // 세션 만료 시간 추가 + sessionExpiredAt?: number | null + dbSessionId?: string | null } } -// 보안 설정 캐시 (성능 최적화) +// 보안 설정 캐시 (기존과 동일) let securitySettingsCache: { data: any | null lastFetch: number @@ -69,7 +74,6 @@ let securitySettingsCache: { ttl: 5 * 60 * 1000 // 5분 캐시 } -// 보안 설정을 가져오는 함수 (캐시 적용) async function getCachedSecuritySettings() { const now = Date.now() @@ -80,7 +84,6 @@ async function getCachedSecuritySettings() { securitySettingsCache.lastFetch = now } catch (error) { console.error('Failed to fetch security settings:', error) - // 기본값 사용 securitySettingsCache.data = { sessionTimeoutMinutes: 480 // 8시간 기본값 } @@ -90,11 +93,28 @@ async function getCachedSecuritySettings() { return securitySettingsCache.data } +// 클라이언트 IP 추출 헬퍼 +function getClientIP(req: any): string { + const forwarded = req.headers['x-forwarded-for'] + const realIP = req.headers['x-real-ip'] + + if (forwarded) { + return forwarded.split(',')[0].trim() + } + + if (realIP) { + return realIP + } + + return req.ip || req.connection?.remoteAddress || '127.0.0.1' +} + export const authOptions: NextAuthOptions = { providers: [ - // OTP provider + // OTP 로그인 (기존 유지) CredentialsProvider({ - name: 'Credentials', + id: 'credentials-otp', + name: 'OTP', credentials: { email: { label: 'Email', type: 'text' }, code: { label: 'OTP code', type: 'text' }, @@ -107,9 +127,7 @@ export const authOptions: NextAuthOptions = { return null } - // 보안 설정에서 세션 타임아웃 가져오기 const securitySettings = await getCachedSecuritySettings() - const sessionTimeoutMs = securitySettings.sessionTimeoutMinutes * 60 * 1000 const reAuthTime = Date.now() return { @@ -125,61 +143,101 @@ export const authOptions: NextAuthOptions = { } }, }), - - // ID/패스워드 provider (S-Gips와 일반 이메일 구분) + + // MFA 완료 후 최종 인증 (DB 연동 버전) CredentialsProvider({ - id: 'credentials-password', - name: 'Username Password', + id: 'credentials-mfa', + name: 'MFA Verification', credentials: { - username: { label: "Username", type: "text" }, - password: { label: "Password", type: "password" }, - provider: { label: "Provider", type: "text" }, + userId: { label: 'User ID', type: 'text' }, + smsToken: { label: 'SMS Token', type: 'text' }, + tempAuthKey: { label: 'Temp Auth Key', type: 'text' }, }, async authorize(credentials, req) { - if (!credentials?.username || !credentials?.password) { - return null; + if (!credentials?.userId || !credentials?.smsToken || !credentials?.tempAuthKey) { + console.error('MFA credentials missing') + return null } - + try { - let authResult; - const isSSgips = credentials.provider === 'sgips'; - - if (isSSgips) { - authResult = await authenticateWithSGips( - credentials.username, - credentials.password - ); - } else { - authResult = await verifyExternalCredentials( - credentials.username, - credentials.password - ); + // DB에서 임시 인증 정보 확인 + const tempAuth = await SessionRepository.getTempAuthSession(credentials.tempAuthKey) + if (!tempAuth || tempAuth.userId !== credentials.userId) { + console.error('Temp auth expired or not found') + return null } - - if (authResult.success && authResult.user) { - return { - id: authResult.user.id, - name: authResult.user.name, - email: authResult.user.email, - imageUrl: authResult.user.imageUrl ?? null, - companyId: authResult.user.companyId, - techCompanyId: authResult.user.techCompanyId, - domain: authResult.user.domain, - reAuthTime: Date.now(), - authMethod: isSSgips ? 'sgips' as AuthMethod : 'email' as AuthMethod, - }; + + // SMS 토큰 검증 + const smsVerificationResult = await verifySmsToken(Number(credentials.userId), credentials.smsToken) + if (!smsVerificationResult || !smsVerificationResult.success) { + console.error('SMS token verification failed') + return null } - return null; + // 사용자 정보 조회 + const user = await getUserById(Number(credentials.userId)) + if (!user) { + console.error('User not found after MFA verification') + return null + } + + // 임시 인증 정보를 사용됨으로 표시 + await SessionRepository.markTempAuthSessionAsUsed(credentials.tempAuthKey) + + // 보안 설정 및 세션 정보 설정 + const securitySettings = await getCachedSecuritySettings() + const reAuthTime = Date.now() + const sessionExpiredAt = new Date(reAuthTime + (securitySettings.sessionTimeoutMinutes * 60 * 1000)) + + // DB에 로그인 세션 생성 + const ipAddress = getClientIP(req) + const userAgent = req.headers?.['user-agent'] + const dbSession = await SessionRepository.createLoginSession({ + userId: String(user.id), + ipAddress, + userAgent, + authMethod: tempAuth.authMethod, + sessionExpiredAt, + }) + + console.log(`MFA completed for user ${user.email} (${tempAuth.authMethod})`) + + return { + id: String(user.id), + email: user.email, + imageUrl: user.imageUrl ?? null, + name: user.name, + companyId: user.companyId, + techCompanyId: user.techCompanyId as number | undefined, + domain: user.domain, + reAuthTime, + authMethod: tempAuth.authMethod as AuthMethod, + dbSessionId: dbSession.id, + } + } catch (error) { - console.error("Authentication error:", error); - return null; + console.error('MFA authorization error:', error) + return null } + }, + }), + + // 1차 인증용 프로바이더 (기존 유지) + CredentialsProvider({ + id: 'credentials-first-auth', + name: 'First Factor Authentication', + credentials: { + username: { label: "Username", type: "text" }, + password: { label: "Password", type: "password" }, + provider: { label: "Provider", type: "text" }, + }, + async authorize(credentials, req) { + return null } }), - // SAML Provider + // SAML Provider (기존 유지) SAMLProvider({ id: "credentials-saml", name: "SAML SSO", @@ -199,18 +257,15 @@ export const authOptions: NextAuthOptions = { session: { strategy: 'jwt', - // JWT 기본 maxAge는 30일로 설정하되, 실제 세션 만료는 콜백에서 처리 maxAge: 30 * 24 * 60 * 60, // 30일 }, callbacks: { - // JWT 콜백 - 세션 타임아웃 설정 (만료 체크는 session 콜백에서) async jwt({ token, user, account, trigger, session }) { - // 보안 설정 가져오기 const securitySettings = await getCachedSecuritySettings() const sessionTimeoutMs = securitySettings.sessionTimeoutMinutes * 60 * 1000 - // 최초 로그인 시 + // 최초 로그인 시 (MFA 완료 후) if (user) { const reAuthTime = Date.now() token.id = user.id @@ -223,34 +278,44 @@ export const authOptions: NextAuthOptions = { token.reAuthTime = reAuthTime token.authMethod = user.authMethod token.sessionExpiredAt = reAuthTime + sessionTimeoutMs + token.dbSessionId = user.dbSessionId } - // 인증 방식 결정 (account 정보 기반) - if (account && !token.authMethod) { + // SAML 인증 시 DB 세션 생성 + if (account && account.provider === 'credentials-saml' && token.id) { const reAuthTime = Date.now() - if (account.provider === 'credentials-saml') { + const sessionExpiredAt = new Date(reAuthTime + sessionTimeoutMs) + + try { + const dbSession = await SessionRepository.createLoginSession({ + userId: token.id, + ipAddress: '0.0.0.0', // SAML의 경우 IP 추적 제한적 + authMethod: 'saml', + sessionExpiredAt, + }) + token.authMethod = 'saml' token.reAuthTime = reAuthTime token.sessionExpiredAt = reAuthTime + sessionTimeoutMs - } else if (account.provider === 'credentials') { - // OTP는 이미 user.authMethod에서 설정됨 - if (!token.sessionExpiredAt) { - token.sessionExpiredAt = (token.reAuthTime || Date.now()) + sessionTimeoutMs - } - } else if (account.provider === 'credentials-password') { - // credentials-password는 이미 user.authMethod에서 설정됨 - if (!token.sessionExpiredAt) { - token.sessionExpiredAt = (token.reAuthTime || Date.now()) + sessionTimeoutMs - } + token.dbSessionId = dbSession.id + } catch (error) { + console.error('Failed to create SAML session:', error) } } - // 세션 업데이트 시 (재인증 시간 업데이트) + // 세션 업데이트 시 if (trigger === "update" && session) { if (session.reAuthTime !== undefined) { token.reAuthTime = session.reAuthTime - // 재인증 시간 업데이트 시 세션 만료 시간도 연장 token.sessionExpiredAt = session.reAuthTime + sessionTimeoutMs + + // DB 세션 업데이트 + if (token.dbSessionId) { + await SessionRepository.updateLoginSession(token.dbSessionId, { + lastActivityAt: new Date(), + sessionExpiredAt: new Date(session.reAuthTime + sessionTimeoutMs) + }) + } } if (session.user) { @@ -263,14 +328,18 @@ export const authOptions: NextAuthOptions = { return token }, - // Session 콜백 - 세션 만료 체크 및 정보 포함 async session({ session, token }: { session: Session; token: JWT }) { // 세션 만료 체크 if (token.sessionExpiredAt && Date.now() > token.sessionExpiredAt) { console.log(`Session expired for user ${token.email}. Expired at: ${new Date(token.sessionExpiredAt)}`) - // 만료된 세션 처리 - 빈 세션 반환하여 로그아웃 유도 + + // DB 세션 만료 처리 + if (token.dbSessionId) { + await SessionRepository.logoutSession(token.dbSessionId) + } + return { - expires: new Date(0).toISOString(), // 즉시 만료 + expires: new Date(0).toISOString(), user: null as any } } @@ -287,12 +356,12 @@ export const authOptions: NextAuthOptions = { reAuthTime: token.reAuthTime as number | null, authMethod: token.authMethod as AuthMethod, sessionExpiredAt: token.sessionExpiredAt as number | null, + dbSessionId: token.dbSessionId as string | null, } } return session }, - // Redirect 콜백 async redirect({ url, baseUrl }) { if (url.startsWith("/")) { return `${baseUrl}${url}`; @@ -309,18 +378,45 @@ export const authOptions: NextAuthOptions = { error: '/auth/error', }, - // 디버깅을 위한 이벤트 로깅 events: { async signIn({ user, account, profile }) { const securitySettings = await getCachedSecuritySettings() console.log(`User ${user.email} signed in via ${account?.provider} (authMethod: ${user.authMethod}), session timeout: ${securitySettings.sessionTimeoutMinutes} minutes`); + + // 이미 MFA에서 DB 세션이 생성된 경우가 아니라면 여기서 생성 + if (account?.provider !== 'credentials-mfa' && user.id) { + try { + // 기존 활성 세션 확인 + const existingSession = await SessionRepository.getActiveSessionByUserId(user.id) + if (!existingSession) { + const sessionExpiredAt = new Date(Date.now() + (securitySettings.sessionTimeoutMinutes * 60 * 1000)) + + await SessionRepository.createLoginSession({ + userId: user.id, + ipAddress: '0.0.0.0', // signIn 이벤트에서는 IP 접근 제한적 + authMethod: user.authMethod || 'unknown', + sessionExpiredAt, + }) + } + } catch (error) { + console.error('Failed to create session in signIn event:', error) + } + } }, + async signOut({ session, token }) { console.log(`User ${session?.user?.email || token?.email} signed out`); + + // DB에서 세션 로그아웃 처리 + const userId = session?.user?.id || token?.id + const dbSessionId = session?.user?.dbSessionId || token?.dbSessionId + + if (dbSessionId) { + await SessionRepository.logoutSession(dbSessionId) + } else if (userId) { + // dbSessionId가 없는 경우 사용자의 모든 활성 세션 로그아웃 + await SessionRepository.logoutAllUserSessions(userId) + } } } } - -const handler = NextAuth(authOptions) -export { handler as GET, handler as POST } - diff --git a/app/api/auth/first-auth/route.ts b/app/api/auth/first-auth/route.ts new file mode 100644 index 00000000..18f44904 --- /dev/null +++ b/app/api/auth/first-auth/route.ts @@ -0,0 +1,112 @@ +// /api/auth/first-auth/route.ts +// 1차 인증 처리 API 엔드포인트 + +import { NextRequest, NextResponse } from 'next/server' +import { authHelpers } from '../[...nextauth]/route' + +// 요청 데이터 타입 +interface FirstAuthRequest { + username: string + password: string + provider: 'email' | 'sgips' +} + +// 응답 데이터 타입 +interface FirstAuthResponse { + success: boolean + tempAuthKey?: string + userId?: string + email?: string + error?: string +} + +export async function POST(request: NextRequest): Promise<NextResponse<FirstAuthResponse>> { + try { + // 요청 데이터 파싱 + const body: FirstAuthRequest = await request.json() + const { username, password, provider } = body + + // 입력 검증 + if (!username || !password || !provider) { + return NextResponse.json( + { + success: false, + error: '필수 입력값이 누락되었습니다.' + }, + { status: 400 } + ) + } + + if (!['email', 'sgips'].includes(provider)) { + return NextResponse.json( + { + success: false, + error: '지원하지 않는 인증 방식입니다.' + }, + { status: 400 } + ) + } + + // 레이트 리미팅 (옵셔널) + // const rateLimitResult = await rateLimit.check(request, `first-auth:${username}`) + // if (!rateLimitResult.success) { + // return NextResponse.json( + // { + // success: false, + // error: '너무 많은 시도입니다. 잠시 후 다시 시도해주세요.' + // }, + // { status: 429 } + // ) + // } + + // 1차 인증 수행 + const authResult = await authHelpers.performFirstAuth(username, password, provider) + + if (!authResult.success) { + // 인증 실패 응답 + let errorMessage = '인증에 실패했습니다.' + + if (provider === 'sgips') { + errorMessage = 'S-Gips 계정 정보가 올바르지 않습니다.' + } else { + errorMessage = '이메일 또는 비밀번호가 올바르지 않습니다.' + } + + return NextResponse.json( + { + success: false, + error: authResult.error || errorMessage + }, + { status: 401 } + ) + } + + // 1차 인증 성공 응답 + return NextResponse.json({ + success: true, + tempAuthKey: authResult.tempAuthKey, + userId: authResult.userId, + email: authResult.email + }) + + } catch (error) { + console.error('First auth API error:', error) + + // 에러 응답 + return NextResponse.json( + { + success: false, + error: '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.' + }, + { status: 500 } + ) + } +} + +// GET 요청은 지원하지 않음 +export async function GET() { + return NextResponse.json( + { error: 'Method not allowed' }, + { status: 405 } + ) +}
\ No newline at end of file diff --git a/app/api/auth/send-sms/route.ts b/app/api/auth/send-sms/route.ts index 3d51d445..6b9eb114 100644 --- a/app/api/auth/send-sms/route.ts +++ b/app/api/auth/send-sms/route.ts @@ -4,7 +4,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/app/api/auth/[...nextauth]/route'; -import { getUserById } from '@/lib/users/repository'; +import { getUserByEmail, getUserById } from '@/lib/users/repository'; import { generateAndSendSmsToken } from '@/lib/users/auth/passwordUtil'; const sendSmsSchema = z.object({ @@ -13,20 +13,14 @@ const sendSmsSchema = z.object({ export async function POST(request: NextRequest) { try { - // 세션 확인 - const session = await getServerSession(authOptions); - if (!session?.user?.id) { - return NextResponse.json( - { error: '인증이 필요합니다' }, - { status: 401 } - ); - } const body = await request.json(); const { userId } = sendSmsSchema.parse(body); + console.log(userId, "userId") + // 본인 확인 - if (session.user.id !== userId) { + if (!userId) { return NextResponse.json( { error: '권한이 없습니다' }, { status: 403 } @@ -42,8 +36,12 @@ export async function POST(request: NextRequest) { ); } + console.log(user, "user") + + + // SMS 전송 - const result = await generateAndSendSmsToken(parseInt(userId), user.phone); + const result = await generateAndSendSmsToken(Number(userId), user.phone); if (result.success) { return NextResponse.json({ diff --git a/app/api/auth/verify-mfa/route.ts b/app/api/auth/verify-mfa/route.ts index f9d1b51e..dea06164 100644 --- a/app/api/auth/verify-mfa/route.ts +++ b/app/api/auth/verify-mfa/route.ts @@ -5,6 +5,7 @@ import { z } from 'zod'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/app/api/auth/[...nextauth]/route'; import { verifySmsToken } from '@/lib/users/auth/passwordUtil'; +import { getUserByEmail } from '@/lib/users/repository'; const verifyMfaSchema = z.object({ userId: z.string(), @@ -25,16 +26,32 @@ export async function POST(request: NextRequest) { const body = await request.json(); const { userId, token } = verifyMfaSchema.parse(body); + + console.log(userId) + + + // 본인 확인 - if (session.user.id !== userId) { + if (session.user.email !== userId) { return NextResponse.json( { error: '권한이 없습니다' }, { status: 403 } ); } + const user = await getUserByEmail(userId); + if (!user || !user.phone) { + return NextResponse.json( + { error: '전화번호가 등록되지 않았습니다' }, + { status: 400 } + ); + } + + const userIdfromUsers = user.id + + // MFA 토큰 검증 - const result = await verifySmsToken(parseInt(userId), token); + const result = await verifySmsToken(userIdfromUsers, token); if (result.success) { return NextResponse.json({ diff --git a/app/api/files/[...path]/route.ts b/app/api/files/[...path]/route.ts index f92dd1d8..e03187e3 100644 --- a/app/api/files/[...path]/route.ts +++ b/app/api/files/[...path]/route.ts @@ -1,74 +1,216 @@ // app/api/files/[...path]/route.ts -import { NextRequest, NextResponse } from 'next/server' -import { readFile } from 'fs/promises' -import { join } from 'path' -import { stat } from 'fs/promises' +// /nas_evcp 경로에서 파일을 서빙하는 API (다운로드 강제 기능 추가) + +import { NextRequest, NextResponse } from "next/server"; +import { promises as fs } from "fs"; +import path from "path"; + +const nasPath = process.env.NAS_PATH || "/evcp_nas" + +// MIME 타입 매핑 +const getMimeType = (filePath: string): string => { + const ext = path.extname(filePath).toLowerCase(); + const mimeTypes: Record<string, string> = { + '.pdf': 'application/pdf', + '.doc': 'application/msword', + '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + '.xls': 'application/vnd.ms-excel', + '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.txt': 'text/plain', + '.zip': 'application/zip', + }; + + return mimeTypes[ext] || 'application/octet-stream'; +}; + +// 보안: 허용된 디렉토리 체크 +const isAllowedPath = (requestedPath: string): boolean => { + const allowedPaths = [ + 'basicContract', + 'basicContract/template', + 'basicContract/signed', + 'vendorFormReportSample', + 'vendorFormData', + ]; + + return allowedPaths.some(allowed => + requestedPath.startsWith(allowed) || requestedPath === allowed + ); +}; export async function GET( request: NextRequest, { params }: { params: { path: string[] } } ) { try { + // 요청된 파일 경로 구성 + const requestedPath = params.path.join('/'); + + console.log(`📂 파일 요청: ${requestedPath}`); + + // ✅ 다운로드 강제 여부 확인 + const url = new URL(request.url); + const forceDownload = url.searchParams.get('download') === 'true'; + + console.log(`📥 다운로드 강제 모드: ${forceDownload}`); + + // 보안 체크: 허용된 경로인지 확인 + if (!isAllowedPath(requestedPath)) { + console.log(`❌ 허용되지 않은 경로: ${requestedPath}`); + return new NextResponse('Forbidden', { status: 403 }); + } + + // 경로 트래버설 공격 방지 + if (requestedPath.includes('..') || requestedPath.includes('~')) { + console.log(`❌ 위험한 경로 패턴: ${requestedPath}`); + return new NextResponse('Bad Request', { status: 400 }); + } - const path = request.nextUrl.searchParams.get("path"); + // 환경에 따른 파일 경로 설정 + let filePath: string; + + if (process.env.NODE_ENV === 'production') { + // ✅ 프로덕션: NAS 경로 사용 + filePath = path.join(nasPath, requestedPath); + } else { + // 개발: public 폴더 + filePath = path.join(process.cwd(), 'public', requestedPath); + } + console.log(`📁 실제 파일 경로: ${filePath}`); - // 경로 파라미터에서 파일 경로 조합 - const filePath = join(process.cwd(), 'uploads', ...params.path) - // 파일 존재 여부 확인 try { - await stat(filePath) - } catch (error) { - return NextResponse.json( - { error: 'File not found' }, - { status: 404 } - ) + await fs.access(filePath); + } catch { + console.log(`❌ 파일 없음: ${filePath}`); + return new NextResponse('File not found', { status: 404 }); } - + + // 파일 통계 정보 가져오기 + const stats = await fs.stat(filePath); + if (!stats.isFile()) { + console.log(`❌ 파일이 아님: ${filePath}`); + return new NextResponse('Not a file', { status: 400 }); + } + // 파일 읽기 - const fileBuffer = await readFile(filePath) + const fileBuffer = await fs.readFile(filePath); - // 파일 확장자에 따른 MIME 타입 설정 - const fileName = params.path[params.path.length - 1] - const fileExtension = fileName.split('.').pop()?.toLowerCase() + // MIME 타입 결정 + const mimeType = getMimeType(filePath); + const fileName = path.basename(filePath); + + console.log(`✅ 파일 서빙 성공: ${fileName} (${stats.size} bytes)`); + + // ✅ Content-Disposition 헤더 결정 + const contentDisposition = forceDownload + ? `attachment; filename="${fileName}"` // 강제 다운로드 + : `inline; filename="${fileName}"`; // 브라우저에서 열기 + + // Range 요청 처리 (큰 파일의 부분 다운로드 지원) + const range = request.headers.get('range'); - let contentType = 'application/octet-stream' + if (range) { + const parts = range.replace(/bytes=/, "").split("-"); + const start = parseInt(parts[0], 10); + const end = parts[1] ? parseInt(parts[1], 10) : stats.size - 1; + const chunksize = (end - start) + 1; + const chunk = fileBuffer.slice(start, end + 1); + + return new NextResponse(chunk, { + status: 206, + headers: { + 'Content-Range': `bytes ${start}-${end}/${stats.size}`, + 'Accept-Ranges': 'bytes', + 'Content-Length': chunksize.toString(), + 'Content-Type': mimeType, + 'Content-Disposition': contentDisposition, // ✅ 다운로드 모드 적용 + }, + }); + } + + // 일반 파일 응답 + return new NextResponse(fileBuffer, { + headers: { + 'Content-Type': mimeType, + 'Content-Length': stats.size.toString(), + 'Content-Disposition': contentDisposition, // ✅ 다운로드 모드 적용 + 'Cache-Control': 'public, max-age=31536000', // 1년 캐시 + 'ETag': `"${stats.mtime.getTime()}-${stats.size}"`, + // ✅ 추가 보안 헤더 + 'X-Content-Type-Options': 'nosniff', + }, + }); + + } catch (error) { + console.error('❌ 파일 서빙 오류:', error); + return new NextResponse('Internal Server Error', { status: 500 }); + } +} + +// HEAD 요청 지원 (파일 정보만 확인) +export async function HEAD( + request: NextRequest, + { params }: { params: { path: string[] } } +) { + try { + const requestedPath = params.path.join('/'); - if (fileExtension) { - const mimeTypes: Record<string, string> = { - 'pdf': 'application/pdf', - 'doc': 'application/msword', - 'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'xls': 'application/vnd.ms-excel', - 'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'ppt': 'application/vnd.ms-powerpoint', - 'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'txt': 'text/plain', - 'csv': 'text/csv', - 'png': 'image/png', - 'jpg': 'image/jpeg', - 'jpeg': 'image/jpeg', - 'gif': 'image/gif', - } - - contentType = mimeTypes[fileExtension] || contentType + // ✅ HEAD 요청에서도 다운로드 강제 여부 확인 + const url = new URL(request.url); + const forceDownload = url.searchParams.get('download') === 'true'; + + if (!isAllowedPath(requestedPath)) { + return new NextResponse(null, { status: 403 }); } - // 다운로드 설정 - const headers = new Headers() - headers.set('Content-Type', contentType) - headers.set('Content-Disposition', `attachment; filename="${fileName}"`) + if (requestedPath.includes('..') || requestedPath.includes('~')) { + return new NextResponse(null, { status: 400 }); + } + + let filePath: string; - return new NextResponse(fileBuffer, { - status: 200, - headers, - }) + if (process.env.NODE_ENV === 'production') { + filePath = path.join(nasPath, requestedPath); + } else { + filePath = path.join(process.cwd(), 'public', requestedPath); + } + + try { + const stats = await fs.stat(filePath); + if (!stats.isFile()) { + return new NextResponse(null, { status: 400 }); + } + + const mimeType = getMimeType(filePath); + const fileName = path.basename(filePath); + + // ✅ HEAD 요청에서도 Content-Disposition 헤더 적용 + const contentDisposition = forceDownload + ? `attachment; filename="${fileName}"` // 강제 다운로드 + : `inline; filename="${fileName}"`; // 브라우저에서 열기 + + return new NextResponse(null, { + headers: { + 'Content-Type': mimeType, + 'Content-Length': stats.size.toString(), + 'Content-Disposition': contentDisposition, // ✅ 다운로드 모드 적용 + 'Last-Modified': stats.mtime.toUTCString(), + 'ETag': `"${stats.mtime.getTime()}-${stats.size}"`, + 'X-Content-Type-Options': 'nosniff', + }, + }); + } catch { + return new NextResponse(null, { status: 404 }); + } + } catch (error) { - console.error('Error downloading file:', error) - return NextResponse.json( - { error: 'Failed to download file' }, - { status: 500 } - ) + console.error('File HEAD error:', error); + return new NextResponse(null, { status: 500 }); } }
\ No newline at end of file diff --git a/app/api/ocr/utils/tableExtraction.ts b/app/api/ocr/utils/tableExtraction.ts index 720e5a5f..0a727f84 100644 --- a/app/api/ocr/utils/tableExtraction.ts +++ b/app/api/ocr/utils/tableExtraction.ts @@ -69,37 +69,107 @@ export async function extractTablesFromOCR (ocrResult: any): Promise<ExtractedRo function isRelevantTable (table: OCRTable): boolean { const headers = table.cells.filter(c => c.rowIndex < 3).map(getCellText).join(' ').toLowerCase(); - return /\bno\b|번호/.test(headers) && /identification|식별|ident|id/.test(headers); + console.log(`🔍 Checking table relevance. Headers: "${headers}"`); + + // 기존 조건 + const hasNoColumn = /\bno\b|번호/.test(headers); + const hasIdentification = /identification|식별|ident|id/.test(headers); + + console.log(`📝 Has NO column: ${hasNoColumn}`); + console.log(`📝 Has Identification: ${hasIdentification}`); + + // 기본 조건 + if (hasNoColumn && hasIdentification) { + console.log(`✅ Table passes strict criteria`); + return true; + } + + // 완화된 조건들 + const relaxedConditions = [ + // 조건 1: 테이블에 여러 열이 있고 숫자나 식별자 패턴이 보이는 경우 + table.cells.length > 10 && /\d+/.test(headers), + + // 조건 2: joint, tag, weld 등 관련 키워드가 있는 경우 + /joint|tag|weld|type|date/.test(headers), + + // 조건 3: 식별번호 패턴이 보이는 경우 (하이픈이 포함된 문자열) + headers.includes('-') && headers.length > 20, + + // 조건 4: 한국어 관련 키워드 + /용접|조인트|태그/.test(headers) + ]; + + const passedConditions = relaxedConditions.filter(Boolean).length; + console.log(`📊 Relaxed conditions passed: ${passedConditions}/${relaxedConditions.length}`); + + if (passedConditions >= 1) { + console.log(`✅ Table passes relaxed criteria`); + return true; + } + + console.log(`❌ Table does not meet any criteria`); + return false; } - /* -------------------------------------------------------------------------- */ /* 표 해석 */ /* -------------------------------------------------------------------------- */ function extractTableData (table: OCRTable, imgIdx: number, tblIdx: number): ExtractedRow[] { + console.log(`🔧 Starting extractTableData for table ${imgIdx}-${tblIdx}`); + const grid = buildGrid(table); + console.log(`📊 Grid size: ${grid.length} rows x ${grid[0]?.length || 0} columns`); + const headerRowIdx = findHeaderRow(grid); - if (headerRowIdx === -1) return []; + console.log(`📍 Header row index: ${headerRowIdx}`); - const format = detectFormat(grid[headerRowIdx]); - const mapping = mapColumns(grid[headerRowIdx]); + if (headerRowIdx === -1) { + console.log(`❌ No header row found`); + return []; + } + + const format = detectFormat(grid[headerRowIdx]); + const mapping = mapColumns(grid[headerRowIdx]); + + console.log(`📋 Detected format: ${format}`); + console.log(`🗂️ Column mapping:`, mapping); const seen = new Set<string>(); const data: ExtractedRow[] = []; for (let r = headerRowIdx + 1; r < grid.length; r++) { const row = grid[r]; - if (isBlankRow(row)) continue; + + if (isBlankRow(row)) { + console.log(`⏭️ Row ${r}: blank, skipping`); + continue; + } + + console.log(`🔍 Processing row ${r}: [${row.join(' | ')}]`); const parsed = buildRow(row, format, mapping, tblIdx, r); - if (!parsed || !isValidRow(parsed)) continue; + if (!parsed) { + console.log(`❌ Row ${r}: failed to parse`); + continue; + } + + if (!isValidRow(parsed)) { + console.log(`❌ Row ${r}: invalid (no: "${parsed.no}", id: "${parsed.identificationNo}")`); + continue; + } const key = `${parsed.no}-${parsed.identificationNo}`; - if (seen.has(key)) continue; + if (seen.has(key)) { + console.log(`⚠️ Row ${r}: duplicate key "${key}", skipping`); + continue; + } + seen.add(key); - data.push(parsed); + console.log(`✅ Row ${r}: added (${JSON.stringify(parsed)})`); } + + console.log(`🎯 Table ${imgIdx}-${tblIdx}: extracted ${data.length} valid rows`); return data; } @@ -108,18 +178,39 @@ function extractTableData (table: OCRTable, imgIdx: number, tblIdx: number): Ext /* -------------------------------------------------------------------------- */ function buildGrid (table: OCRTable): string[][] { + console.log(`🔧 Building grid from ${table.cells.length} cells`); + const maxR = Math.max(...table.cells.map(c => c.rowIndex + c.rowSpan - 1)); const maxC = Math.max(...table.cells.map(c => c.columnIndex + c.columnSpan - 1)); + + console.log(`📊 Grid dimensions: ${maxR + 1} rows x ${maxC + 1} columns`); + const grid = Array.from({ length: maxR + 1 }, () => Array(maxC + 1).fill('')); - table.cells.forEach(cell => { + // 셀별 상세 정보 출력 + table.cells.forEach((cell, idx) => { const txt = getCellText(cell); + console.log(`📱 Cell ${idx}: (${cell.rowIndex},${cell.columnIndex}) span(${cell.rowSpan},${cell.columnSpan}) = "${txt}"`); + for (let r = cell.rowIndex; r < cell.rowIndex + cell.rowSpan; r++) { for (let c = cell.columnIndex; c < cell.columnIndex + cell.columnSpan; c++) { - grid[r][c] = grid[r][c] ? `${grid[r][c]} ${txt}` : txt; + const oldValue = grid[r][c]; + const newValue = oldValue ? `${oldValue} ${txt}` : txt; + grid[r][c] = newValue; + + if (oldValue) { + console.log(`🔄 Grid[${r}][${c}]: "${oldValue}" → "${newValue}"`); + } } } }); + + // 최종 그리드 출력 + console.log(`📋 Final grid:`); + grid.forEach((row, r) => { + console.log(` Row ${r}: [${row.map(cell => `"${cell}"`).join(', ')}]`); + }); + return grid; } @@ -128,13 +219,52 @@ function getCellText (cell: TableCell): string { } function findHeaderRow (grid: string[][]): number { + console.log(`🔍 Finding header row in grid with ${grid.length} rows`); + + for (let i = 0; i < Math.min(5, grid.length); i++) { + const rowText = grid[i].join(' ').toLowerCase(); + console.log(`📝 Row ${i}: "${rowText}"`); + + // 기존 엄격한 조건 + if (/\bno\b|번호/.test(rowText) && /identification|식별|ident/.test(rowText)) { + console.log(`✅ Row ${i}: Strict match`); + return i; + } + + // 완화된 조건들 + const relaxedMatches = [ + // 1. NO 컬럼 + 다른 관련 키워드 + (/\bno\b|번호/.test(rowText) && /joint|tag|type|weld|date/.test(rowText)), + + // 2. ID/식별 + 다른 관련 키워드 + (/identification|식별|ident|id/.test(rowText) && /joint|tag|no|type/.test(rowText)), + + // 3. 용접 관련 키워드가 여러 개 + (rowText.match(/joint|tag|type|weld|date|no|id|식별|번호|용접/g)?.length >= 3), + + // 4. 첫 번째 행이고 여러 단어가 있는 경우 + (i === 0 && rowText.split(/\s+/).filter(w => w.length > 1).length >= 3) + ]; + + if (relaxedMatches.some(Boolean)) { + console.log(`✅ Row ${i}: Relaxed match`); + return i; + } + + console.log(`❌ Row ${i}: No match`); + } + + // 최후의 수단: 첫 번째 비어있지 않은 행 for (let i = 0; i < Math.min(3, grid.length); i++) { - const t = grid[i].join(' ').toLowerCase(); - if (/\bno\b|번호/.test(t) && /identification|식별|ident/.test(t)) return i; + if (grid[i].some(cell => cell.trim().length > 0)) { + console.log(`⚠️ Using row ${i} as fallback header`); + return i; + } } + + console.log(`❌ No header row found`); return -1; } - /* -------------------------------------------------------------------------- */ /* Column Mapping */ /* -------------------------------------------------------------------------- */ @@ -146,19 +276,153 @@ function detectFormat (header: string[]): 'format1' | 'format2' { function mapColumns (header: string[]): ColumnMapping { const mp: ColumnMapping = { no: -1, identification: -1, tagNo: -1, jointNo: -1, jointType: -1, weldingDate: -1 }; + + console.log(`🗂️ Smart mapping columns from header: [${header.map(h => `"${h}"`).join(', ')}]`); + // === STEP 1: 기존 개별 컬럼 매핑 === header.forEach((h, i) => { - const t = h.toLowerCase(); - if (/^no\.?$/.test(t) && !/ident|tag|joint/.test(t)) mp.no = i; - else if (/identification|ident/.test(t)) mp.identification = i; - else if (/tag.*no/.test(t)) mp.tagNo = i; - else if (/joint.*no/.test(t)) mp.jointNo = i; - else if (/joint.*type/.test(t) || (/^type$/.test(t) && mp.jointType === -1)) mp.jointType = i; - else if (/welding|date/.test(t)) mp.weldingDate = i; + const t = h.toLowerCase().trim(); + console.log(`📋 Column ${i}: "${h}" → "${t}"`); + + if (mp.no === -1 && (/^no\.?$/i.test(t) || /^번호$/i.test(t) || /^순번$/i.test(t))) { + mp.no = i; + console.log(`✅ NO column (individual) mapped to index ${i}`); + } + + if (mp.identification === -1 && (/identification.*no/i.test(t) || /식별.*번호/i.test(t))) { + mp.identification = i; + console.log(`✅ Identification column (individual) mapped to index ${i}`); + } + + if (mp.tagNo === -1 && (/tag.*no/i.test(t) || /태그.*번호/i.test(t))) { + mp.tagNo = i; + console.log(`✅ Tag No column (individual) mapped to index ${i}`); + } + + if (mp.jointNo === -1 && (/joint.*no/i.test(t) || /조인트.*번호/i.test(t) || /oint.*no/i.test(t))) { + mp.jointNo = i; + console.log(`✅ Joint No column (individual) mapped to index ${i}`); + } + + if (mp.jointType === -1 && (/joint.*type/i.test(t) || /^type$/i.test(t) || /형태/i.test(t))) { + mp.jointType = i; + console.log(`✅ Joint Type column (individual) mapped to index ${i}`); + } + + if (mp.weldingDate === -1 && (/welding.*date/i.test(t) || /weld.*date/i.test(t) || /^date$/i.test(t) || /날짜/i.test(t))) { + mp.weldingDate = i; + console.log(`✅ Welding Date column (individual) mapped to index ${i}`); + } + }); + + // === STEP 2: 실용적 추론 === + console.log(`🤖 Starting practical column inference...`); + + // NO 컬럼이 매핑되지 않았다면, 첫 번째 컬럼을 NO로 추정 + if (mp.no === -1) { + mp.no = 0; + console.log(`🔮 NO column inferred as index 0 (first column)`); + } + + // Identification 컬럼 찾기 - "identification" 키워드가 포함된 컬럼 중에서 + if (mp.identification === -1) { + for (let i = 0; i < header.length; i++) { + const text = header[i].toLowerCase(); + if (text.includes('identification') || text.includes('식별')) { + mp.identification = i; + console.log(`🆔 Identification column found at index ${i}`); + break; + } + } + } + + // Tag No 컬럼 찾기 - "tag" 키워드가 포함된 컬럼 중에서 + if (mp.tagNo === -1) { + for (let i = 0; i < header.length; i++) { + const text = header[i].toLowerCase(); + if (text.includes('tag') && !text.includes('no')) { + mp.tagNo = i; + console.log(`🏷️ Tag column found at index ${i}`); + break; + } + } + } + + // Joint No 컬럼 찾기 + if (mp.jointNo === -1) { + for (let i = 0; i < header.length; i++) { + const text = header[i].toLowerCase(); + if (text.includes('joint') || text.includes('oint')) { + mp.jointNo = i; + console.log(`🔗 Joint column found at index ${i}`); + break; + } + } + } + + // === STEP 3: 패턴 기반 추론 (마지막 수단) === + console.log(`🎯 Pattern-based fallback mapping...`); + + // 전체 헤더에서 실제 식별번호 패턴이 있는 컬럼 찾기 + if (mp.identification === -1) { + for (let i = 0; i < header.length; i++) { + const text = header[i]; + // 하이픈이 포함된 긴 문자열이 있는 컬럼 + if (text.includes('-') && text.length > 15) { + mp.identification = i; + console.log(`🆔 Identification inferred at index ${i} (contains ID pattern)`); + break; + } + } + } + + // 숫자 패턴이 있는 컬럼을 Tag No로 추정 + if (mp.tagNo === -1) { + for (let i = 1; i < header.length; i++) { // 첫 번째 컬럼 제외 + const text = header[i]; + // 7-8자리 숫자가 있는 컬럼 + if (/\d{7,8}/.test(text)) { + mp.tagNo = i; + console.log(`🏷️ Tag No inferred at index ${i} (contains number pattern)`); + break; + } + } + } + + // === STEP 4: 기본값 설정 === + console.log(`🔧 Setting default values for unmapped columns...`); + + // 여전히 매핑되지 않은 중요한 컬럼들에 대해 순서 기반 추정 + const essentialColumns = [ + { key: 'identification', currentValue: mp.identification, defaultIndex: 1 }, + { key: 'tagNo', currentValue: mp.tagNo, defaultIndex: 2 }, + { key: 'jointNo', currentValue: mp.jointNo, defaultIndex: 3 }, + { key: 'jointType', currentValue: mp.jointType, defaultIndex: 4 }, + { key: 'weldingDate', currentValue: mp.weldingDate, defaultIndex: Math.min(5, header.length - 1) } + ]; + + essentialColumns.forEach(col => { + if ((col.currentValue as number) === -1 && col.defaultIndex < header.length) { + (mp as any)[col.key] = col.defaultIndex; + console.log(`🔧 ${col.key} set to default index ${col.defaultIndex}`); + } }); + + console.log(`🎯 Final optimized column mapping:`, mp); + + // === STEP 5: 매핑 품질 검증 === + const mappedCount = Object.values(mp).filter(v => v !== -1).length; + const totalColumns = Object.keys(mp).length; + const mappingQuality = mappedCount / totalColumns; + + console.log(`📊 Mapping quality: ${mappedCount}/${totalColumns} (${(mappingQuality * 100).toFixed(1)}%)`); + + if (mappingQuality < 0.5) { + console.warn(`⚠️ Low mapping quality detected. Consider manual adjustment.`); + } + return mp; } - /* -------------------------------------------------------------------------- */ /* Row Extraction */ /* -------------------------------------------------------------------------- */ @@ -170,71 +434,351 @@ function buildRow ( tblIdx: number, rowIdx: number ): ExtractedRow | null { + console.log(`🔨 Building row from: [${row.map(r => `"${r}"`).join(', ')}]`); + console.log(`📋 Using mapping:`, mp); + console.log(`📄 Format: ${format}`); + const out: ExtractedRow = { - no: mp.no >= 0 ? clean(row[mp.no]) : '', + no: '', identificationNo: '', tagNo: '', jointNo: '', - jointType: mp.jointType >= 0 ? clean(row[mp.jointType]) : '', + jointType: '', weldingDate: '', confidence: 0, sourceTable: tblIdx, sourceRow: rowIdx, }; - if (mp.weldingDate >= 0) out.weldingDate = clean(row[mp.weldingDate]); - else { - const idx = row.findIndex(col => /\d{4}[.\-/]\d{1,2}[.\-/]\d{1,2}/.test(col)); - if (idx >= 0) out.weldingDate = clean(row[idx]); + // === STEP 1: 매핑된 컬럼에서 기본 추출 === + + // NO 컬럼 추출 + if (mp.no >= 0 && mp.no < row.length) { + const rawNo = clean(row[mp.no]); + // NO 필드에서 첫 번째 숫자 패턴 추출 + const noMatch = rawNo.match(/\b(\d{2,4})\b/); + out.no = noMatch ? noMatch[1] : rawNo; + console.log(`📍 NO from column ${mp.no}: "${out.no}" (raw: "${rawNo}")`); + } + + // Joint Type, Welding Date는 기존대로 + if (mp.jointType >= 0 && mp.jointType < row.length) { + out.jointType = clean(row[mp.jointType]); + console.log(`🔗 Joint Type from column ${mp.jointType}: "${out.jointType}"`); } + if (mp.weldingDate >= 0 && mp.weldingDate < row.length) { + out.weldingDate = clean(row[mp.weldingDate]); + console.log(`📅 Welding Date from column ${mp.weldingDate}: "${out.weldingDate}"`); + } + + // === STEP 2: Format별 데이터 추출 === + if (format === 'format2') { - if (mp.identification >= 0) out.identificationNo = clean(row[mp.identification]); - if (mp.jointNo >= 0) out.jointNo = clean(row[mp.jointNo]); - if (mp.tagNo >= 0) out.tagNo = clean(row[mp.tagNo]); + console.log(`📄 Processing Format 2 (separate columns)`); + + if (mp.identification >= 0 && mp.identification < row.length) { + out.identificationNo = clean(row[mp.identification]); + console.log(`🆔 Identification from column ${mp.identification}: "${out.identificationNo}"`); + } + + if (mp.jointNo >= 0 && mp.jointNo < row.length) { + out.jointNo = clean(row[mp.jointNo]); + console.log(`🔗 Joint No from column ${mp.jointNo}: "${out.jointNo}"`); + } + + if (mp.tagNo >= 0 && mp.tagNo < row.length) { + out.tagNo = clean(row[mp.tagNo]); + console.log(`🏷️ Tag No from column ${mp.tagNo}: "${out.tagNo}"`); + } } else { - const combined = mp.identification >= 0 ? row[mp.identification] : ''; - const parsed = parseIdentificationData(combined); + console.log(`📄 Processing Format 1 (combined identification column)`); + + let combinedText = ''; + + // 매핑된 identification 컬럼에서 텍스트 가져오기 + if (mp.identification >= 0 && mp.identification < row.length) { + combinedText = row[mp.identification]; + console.log(`🆔 Combined text from column ${mp.identification}: "${combinedText}"`); + } + + const parsed = parseIdentificationData(combinedText); out.identificationNo = parsed.identificationNo; - out.jointNo = parsed.jointNo; - out.tagNo = parsed.tagNo; + out.jointNo = parsed.jointNo; + out.tagNo = parsed.tagNo; + + console.log(`📊 Parsed from identification column:`, parsed); } + // === STEP 3: 적극적 패턴 매칭으로 누락된 필드 채우기 === + console.log(`🔍 Aggressive pattern matching for missing fields...`); + + const allText = row.join(' '); + console.log(`📝 Full row text: "${allText}"`); + + // NO 필드가 비어있다면 첫 번째 컬럼에서 숫자 패턴 찾기 + if (!out.no && row.length > 0) { + const firstCol = clean(row[0]); + const noPatterns = [ + /\b(\d{3})\b/g, // 3자리 숫자 + /\b(\d{2,4})\b/g, // 2-4자리 숫자 + /^(\d+)/ // 맨 앞 숫자 + ]; + + for (const pattern of noPatterns) { + const matches = firstCol.match(pattern); + if (matches && matches.length > 0) { + out.no = matches[0].replace(/\D/g, ''); // 숫자만 추출 + console.log(`📍 NO found via pattern in first column: "${out.no}"`); + break; + } + } + } + + // Identification No 패턴 찾기 (하이픈이 포함된 긴 문자열) + if (!out.identificationNo) { + const idPatterns = [ + /[A-Za-z0-9]+-[A-Za-z0-9]+-[A-Za-z0-9\-]+/g, + /-\d+[A-Za-z0-9]+-[A-Za-z0-9]+-[A-Za-z0-9]+/g, + /\b[A-Z]\d+[A-Z]-\d+-\d+-[A-Z]+-\d+-[A-Z0-9]+-[A-Z]-[A-Z0-9]+\b/g + ]; + + for (const pattern of idPatterns) { + const matches = allText.match(pattern); + if (matches && matches.length > 0) { + out.identificationNo = matches[0]; + console.log(`🆔 Identification found via pattern: "${out.identificationNo}"`); + break; + } + } + } + + // Tag No 패턴 찾기 (7-8자리 숫자) + if (!out.tagNo) { + const tagMatches = allText.match(/\b\d{7,8}\b/g); + if (tagMatches && tagMatches.length > 0) { + out.tagNo = tagMatches[0]; + console.log(`🏷️ Tag found via pattern: "${out.tagNo}"`); + } + } + + // Joint No 패턴 찾기 (짧은 영숫자 조합) + if (!out.jointNo) { + const jointPatterns = [ + /\b[A-Z]{2,4}\d*\b/g, // 대문자+숫자 조합 + /\b[A-Za-z0-9]{2,6}\b/g // 일반적인 짧은 조합 + ]; + + for (const pattern of jointPatterns) { + const matches = allText.match(pattern); + if (matches) { + const candidates = matches.filter(m => + m !== out.no && + m !== out.tagNo && + m !== out.identificationNo && + m.length >= 2 && m.length <= 6 && + !/^(no|tag|joint|type|date|welding|project|samsung|class)$/i.test(m) + ); + + if (candidates.length > 0) { + out.jointNo = candidates[0]; + console.log(`🔗 Joint found via pattern: "${out.jointNo}"`); + break; + } + } + } + } + + // Welding Date 패턴 찾기 + if (!out.weldingDate) { + const datePatterns = [ + /\d{4}[.\-/]\d{1,2}[.\-/]\d{1,2}/g, + /\d{4}\.\d{2}\.\d{2}/g + ]; + + for (const pattern of datePatterns) { + const matches = allText.match(pattern); + if (matches && matches.length > 0) { + out.weldingDate = matches[0]; + console.log(`📅 Date found via pattern: "${out.weldingDate}"`); + break; + } + } + } + + // === STEP 4: 품질 검증 및 후처리 === + + // 추출된 값들 정리 + Object.keys(out).forEach(key => { + const value = (out as any)[key]; + if (typeof value === 'string' && value) { + (out as any)[key] = value.replace(/^[^\w]+|[^\w]+$/g, '').trim(); + } + }); + out.confidence = scoreRow(out); + + console.log(`📊 Final extracted row:`, out); + console.log(`🎯 Row confidence: ${out.confidence}`); + + // 최소한의 데이터가 있는지 검증 + const hasAnyData = !!(out.no || out.identificationNo || out.tagNo || out.jointNo); + + if (!hasAnyData) { + console.log(`⚠️ No meaningful data extracted from row`); + return null; + } + return out; } - /* -------------------------------------------------------------------------- */ /* Format‑1 셀 파싱 */ /* -------------------------------------------------------------------------- */ function parseIdentificationData (txt: string): { identificationNo: string; jointNo: string; tagNo: string } { + console.log(`🔍 Parsing identification data from: "${txt}"`); + const cleaned = clean(txt); - if (!cleaned) return { identificationNo: '', jointNo: '', tagNo: '' }; + if (!cleaned) { + console.log(`❌ Empty input text`); + return { identificationNo: '', jointNo: '', tagNo: '' }; + } + console.log(`🧹 Cleaned text: "${cleaned}"`); + + const result = { identificationNo: '', jointNo: '', tagNo: '' }; + + // 1. Identification No 추출 (하이픈이 2개 이상 포함된 패턴) + const idPatterns = [ + /[A-Za-z0-9]+-[A-Za-z0-9]+-[A-Za-z0-9\-]+/g, // 기본 패턴 + /-\d+[A-Za-z0-9]+-[A-Za-z0-9]+-[A-Za-z0-9]+/g, // 앞에 하이픈이 있는 경우 + /\b[A-Za-z0-9]{2,}-[A-Za-z0-9]{2,}-[A-Za-z0-9]{2,}\b/g // 더 엄격한 패턴 + ]; + + for (const pattern of idPatterns) { + const matches = cleaned.match(pattern); + if (matches && matches.length > 0) { + // 가장 긴 매치를 선택 + result.identificationNo = matches.reduce((a, b) => a.length >= b.length ? a : b); + console.log(`🆔 Found identification: "${result.identificationNo}"`); + break; + } + } + + // 2. Tag No 추출 (7-8자리 숫자) + const tagPatterns = [ + /\btag[:\s]*(\d{7,8})\b/i, // "tag: 1234567" 형태 + /\b(\d{7,8})\b/g // 단순 7-8자리 숫자 + ]; + + for (const pattern of tagPatterns) { + const matches = cleaned.match(pattern); + if (matches) { + if (pattern.source.includes('tag')) { + result.tagNo = matches[1] || matches[0]; + } else { + // 모든 7-8자리 숫자를 찾아서 가장 적절한 것 선택 + const candidates = matches.filter(m => m && m.length >= 7 && m.length <= 8); + if (candidates.length > 0) { + result.tagNo = candidates[0]; + } + } + if (result.tagNo) { + console.log(`🏷️ Found tag: "${result.tagNo}"`); + break; + } + } + } + + // 3. Joint No 추출 (나머지 토큰 중에서) const tokens = cleaned.split(/\s+/).map(clean).filter(Boolean); - - // Identification 후보: 하이픈이 2개 이상 포함된 토큰 가운데 가장 긴 것 - const idCand = tokens.filter(t => t.split('-').length >= 3).sort((a, b) => b.length - a.length); - const identificationNo = idCand[0] || ''; - - const residual = tokens.filter(t => t !== identificationNo); - if (!residual.length) return { identificationNo, jointNo: '', tagNo: '' }; - - residual.sort((a, b) => a.length - b.length); - const jointNo = residual[0] || ''; - const tagNo = residual[residual.length - 1] || ''; - - return { identificationNo, jointNo, tagNo }; + console.log(`📝 All tokens: [${tokens.join(', ')}]`); + + // 이미 사용된 토큰들 제외 + const usedTokens = new Set([result.identificationNo, result.tagNo]); + const remainingTokens = tokens.filter(token => + !usedTokens.has(token) && + !result.identificationNo.includes(token) && + !result.tagNo.includes(token) && + token.length > 1 && + !/^(tag|joint|no|identification|식별|번호)$/i.test(token) + ); + + console.log(`🔄 Remaining tokens for joint: [${remainingTokens.join(', ')}]`); + + if (remainingTokens.length > 0) { + // 가장 짧고 알파벳+숫자 조합인 토큰을 Joint No로 선택 + const jointCandidates = remainingTokens + .filter(token => /^[A-Za-z0-9]+$/.test(token) && token.length >= 2 && token.length <= 8) + .sort((a, b) => a.length - b.length); + + if (jointCandidates.length > 0) { + result.jointNo = jointCandidates[0]; + console.log(`🔗 Found joint: "${result.jointNo}"`); + } else if (remainingTokens.length > 0) { + // 후보가 없으면 가장 짧은 토큰 사용 + result.jointNo = remainingTokens.reduce((a, b) => a.length <= b.length ? a : b); + console.log(`🔗 Found joint (fallback): "${result.jointNo}"`); + } + } + + // 4. 결과 검증 및 정리 + Object.keys(result).forEach(key => { + const value = (result as any)[key]; + if (value && typeof value === 'string') { + (result as any)[key] = value.replace(/^[^\w]+|[^\w]+$/g, ''); // 앞뒤 특수문자 제거 + } + }); + + console.log(`📊 Final parsed result:`, result); + return result; } - /* -------------------------------------------------------------------------- */ /* Helpers */ /* -------------------------------------------------------------------------- */ const clean = (s: string = '') => s.replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim(); const isBlankRow = (row: string[]) => row.every(c => !clean(c)); -const isValidRow = (r: ExtractedRow) => !!(r.no || r.identificationNo); +function isValidRow (r: ExtractedRow): boolean { + console.log(`✅ Validating row: no="${r.no}", id="${r.identificationNo}", tag="${r.tagNo}", joint="${r.jointNo}"`); + + // Level 1: 기존 엄격한 조건 + if (r.no && r.no.trim() || r.identificationNo && r.identificationNo.trim()) { + console.log(`✅ Level 1 validation passed (has no or identification)`); + return true; + } + + // Level 2: 완화된 조건 - 주요 필드 중 2개 이상 + const mainFields = [ + r.no?.trim(), + r.identificationNo?.trim(), + r.tagNo?.trim(), + r.jointNo?.trim() + ].filter(Boolean); + + if (mainFields.length >= 2) { + console.log(`✅ Level 2 validation passed (${mainFields.length} main fields present)`); + return true; + } + + // Level 3: 더 관대한 조건 - 어떤 필드든 하나라도 의미있는 값 + const allFields = [ + r.no?.trim(), + r.identificationNo?.trim(), + r.tagNo?.trim(), + r.jointNo?.trim(), + r.jointType?.trim(), + r.weldingDate?.trim() + ].filter(field => field && field.length > 1); // 1글자 이상 + + if (allFields.length >= 1) { + console.log(`✅ Level 3 validation passed (${allFields.length} fields with meaningful content)`); + return true; + } + + console.log(`❌ Validation failed - no meaningful content found`); + return false; +} function scoreRow (r: ExtractedRow): number { const w: Record<keyof ExtractedRow, number> = { diff --git a/app/api/vendors/route.ts b/app/api/vendors/route.ts new file mode 100644 index 00000000..7c7dbb84 --- /dev/null +++ b/app/api/vendors/route.ts @@ -0,0 +1,248 @@ +// app/api/vendors/route.ts +import { NextRequest, NextResponse } from 'next/server' +import { unstable_noStore } from 'next/cache' +import { revalidateTag } from 'next/cache' +import { randomUUID } from 'crypto' +import * as fs from 'fs/promises' +import * as path from 'path' +import { eq } from 'drizzle-orm' +import { PgTransaction } from 'drizzle-orm/pg-core' + +import db from '@/db/db' +import { users, vendors, vendorContacts, vendorAttachments } from '@/db/schema' +import { insertVendor } from '@/lib/vendors/repository' +import { getErrorMessage } from '@/lib/handle-error' + +// Types +interface CreateVendorData { + vendorName: string + vendorCode?: string + address?: string + country?: string + phone?: string + email: string + website?: string + status?: string + taxId: string + vendorTypeId: number + items?: string + representativeName?: string + representativeBirth?: string + representativeEmail?: string + representativePhone?: string + corporateRegistrationNumber?: string +} + +interface ContactData { + contactName: string + contactPosition?: string + contactEmail: string + contactPhone?: string + isPrimary?: boolean +} + +// File attachment types +const FILE_TYPES = { + BUSINESS_REGISTRATION: 'BUSINESS_REGISTRATION', + ISO_CERTIFICATION: 'ISO_CERTIFICATION', + CREDIT_REPORT: 'CREDIT_REPORT', + BANK_ACCOUNT_COPY: 'BANK_ACCOUNT_COPY' +} as const + +type FileType = typeof FILE_TYPES[keyof typeof FILE_TYPES] + +async function storeVendorFiles( + tx: PgTransaction<any, any, any>, + vendorId: number, + files: File[], + attachmentType: FileType +) { + const vendorDir = path.join( + process.cwd(), + "public", + "vendors", + String(vendorId) + ) + await fs.mkdir(vendorDir, { recursive: true }) + + for (const file of files) { + // Convert file to buffer + const ab = await file.arrayBuffer() + const buffer = Buffer.from(ab) + + // Generate a unique filename + const uniqueName = `${randomUUID()}-${file.name}` + const relativePath = path.join("vendors", String(vendorId), uniqueName) + const absolutePath = path.join(process.cwd(), "public", relativePath) + + // Write to disk + await fs.writeFile(absolutePath, buffer) + + // Insert attachment record + await tx.insert(vendorAttachments).values({ + vendorId, + fileName: file.name, + filePath: "/" + relativePath.replace(/\\/g, "/"), + attachmentType, + }) + } +} + +export async function POST(request: NextRequest) { + unstable_noStore() + + try { + const formData = await request.formData() + + // Parse vendor data and contacts from JSON strings + const vendorDataString = formData.get('vendorData') as string + const contactsString = formData.get('contacts') as string + + if (!vendorDataString || !contactsString) { + return NextResponse.json( + { error: 'Missing vendor data or contacts' }, + { status: 400 } + ) + } + + const vendorData: CreateVendorData = JSON.parse(vendorDataString) + const contacts: ContactData[] = JSON.parse(contactsString) + + // Extract files by type + const businessRegistrationFiles = formData.getAll('businessRegistration') as File[] + const isoCertificationFiles = formData.getAll('isoCertification') as File[] + const creditReportFiles = formData.getAll('creditReport') as File[] + const bankAccountFiles = formData.getAll('bankAccount') as File[] + + // Validate required files + if (businessRegistrationFiles.length === 0) { + return NextResponse.json( + { error: '사업자등록증을 업로드해주세요.' }, + { status: 400 } + ) + } + + if (isoCertificationFiles.length === 0) { + return NextResponse.json( + { error: 'ISO 인증서를 업로드해주세요.' }, + { status: 400 } + ) + } + + if (creditReportFiles.length === 0) { + return NextResponse.json( + { error: '신용평가보고서를 업로드해주세요.' }, + { status: 400 } + ) + } + + if (vendorData.country !== "KR" && bankAccountFiles.length === 0) { + return NextResponse.json( + { error: '대금지급 통장사본을 업로드해주세요.' }, + { status: 400 } + ) + } + + // Check for existing email + const existingUser = await db + .select({ id: users.id }) + .from(users) + .where(eq(users.email, vendorData.email)) + .limit(1) + + if (existingUser.length > 0) { + return NextResponse.json( + { + error: `이미 등록된 이메일입니다. 다른 이메일을 사용해주세요. (Email ${vendorData.email} already exists in the system)` + }, + { status: 400 } + ) + } + + // Check for existing taxId + const existingVendor = await db + .select({ id: vendors.id }) + .from(vendors) + .where(eq(vendors.taxId, vendorData.taxId)) + .limit(1) + + if (existingVendor.length > 0) { + return NextResponse.json( + { + error: `이미 등록된 사업자등록번호입니다. (Tax ID ${vendorData.taxId} already exists in the system)` + }, + { status: 400 } + ) + } + + // Create vendor and handle files in transaction + await db.transaction(async (tx) => { + // Insert the vendor + const [newVendor] = await insertVendor(tx, { + vendorName: vendorData.vendorName, + vendorCode: vendorData.vendorCode || null, + address: vendorData.address || null, + country: vendorData.country || null, + phone: vendorData.phone || null, + email: vendorData.email, + website: vendorData.website || null, + status: vendorData.status ?? "PENDING_REVIEW", + taxId: vendorData.taxId, + vendorTypeId: vendorData.vendorTypeId, + items: vendorData.items || null, + + // Representative info + representativeName: vendorData.representativeName || null, + representativeBirth: vendorData.representativeBirth || null, + representativeEmail: vendorData.representativeEmail || null, + representativePhone: vendorData.representativePhone || null, + corporateRegistrationNumber: vendorData.corporateRegistrationNumber || null, + representativeWorkExpirence: vendorData.representativeWorkExpirence || false, + + }) + + // Store files by type + if (businessRegistrationFiles.length > 0) { + await storeVendorFiles(tx, newVendor.id, businessRegistrationFiles, FILE_TYPES.BUSINESS_REGISTRATION) + } + + if (isoCertificationFiles.length > 0) { + await storeVendorFiles(tx, newVendor.id, isoCertificationFiles, FILE_TYPES.ISO_CERTIFICATION) + } + + if (creditReportFiles.length > 0) { + await storeVendorFiles(tx, newVendor.id, creditReportFiles, FILE_TYPES.CREDIT_REPORT) + } + + if (bankAccountFiles.length > 0) { + await storeVendorFiles(tx, newVendor.id, bankAccountFiles, FILE_TYPES.BANK_ACCOUNT_COPY) + } + + // Insert contacts + for (const contact of contacts) { + await tx.insert(vendorContacts).values({ + vendorId: newVendor.id, + contactName: contact.contactName, + contactPosition: contact.contactPosition || null, + contactEmail: contact.contactEmail, + contactPhone: contact.contactPhone || null, + isPrimary: contact.isPrimary ?? false, + }) + } + }) + + revalidateTag("vendors") + + return NextResponse.json( + { message: '벤더 등록이 완료되었습니다.' }, + { status: 201 } + ) + + } catch (error) { + console.error('Vendor creation error:', error) + return NextResponse.json( + { error: getErrorMessage(error) }, + { status: 500 } + ) + } +}
\ No newline at end of file diff --git a/components/BidProjectSelector.tsx b/components/BidProjectSelector.tsx index 8e229b10..5cbcfee6 100644 --- a/components/BidProjectSelector.tsx +++ b/components/BidProjectSelector.tsx @@ -6,18 +6,20 @@ import { Button } from "@/components/ui/button" import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover" import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command" import { cn } from "@/lib/utils" -import { getBidProjects, type Project } from "@/lib/rfqs/service" +import { getBidProjects, type Project } from "@/lib/techsales-rfq/service" interface ProjectSelectorProps { selectedProjectId?: number | null; onProjectSelect: (project: Project) => void; placeholder?: string; + pjtType?: 'SHIP' | 'TOP' | 'HULL'; } export function EstimateProjectSelector ({ selectedProjectId, onProjectSelect, - placeholder = "프로젝트 선택..." + placeholder = "프로젝트 선택...", + pjtType }: ProjectSelectorProps) { const [open, setOpen] = React.useState(false) const [searchTerm, setSearchTerm] = React.useState("") @@ -30,7 +32,7 @@ export function EstimateProjectSelector ({ async function loadAllProjects() { setIsLoading(true); try { - const allProjects = await getBidProjects(); + const allProjects = await getBidProjects(pjtType); setProjects(allProjects); // 초기 선택된 프로젝트가 있으면 설정 @@ -48,7 +50,7 @@ export function EstimateProjectSelector ({ } loadAllProjects(); - }, [selectedProjectId]); + }, [selectedProjectId, pjtType]); // 클라이언트 측에서 검색어로 필터링 const filteredProjects = React.useMemo(() => { diff --git a/components/data-table/data-table-grobal-filter.tsx b/components/data-table/data-table-grobal-filter.tsx index 240e9fa7..a1f0a6f3 100644 --- a/components/data-table/data-table-grobal-filter.tsx +++ b/components/data-table/data-table-grobal-filter.tsx @@ -17,7 +17,6 @@ export function DataTableGlobalFilter() { eq: (a, b) => a === b, clearOnDefault: true, shallow: false, - history: "replace" }) // Local tempValue to update instantly on user keystroke diff --git a/components/data-table/data-table-sort-list.tsx b/components/data-table/data-table-sort-list.tsx index c3c537ac..c752f2f4 100644 --- a/components/data-table/data-table-sort-list.tsx +++ b/components/data-table/data-table-sort-list.tsx @@ -54,19 +54,30 @@ interface DataTableSortListProps<TData> { shallow?: boolean } +let renderCount = 0; + export function DataTableSortList<TData>({ table, debounceMs, shallow, }: DataTableSortListProps<TData>) { + renderCount++; + const id = React.useId() const initialSorting = (table.initialState.sorting ?? []) as ExtendedSortingState<TData> + // ✅ 파서를 안정화 - 한 번만 생성되도록 수정 + const sortingParser = React.useMemo(() => { + // 첫 번째 행의 데이터를 안정적으로 가져오기 + const sampleData = table.getRowModel().rows[0]?.original; + return getSortingStateParser(sampleData); + }, []); // ✅ 빈 dependency - 한 번만 생성 + const [sorting, setSorting] = useQueryState( "sort", - getSortingStateParser(table.getRowModel().rows[0]?.original) + sortingParser .withDefault(initialSorting) .withOptions({ clearOnDefault: true, @@ -74,6 +85,10 @@ export function DataTableSortList<TData>({ }) ) + // ✅ debouncedSetSorting - 컴포넌트 최상위로 이동 + const debouncedSetSorting = useDebouncedCallback(setSorting, debounceMs); + + // ✅ uniqueSorting 메모이제이션 const uniqueSorting = React.useMemo( () => sorting.filter( @@ -82,8 +97,7 @@ export function DataTableSortList<TData>({ [sorting] ) - const debouncedSetSorting = useDebouncedCallback(setSorting, debounceMs) - + // ✅ sortableColumns 메모이제이션 const sortableColumns = React.useMemo( () => table @@ -100,7 +114,8 @@ export function DataTableSortList<TData>({ [sorting, table] ) - function addSort() { + // ✅ 함수들을 useCallback으로 메모이제이션 + const addSort = React.useCallback(() => { const firstAvailableColumn = sortableColumns.find( (column) => !sorting.some((s) => s.id === column.id) ) @@ -113,9 +128,9 @@ export function DataTableSortList<TData>({ desc: false, }, ]) - } + }, [sortableColumns, sorting, setSorting]); - function updateSort({ + const updateSort = React.useCallback(({ id, field, debounced = false, @@ -123,7 +138,7 @@ export function DataTableSortList<TData>({ id: string field: Partial<ExtendedColumnSort<TData>> debounced?: boolean - }) { + }) => { const updateFunction = debounced ? debouncedSetSorting : setSorting updateFunction((prevSorting) => { @@ -134,13 +149,17 @@ export function DataTableSortList<TData>({ ) return updatedSorting }) - } + }, [debouncedSetSorting, setSorting]); - function removeSort(id: string) { + const removeSort = React.useCallback((id: string) => { void setSorting((prevSorting) => prevSorting.filter((item) => item.id !== id) ) - } + }, [setSorting]); + + const resetSorting = React.useCallback(() => { + setSorting(null); + }, [setSorting]); return ( <Sortable @@ -167,7 +186,7 @@ export function DataTableSortList<TData>({ <ArrowDownUp className="size-3" aria-hidden="true" /> <span className="hidden sm:inline"> - 정렬 + 정렬 </span> {uniqueSorting.length > 0 && ( @@ -357,7 +376,7 @@ export function DataTableSortList<TData>({ size="sm" variant="outline" className="rounded" - onClick={() => setSorting(null)} + onClick={resetSorting} > Reset sorting </Button> @@ -367,4 +386,4 @@ export function DataTableSortList<TData>({ </Popover> </Sortable> ) -} +}
\ No newline at end of file diff --git a/components/data-table/data-table.tsx b/components/data-table/data-table.tsx index 64afcb7e..33fca5b8 100644 --- a/components/data-table/data-table.tsx +++ b/components/data-table/data-table.tsx @@ -25,6 +25,25 @@ interface DataTableProps<TData> extends React.HTMLAttributes<HTMLDivElement> { compact?: boolean // 컴팩트 모드 옵션 추가 } +// ✅ compactStyles를 정적으로 정의 (매번 새로 생성 방지) +const COMPACT_STYLES = { + row: "h-7", // 행 높이 축소 + cell: "py-1 px-2 text-sm", // 셀 패딩 축소 및 폰트 크기 조정 + groupRow: "py-1 bg-muted/20 text-sm", // 그룹 행 패딩 축소 + emptyRow: "h-16", // 데이터 없을 때 행 높이 조정 + header: "py-1 px-2 text-sm", // 헤더 패딩 축소 + headerHeight: "h-8", // 헤더 높이 축소 +}; + +const NORMAL_STYLES = { + row: "", + cell: "", + groupRow: "bg-muted/20", + emptyRow: "h-24", + header: "", + headerHeight: "", +}; + /** * 멀티 그룹핑 + 그룹 토글 + 그룹 컬럼/헤더 숨김 + Indent + 리사이징 + 컴팩트 모드 */ @@ -41,18 +60,11 @@ export function DataTable<TData>({ useAutoSizeColumns(table, autoSizeColumns) - // 컴팩트 모드를 위한 클래스 정의 - const compactStyles = compact ? { - row: "h-7", // 행 높이 축소 - cell: "py-1 px-2 text-sm", // 셀 패딩 축소 및 폰트 크기 조정 - groupRow: "py-1 bg-muted/20 text-sm", // 그룹 행 패딩 축소 - emptyRow: "h-16", // 데이터 없을 때 행 높이 조정 - } : { - row: "", - cell: "", - groupRow: "bg-muted/20", - emptyRow: "h-24", - } + // ✅ compactStyles를 useMemo로 메모이제이션 + const compactStyles = React.useMemo(() => + compact ? COMPACT_STYLES : NORMAL_STYLES, + [compact] + ); return ( <div className={cn("w-full space-y-2.5 overflow-auto", className)} {...props}> @@ -62,7 +74,7 @@ export function DataTable<TData>({ {/* 테이블 헤더 */} <TableHeader> {table.getHeaderGroups().map((headerGroup) => ( - <TableRow key={headerGroup.id} className={compact ? "h-8" : ""}> + <TableRow key={headerGroup.id} className={compactStyles.headerHeight}> {headerGroup.headers.map((header) => { if (header.column.getIsGrouped()) { return null @@ -73,7 +85,7 @@ export function DataTable<TData>({ key={header.id} colSpan={header.colSpan} data-column-id={header.column.id} - className={compact ? "py-1 px-2 text-sm" : ""} + className={compactStyles.header} style={{ ...getCommonPinningStylesWithBorder({ column: header.column, diff --git a/components/form-data/form-data-report-temp-upload-dialog.tsx b/components/form-data/form-data-report-temp-upload-dialog.tsx index e4d78248..fe137daf 100644 --- a/components/form-data/form-data-report-temp-upload-dialog.tsx +++ b/components/form-data/form-data-report-temp-upload-dialog.tsx @@ -11,17 +11,11 @@ import { import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { TempDownloadBtn } from "./temp-download-btn"; import { VarListDownloadBtn } from "./var-list-download-btn"; import { FormDataReportTempUploadTab } from "./form-data-report-temp-upload-tab"; import { FormDataReportTempUploadedListTab } from "./form-data-report-temp-uploaded-list-tab"; import { DataTableColumnJSON } from "./form-data-table-columns"; +import { FileActionsDropdown } from "../ui/file-actions"; interface FormDataReportTempUploadDialogProps { columnsJSON: DataTableColumnJSON[]; @@ -44,54 +38,60 @@ export const FormDataReportTempUploadDialog: FC< formCode, uploaderType, }) => { - const [tabValue, setTabValue] = useState<"upload" | "uploaded">("upload"); + const [tabValue, setTabValue] = useState<"upload" | "uploaded">("upload"); - return ( - <Dialog open={open} onOpenChange={setOpen}> - <DialogContent className="w-[600px]" style={{ maxWidth: "none" }}> - <DialogHeader className="gap-2"> - <DialogTitle>Vendor Document Template</DialogTitle> - <DialogDescription className="flex justify-around gap-[16px] "> - {/* 사용하시고자 하는 Vendor Document Template(.docx)를 업로드 + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogContent className="w-[600px]" style={{ maxWidth: "none" }}> + <DialogHeader className="gap-2"> + <DialogTitle>Vendor Document Template</DialogTitle> + <DialogDescription className="flex justify-around gap-[16px] "> + {/* 사용하시고자 하는 Vendor Document Template(.docx)를 업로드 하여주시기 바랍니다. */} - <TempDownloadBtn /> - <VarListDownloadBtn columnsJSON={columnsJSON} formCode={formCode} /> - </DialogDescription> - </DialogHeader> - <Tabs value={tabValue}> - <div className="flex justify-between items-center"> - <TabsList className="w-full"> - <TabsTrigger - value="upload" - onClick={() => setTabValue("upload")} - className="flex-1" - > - Upload Template File - </TabsTrigger> - <TabsTrigger - value="uploaded" - onClick={() => setTabValue("uploaded")} - className="flex-1" - > - Uploaded Template File List - </TabsTrigger> - </TabsList> - </div> - <TabsContent value="upload"> - <FormDataReportTempUploadTab - packageId={packageId} - formId={formId} - uploaderType={uploaderType} - /> - </TabsContent> - <TabsContent value="uploaded"> - <FormDataReportTempUploadedListTab - packageId={packageId} - formId={formId} - /> - </TabsContent> - </Tabs> - </DialogContent> - </Dialog> - ); -};
\ No newline at end of file + <FileActionsDropdown + filePath={"/vendorFormReportSample"} + fileName={"sample_template_file.docx"} + variant="ghost" + size="icon" + description="Sample File" + /> + <VarListDownloadBtn columnsJSON={columnsJSON} formCode={formCode} /> + </DialogDescription> + </DialogHeader> + <Tabs value={tabValue}> + <div className="flex justify-between items-center"> + <TabsList className="w-full"> + <TabsTrigger + value="upload" + onClick={() => setTabValue("upload")} + className="flex-1" + > + Upload Template File + </TabsTrigger> + <TabsTrigger + value="uploaded" + onClick={() => setTabValue("uploaded")} + className="flex-1" + > + Uploaded Template File List + </TabsTrigger> + </TabsList> + </div> + <TabsContent value="upload"> + <FormDataReportTempUploadTab + packageId={packageId} + formId={formId} + uploaderType={uploaderType} + /> + </TabsContent> + <TabsContent value="uploaded"> + <FormDataReportTempUploadedListTab + packageId={packageId} + formId={formId} + /> + </TabsContent> + </Tabs> + </DialogContent> + </Dialog> + ); + };
\ No newline at end of file diff --git a/components/form-data/temp-download-btn.tsx b/components/form-data/temp-download-btn.tsx deleted file mode 100644 index 793022d6..00000000 --- a/components/form-data/temp-download-btn.tsx +++ /dev/null @@ -1,46 +0,0 @@ -"use client"; - -import React from "react"; -import Image from "next/image"; -import { useToast } from "@/hooks/use-toast"; -import { toast as toastMessage } from "sonner"; -import { saveAs } from "file-saver"; -import { Button } from "@/components/ui/button"; -import { getReportTempFileData } from "@/lib/forms/services"; - -export const TempDownloadBtn = () => { - const { toast } = useToast(); - - const downloadTempFile = async () => { - try { - const { fileName, fileType, base64 } = await getReportTempFileData(); - - saveAs(`data:${fileType};base64,${base64}`, fileName); - - toastMessage.success("Report Sample File 다운로드 완료!"); - } catch (err) { - console.log(err); - toast({ - title: "Error", - description: "Sample File을 찾을 수가 없습니다.", - variant: "destructive", - }); - } - }; - return ( - <Button - variant="outline" - className="relative px-[8px] py-[6px] flex-1" - aria-label="Template Sample Download" - onClick={downloadTempFile} - > - <Image - src="/icons/temp_sample_icon.svg" - alt="Template Sample Download Icon" - width={16} - height={16} - /> - <div className='text-[12px]'>Sample Template Download</div> - </Button> - ); -};
\ No newline at end of file diff --git a/components/information/information-button.tsx b/components/information/information-button.tsx index 38e8cb12..f8707439 100644 --- a/components/information/information-button.tsx +++ b/components/information/information-button.tsx @@ -11,7 +11,7 @@ import { DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
-import { Info, Download, Edit } from "lucide-react"
+import { Info, Download, Edit, Loader2 } from "lucide-react"
import { getCachedPageInformation, getCachedEditPermission } from "@/lib/information/service"
import { getCachedPageNotices } from "@/lib/notice/service"
import { UpdateInformationDialog } from "@/lib/information/table/update-information-dialog"
@@ -48,11 +48,13 @@ export function InformationButton({ const [selectedNotice, setSelectedNotice] = useState<NoticeWithAuthor | null>(null)
const [isNoticeViewDialogOpen, setIsNoticeViewDialogOpen] = useState(false)
const [dataLoaded, setDataLoaded] = useState(false)
+ const [isLoading, setIsLoading] = useState(false)
// 데이터 로드 함수 (단순화)
const loadData = React.useCallback(async () => {
if (dataLoaded) return // 이미 로드되었으면 중복 방지
+ setIsLoading(true)
try {
// pagePath 정규화 (앞의 / 제거)
const normalizedPath = pagePath.startsWith('/') ? pagePath.slice(1) : pagePath
@@ -74,6 +76,8 @@ export function InformationButton({ }
} catch (error) {
console.error("데이터 로딩 중 오류:", error)
+ } finally {
+ setIsLoading(false)
}
}, [pagePath, session?.user?.id, dataLoaded])
@@ -140,100 +144,119 @@ export function InformationButton({ </div>
</DialogHeader>
- <div className="mt-4 space-y-6">
- {/* 공지사항 섹션 */}
- {notices.length > 0 && (
- <div className="space-y-3">
- <div className="flex items-center justify-between">
- <h4 className="font-semibold">공지사항</h4>
- <span className="text-xs text-gray-500">{notices.length}개</span>
- </div>
- <div className="max-h-60 overflow-y-auto border rounded-lg bg-gray-50 p-2">
- <div className="space-y-2">
- {notices.map((notice) => (
- <div
- key={notice.id}
- className="p-3 bg-white border rounded-lg hover:bg-gray-50 cursor-pointer transition-colors"
- onClick={() => handleNoticeClick(notice)}
- >
- <div className="space-y-1">
- <h5 className="font-medium text-sm line-clamp-2">
- {notice.title}
- </h5>
- <div className="flex items-center gap-3 text-xs text-gray-500">
- <span>{formatDate(notice.createdAt)}</span>
- {notice.authorName && (
- <span>{notice.authorName}</span>
- )}
+ <div className="mt-4">
+ {isLoading ? (
+ <div className="flex items-center justify-center py-12">
+ <Loader2 className="h-6 w-6 animate-spin text-gray-500" />
+ <span className="ml-2 text-gray-500">정보를 불러오는 중...</span>
+ </div>
+ ) : (
+ <div className="space-y-6">
+ {/* 공지사항 섹션 */}
+ <div className="space-y-3">
+ <div className="flex items-center justify-between">
+ <h4 className="font-semibold">공지사항</h4>
+ {notices.length > 0 && (
+ <span className="text-xs text-gray-500">{notices.length}개</span>
+ )}
+ </div>
+ {notices.length > 0 ? (
+ <div className="max-h-60 overflow-y-auto border rounded-lg bg-gray-50 p-2">
+ <div className="space-y-2">
+ {notices.map((notice) => (
+ <div
+ key={notice.id}
+ className="p-3 bg-white border rounded-lg hover:bg-gray-50 cursor-pointer transition-colors"
+ onClick={() => handleNoticeClick(notice)}
+ >
+ <div className="space-y-1">
+ <h5 className="font-medium text-sm line-clamp-2">
+ {notice.title}
+ </h5>
+ <div className="flex items-center gap-3 text-xs text-gray-500">
+ <span>{formatDate(notice.createdAt)}</span>
+ {notice.authorName && (
+ <span>{notice.authorName}</span>
+ )}
+ </div>
+ </div>
</div>
- </div>
+ ))}
</div>
- ))}
- </div>
+ </div>
+ ) : (
+ <div className="bg-gray-50 border rounded-lg p-4">
+ <div className="text-center text-gray-500">
+ 공지사항이 없습니다
+ </div>
+ </div>
+ )}
</div>
- </div>
- )}
- {/* 인포메이션 컨텐츠 */}
- {information?.informationContent && (
- <div className="space-y-3">
- <div className="flex items-center justify-between">
- <h4 className="font-semibold">안내사항</h4>
- {hasEditPermission && information && (
- <Button
- variant="outline"
- size="sm"
- onClick={handleEditClick}
- className="flex items-center gap-2 mr-2"
- >
- <Edit className="h-4 w-4" />
- 편집
- </Button>
- )}
- </div>
- <div className="bg-gray-50 border rounded-lg p-4">
- <div className="text-sm text-gray-600 whitespace-pre-wrap max-h-40 overflow-y-auto">
- {information.informationContent}
+ {/* 인포메이션 컨텐츠 */}
+ <div className="space-y-3">
+ <div className="flex items-center justify-between">
+ <h4 className="font-semibold">안내사항</h4>
+ {hasEditPermission && information && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleEditClick}
+ className="flex items-center gap-2 mr-2"
+ >
+ <Edit className="h-4 w-4" />
+ 편집
+ </Button>
+ )}
+ </div>
+ <div className="bg-gray-50 border rounded-lg p-4">
+ {information?.informationContent ? (
+ <div className="text-sm text-gray-600 whitespace-pre-wrap max-h-40 overflow-y-auto">
+ {information.informationContent}
+ </div>
+ ) : (
+ <div className="text-center text-gray-500">
+ 안내사항이 없습니다
+ </div>
+ )}
</div>
</div>
- </div>
- )}
- {/* 첨부파일 */}
- {information?.attachmentFileName && (
- <div className="space-y-3">
- <h4 className="font-semibold">첨부파일</h4>
- <div className="bg-gray-50 border rounded-lg p-4">
- <div className="flex items-center justify-between p-3 bg-white rounded border">
- <div className="flex-1">
- <div className="text-sm font-medium">
- {information.attachmentFileName}
- </div>
- {information.attachmentFileSize && (
- <div className="text-xs text-gray-500 mt-1">
- {information.attachmentFileSize}
+ {/* 첨부파일 */}
+ <div className="space-y-3">
+ <h4 className="font-semibold">첨부파일</h4>
+ <div className="bg-gray-50 border rounded-lg p-4">
+ {information?.attachmentFileName ? (
+ <div className="flex items-center justify-between p-3 bg-white rounded border">
+ <div className="flex-1">
+ <div className="text-sm font-medium">
+ {information.attachmentFileName}
+ </div>
+ {information.attachmentFileSize && (
+ <div className="text-xs text-gray-500 mt-1">
+ {information.attachmentFileSize}
+ </div>
+ )}
</div>
- )}
- </div>
- <Button
- size="sm"
- variant="outline"
- onClick={handleDownload}
- className="flex items-center gap-1"
- >
- <Download className="h-3 w-3" />
- 다운로드
- </Button>
+ <Button
+ size="sm"
+ variant="outline"
+ onClick={handleDownload}
+ className="flex items-center gap-1"
+ >
+ <Download className="h-3 w-3" />
+ 다운로드
+ </Button>
+ </div>
+ ) : (
+ <div className="text-center text-gray-500">
+ 첨부파일이 없습니다
+ </div>
+ )}
</div>
</div>
</div>
)}
-
- {!information && notices.length === 0 && (
- <div className="text-center py-8 text-gray-500">
- <p>이 페이지에 대한 정보가 없습니다.</p>
- </div>
- )}
</div>
</DialogContent>
</Dialog>
diff --git a/components/login/login-form-shi.tsx b/components/login/login-form-shi.tsx index 6be8d5c8..862f9f8a 100644 --- a/components/login/login-form-shi.tsx +++ b/components/login/login-form-shi.tsx @@ -99,12 +99,12 @@ export function LoginFormSHI({ try { // next-auth의 Credentials Provider로 로그인 시도 - const result = await signIn('credentials', { + const result = await signIn('credentials-otp', { email, code: otp, redirect: false, // 커스텀 처리 위해 redirect: false }); - + if (result?.ok) { // 토스트 메시지 표시 toast({ @@ -204,9 +204,9 @@ export function LoginFormSHI({ <div className="mx-auto w-full flex flex-col space-y-6 sm:w-[350px]"> {/* Here's your existing login/OTP forms: */} - {!otpSent ? ( - // ( */} - <form onSubmit={handleSubmit} className="p-6 md:p-8"> + {/* {!otpSent ? ( */} + + <form onSubmit={handleOtpSubmit} className="p-6 md:p-8"> {/* <form onSubmit={handleOtpSubmit} className="p-6 md:p-8"> */} <div className="flex flex-col gap-6"> <div className="flex flex-col items-center text-center"> @@ -269,7 +269,7 @@ export function LoginFormSHI({ </div> </div> </form> - ) + {/* ) : ( @@ -323,7 +323,7 @@ export function LoginFormSHI({ </div> </div> </form> - )} + )} */} <div className="text-balance text-center text-xs text-muted-foreground [&_a]:underline [&_a]:underline-offset-4 hover:[&_a]:text-primary"> {t('termsMessage')} <a href="#">{t('termsOfService')}</a> {t('and')} diff --git a/components/login/login-form.tsx b/components/login/login-form.tsx index bb588ba0..a71fd15e 100644 --- a/components/login/login-form.tsx +++ b/components/login/login-form.tsx @@ -38,12 +38,13 @@ export function LoginForm({ // 상태 관리 const [loginMethod, setLoginMethod] = useState<LoginMethod>('username'); - const [isLoading, setIsLoading] = useState(false); + const [isFirstAuthLoading, setIsFirstAuthLoading] = useState(false); const [showForgotPassword, setShowForgotPassword] = useState(false); // MFA 관련 상태 const [showMfaForm, setShowMfaForm] = useState(false); const [mfaToken, setMfaToken] = useState(''); + const [tempAuthKey, setTempAuthKey] = useState(''); const [mfaUserId, setMfaUserId] = useState(''); const [mfaUserEmail, setMfaUserEmail] = useState(''); const [mfaCountdown, setMfaCountdown] = useState(0); @@ -56,6 +57,9 @@ export function LoginForm({ const [sgipsUsername, setSgipsUsername] = useState(''); const [sgipsPassword, setSgipsPassword] = useState(''); + const [isMfaLoading, setIsMfaLoading] = useState(false); + const [isSmsLoading, setIsSmsLoading] = useState(false); + // 서버 액션 상태 const [passwordResetState, passwordResetAction] = useFormState(requestPasswordResetAction, { success: false, @@ -100,29 +104,56 @@ export function LoginForm({ } }, [passwordResetState, toast, t]); - // SMS 토큰 전송 - const handleSendSms = async () => { - if (!mfaUserId || mfaCountdown > 0) return; + // 1차 인증 수행 (공통 함수) + const performFirstAuth = async (username: string, password: string, provider: 'email' | 'sgips') => { + try { + const response = await fetch('/api/auth/first-auth', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username, + password, + provider + }), + }); + + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.error || '인증에 실패했습니다.'); + } + + return result; + } catch (error) { + console.error('First auth error:', error); + throw error; + } + }; + + // SMS 토큰 전송 (userId 파라미터 추가) + const handleSendSms = async (userIdParam?: string) => { + const targetUserId = userIdParam || mfaUserId; + if (!targetUserId || mfaCountdown > 0) return; - setIsLoading(true); + setIsSmsLoading(true); try { - // SMS 전송 API 호출 (실제 구현 필요) const response = await fetch('/api/auth/send-sms', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ userId: mfaUserId }), + body: JSON.stringify({ userId: targetUserId }), }); if (response.ok) { - setMfaCountdown(60); // 60초 카운트다운 + setMfaCountdown(60); toast({ title: 'SMS 전송 완료', description: '인증번호를 전송했습니다.', }); } else { + const errorData = await response.json(); toast({ title: t('errorTitle'), - description: 'SMS 전송에 실패했습니다.', + description: errorData.message || 'SMS 전송에 실패했습니다.', variant: 'destructive', }); } @@ -134,11 +165,11 @@ export function LoginForm({ variant: 'destructive', }); } finally { - setIsLoading(false); + setIsSmsLoading(false); } }; - // MFA 토큰 검증 + // MFA 토큰 검증 및 최종 로그인 const handleMfaSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -151,26 +182,34 @@ export function LoginForm({ return; } - setIsLoading(true); + if (!tempAuthKey) { + toast({ + title: t('errorTitle'), + description: '인증 세션이 만료되었습니다. 다시 로그인해주세요.', + variant: 'destructive', + }); + setShowMfaForm(false); + return; + } + + setIsMfaLoading(true); try { - // MFA 토큰 검증 API 호출 - const response = await fetch('/api/auth/verify-mfa', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - userId: mfaUserId, - token: mfaToken - }), + // NextAuth의 credentials-mfa 프로바이더로 최종 인증 + const result = await signIn('credentials-mfa', { + userId: mfaUserId, + smsToken: mfaToken, + tempAuthKey: tempAuthKey, + redirect: false, }); - if (response.ok) { + if (result?.ok) { toast({ title: '인증 완료', description: '로그인이 완료되었습니다.', }); - // callbackUrl 처리 + // 콜백 URL 처리 const callbackUrlParam = searchParams?.get('callbackUrl'); if (callbackUrlParam) { try { @@ -184,10 +223,24 @@ export function LoginForm({ router.push(`/${lng}/partners/dashboard`); } } else { - const errorData = await response.json(); + let errorMessage = '인증번호가 올바르지 않습니다.'; + + if (result?.error) { + switch (result.error) { + case 'CredentialsSignin': + errorMessage = '인증번호가 올바르지 않거나 만료되었습니다.'; + break; + case 'AccessDenied': + errorMessage = '접근이 거부되었습니다.'; + break; + default: + errorMessage = 'MFA 인증에 실패했습니다.'; + } + } + toast({ title: t('errorTitle'), - description: errorData.message || '인증번호가 올바르지 않습니다.', + description: errorMessage, variant: 'destructive', }); } @@ -199,11 +252,11 @@ export function LoginForm({ variant: 'destructive', }); } finally { - setIsLoading(false); + setIsMfaLoading(false); } }; - // 일반 사용자명/패스워드 로그인 처리 (간소화된 버전) + // 일반 사용자명/패스워드 1차 인증 처리 const handleUsernameLogin = async (e: React.FormEvent) => { e.preventDefault(); @@ -216,76 +269,53 @@ export function LoginForm({ return; } - setIsLoading(true); + setIsFirstAuthLoading(true); try { - // NextAuth credentials-password provider로 로그인 - const result = await signIn('credentials-password', { - username: username, - password: password, - redirect: false, - }); + // 1차 인증만 수행 (세션 생성 안함) + const authResult = await performFirstAuth(username, password, 'email'); - if (result?.ok) { - // 로그인 1차 성공 - 바로 MFA 화면으로 전환 + if (authResult.success) { toast({ - title: t('loginSuccess'), - description: '1차 인증이 완료되었습니다.', + title: '1차 인증 완료', + description: 'SMS 인증을 진행합니다.', }); - // 모든 사용자는 MFA 필수이므로 바로 MFA 폼으로 전환 - setMfaUserId(username); // 입력받은 username 사용 - setMfaUserEmail(username); // 입력받은 username 사용 (보통 이메일) + // MFA 화면으로 전환 + setTempAuthKey(authResult.tempAuthKey); + setMfaUserId(authResult.userId); + setMfaUserEmail(authResult.email); setShowMfaForm(true); - // 자동으로 SMS 전송 + // 자동으로 SMS 전송 (userId 직접 전달) setTimeout(() => { - handleSendSms(); + handleSendSms(authResult.userId); }, 500); toast({ title: 'SMS 인증 필요', description: '등록된 전화번호로 인증번호를 전송합니다.', }); - - } else { - // 로그인 실패 처리 - let errorMessage = t('invalidCredentials'); - - if (result?.error) { - switch (result.error) { - case 'CredentialsSignin': - errorMessage = t('invalidCredentials'); - break; - case 'AccessDenied': - errorMessage = t('accessDenied'); - break; - default: - errorMessage = t('defaultErrorMessage'); - } - } - - toast({ - title: t('errorTitle'), - description: errorMessage, - variant: 'destructive', - }); } - } catch (error) { - console.error('S-GIPS Login error:', error); + } catch (error: any) { + console.error('Username login error:', error); + + let errorMessage = t('invalidCredentials'); + if (error.message) { + errorMessage = error.message; + } + toast({ title: t('errorTitle'), - description: t('defaultErrorMessage'), + description: errorMessage, variant: 'destructive', }); } finally { - setIsLoading(false); + setIsFirstAuthLoading(false); } }; - - // S-Gips 로그인 처리 - // S-Gips 로그인 처리 (간소화된 버전) + // S-Gips 1차 인증 처리 const handleSgipsLogin = async (e: React.FormEvent) => { e.preventDefault(); @@ -298,73 +328,62 @@ export function LoginForm({ return; } - setIsLoading(true); + setIsFirstAuthLoading(true); try { - // NextAuth credentials-password provider로 로그인 (S-Gips 구분) - const result = await signIn('credentials-password', { - username: sgipsUsername, - password: sgipsPassword, - provider: 'sgips', // S-Gips 구분을 위한 추가 파라미터 - redirect: false, - }); + // S-Gips 1차 인증만 수행 (세션 생성 안함) + const authResult = await performFirstAuth(sgipsUsername, sgipsPassword, 'sgips'); - if (result?.ok) { - // S-Gips 1차 인증 성공 - 바로 MFA 화면으로 전환 + if (authResult.success) { toast({ - title: t('loginSuccess'), - description: 'S-Gips 인증이 완료되었습니다.', + title: 'S-Gips 인증 완료', + description: 'SMS 인증을 진행합니다.', }); - // S-Gips도 MFA 필수이므로 바로 MFA 폼으로 전환 - setMfaUserId(sgipsUsername); - setMfaUserEmail(sgipsUsername); + // MFA 화면으로 전환 + setTempAuthKey(authResult.tempAuthKey); + setMfaUserId(authResult.userId); + setMfaUserEmail(authResult.email); setShowMfaForm(true); - // 자동으로 SMS 전송 + // 자동으로 SMS 전송 (userId 직접 전달) setTimeout(() => { - handleSendSms(); + handleSendSms(authResult.userId); }, 500); toast({ title: 'SMS 인증 시작', description: 'S-Gips 등록 전화번호로 인증번호를 전송합니다.', }); - - } else { - let errorMessage = t('sgipsLoginFailed'); - - if (result?.error) { - switch (result.error) { - case 'CredentialsSignin': - errorMessage = t('invalidSgipsCredentials'); - break; - case 'AccessDenied': - errorMessage = t('sgipsAccessDenied'); - break; - default: - errorMessage = t('sgipsSystemError'); - } - } - - toast({ - title: t('errorTitle'), - description: errorMessage, - variant: 'destructive', - }); } - } catch (error) { + } catch (error: any) { console.error('S-Gips login error:', error); + + let errorMessage = t('sgipsLoginFailed'); + if (error.message) { + errorMessage = error.message; + } + toast({ title: t('errorTitle'), - description: t('sgipsSystemError'), + description: errorMessage, variant: 'destructive', }); } finally { - setIsLoading(false); + setIsFirstAuthLoading(false); } }; + // MFA 화면에서 뒤로 가기 + const handleBackToLogin = () => { + setShowMfaForm(false); + setMfaToken(''); + setTempAuthKey(''); + setMfaUserId(''); + setMfaUserEmail(''); + setMfaCountdown(0); + }; + return ( <div className="container relative flex h-screen flex-col items-center justify-center md:grid lg:max-w-none lg:grid-cols-2 lg:px-0"> {/* Left Content */} @@ -405,7 +424,7 @@ export function LoginForm({ </div> <h1 className="text-2xl font-bold">SMS 인증</h1> <p className="text-sm text-muted-foreground mt-2"> - {mfaUserEmail}로 로그인하셨습니다 + {mfaUserEmail}로 1차 인증이 완료되었습니다 </p> <p className="text-xs text-muted-foreground mt-1"> 등록된 전화번호로 전송된 6자리 인증번호를 입력해주세요 @@ -457,7 +476,7 @@ export function LoginForm({ className="h-10" value={username} onChange={(e) => setUsername(e.target.value)} - disabled={isLoading} + disabled={isFirstAuthLoading} /> </div> <div className="grid gap-2"> @@ -469,16 +488,16 @@ export function LoginForm({ className="h-10" value={password} onChange={(e) => setPassword(e.target.value)} - disabled={isLoading} + disabled={isFirstAuthLoading} /> </div> <Button type="submit" className="w-full" variant="samsung" - disabled={isLoading || !username || !password} + disabled={isFirstAuthLoading || !username || !password} > - {isLoading ? '로그인 중...' : t('login')} + {isFirstAuthLoading ? '인증 중...' : t('login')} </Button> </form> )} @@ -495,7 +514,7 @@ export function LoginForm({ className="h-10" value={sgipsUsername} onChange={(e) => setSgipsUsername(e.target.value)} - disabled={isLoading} + disabled={isFirstAuthLoading} /> </div> <div className="grid gap-2"> @@ -507,16 +526,16 @@ export function LoginForm({ className="h-10" value={sgipsPassword} onChange={(e) => setSgipsPassword(e.target.value)} - disabled={isLoading} + disabled={isFirstAuthLoading} /> </div> <Button type="submit" className="w-full" variant="default" - disabled={isLoading || !sgipsUsername || !sgipsPassword} + disabled={isFirstAuthLoading || !sgipsUsername || !sgipsPassword} > - {isLoading ? 'S-Gips 인증 중...' : 'S-Gips 로그인'} + {isFirstAuthLoading ? 'S-Gips 인증 중...' : 'S-Gips 로그인'} </Button> <p className="text-xs text-muted-foreground text-center"> S-Gips 계정으로 로그인하면 자동으로 SMS 인증이 진행됩니다. @@ -553,6 +572,7 @@ export function LoginForm({ variant="link" className="text-green-600 hover:text-green-800 text-sm" onClick={() => { + setTempAuthKey('test-temp-key'); setMfaUserId('test-user'); setMfaUserEmail('test@example.com'); setShowMfaForm(true); @@ -572,13 +592,7 @@ export function LoginForm({ type="button" variant="ghost" size="sm" - onClick={() => { - setShowMfaForm(false); - setMfaToken(''); - setMfaUserId(''); - setMfaUserEmail(''); - setMfaCountdown(0); - }} + onClick={handleBackToLogin} className="text-blue-600 hover:text-blue-800" > <ArrowLeft className="w-4 h-4 mr-1" /> @@ -595,13 +609,14 @@ export function LoginForm({ 인증번호를 받지 못하셨나요? </p> <Button - onClick={handleSendSms} - disabled={isLoading || mfaCountdown > 0} + onClick={() => handleSendSms()} + disabled={isSmsLoading || mfaCountdown > 0} variant="outline" size="sm" className="w-full" + type="button" > - {isLoading ? ( + {isSmsLoading ? ( '전송 중...' ) : mfaCountdown > 0 ? ( `재전송 가능 (${mfaCountdown}초)` @@ -641,9 +656,9 @@ export function LoginForm({ type="submit" className="w-full" variant="samsung" - disabled={isLoading || mfaToken.length !== 6} + disabled={isMfaLoading || mfaToken.length !== 6} > - {isLoading ? '인증 중...' : '인증 완료'} + {isMfaLoading ? '인증 중...' : '인증 완료'} </Button> </form> @@ -755,7 +770,7 @@ export function LoginForm({ <div className="text-balance text-center text-xs text-muted-foreground [&_a]:underline [&_a]:underline-offset-4 hover:[&_a]:text-primary"> {t("agreement")}{" "} <Link - href={`/${lng}/privacy`} // 개인정보처리방침만 남김 + href={`/${lng}/privacy`} className="underline underline-offset-4 hover:text-primary" > {t("privacyPolicy")} diff --git a/components/signup/join-form.tsx b/components/signup/join-form.tsx index 30449a63..ecaf6bc3 100644 --- a/components/signup/join-form.tsx +++ b/components/signup/join-form.tsx @@ -39,7 +39,7 @@ import { Check, ChevronsUpDown, Loader2, Plus, X } from "lucide-react" import { cn } from "@/lib/utils" import { useTranslation } from "@/i18n/client" -import { createVendor, getVendorTypes } from "@/lib/vendors/service" +import { getVendorTypes } from "@/lib/vendors/service" import { createVendorSchema, CreateVendorSchema } from "@/lib/vendors/validations" import { Select, @@ -70,6 +70,7 @@ import { import { Badge } from "@/components/ui/badge" import { ScrollArea } from "@/components/ui/scroll-area" import prettyBytes from "pretty-bytes" +import { Checkbox } from "../ui/checkbox" i18nIsoCountries.registerLocale(enLocale) i18nIsoCountries.registerLocale(koLocale) @@ -161,8 +162,11 @@ export function JoinForm() { const [vendorTypes, setVendorTypes] = React.useState<VendorType[]>([]) const [isLoadingVendorTypes, setIsLoadingVendorTypes] = React.useState(true) - // File states - const [selectedFiles, setSelectedFiles] = React.useState<File[]>([]) + // Individual file states + const [businessRegistrationFiles, setBusinessRegistrationFiles] = React.useState<File[]>([]) + const [isoCertificationFiles, setIsoCertificationFiles] = React.useState<File[]>([]) + const [creditReportFiles, setCreditReportFiles] = React.useState<File[]>([]) + const [bankAccountFiles, setBankAccountFiles] = React.useState<File[]>([]) const [isSubmitting, setIsSubmitting] = React.useState(false) @@ -207,7 +211,7 @@ export function JoinForm() { representativeEmail: "", representativePhone: "", corporateRegistrationNumber: "", - attachedFiles: undefined, + representativeWorkExpirence: false, // contacts (no isPrimary) contacts: [ { @@ -220,11 +224,31 @@ export function JoinForm() { }, mode: "onChange", }) - const isFormValid = form.formState.isValid - console.log("Form errors:", form.formState.errors); - console.log("Form values:", form.getValues()); - console.log("Form valid:", form.formState.isValid); + // Custom validation for file uploads + const validateRequiredFiles = () => { + const errors = [] + + if (businessRegistrationFiles.length === 0) { + errors.push("사업자등록증을 업로드해주세요.") + } + + if (isoCertificationFiles.length === 0) { + errors.push("ISO 인증서를 업로드해주세요.") + } + + if (creditReportFiles.length === 0) { + errors.push("신용평가보고서를 업로드해주세요.") + } + + if (form.watch("country") !== "KR" && bankAccountFiles.length === 0) { + errors.push("대금지급 통장사본을 업로드해주세요.") + } + + return errors + } + + const isFormValid = form.formState.isValid && validateRequiredFiles().length === 0 // Field array for contacts const { fields: contactFields, append: addContact, remove: removeContact } = @@ -233,36 +257,53 @@ export function JoinForm() { name: "contacts", }) - // Dropzone handlers - const handleDropAccepted = (acceptedFiles: File[]) => { - const newFiles = [...selectedFiles, ...acceptedFiles] - setSelectedFiles(newFiles) - form.setValue("attachedFiles", newFiles, { shouldValidate: true }) - } - const handleDropRejected = (fileRejections: any[]) => { - fileRejections.forEach((rej) => { - toast({ - variant: "destructive", - title: "File Error", - description: `${rej.file.name}: ${rej.errors[0]?.message || "Upload failed"}`, + // File upload handlers + const createFileUploadHandler = ( + setFiles: React.Dispatch<React.SetStateAction<File[]>>, + currentFiles: File[] + ) => ({ + onDropAccepted: (acceptedFiles: File[]) => { + const newFiles = [...currentFiles, ...acceptedFiles] + setFiles(newFiles) + }, + onDropRejected: (fileRejections: any[]) => { + fileRejections.forEach((rej) => { + toast({ + variant: "destructive", + title: "File Error", + description: `${rej.file.name}: ${rej.errors[0]?.message || "Upload failed"}`, + }) }) - }) - } - const removeFile = (index: number) => { - const updated = [...selectedFiles] - updated.splice(index, 1) - setSelectedFiles(updated) - form.setValue("attachedFiles", updated, { shouldValidate: true }) - } + }, + removeFile: (index: number) => { + const updated = [...currentFiles] + updated.splice(index, 1) + setFiles(updated) + } + }) + + const businessRegistrationHandler = createFileUploadHandler(setBusinessRegistrationFiles, businessRegistrationFiles) + const isoCertificationHandler = createFileUploadHandler(setIsoCertificationFiles, isoCertificationFiles) + const creditReportHandler = createFileUploadHandler(setCreditReportFiles, creditReportFiles) + const bankAccountHandler = createFileUploadHandler(setBankAccountFiles, bankAccountFiles) // Submit async function onSubmit(values: CreateVendorSchema) { + const fileErrors = validateRequiredFiles() + if (fileErrors.length > 0) { + toast({ + variant: "destructive", + title: "파일 업로드 필수", + description: fileErrors.join("\n"), + }) + return + } + setIsSubmitting(true) try { - const mainFiles = values.attachedFiles - ? Array.from(values.attachedFiles as FileList) - : [] + const formData = new FormData() + // Add vendor data const vendorData = { vendorName: values.vendorName, vendorTypeId: values.vendorTypeId, @@ -279,16 +320,40 @@ export function JoinForm() { representativeBirth: values.representativeBirth || "", representativeEmail: values.representativeEmail || "", representativePhone: values.representativePhone || "", - corporateRegistrationNumber: values.corporateRegistrationNumber || "" + corporateRegistrationNumber: values.corporateRegistrationNumber || "", + representativeWorkExpirence: values.representativeWorkExpirence || false + } + + formData.append('vendorData', JSON.stringify(vendorData)) + formData.append('contacts', JSON.stringify(values.contacts)) + + // Add files with specific types + businessRegistrationFiles.forEach(file => { + formData.append('businessRegistration', file) + }) + + isoCertificationFiles.forEach(file => { + formData.append('isoCertification', file) + }) + + creditReportFiles.forEach(file => { + formData.append('creditReport', file) + }) + + if (values.country !== "KR") { + bankAccountFiles.forEach(file => { + formData.append('bankAccount', file) + }) } - const result = await createVendor({ - vendorData, - files: mainFiles, - contacts: values.contacts, + const response = await fetch('/api/vendors', { + method: 'POST', + body: formData, }) - if (!result.error) { + const result = await response.json() + + if (response.ok) { toast({ title: "등록 완료", description: "회사 등록이 완료되었습니다. (status=PENDING_REVIEW)", @@ -340,7 +405,7 @@ export function JoinForm() { } }; - const getPhoneDescription = (countryCode: string) => { + const getPhoneDescription = (countryCode: string) => { if (!countryCode) return "국가를 먼저 선택해주세요."; const dialCode = countryDialCodes[countryCode]; @@ -359,7 +424,84 @@ export function JoinForm() { return `${dialCode}로 시작하는 국제 전화번호를 입력하세요.`; } }; - + + // File display component + const FileUploadSection = ({ + title, + description, + files, + onDropAccepted, + onDropRejected, + removeFile, + required = true + }: { + title: string; + description: string; + files: File[]; + onDropAccepted: (files: File[]) => void; + onDropRejected: (rejections: any[]) => void; + removeFile: (index: number) => void; + required?: boolean; + }) => ( + <div className="space-y-4"> + <div> + <h5 className="text-sm font-medium"> + {title} + {required && <span className="text-red-500 ml-1">*</span>} + </h5> + <p className="text-xs text-muted-foreground mt-1">{description}</p> + </div> + + <Dropzone + maxSize={MAX_FILE_SIZE} + multiple + onDropAccepted={onDropAccepted} + onDropRejected={onDropRejected} + disabled={isSubmitting} + > + {({ maxSize }) => ( + <DropzoneZone className="flex justify-center"> + <DropzoneInput /> + <div className="flex items-center gap-4"> + <DropzoneUploadIcon /> + <div className="grid gap-1"> + <DropzoneTitle>파일 업로드</DropzoneTitle> + <DropzoneDescription> + 드래그 또는 클릭 + {maxSize ? ` (최대: ${prettyBytes(maxSize)})` : null} + </DropzoneDescription> + </div> + </div> + </DropzoneZone> + )} + </Dropzone> + + {files.length > 0 && ( + <div className="mt-2"> + <ScrollArea className="max-h-32"> + <FileList className="gap-2"> + {files.map((file, i) => ( + <FileListItem key={file.name + i}> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <FileListName>{file.name}</FileListName> + <FileListDescription> + {prettyBytes(file.size)} + </FileListDescription> + </FileListInfo> + <FileListAction onClick={() => removeFile(i)}> + <X className="h-4 w-4" /> + </FileListAction> + </FileListHeader> + </FileListItem> + ))} + </FileList> + </ScrollArea> + </div> + )} + </div> + ) // Render return ( @@ -391,7 +533,7 @@ export function JoinForm() { <div className="rounded-md border p-4 space-y-4"> <h4 className="text-md font-semibold">기본 정보</h4> <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> - {/* Vendor Type - New Field */} + {/* Vendor Type */} <FormField control={form.control} name="vendorTypeId" @@ -481,7 +623,7 @@ export function JoinForm() { )} /> - {/* Items - New Field */} + {/* Items */} <FormField control={form.control} name="items" @@ -516,7 +658,7 @@ export function JoinForm() { )} /> - {/* Country - Updated with enhanced list */} + {/* Country */} <FormField control={form.control} name="country" @@ -583,8 +725,7 @@ export function JoinForm() { ) }} /> - - {/* Phone - Updated with country code hint */} + {/* Phone */} <FormField control={form.control} name="phone" @@ -611,7 +752,7 @@ export function JoinForm() { )} /> - {/* Email - Updated with company domain guidance */} + {/* Email */} <FormField control={form.control} name="email" @@ -679,7 +820,7 @@ export function JoinForm() { className="bg-muted/10 rounded-md p-4 space-y-4" > <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> - {/* contactName - All required now */} + {/* contactName */} <FormField control={form.control} name={`contacts.${index}.contactName`} @@ -696,7 +837,7 @@ export function JoinForm() { )} /> - {/* contactPosition - Now required */} + {/* contactPosition */} <FormField control={form.control} name={`contacts.${index}.contactPosition`} @@ -730,7 +871,7 @@ export function JoinForm() { )} /> - {/* contactPhone - Now required */} + {/* contactPhone */} <FormField control={form.control} name={`contacts.${index}.contactPhone`} @@ -777,7 +918,6 @@ export function JoinForm() { <div className="rounded-md border p-4 space-y-4"> <h4 className="text-md font-semibold">한국 사업자 정보</h4> - {/* 대표자 등... all now required for Korean companies */} <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <FormField control={form.control} @@ -858,78 +998,89 @@ export function JoinForm() { </FormItem> )} /> + +<FormField + control={form.control} + name="representativeWorkExpirence" + render={({ field }) => ( + <FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4"> + <FormControl> + <Checkbox + checked={field.value} + onCheckedChange={field.onChange} + disabled={isSubmitting} + /> + </FormControl> + <div className="space-y-1 leading-none"> + <FormLabel> + 대표자 삼성중공업 근무이력 + </FormLabel> + <FormDescription> + 대표자가 삼성중공업에서 근무한 경험이 있는 경우 체크해주세요. + </FormDescription> + </div> + </FormItem> + )} + /> + </div> </div> )} {/* ───────────────────────────────────────── - 첨부파일 (사업자등록증 등) + Required Document Uploads ───────────────────────────────────────── */} - <div className="rounded-md border p-4 space-y-4"> - <h4 className="text-md font-semibold">기타 첨부파일</h4> - <FormField - control={form.control} - name="attachedFiles" - render={() => ( - <FormItem> - <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> - 첨부 파일 - </FormLabel> - <FormDescription> - 사업자등록증, ISO 9001 인증서, 회사 브로셔, 기본 소개자료 등을 첨부해주세요. - </FormDescription> - <Dropzone - maxSize={MAX_FILE_SIZE} - multiple - onDropAccepted={handleDropAccepted} - onDropRejected={handleDropRejected} - disabled={isSubmitting} - > - {({ maxSize }) => ( - <DropzoneZone className="flex justify-center"> - <DropzoneInput /> - <div className="flex items-center gap-4"> - <DropzoneUploadIcon /> - <div className="grid gap-1"> - <DropzoneTitle>파일 업로드</DropzoneTitle> - <DropzoneDescription> - 드래그 또는 클릭 - {maxSize - ? ` (최대: ${prettyBytes(maxSize)})` - : null} - </DropzoneDescription> - </div> - </div> - </DropzoneZone> - )} - </Dropzone> - {selectedFiles.length > 0 && ( - <div className="mt-2"> - <ScrollArea className="max-h-32"> - <FileList className="gap-2"> - {selectedFiles.map((file, i) => ( - <FileListItem key={file.name + i}> - <FileListHeader> - <FileListIcon /> - <FileListInfo> - <FileListName>{file.name}</FileListName> - <FileListDescription> - {prettyBytes(file.size)} - </FileListDescription> - </FileListInfo> - <FileListAction onClick={() => removeFile(i)}> - <X className="h-4 w-4" /> - </FileListAction> - </FileListHeader> - </FileListItem> - ))} - </FileList> - </ScrollArea> - </div> - )} - </FormItem> - )} + <div className="rounded-md border p-4 space-y-6"> + <h4 className="text-md font-semibold">필수 첨부 서류</h4> + + {/* Business Registration */} + <FileUploadSection + title="사업자등록증" + description="사업자등록증 스캔본 또는 사진을 업로드해주세요. 모든 내용이 선명하게 보여야 합니다." + files={businessRegistrationFiles} + onDropAccepted={businessRegistrationHandler.onDropAccepted} + onDropRejected={businessRegistrationHandler.onDropRejected} + removeFile={businessRegistrationHandler.removeFile} /> + + <Separator /> + + {/* ISO Certification */} + <FileUploadSection + title="ISO 인증서" + description="ISO 9001, ISO 14001 등 품질/환경 관리 인증서를 업로드해주세요. 유효기간이 확인 가능해야 합니다." + files={isoCertificationFiles} + onDropAccepted={isoCertificationHandler.onDropAccepted} + onDropRejected={isoCertificationHandler.onDropRejected} + removeFile={isoCertificationHandler.removeFile} + /> + + <Separator /> + + {/* Credit Report */} + <FileUploadSection + title="신용평가보고서" + description="신용평가기관(KIS, NICE 등)에서 발급한 발행 1년 이내의 신용평가보고서를 업로드해주세요. 전년도 재무제표 필수표시. 신규업체, 영세업체로 재무제표 및 신용평가 결과가 없을 경우는 국세, 지방세 납입 증명으로 신용평가를 갈음할 수 있음" + files={creditReportFiles} + onDropAccepted={creditReportHandler.onDropAccepted} + onDropRejected={creditReportHandler.onDropRejected} + removeFile={creditReportHandler.removeFile} + /> + + {/* Bank Account Copy - Only for non-Korean companies */} + {form.watch("country") !== "KR" && ( + <> + <Separator /> + <FileUploadSection + title="대금지급 통장사본" + description="대금 지급용 은행 계좌의 통장 사본 또는 계좌증명서를 업로드해주세요. 계좌번호와 예금주명이 명확히 보여야 합니다." + files={bankAccountFiles} + onDropAccepted={bankAccountHandler.onDropAccepted} + onDropRejected={bankAccountHandler.onDropRejected} + removeFile={bankAccountHandler.removeFile} + /> + </> + )} </div> {/* ───────────────────────────────────────── diff --git a/components/tech-vendor-possible-items/tech-vendor-possible-items-container.tsx b/components/tech-vendor-possible-items/tech-vendor-possible-items-container.tsx new file mode 100644 index 00000000..90b28176 --- /dev/null +++ b/components/tech-vendor-possible-items/tech-vendor-possible-items-container.tsx @@ -0,0 +1,102 @@ +"use client"
+
+import * as React from "react"
+import { useRouter, usePathname, useSearchParams } from "next/navigation"
+import { ChevronDown } from "lucide-react"
+
+import { Button } from "@/components/ui/button"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+
+interface VendorType {
+ id: string;
+ name: string;
+ value: string;
+}
+
+interface TechVendorPossibleItemsContainerProps {
+ vendorTypes: VendorType[];
+ children: React.ReactNode;
+}
+
+export function TechVendorPossibleItemsContainer({
+ vendorTypes,
+ children,
+}: TechVendorPossibleItemsContainerProps) {
+ const router = useRouter();
+ const pathname = usePathname();
+ const searchParamsObj = useSearchParams();
+
+ // useSearchParams를 메모이제이션하여 안정적인 참조 생성
+ const searchParams = React.useMemo(
+ () => searchParamsObj || new URLSearchParams(),
+ [searchParamsObj]
+ );
+
+ // URL에서 현재 선택된 벤더 타입 가져오기
+ const vendorType = searchParams.get("vendorType") || "all";
+
+ // 선택한 벤더 타입에 해당하는 이름 찾기
+ const selectedVendor = vendorTypes.find((vendor) => vendor.id === vendorType)?.name || "전체";
+
+ // 벤더 타입 변경 핸들러
+ const handleVendorTypeChange = React.useCallback((value: string) => {
+ const params = new URLSearchParams(searchParams.toString());
+ if (value === "all") {
+ params.delete("vendorType");
+ } else {
+ params.set("vendorType", value);
+ }
+
+ router.push(`${pathname}?${params.toString()}`);
+ }, [router, pathname, searchParams]);
+
+
+
+ return (
+ <>
+ {/* 상단 영역: 제목 왼쪽 / 벤더 타입 선택기 오른쪽 */}
+ <div className="flex items-center justify-between">
+ {/* 왼쪽: 타이틀 & 설명 */}
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">기술영업 벤더 아이템 관리</h2>
+ <p className="text-muted-foreground">
+ 기술영업 벤더별 가능 아이템을 관리합니다.
+ </p>
+ </div>
+
+ {/* 오른쪽: 벤더 타입 드롭다운 */}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="outline" className="min-w-[150px]">
+ {selectedVendor}
+ <ChevronDown className="ml-2 h-4 w-4" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-[200px]">
+ {vendorTypes.map((vendor) => (
+ <DropdownMenuItem
+ key={vendor.id}
+ onClick={() => handleVendorTypeChange(vendor.id)}
+ className={vendor.id === vendorType ? "bg-muted" : ""}
+ >
+ {vendor.name}
+ </DropdownMenuItem>
+ ))}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+
+ {/* 컨텐츠 영역 */}
+ <section className="overflow-hidden">
+ <div>
+ {children}
+ </div>
+ </section>
+ </>
+ );
+}
\ No newline at end of file diff --git a/components/ui/file-actions.tsx b/components/ui/file-actions.tsx new file mode 100644 index 00000000..ed2103d3 --- /dev/null +++ b/components/ui/file-actions.tsx @@ -0,0 +1,440 @@ +// components/ui/file-actions.tsx +// 재사용 가능한 파일 액션 컴포넌트들 + +"use client"; + +import * as React from "react"; +import { + Download, + Eye, + Paperclip, + Loader2, + AlertCircle, + FileText, + Image as ImageIcon, + Archive +} from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +import { useMultiFileDownload } from "@/hooks/use-file-download"; +import { getFileInfo, quickDownload, quickPreview, smartFileAction } from "@/lib/file-download"; +import { cn } from "@/lib/utils"; + +/** + * 파일 아이콘 컴포넌트 + */ +interface FileIconProps { + fileName: string; + className?: string; +} + +export const FileIcon: React.FC<FileIconProps> = ({ fileName, className }) => { + const fileInfo = getFileInfo(fileName); + + const iconMap = { + pdf: FileText, + document: FileText, + spreadsheet: FileText, + image: ImageIcon, + archive: Archive, + other: Paperclip, + }; + + const IconComponent = iconMap[fileInfo.type]; + + return ( + <IconComponent className={cn("h-4 w-4", className)} /> + ); +}; + +/** + * 기본 파일 다운로드 버튼 + */ +interface FileDownloadButtonProps { + filePath: string; + fileName: string; + variant?: "default" | "ghost" | "outline"; + size?: "default" | "sm" | "lg" | "icon"; + children?: React.ReactNode; + className?: string; + showIcon?: boolean; + disabled?: boolean; +} + +export const FileDownloadButton: React.FC<FileDownloadButtonProps> = ({ + filePath, + fileName, + variant = "ghost", + size = "icon", + children, + className, + showIcon = true, + disabled, +}) => { + const { downloadFile, isFileLoading, getFileError } = useMultiFileDownload(); + + const isLoading = isFileLoading(filePath); + const error = getFileError(filePath); + + const handleClick = () => { + if (!disabled && !isLoading) { + quickDownload(filePath, fileName); + } + }; + + if (isLoading) { + return ( + <Button variant={variant} size={size} disabled className={className}> + <Loader2 className="h-4 w-4 animate-spin" /> + {children} + </Button> + ); + } + + return ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant={variant} + size={size} + onClick={handleClick} + disabled={disabled} + className={cn( + error && "text-destructive hover:text-destructive", + className + )} + > + {error ? ( + <AlertCircle className="h-4 w-4" /> + ) : showIcon ? ( + <Download className="h-4 w-4" /> + ) : null} + {children} + </Button> + </TooltipTrigger> + <TooltipContent> + {error ? `오류: ${error} (클릭하여 재시도)` : `${fileName} 다운로드`} + </TooltipContent> + </Tooltip> + </TooltipProvider> + ); +}; + +/** + * 미리보기 버튼 + */ +interface FilePreviewButtonProps extends Omit<FileDownloadButtonProps, 'children'> { + fallbackToDownload?: boolean; +} + +export const FilePreviewButton: React.FC<FilePreviewButtonProps> = ({ + filePath, + fileName, + variant = "ghost", + size = "icon", + className, + fallbackToDownload = true, + disabled, +}) => { + const { isFileLoading, getFileError } = useMultiFileDownload(); + const fileInfo = getFileInfo(fileName); + + const isLoading = isFileLoading(filePath); + const error = getFileError(filePath); + + const handleClick = () => { + if (!disabled && !isLoading) { + if (fileInfo.canPreview) { + quickPreview(filePath, fileName); + } else if (fallbackToDownload) { + quickDownload(filePath, fileName); + } + } + }; + + if (!fileInfo.canPreview && !fallbackToDownload) { + return ( + <Button variant={variant} size={size} disabled className={className}> + <Eye className="h-4 w-4 opacity-50" /> + </Button> + ); + } + + if (isLoading) { + return ( + <Button variant={variant} size={size} disabled className={className}> + <Loader2 className="h-4 w-4 animate-spin" /> + </Button> + ); + } + + return ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant={variant} + size={size} + onClick={handleClick} + disabled={disabled} + className={cn( + error && "text-destructive hover:text-destructive", + className + )} + > + {error ? ( + <AlertCircle className="h-4 w-4" /> + ) : fileInfo.canPreview ? ( + <Eye className="h-4 w-4" /> + ) : ( + <Download className="h-4 w-4" /> + )} + </Button> + </TooltipTrigger> + <TooltipContent> + {error + ? `오류: ${error} (클릭하여 재시도)` + : fileInfo.canPreview + ? `${fileName} 미리보기` + : `${fileName} 다운로드` + } + </TooltipContent> + </Tooltip> + </TooltipProvider> + ); +}; + +/** + * 드롭다운 파일 액션 버튼 (미리보기 + 다운로드) + */ +interface FileActionsDropdownProps { + filePath: string; + fileName: string; + description?: string; + variant?: "default" | "ghost" | "outline"; + size?: "default" | "sm" | "lg" | "icon"; + className?: string; + disabled?: boolean; + triggerIcon?: React.ReactNode; +} + +export const FileActionsDropdown: React.FC<FileActionsDropdownProps> = ({ + filePath, + fileName, + variant = "ghost", + size = "icon", + className, + disabled, + triggerIcon, + description +}) => { + const { isFileLoading, getFileError } = useMultiFileDownload(); + const fileInfo = getFileInfo(fileName); + + const isLoading = isFileLoading(filePath); + const error = getFileError(filePath); + + const handlePreview = () => quickPreview(filePath, fileName); + const handleDownload = () => quickDownload(filePath, fileName); + + if (isLoading) { + return ( + <Button variant={variant} size={size} disabled className={className}> + <Loader2 className="h-4 w-4 animate-spin" /> + </Button> + ); + } + + if (error) { + return ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant={variant} + size={size} + onClick={handleDownload} + className={cn("text-destructive hover:text-destructive", className)} + > + <AlertCircle className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent> + <div className="text-sm"> + <div className="font-medium text-destructive">오류 발생</div> + <div className="text-muted-foreground">{error}</div> + <div className="mt-1 text-xs">클릭하여 재시도</div> + </div> + </TooltipContent> + </Tooltip> + </TooltipProvider> + ); + } + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + variant={variant} + size={size} + disabled={disabled} + className={className} + > + {triggerIcon || <Paperclip className="h-4 w-4" />} + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + {fileInfo.canPreview && ( + <> + <DropdownMenuItem onClick={handlePreview}> + <Eye className="mr-2 h-4 w-4" /> + {fileInfo.icon} 미리보기 + </DropdownMenuItem> + <DropdownMenuSeparator /> + </> + )} + <DropdownMenuItem onClick={handleDownload}> + <Download className="mr-2 h-4 w-4" /> + {description} 다운로드 + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ); +}; + +/** + * 스마트 파일 액션 버튼 (자동 판단) + */ +interface SmartFileActionButtonProps extends Omit<FileDownloadButtonProps, 'children'> { + showLabel?: boolean; +} + +export const SmartFileActionButton: React.FC<SmartFileActionButtonProps> = ({ + filePath, + fileName, + variant = "ghost", + size = "icon", + className, + showLabel = false, + disabled, +}) => { + const { isFileLoading, getFileError } = useMultiFileDownload(); + const fileInfo = getFileInfo(fileName); + + const isLoading = isFileLoading(filePath); + const error = getFileError(filePath); + + const handleClick = () => { + if (!disabled && !isLoading) { + smartFileAction(filePath, fileName); + } + }; + + if (isLoading) { + return ( + <Button variant={variant} size={size} disabled className={className}> + <Loader2 className="h-4 w-4 animate-spin" /> + {showLabel && <span className="ml-2">처리 중...</span>} + </Button> + ); + } + + const actionText = fileInfo.canPreview ? '미리보기' : '다운로드'; + const IconComponent = fileInfo.canPreview ? Eye : Download; + + return ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant={variant} + size={size} + onClick={handleClick} + disabled={disabled} + className={cn( + error && "text-destructive hover:text-destructive", + className + )} + > + {error ? ( + <AlertCircle className="h-4 w-4" /> + ) : ( + <IconComponent className="h-4 w-4" /> + )} + {showLabel && ( + <span className="ml-2"> + {error ? '재시도' : actionText} + </span> + )} + </Button> + </TooltipTrigger> + <TooltipContent> + {error + ? `오류: ${error} (클릭하여 재시도)` + : `${fileInfo.icon} ${fileName} ${actionText}` + } + </TooltipContent> + </Tooltip> + </TooltipProvider> + ); +}; + +/** + * 파일명 링크 컴포넌트 + */ +interface FileNameLinkProps { + filePath: string; + fileName: string; + className?: string; + showIcon?: boolean; + maxLength?: number; +} + +export const FileNameLink: React.FC<FileNameLinkProps> = ({ + filePath, + fileName, + className, + showIcon = true, + maxLength = 200, +}) => { + const fileInfo = getFileInfo(fileName); + + const handleClick = () => { + smartFileAction(filePath, fileName); + }; + + const displayName = fileName.length > maxLength + ? `${fileName.substring(0, maxLength)}...` + : fileName; + + return ( + <button + onClick={handleClick} + className={cn( + "flex items-center gap-1 text-blue-600 hover:text-blue-800 hover:underline cursor-pointer text-left", + className + )} + title={`${fileInfo.icon} ${fileName} ${fileInfo.canPreview ? '미리보기' : '다운로드'}`} + > + {showIcon && ( + <span className="text-xs flex-shrink-0">{fileInfo.icon}</span> + )} + <span className="truncate">{displayName}</span> + </button> + ); +};
\ No newline at end of file diff --git a/components/ui/text-utils.tsx b/components/ui/text-utils.tsx new file mode 100644 index 00000000..a3507dd0 --- /dev/null +++ b/components/ui/text-utils.tsx @@ -0,0 +1,131 @@ +"use client" + +import { useState } from "react" +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible" +import { ChevronDown, ChevronUp } from "lucide-react" + +export function TruncatedText({ + text, + maxLength = 50, + showTooltip = true +}: { + text: string | null + maxLength?: number + showTooltip?: boolean +}) { + if (!text) return <span className="text-muted-foreground">-</span> + + if (text.length <= maxLength) { + return <span>{text}</span> + } + + const truncated = text.slice(0, maxLength) + "..." + + if (!showTooltip) { + return <span>{truncated}</span> + } + + return ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <span className="cursor-help border-b border-dotted border-gray-400"> + {truncated} + </span> + </TooltipTrigger> + <TooltipContent className="max-w-xs"> + <p className="whitespace-pre-wrap">{text}</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + ) +} + +export function ExpandableText({ + text, + maxLength = 100, + className = "" +}: { + text: string | null + maxLength?: number + className?: string +}) { + const [isExpanded, setIsExpanded] = useState(false) + + if (!text) return <span className="text-muted-foreground">-</span> + + if (text.length <= maxLength) { + return <span className={className}>{text}</span> + } + + return ( + <Collapsible open={isExpanded} onOpenChange={setIsExpanded}> + <div className={className}> + <CollapsibleTrigger asChild> + <button className="text-left w-full group"> + <span className="whitespace-pre-wrap"> + {isExpanded ? text : text.slice(0, maxLength) + "..."} + </span> + <span className="inline-flex items-center ml-2 text-blue-600 hover:text-blue-800"> + {isExpanded ? ( + <> + <ChevronUp className="w-3 h-3" /> + <span className="text-xs ml-1">접기</span> + </> + ) : ( + <> + <ChevronDown className="w-3 h-3" /> + <span className="text-xs ml-1">더보기</span> + </> + )} + </span> + </button> + </CollapsibleTrigger> + </div> + </Collapsible> + ) +} + +export function AddressDisplay({ + address, + addressEng, + postalCode, + addressDetail +}: { + address: string | null + addressEng: string | null + postalCode: string | null + addressDetail: string | null +}) { + const hasAnyAddress = address || addressEng || postalCode || addressDetail + + if (!hasAnyAddress) { + return <span className="text-muted-foreground">-</span> + } + + return ( + <div className="space-y-1"> + {postalCode && ( + <div className="text-xs text-muted-foreground"> + 우편번호: {postalCode} + </div> + )} + {address && ( + <div className="font-medium break-words"> + {address} + </div> + )} + {addressDetail && ( + <div className="text-sm text-muted-foreground break-words"> + {addressDetail} + </div> + )} + {addressEng && ( + <div className="text-sm text-muted-foreground break-words italic"> + {addressEng} + </div> + )} + </div> + ) +}
\ No newline at end of file diff --git a/config/partners-dashboard-table.ts b/config/partners-dashboard-table.ts index c7b38d5e..0c25877a 100644 --- a/config/partners-dashboard-table.ts +++ b/config/partners-dashboard-table.ts @@ -32,8 +32,25 @@ export const PARTNERS_DASHBOARD_TABLES: TableConfig[] = [ 'RESPONDED': 'completed' }, userFields: { - creator: 'contract_manager', - updater: 'last_updated_by' + creator: 'created_by', + updater: 'updated_by' + } + }, + + { + tableName: 'enhanced_documents_view', + displayName: 'Vendor Documents', + domain: 'partners', + statusField: 'status', + statusMapping: { + 'pending': 'pending', + // 'REVISION_REQUESTED': 'in_progress', + // 'WAIVED': 'completed', + // 'RESPONDED': 'completed' + }, + userFields: { + // creator: 'created_by', + // updater: 'last_updated_by' } } // 다른 파트너 관련 테이블들... diff --git a/middleware.ts b/middleware.ts index 6424a02f..e32415dd 100644 --- a/middleware.ts +++ b/middleware.ts @@ -5,8 +5,11 @@ import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; import acceptLanguage from 'accept-language'; import { getToken } from 'next-auth/jwt'; +// UAParser 임포트 수정 +import { UAParser } from 'ua-parser-js'; import { fallbackLng, languages, cookieName } from '@/i18n/settings'; +import { SessionRepository } from './lib/users/session/repository'; acceptLanguage.languages(languages); @@ -25,6 +28,27 @@ const publicPaths = [ '/auth/reset-password', ]; +// 페이지 추적에서 제외할 경로들 +const trackingExcludePaths = [ + '/api', + '/_next', + '/favicon.ico', + '/robots.txt', + '/sitemap.xml', + '.png', + '.jpg', + '.jpeg', + '.gif', + '.svg', + '.ico', + '.css', + '.js', + '.woff', + '.woff2', + '.ttf', + '.eot' +]; + // 경로가 공개 경로인지 확인하는 함수 function isPublicPath(path: string, lng: string) { // 1. 정확한 로그인 페이지 매칭 (/ko/evcp, /en/partners 등) @@ -40,6 +64,13 @@ function isPublicPath(path: string, lng: string) { return false; } +// 페이지 추적 제외 경로인지 확인 +function shouldExcludeFromTracking(pathname: string): boolean { + return trackingExcludePaths.some(excludePath => + pathname.startsWith(excludePath) || pathname.includes(excludePath) + ); +} + // 도메인별 기본 대시보드 경로 정의 function getDashboardPath(domain: string, lng: string): string { switch (domain) { @@ -171,6 +202,125 @@ function createLoginUrl(pathname: string, detectedLng: string, origin: string, r return redirectUrl; } +// 클라이언트 IP 추출 함수 (수정됨) +function getClientIP(request: NextRequest): string { + const forwarded = request.headers.get('x-forwarded-for'); + const realIP = request.headers.get('x-real-ip'); + const cfConnectingIP = request.headers.get('cf-connecting-ip'); // Cloudflare + + if (cfConnectingIP) { + return cfConnectingIP; + } + + if (forwarded) { + return forwarded.split(',')[0].trim(); + } + + if (realIP) { + return realIP; + } + + // NextRequest에는 ip 프로퍼티가 없으므로 기본값 반환 + return '127.0.0.1'; +} + +// 디바이스 타입 판단 함수 +function getDeviceType(deviceType?: string): string { + if (!deviceType) return 'desktop'; + if (deviceType === 'mobile') return 'mobile'; + if (deviceType === 'tablet') return 'tablet'; + return 'desktop'; +} + +// 페이지 제목 추출 함수 +function extractPageTitle(pathname: string, lng: string): string { + // 언어 코드 제거 + const cleanPath = pathname.replace(`/${lng}`, '') || '/'; + + // 라우트 기반 페이지 제목 매핑 + const titleMap: Record<string, string> = { + '/': 'Home', + '/evcp': 'EVCP Login', + '/evcp/report': 'EVCP Report', + '/evcp/dashboard': 'EVCP Dashboard', + '/procurement': 'Procurement Login', + '/procurement/dashboard': 'Procurement Dashboard', + '/sales': 'Sales Login', + '/sales/dashboard': 'Sales Dashboard', + '/engineering': 'Engineering Login', + '/engineering/dashboard': 'Engineering Dashboard', + '/partners': 'Partners Login', + '/partners/dashboard': 'Partners Dashboard', + '/pending': 'Pending', + '/profile': 'Profile', + '/settings': 'Settings', + }; + + // 정확한 매칭 우선 + if (titleMap[cleanPath]) { + return titleMap[cleanPath]; + } + + // 부분 매칭으로 fallback + for (const [route, title] of Object.entries(titleMap)) { + if (cleanPath.startsWith(route) && route !== '/') { + return title; + } + } + + return cleanPath || 'Unknown Page'; +} + +// 페이지 방문 추적 함수 (비동기, 논블로킹) - UAParser 수정됨 +async function trackPageVisit(request: NextRequest, token: any, detectedLng: string) { + // 백그라운드에서 실행하여 메인 요청을 블로킹하지 않음 + setImmediate(async () => { + try { + const { pathname, searchParams } = request.nextUrl; + + // 추적 제외 경로 체크 + if (shouldExcludeFromTracking(pathname)) { + return; + } + + const userAgent = request.headers.get('user-agent') || ''; + // UAParser 사용 방법 수정 + const parser = new UAParser(userAgent); + const result = parser.getResult(); + + // 활성 세션 조회 및 업데이트 + let sessionId = null; + if (token?.id && token?.dbSessionId) { + sessionId = token.dbSessionId; + + // 세션 활동 시간 업데이트 (await 없이 비동기 실행) + SessionRepository.updateSessionActivity(sessionId).catch(error => { + console.error('Failed to update session activity:', error); + }); + } + + // 페이지 방문 기록 + await SessionRepository.recordPageVisit({ + userId: token?.id || undefined, + sessionId, + route: pathname, + pageTitle: extractPageTitle(pathname, detectedLng), + referrer: request.headers.get('referer') || undefined, + ipAddress: getClientIP(request), + userAgent, + queryParams: searchParams.toString() || undefined, + deviceType: getDeviceType(result.device.type), + browserName: result.browser.name || undefined, + osName: result.os.name || undefined, + }); + + } catch (error) { + // 추적 실패는 로그만 남기고 메인 플로우에 영향 주지 않음 + console.error('Failed to track page visit:', error); + } + }); +} + export async function middleware(request: NextRequest) { /** * 1. 쿠키에서 언어 가져오기 @@ -220,7 +370,16 @@ export async function middleware(request: NextRequest) { const token = await getToken({ req: request }); /** - * 6. 세션 타임아웃 체크 (인증된 사용자에 대해서만) + * 6. 페이지 방문 추적 (비동기, 논블로킹) + * - 리다이렉트가 발생하기 전에 실행 + * - API나 정적 파일은 제외 + */ + if (!shouldExcludeFromTracking(pathname)) { + trackPageVisit(request, token, detectedLng); // await 하지 않음 (논블로킹) + } + + /** + * 7. 세션 타임아웃 체크 (인증된 사용자에 대해서만) */ if (token && !isPublicPath(pathname, detectedLng)) { const { isExpired, isExpiringSoon } = checkSessionTimeout(token); @@ -233,7 +392,7 @@ export async function middleware(request: NextRequest) { } /** - * 7. 인증된 사용자의 도메인-URL 일치 확인 및 리다이렉션 + * 8. 인증된 사용자의 도메인-URL 일치 확인 및 리다이렉션 */ if (token && token.domain && !isPublicPath(pathname, detectedLng)) { // 사용자의 domain과 URL 경로가 일치하는지 확인 @@ -250,7 +409,7 @@ export async function middleware(request: NextRequest) { } /** - * 8. 이미 로그인한 사용자가 로그인 페이지에 접근할 경우 대시보드로 리다이렉트 + * 9. 이미 로그인한 사용자가 로그인 페이지에 접근할 경우 대시보드로 리다이렉트 */ if (token) { // 세션이 만료되지 않은 경우에만 대시보드로 리다이렉트 @@ -278,7 +437,7 @@ export async function middleware(request: NextRequest) { } /** - * 9. 인증 확인: 공개 경로가 아닌 경우 로그인 체크 및 리다이렉트 + * 10. 인증 확인: 공개 경로가 아닌 경우 로그인 체크 및 리다이렉트 */ if (!isPublicPath(pathname, detectedLng)) { if (!token) { @@ -295,12 +454,12 @@ export async function middleware(request: NextRequest) { } /** - * 10. 위 조건에 걸리지 않았다면 그대로 Next.js로 넘긴다. + * 11. 위 조건에 걸리지 않았다면 그대로 Next.js로 넘긴다. */ const response = NextResponse.next(); /** - * 11. 세션 만료 경고를 위한 헤더 추가 + * 12. 세션 만료 경고를 위한 헤더 추가 */ if (token && !isPublicPath(pathname, detectedLng)) { const { isExpiringSoon } = checkSessionTimeout(token); @@ -313,7 +472,7 @@ export async function middleware(request: NextRequest) { } /** - * 12. 쿠키에 저장된 언어와 현재 lng가 다르면 업데이트 + * 13. 쿠키에 저장된 언어와 현재 lng가 다르면 업데이트 */ const currentCookie = request.cookies.get(cookieName)?.value; if (detectedLng && detectedLng !== currentCookie) { @@ -324,7 +483,7 @@ export async function middleware(request: NextRequest) { } /** - * 13. 매칭할 경로 설정 + * 14. 매칭할 경로 설정 */ export const config = { matcher: [ diff --git a/package-lock.json b/package-lock.json index 7580549a..d37de5ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -151,6 +151,7 @@ "swr": "^2.3.3", "tailwind-merge": "^2.5.5", "tailwindcss-animate": "^1.0.7", + "ua-parser-js": "^2.0.4", "uuid": "^11.0.5", "vaul": "^1.1.2", "zod": "^3.24.1" @@ -166,6 +167,8 @@ "@types/react": "^19", "@types/react-dom": "^19", "@types/sharp": "^0.31.1", + "@types/ua-parser-js": "^0.7.39", + "@types/uuid": "^10.0.0", "drizzle-kit": "^0.30.1", "eslint": "^9", "eslint-config-next": "15.1.0", @@ -5410,6 +5413,16 @@ "undici-types": "~6.19.2" } }, + "node_modules/@types/node-fetch": { + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.12.tgz", + "integrity": "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.0" + } + }, "node_modules/@types/nodemailer": { "version": "6.4.17", "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.17.tgz", @@ -5550,12 +5563,26 @@ "@types/node": "*" } }, + "node_modules/@types/ua-parser-js": { + "version": "0.7.39", + "resolved": "https://registry.npmjs.org/@types/ua-parser-js/-/ua-parser-js-0.7.39.tgz", + "integrity": "sha512-P/oDfpofrdtF5xw433SPALpdSchtJmY7nsJItf8h3KXqOslkbySh8zq4dSWXH2oTjRvJ5PczVEoCZPow6GicLg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "license": "MIT" }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/xml-encryption": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@types/xml-encryption/-/xml-encryption-1.2.4.tgz", @@ -7574,6 +7601,26 @@ "node": ">=6" } }, + "node_modules/detect-europe-js": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/detect-europe-js/-/detect-europe-js-0.1.2.tgz", + "integrity": "sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "license": "MIT" + }, "node_modules/detect-libc": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", @@ -10395,6 +10442,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-standalone-pwa": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-standalone-pwa/-/is-standalone-pwa-0.1.1.tgz", + "integrity": "sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "license": "MIT" + }, "node_modules/is-string": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", @@ -15955,6 +16022,59 @@ "node": ">=14.17" } }, + "node_modules/ua-is-frozen": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ua-is-frozen/-/ua-is-frozen-0.1.2.tgz", + "integrity": "sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "license": "MIT" + }, + "node_modules/ua-parser-js": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-2.0.4.tgz", + "integrity": "sha512-XiBOnM/UpUq21ZZ91q2AVDOnGROE6UQd37WrO9WBgw4u2eGvUCNOheMmZ3EfEUj7DLHr8tre+Um/436Of/Vwzg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "AGPL-3.0-or-later", + "dependencies": { + "@types/node-fetch": "^2.6.12", + "detect-europe-js": "^0.1.2", + "is-standalone-pwa": "^0.1.1", + "node-fetch": "^2.7.0", + "ua-is-frozen": "^0.1.2" + }, + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, "node_modules/uc.micro": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", diff --git a/package.json b/package.json index 308f8259..0c974c9f 100644 --- a/package.json +++ b/package.json @@ -153,6 +153,7 @@ "swr": "^2.3.3", "tailwind-merge": "^2.5.5", "tailwindcss-animate": "^1.0.7", + "ua-parser-js": "^2.0.4", "uuid": "^11.0.5", "vaul": "^1.1.2", "zod": "^3.24.1" @@ -168,6 +169,8 @@ "@types/react": "^19", "@types/react-dom": "^19", "@types/sharp": "^0.31.1", + "@types/ua-parser-js": "^0.7.39", + "@types/uuid": "^10.0.0", "drizzle-kit": "^0.30.1", "eslint": "^9", "eslint-config-next": "15.1.0", |
