summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/[lng]/engineering/(engineering)/b-rfq/[id]/final/page.tsx0
-rw-r--r--app/[lng]/engineering/(engineering)/b-rfq/[id]/initial/page.tsx52
-rw-r--r--app/[lng]/engineering/(engineering)/b-rfq/[id]/layout.tsx87
-rw-r--r--app/[lng]/engineering/(engineering)/b-rfq/[id]/page.tsx53
-rw-r--r--app/[lng]/engineering/(engineering)/b-rfq/page.tsx79
-rw-r--r--app/[lng]/engineering/(engineering)/basic-contract-template/page.tsx74
-rw-r--r--app/[lng]/engineering/(engineering)/basic-contract/page.tsx74
-rw-r--r--app/[lng]/engineering/(engineering)/bid-projects/page.tsx74
-rw-r--r--app/[lng]/engineering/(engineering)/bqcbe/page.tsx74
-rw-r--r--app/[lng]/engineering/(engineering)/bqtbe/page.tsx72
-rw-r--r--app/[lng]/engineering/(engineering)/budgetary-rfq/[id]/cbe/page.tsx56
-rw-r--r--app/[lng]/engineering/(engineering)/budgetary-rfq/[id]/layout.tsx90
-rw-r--r--app/[lng]/engineering/(engineering)/budgetary-rfq/[id]/page.tsx57
-rw-r--r--app/[lng]/engineering/(engineering)/budgetary-rfq/[id]/tbe/page.tsx55
-rw-r--r--app/[lng]/engineering/(engineering)/budgetary-rfq/page.tsx86
-rw-r--r--app/[lng]/engineering/(engineering)/budgetary-tech-sales-hull/page.tsx61
-rw-r--r--app/[lng]/engineering/(engineering)/budgetary-tech-sales-ship/page.tsx61
-rw-r--r--app/[lng]/engineering/(engineering)/budgetary-tech-sales-top/page.tsx61
-rw-r--r--app/[lng]/engineering/(engineering)/budgetary/[id]/cbe/page.tsx56
-rw-r--r--app/[lng]/engineering/(engineering)/budgetary/[id]/layout.tsx90
-rw-r--r--app/[lng]/engineering/(engineering)/budgetary/[id]/page.tsx57
-rw-r--r--app/[lng]/engineering/(engineering)/budgetary/[id]/tbe/page.tsx55
-rw-r--r--app/[lng]/engineering/(engineering)/budgetary/page.tsx86
-rw-r--r--app/[lng]/engineering/(engineering)/cbe-tech/page.tsx67
-rw-r--r--app/[lng]/engineering/(engineering)/dashboard/page.tsx17
-rw-r--r--app/[lng]/engineering/(engineering)/email-template/[name]/page.tsx26
-rw-r--r--app/[lng]/engineering/(engineering)/email-template/page.tsx19
-rw-r--r--app/[lng]/engineering/(engineering)/equip-class/page.tsx75
-rw-r--r--app/[lng]/engineering/(engineering)/esg-check-list/page.tsx74
-rw-r--r--app/[lng]/engineering/(engineering)/evaluation-check-list/page.tsx81
-rw-r--r--app/[lng]/engineering/(engineering)/evaluation-target-list/page.tsx115
-rw-r--r--app/[lng]/engineering/(engineering)/evaluation/page.tsx181
-rw-r--r--app/[lng]/engineering/(engineering)/incoterms/page.tsx53
-rw-r--r--app/[lng]/engineering/(engineering)/items-tech/layout.tsx38
-rw-r--r--app/[lng]/engineering/(engineering)/items-tech/page.tsx67
-rw-r--r--app/[lng]/engineering/(engineering)/menu-list/page.tsx70
-rw-r--r--app/[lng]/engineering/(engineering)/payment-conditions/page.tsx53
-rw-r--r--app/[lng]/engineering/(engineering)/po-rfq/page.tsx61
-rw-r--r--app/[lng]/engineering/(engineering)/po/page.tsx65
-rw-r--r--app/[lng]/engineering/(engineering)/poa/page.tsx61
-rw-r--r--app/[lng]/engineering/(engineering)/pq-criteria/[id]/page.tsx81
-rw-r--r--app/[lng]/engineering/(engineering)/pq-criteria/page.tsx70
-rw-r--r--app/[lng]/engineering/(engineering)/pq/[vendorId]/page.tsx108
-rw-r--r--app/[lng]/engineering/(engineering)/pq/page.tsx71
-rw-r--r--app/[lng]/engineering/(engineering)/pq_new/[vendorId]/[submissionId]/page.tsx215
-rw-r--r--app/[lng]/engineering/(engineering)/pq_new/page.tsx96
-rw-r--r--app/[lng]/engineering/(engineering)/project-gtc/page.tsx63
-rw-r--r--app/[lng]/engineering/(engineering)/project-vendors/page.tsx74
-rw-r--r--app/[lng]/engineering/(engineering)/report/page.tsx69
-rw-r--r--app/[lng]/engineering/(engineering)/rfq/[id]/cbe/page.tsx55
-rw-r--r--app/[lng]/engineering/(engineering)/rfq/[id]/layout.tsx89
-rw-r--r--app/[lng]/engineering/(engineering)/rfq/[id]/page.tsx55
-rw-r--r--app/[lng]/engineering/(engineering)/rfq/[id]/tbe/page.tsx55
-rw-r--r--app/[lng]/engineering/(engineering)/rfq/page.tsx80
-rw-r--r--app/[lng]/engineering/(engineering)/settings/layout.tsx68
-rw-r--r--app/[lng]/engineering/(engineering)/settings/page.tsx18
-rw-r--r--app/[lng]/engineering/(engineering)/settings/preferences/page.tsx17
-rw-r--r--app/[lng]/engineering/(engineering)/system/admin-users/page.tsx60
-rw-r--r--app/[lng]/engineering/(engineering)/system/layout.tsx80
-rw-r--r--app/[lng]/engineering/(engineering)/system/page.tsx56
-rw-r--r--app/[lng]/engineering/(engineering)/system/password-policy/page.tsx63
-rw-r--r--app/[lng]/engineering/(engineering)/system/permissions/page.tsx17
-rw-r--r--app/[lng]/engineering/(engineering)/system/roles/page.tsx68
-rw-r--r--app/[lng]/engineering/(engineering)/tech-vendor-candidates/page.tsx78
-rw-r--r--app/[lng]/engineering/(engineering)/tech-vendors/[id]/info/items/page.tsx48
-rw-r--r--app/[lng]/engineering/(engineering)/tech-vendors/[id]/info/layout.tsx82
-rw-r--r--app/[lng]/engineering/(engineering)/tech-vendors/[id]/info/page.tsx55
-rw-r--r--app/[lng]/engineering/(engineering)/tech-vendors/[id]/info/rfq-history/page.tsx55
-rw-r--r--app/[lng]/engineering/(engineering)/tech-vendors/page.tsx58
-rw-r--r--app/[lng]/engineering/(engineering)/vendor-candidates/page.tsx78
-rw-r--r--app/[lng]/engineering/(engineering)/vendors/[id]/info/items/page.tsx56
-rw-r--r--app/[lng]/engineering/(engineering)/vendors/[id]/info/layout.tsx94
-rw-r--r--app/[lng]/engineering/(engineering)/vendors/[id]/info/materials/page.tsx56
-rw-r--r--app/[lng]/engineering/(engineering)/vendors/[id]/info/page.tsx56
-rw-r--r--app/[lng]/engineering/(engineering)/vendors/[id]/info/rfq-history/page.tsx55
-rw-r--r--app/[lng]/engineering/(engineering)/vendors/page.tsx78
-rw-r--r--app/[lng]/evcp/(evcp)/evaluation-input/[id]/page.tsx22
-rw-r--r--app/[lng]/evcp/(evcp)/evaluation-input/page.tsx135
-rw-r--r--app/[lng]/evcp/(evcp)/login-history/page.tsx (renamed from app/[lng]/engineering/(engineering)/vendor-type/page.tsx)46
-rw-r--r--app/[lng]/evcp/(evcp)/report/page.tsx16
-rw-r--r--app/[lng]/partners/(partners)/dashboard/page.tsx69
-rw-r--r--app/[lng]/procurement/(procurement)/bid-projects/page.tsx74
-rw-r--r--app/[lng]/procurement/(procurement)/budgetary-tech-sales-hull/page.tsx61
-rw-r--r--app/[lng]/procurement/(procurement)/budgetary-tech-sales-ship/page.tsx61
-rw-r--r--app/[lng]/procurement/(procurement)/budgetary-tech-sales-top/page.tsx61
-rw-r--r--app/[lng]/procurement/(procurement)/cbe-tech/page.tsx67
-rw-r--r--app/[lng]/procurement/(procurement)/evaluation-input/[id]/page.tsx22
-rw-r--r--app/[lng]/procurement/(procurement)/evaluation-input/page.tsx135
-rw-r--r--app/[lng]/procurement/(procurement)/evaluation-target-list/page.tsx11
-rw-r--r--app/[lng]/procurement/(procurement)/form-list/page.tsx75
-rw-r--r--app/[lng]/procurement/(procurement)/report/page.tsx69
-rw-r--r--app/[lng]/procurement/(procurement)/rfq-tech/[id]/cbe/page.tsx55
-rw-r--r--app/[lng]/procurement/(procurement)/rfq-tech/[id]/layout.tsx89
-rw-r--r--app/[lng]/procurement/(procurement)/rfq-tech/[id]/page.tsx55
-rw-r--r--app/[lng]/procurement/(procurement)/rfq-tech/[id]/tbe/page.tsx55
-rw-r--r--app/[lng]/procurement/(procurement)/rfq-tech/page.tsx76
-rw-r--r--app/[lng]/procurement/(procurement)/tag-numbering/page.tsx74
-rw-r--r--app/[lng]/procurement/(procurement)/tasks/page.tsx63
-rw-r--r--app/[lng]/procurement/(procurement)/tbe-tech/page.tsx67
-rw-r--r--app/[lng]/procurement/(procurement)/tech-project-avl/page.tsx85
-rw-r--r--app/[lng]/procurement/(procurement)/tech-vendor-candidates/page.tsx78
-rw-r--r--app/[lng]/procurement/(procurement)/tech-vendors/[id]/info/items/page.tsx48
-rw-r--r--app/[lng]/procurement/(procurement)/tech-vendors/[id]/info/layout.tsx82
-rw-r--r--app/[lng]/procurement/(procurement)/tech-vendors/[id]/info/page.tsx55
-rw-r--r--app/[lng]/procurement/(procurement)/tech-vendors/[id]/info/rfq-history/page.tsx55
-rw-r--r--app/[lng]/procurement/(procurement)/tech-vendors/page.tsx58
-rw-r--r--app/[lng]/sales/(sales)/b-rfq/[id]/final/page.tsx0
-rw-r--r--app/[lng]/sales/(sales)/b-rfq/[id]/initial/page.tsx52
-rw-r--r--app/[lng]/sales/(sales)/b-rfq/[id]/layout.tsx87
-rw-r--r--app/[lng]/sales/(sales)/b-rfq/[id]/page.tsx53
-rw-r--r--app/[lng]/sales/(sales)/b-rfq/page.tsx79
-rw-r--r--app/[lng]/sales/(sales)/basic-contract-template/page.tsx74
-rw-r--r--app/[lng]/sales/(sales)/basic-contract/page.tsx74
-rw-r--r--app/[lng]/sales/(sales)/email-template/[name]/page.tsx26
-rw-r--r--app/[lng]/sales/(sales)/email-template/page.tsx19
-rw-r--r--app/[lng]/sales/(sales)/equip-class/page.tsx75
-rw-r--r--app/[lng]/sales/(sales)/form-list/page.tsx75
-rw-r--r--app/[lng]/sales/(sales)/incoterms/page.tsx53
-rw-r--r--app/[lng]/sales/(sales)/menu-list/page.tsx70
-rw-r--r--app/[lng]/sales/(sales)/payment-conditions/page.tsx53
-rw-r--r--app/[lng]/sales/(sales)/po-rfq/page.tsx61
-rw-r--r--app/[lng]/sales/(sales)/po/page.tsx65
-rw-r--r--app/[lng]/sales/(sales)/poa/page.tsx61
-rw-r--r--app/[lng]/sales/(sales)/pq-criteria/[id]/page.tsx81
-rw-r--r--app/[lng]/sales/(sales)/pq-criteria/page.tsx70
-rw-r--r--app/[lng]/sales/(sales)/pq/[vendorId]/page.tsx108
-rw-r--r--app/[lng]/sales/(sales)/pq/page.tsx71
-rw-r--r--app/[lng]/sales/(sales)/pq_new/[vendorId]/[submissionId]/page.tsx215
-rw-r--r--app/[lng]/sales/(sales)/pq_new/page.tsx96
-rw-r--r--app/[lng]/sales/(sales)/report/page.tsx69
-rw-r--r--app/[lng]/sales/(sales)/rfq/[id]/cbe/page.tsx55
-rw-r--r--app/[lng]/sales/(sales)/rfq/[id]/layout.tsx89
-rw-r--r--app/[lng]/sales/(sales)/rfq/[id]/page.tsx55
-rw-r--r--app/[lng]/sales/(sales)/rfq/[id]/tbe/page.tsx55
-rw-r--r--app/[lng]/sales/(sales)/rfq/page.tsx80
-rw-r--r--app/[lng]/sales/(sales)/tag-numbering/page.tsx74
-rw-r--r--app/[lng]/sales/(sales)/tasks/page.tsx63
-rw-r--r--app/[lng]/sales/(sales)/vendor-check-list/page.tsx74
-rw-r--r--app/[lng]/sales/(sales)/vendor-investigation/page.tsx65
-rw-r--r--app/[lng]/sales/(sales)/vendor-type/page.tsx70
-rw-r--r--app/[lng]/sales/(sales)/vendors/[id]/info/items/page.tsx56
-rw-r--r--app/[lng]/sales/(sales)/vendors/[id]/info/layout.tsx94
-rw-r--r--app/[lng]/sales/(sales)/vendors/[id]/info/materials/page.tsx56
-rw-r--r--app/[lng]/sales/(sales)/vendors/[id]/info/page.tsx56
-rw-r--r--app/[lng]/sales/(sales)/vendors/[id]/info/rfq-history/page.tsx55
-rw-r--r--app/[lng]/sales/(sales)/vendors/page.tsx78
-rw-r--r--app/api/auth/[...nextauth]/route.ts258
-rw-r--r--app/api/auth/first-auth/route.ts112
-rw-r--r--app/api/auth/send-sms/route.ts20
-rw-r--r--app/api/auth/verify-mfa/route.ts21
-rw-r--r--app/api/files/[...path]/route.ts244
-rw-r--r--app/api/ocr/utils/tableExtraction.ts648
-rw-r--r--app/api/vendors/route.ts248
-rw-r--r--components/BidProjectSelector.tsx10
-rw-r--r--components/data-table/data-table-grobal-filter.tsx1
-rw-r--r--components/data-table/data-table-sort-list.tsx45
-rw-r--r--components/data-table/data-table.tsx40
-rw-r--r--components/form-data/form-data-report-temp-upload-dialog.tsx112
-rw-r--r--components/form-data/temp-download-btn.tsx46
-rw-r--r--components/information/information-button.tsx189
-rw-r--r--components/login/login-form-shi.tsx14
-rw-r--r--components/login/login-form.tsx287
-rw-r--r--components/signup/join-form.tsx379
-rw-r--r--components/tech-vendor-possible-items/tech-vendor-possible-items-container.tsx102
-rw-r--r--components/ui/file-actions.tsx440
-rw-r--r--components/ui/text-utils.tsx131
-rw-r--r--config/partners-dashboard-table.ts21
-rw-r--r--middleware.ts175
-rw-r--r--package-lock.json120
-rw-r--r--package.json3
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 />&quot;발행하기&quot; 버튼을 통해 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/>&quot;발행하기&quot; 버튼을 통해 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/>&quot;발행하기&quot; 버튼을 통해 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",