summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-07-21 07:54:26 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-07-21 07:54:26 +0000
commit14f61e24947fb92dd71ec0a7196a6e815f8e66da (patch)
tree317c501d64662d05914330628f867467fba78132
parent194bd4bd7e6144d5c09c5e3f5476d254234dce72 (diff)
(최겸)기술영업 RFQ 담당자 초대, 요구사항 반영
-rw-r--r--app/[lng]/evcp/(evcp)/contact-possible-items/page.tsx56
-rw-r--r--app/[lng]/evcp/(evcp)/tech-vendors/[id]/info/items/page.tsx48
-rw-r--r--app/[lng]/evcp/(evcp)/tech-vendors/[id]/info/layout.tsx162
-rw-r--r--app/[lng]/evcp/(evcp)/tech-vendors/[id]/info/page.tsx108
-rw-r--r--app/[lng]/evcp/(evcp)/tech-vendors/[id]/info/possible-items/page.tsx54
-rw-r--r--app/[lng]/evcp/(evcp)/tech-vendors/[id]/info/rfq-history/page.tsx41
-rw-r--r--app/[lng]/partners/tech-signup/page.tsx (renamed from app/[lng]/auth/tech-signup/page.tsx)0
-rw-r--r--app/[lng]/sales/(sales)/tech-vendors/[id]/info/layout.tsx162
-rw-r--r--app/[lng]/sales/(sales)/tech-vendors/[id]/info/page.tsx108
-rw-r--r--app/[lng]/sales/(sales)/tech-vendors/[id]/info/possible-items/page.tsx54
-rw-r--r--app/[lng]/sales/(sales)/tech-vendors/[id]/info/rfq-history/page.tsx41
-rw-r--r--app/api/tech-sales-rfqs/[rfqId]/vendors/[vendorId]/comments/route.ts32
-rw-r--r--components/signup/tech-vendor-item-selector-dialog.tsx254
-rw-r--r--components/signup/tech-vendor-join-form.tsx69
-rw-r--r--components/tech-vendors/tech-vendor-container.tsx30
-rw-r--r--components/tech-vendors/tech-vendor-items-container.tsx121
-rw-r--r--config/techVendorColumnsConfig.ts60
-rw-r--r--config/techVendorContactsColumnsConfig.ts88
-rw-r--r--lib/contact-possible-items/service.ts190
-rw-r--r--lib/contact-possible-items/table/contact-possible-items-table-columns.tsx301
-rw-r--r--lib/contact-possible-items/table/contact-possible-items-table-toolbar-actions.tsx92
-rw-r--r--lib/contact-possible-items/table/contact-possible-items-table.tsx102
-rw-r--r--lib/contact-possible-items/table/delete-contact-possible-items-dialog.tsx111
-rw-r--r--lib/contact-possible-items/validations.ts59
-rw-r--r--lib/items-tech/repository.ts248
-rw-r--r--lib/items-tech/service.ts34
-rw-r--r--lib/items-tech/table/delete-items-dialog.tsx388
-rw-r--r--lib/items-tech/table/feature-flags.tsx192
-rw-r--r--lib/items-tech/table/hull/import-item-handler.tsx254
-rw-r--r--lib/items-tech/table/hull/item-excel-template.tsx210
-rw-r--r--lib/items-tech/table/import-excel-button.tsx606
-rw-r--r--lib/items-tech/table/ship/import-item-handler.tsx266
-rw-r--r--lib/items-tech/table/ship/item-excel-template.tsx220
-rw-r--r--lib/items-tech/table/ship/items-table-toolbar-actions.tsx352
-rw-r--r--lib/items-tech/table/top/import-item-handler.tsx271
-rw-r--r--lib/items-tech/table/top/item-excel-template.tsx218
-rw-r--r--lib/tech-vendor-invitation-token.ts7
-rw-r--r--lib/tech-vendor-possible-items/repository.ts123
-rw-r--r--lib/tech-vendor-possible-items/service.ts298
-rw-r--r--lib/tech-vendor-possible-items/table/add-possible-item-dialog.tsx450
-rw-r--r--lib/tech-vendor-possible-items/table/delete-possible-items-dialog.tsx175
-rw-r--r--lib/tech-vendor-possible-items/table/excel-export.tsx106
-rw-r--r--lib/tech-vendor-possible-items/table/excel-import.tsx130
-rw-r--r--lib/tech-vendor-possible-items/table/excel-template.tsx151
-rw-r--r--lib/tech-vendor-possible-items/table/possible-items-data-table.tsx26
-rw-r--r--lib/tech-vendor-possible-items/table/possible-items-table-columns.tsx147
-rw-r--r--lib/tech-vendor-possible-items/table/possible-items-table-toolbar-actions.tsx86
-rw-r--r--lib/tech-vendor-possible-items/validations.ts16
-rw-r--r--lib/tech-vendors/contacts-table/add-contact-dialog.tsx390
-rw-r--r--lib/tech-vendors/contacts-table/contact-table-columns.tsx350
-rw-r--r--lib/tech-vendors/contacts-table/contact-table-toolbar-actions.tsx264
-rw-r--r--lib/tech-vendors/contacts-table/contact-table.tsx178
-rw-r--r--lib/tech-vendors/contacts-table/feature-flags-provider.tsx216
-rw-r--r--lib/tech-vendors/contacts-table/update-contact-sheet.tsx217
-rw-r--r--lib/tech-vendors/possible-items/add-item-dialog.tsx284
-rw-r--r--lib/tech-vendors/possible-items/possible-items-columns.tsx206
-rw-r--r--lib/tech-vendors/possible-items/possible-items-table.tsx171
-rw-r--r--lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx119
-rw-r--r--lib/tech-vendors/repository.ts851
-rw-r--r--lib/tech-vendors/rfq-history-table/tech-vendor-rfq-history-table-columns.tsx56
-rw-r--r--lib/tech-vendors/service.ts4506
-rw-r--r--lib/tech-vendors/table/add-vendor-dialog.tsx48
-rw-r--r--lib/tech-vendors/table/attachmentButton.tsx152
-rw-r--r--lib/tech-vendors/table/excel-template-download.tsx380
-rw-r--r--lib/tech-vendors/table/feature-flags-provider.tsx216
-rw-r--r--lib/tech-vendors/table/import-button.tsx692
-rw-r--r--lib/tech-vendors/table/tech-vendor-possible-items-view-dialog.tsx201
-rw-r--r--lib/tech-vendors/table/tech-vendors-filter-sheet.tsx617
-rw-r--r--lib/tech-vendors/table/tech-vendors-table-columns.tsx788
-rw-r--r--lib/tech-vendors/table/tech-vendors-table-floating-bar.tsx240
-rw-r--r--lib/tech-vendors/table/tech-vendors-table-toolbar-actions.tsx396
-rw-r--r--lib/tech-vendors/table/tech-vendors-table.tsx470
-rw-r--r--lib/tech-vendors/table/update-vendor-sheet.tsx1035
-rw-r--r--lib/tech-vendors/table/vendor-all-export.ts512
-rw-r--r--lib/tech-vendors/utils.ts56
-rw-r--r--lib/tech-vendors/validations.ts719
-rw-r--r--lib/techsales-rfq/actions.ts62
-rw-r--r--lib/techsales-rfq/repository.ts1204
-rw-r--r--lib/techsales-rfq/service.ts7112
-rw-r--r--lib/techsales-rfq/table/README.md41
-rw-r--r--lib/techsales-rfq/table/create-rfq-hull-dialog.tsx1294
-rw-r--r--lib/techsales-rfq/table/create-rfq-ship-dialog.tsx1450
-rw-r--r--lib/techsales-rfq/table/create-rfq-top-dialog.tsx1220
-rw-r--r--lib/techsales-rfq/table/delete-vendors-dialog.tsx236
-rw-r--r--lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx946
-rw-r--r--lib/techsales-rfq/table/detail-table/delete-vendors-dialog.tsx297
-rw-r--r--lib/techsales-rfq/table/detail-table/quotation-contacts-view-dialog.tsx173
-rw-r--r--lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx10
-rw-r--r--lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx850
-rw-r--r--lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx1483
-rw-r--r--lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx1238
-rw-r--r--lib/techsales-rfq/table/detail-table/vendor-contact-selection-dialog.tsx343
-rw-r--r--lib/techsales-rfq/table/project-detail-dialog.tsx238
-rw-r--r--lib/techsales-rfq/table/rfq-filter-sheet.tsx1516
-rw-r--r--lib/techsales-rfq/table/rfq-items-view-dialog.tsx6
-rw-r--r--lib/techsales-rfq/table/rfq-table-column.tsx831
-rw-r--r--lib/techsales-rfq/table/rfq-table-toolbar-actions.tsx158
-rw-r--r--lib/techsales-rfq/table/rfq-table.tsx1223
-rw-r--r--lib/techsales-rfq/table/tech-sales-quotation-attachments-sheet.tsx39
-rw-r--r--lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx1118
-rw-r--r--lib/techsales-rfq/validations.ts382
-rw-r--r--lib/techsales-rfq/vendor-response/buyer-communication-drawer.tsx1438
-rw-r--r--lib/techsales-rfq/vendor-response/detail/communication-tab.tsx416
-rw-r--r--lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx296
-rw-r--r--lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx1043
-rw-r--r--lib/techsales-rfq/vendor-response/detail/quotation-tabs.tsx166
-rw-r--r--lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx1380
-rw-r--r--lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx1028
108 files changed, 28511 insertions, 22373 deletions
diff --git a/app/[lng]/evcp/(evcp)/contact-possible-items/page.tsx b/app/[lng]/evcp/(evcp)/contact-possible-items/page.tsx
new file mode 100644
index 00000000..9fda681e
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/contact-possible-items/page.tsx
@@ -0,0 +1,56 @@
+import { Suspense } from "react"
+import { SearchParams } from "@/types/table"
+import { Shell } from "@/components/shell"
+import { ContactPossibleItemsTable } from "@/lib/contact-possible-items/table/contact-possible-items-table"
+import { getContactPossibleItems } from "@/lib/contact-possible-items/service"
+import { searchParamsCache } from "@/lib/contact-possible-items/validations"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+
+
+interface ContactPossibleItemsPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function ContactPossibleItemsPage({
+ searchParams,
+}: ContactPossibleItemsPageProps) {
+ const resolvedSearchParams = await searchParams
+ const search = searchParamsCache.parse(resolvedSearchParams)
+
+ const contactPossibleItemsPromise = getContactPossibleItems(search)
+
+ 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>
+
+
+ <Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={12}
+ searchableColumnCount={2}
+ filterableColumnCount={3}
+ cellWidths={["10rem", "10rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <ContactPossibleItemsTable
+ contactPossibleItemsPromise={contactPossibleItemsPromise}
+ />
+ </Suspense>
+
+ </Shell>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/tech-vendors/[id]/info/items/page.tsx b/app/[lng]/evcp/(evcp)/tech-vendors/[id]/info/items/page.tsx
deleted file mode 100644
index 69c36576..00000000
--- a/app/[lng]/evcp/(evcp)/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]/evcp/(evcp)/tech-vendors/[id]/info/layout.tsx b/app/[lng]/evcp/(evcp)/tech-vendors/[id]/info/layout.tsx
index adeb915c..291cd630 100644
--- a/app/[lng]/evcp/(evcp)/tech-vendors/[id]/info/layout.tsx
+++ b/app/[lng]/evcp/(evcp)/tech-vendors/[id]/info/layout.tsx
@@ -1,82 +1,82 @@
-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: "RFQ 히스토리",
- href: `/${lng}/evcp/tech-vendors/${id}/info/rfq-history`,
- },
- // {
- // title: "자재 리스트",
- // href: `/${lng}/evcp/tech-vendors/${id}/info/items`,
- // },
- ]
-
- 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>
- </>
- )
+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: "RFQ 히스토리",
+ href: `/${lng}/evcp/tech-vendors/${id}/info/rfq-history`,
+ },
+ {
+ title: "자재 리스트",
+ href: `/${lng}/evcp/tech-vendors/${id}/info/possible-items`,
+ },
+ ]
+
+ 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]/evcp/(evcp)/tech-vendors/[id]/info/page.tsx b/app/[lng]/evcp/(evcp)/tech-vendors/[id]/info/page.tsx
index a57d6df7..9969a801 100644
--- a/app/[lng]/evcp/(evcp)/tech-vendors/[id]/info/page.tsx
+++ b/app/[lng]/evcp/(evcp)/tech-vendors/[id]/info/page.tsx
@@ -1,55 +1,55 @@
-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>
- )
+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]/evcp/(evcp)/tech-vendors/[id]/info/possible-items/page.tsx b/app/[lng]/evcp/(evcp)/tech-vendors/[id]/info/possible-items/page.tsx
new file mode 100644
index 00000000..642c6e32
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/tech-vendors/[id]/info/possible-items/page.tsx
@@ -0,0 +1,54 @@
+import { Separator } from "@/components/ui/separator"
+import { getTechVendorPossibleItems } from "@/lib/tech-vendors/service"
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { searchParamsPossibleItemsCache } from "@/lib/tech-vendors/validations"
+import { TechVendorPossibleItemsTable } from "@/lib/tech-vendors/possible-items/possible-items-table"
+
+interface IndexPageProps {
+ // Next.js 13 App Router에서 기본으로 주어지는 객체들
+ params: Promise<{
+ lng: string
+ id: string
+ }>
+ searchParams: Promise<SearchParams>
+}
+
+export default async function TechVendorPossibleItemsPage(props: IndexPageProps) {
+ const resolvedParams = await props.params
+ const id = resolvedParams.id
+
+ const idAsNumber = Number(id)
+ console.log(idAsNumber)
+
+ // 2) SearchParams 파싱 (Zod)
+ // - "filters", "page", "perPage", "sort" 등 possible items 전용 컬럼
+ const searchParams = await props.searchParams
+ const search = searchParamsPossibleItemsCache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getTechVendorPossibleItems({
+ ...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>
+ <TechVendorPossibleItemsTable promises={promises} vendorId={idAsNumber} />
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/tech-vendors/[id]/info/rfq-history/page.tsx b/app/[lng]/evcp/(evcp)/tech-vendors/[id]/info/rfq-history/page.tsx
index a23a988e..9122d524 100644
--- a/app/[lng]/evcp/(evcp)/tech-vendors/[id]/info/rfq-history/page.tsx
+++ b/app/[lng]/evcp/(evcp)/tech-vendors/[id]/info/rfq-history/page.tsx
@@ -1,11 +1,9 @@
-import { Suspense } from "react"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-import { Shell } from "@/components/shell"
import { type SearchParams } from "@/types/table"
import { getValidFilters } from "@/lib/data-table"
import { TechVendorRfqHistoryTable } from "@/lib/tech-vendors/rfq-history-table/tech-vendor-rfq-history-table"
import { getTechVendorRfqHistory } from "@/lib/tech-vendors/service"
import { searchParamsRfqHistoryCache } from "@/lib/tech-vendors/validations"
+import { Separator } from "@/components/ui/separator"
interface IndexPageProps {
// Next.js 13 App Router에서 기본으로 주어지는 객체들
@@ -39,27 +37,20 @@ export default async function SettingsAccountPage(props: IndexPageProps) {
])
return (
- <Shell className="gap-2">
- <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
- <div>
- <h1 className="text-2xl font-bold tracking-tight">RFQ 히스토리</h1>
- <p className="text-muted-foreground">벤더가 참여한 기술영업 RFQ 목록입니다.</p>
- </div>
- </div>
-
- <Suspense
- fallback={
- <DataTableSkeleton
- columnCount={8}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "20rem", "10rem", "15rem", "10rem", "10rem", "10rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <TechVendorRfqHistoryTable promises={promises} />
- </Suspense>
- </Shell>
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">
+ RFQ 히스토리
+ </h3>
+ <p className="text-sm text-muted-foreground">
+ 벤더가 참여한 기술영업 RFQ 목록입니다.
+ </p>
+ </div>
+ <Separator />
+ <div>
+ <TechVendorRfqHistoryTable promises={promises} />
+ </div>
+ </div>
+
)
} \ No newline at end of file
diff --git a/app/[lng]/auth/tech-signup/page.tsx b/app/[lng]/partners/tech-signup/page.tsx
index d5b019ed..d5b019ed 100644
--- a/app/[lng]/auth/tech-signup/page.tsx
+++ b/app/[lng]/partners/tech-signup/page.tsx
diff --git a/app/[lng]/sales/(sales)/tech-vendors/[id]/info/layout.tsx b/app/[lng]/sales/(sales)/tech-vendors/[id]/info/layout.tsx
index b8df24d1..291cd630 100644
--- a/app/[lng]/sales/(sales)/tech-vendors/[id]/info/layout.tsx
+++ b/app/[lng]/sales/(sales)/tech-vendors/[id]/info/layout.tsx
@@ -1,82 +1,82 @@
-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}/sales/tech-vendors/${id}/info`,
- },
- {
- title: "RFQ 히스토리",
- href: `/${lng}/sales/tech-vendors/${id}/info/rfq-history`,
- },
- // {
- // title: "자재 리스트",
- // href: `/${lng}/evcp/tech-vendors/${id}/info/items`,
- // },
- ]
-
- 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>
- </>
- )
+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: "RFQ 히스토리",
+ href: `/${lng}/evcp/tech-vendors/${id}/info/rfq-history`,
+ },
+ {
+ title: "자재 리스트",
+ href: `/${lng}/evcp/tech-vendors/${id}/info/possible-items`,
+ },
+ ]
+
+ 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]/sales/(sales)/tech-vendors/[id]/info/page.tsx b/app/[lng]/sales/(sales)/tech-vendors/[id]/info/page.tsx
index a57d6df7..9969a801 100644
--- a/app/[lng]/sales/(sales)/tech-vendors/[id]/info/page.tsx
+++ b/app/[lng]/sales/(sales)/tech-vendors/[id]/info/page.tsx
@@ -1,55 +1,55 @@
-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>
- )
+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]/sales/(sales)/tech-vendors/[id]/info/possible-items/page.tsx b/app/[lng]/sales/(sales)/tech-vendors/[id]/info/possible-items/page.tsx
new file mode 100644
index 00000000..642c6e32
--- /dev/null
+++ b/app/[lng]/sales/(sales)/tech-vendors/[id]/info/possible-items/page.tsx
@@ -0,0 +1,54 @@
+import { Separator } from "@/components/ui/separator"
+import { getTechVendorPossibleItems } from "@/lib/tech-vendors/service"
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { searchParamsPossibleItemsCache } from "@/lib/tech-vendors/validations"
+import { TechVendorPossibleItemsTable } from "@/lib/tech-vendors/possible-items/possible-items-table"
+
+interface IndexPageProps {
+ // Next.js 13 App Router에서 기본으로 주어지는 객체들
+ params: Promise<{
+ lng: string
+ id: string
+ }>
+ searchParams: Promise<SearchParams>
+}
+
+export default async function TechVendorPossibleItemsPage(props: IndexPageProps) {
+ const resolvedParams = await props.params
+ const id = resolvedParams.id
+
+ const idAsNumber = Number(id)
+ console.log(idAsNumber)
+
+ // 2) SearchParams 파싱 (Zod)
+ // - "filters", "page", "perPage", "sort" 등 possible items 전용 컬럼
+ const searchParams = await props.searchParams
+ const search = searchParamsPossibleItemsCache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getTechVendorPossibleItems({
+ ...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>
+ <TechVendorPossibleItemsTable promises={promises} vendorId={idAsNumber} />
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/tech-vendors/[id]/info/rfq-history/page.tsx b/app/[lng]/sales/(sales)/tech-vendors/[id]/info/rfq-history/page.tsx
index a23a988e..9122d524 100644
--- a/app/[lng]/sales/(sales)/tech-vendors/[id]/info/rfq-history/page.tsx
+++ b/app/[lng]/sales/(sales)/tech-vendors/[id]/info/rfq-history/page.tsx
@@ -1,11 +1,9 @@
-import { Suspense } from "react"
-import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
-import { Shell } from "@/components/shell"
import { type SearchParams } from "@/types/table"
import { getValidFilters } from "@/lib/data-table"
import { TechVendorRfqHistoryTable } from "@/lib/tech-vendors/rfq-history-table/tech-vendor-rfq-history-table"
import { getTechVendorRfqHistory } from "@/lib/tech-vendors/service"
import { searchParamsRfqHistoryCache } from "@/lib/tech-vendors/validations"
+import { Separator } from "@/components/ui/separator"
interface IndexPageProps {
// Next.js 13 App Router에서 기본으로 주어지는 객체들
@@ -39,27 +37,20 @@ export default async function SettingsAccountPage(props: IndexPageProps) {
])
return (
- <Shell className="gap-2">
- <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
- <div>
- <h1 className="text-2xl font-bold tracking-tight">RFQ 히스토리</h1>
- <p className="text-muted-foreground">벤더가 참여한 기술영업 RFQ 목록입니다.</p>
- </div>
- </div>
-
- <Suspense
- fallback={
- <DataTableSkeleton
- columnCount={8}
- searchableColumnCount={1}
- filterableColumnCount={2}
- cellWidths={["10rem", "20rem", "10rem", "15rem", "10rem", "10rem", "10rem", "8rem"]}
- shrinkZero
- />
- }
- >
- <TechVendorRfqHistoryTable promises={promises} />
- </Suspense>
- </Shell>
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">
+ RFQ 히스토리
+ </h3>
+ <p className="text-sm text-muted-foreground">
+ 벤더가 참여한 기술영업 RFQ 목록입니다.
+ </p>
+ </div>
+ <Separator />
+ <div>
+ <TechVendorRfqHistoryTable promises={promises} />
+ </div>
+ </div>
+
)
} \ No newline at end of file
diff --git a/app/api/tech-sales-rfqs/[rfqId]/vendors/[vendorId]/comments/route.ts b/app/api/tech-sales-rfqs/[rfqId]/vendors/[vendorId]/comments/route.ts
index e6bf2b93..ac17766f 100644
--- a/app/api/tech-sales-rfqs/[rfqId]/vendors/[vendorId]/comments/route.ts
+++ b/app/api/tech-sales-rfqs/[rfqId]/vendors/[vendorId]/comments/route.ts
@@ -8,10 +8,7 @@ import { techSalesRfqComments, techSalesRfqCommentAttachments, users } from "@/d
import { revalidateTag } from "next/cache"
import { eq, and } from "drizzle-orm"
-// 파일 저장을 위한 유틸리티
-import { writeFile, mkdir } from 'fs/promises'
-import { join } from 'path'
-import crypto from 'crypto'
+// 파일 저장을 위한 유틸리티는 이제 saveFile 함수를 사용
/**
* 코멘트 조회 API 엔드포인트
@@ -74,6 +71,7 @@ export async function GET(
.select({
id: techSalesRfqCommentAttachments.id,
fileName: techSalesRfqCommentAttachments.fileName,
+ originalFileName: techSalesRfqCommentAttachments.originalFileName,
fileSize: techSalesRfqCommentAttachments.fileSize,
fileType: techSalesRfqCommentAttachments.fileType,
filePath: techSalesRfqCommentAttachments.filePath,
@@ -185,18 +183,20 @@ export async function POST(
if (files.length > 0) {
console.log("첨부파일 처리 시작:", files.length);
- // 디렉토리 생성
- const uploadDir = join(process.cwd(), "public", `tech-sales-rfq-${rfqId}`, `vendor-${vendorId}`, `comment-${comment.id}`)
- await mkdir(uploadDir, { recursive: true })
+ // saveFile 함수 import
+ const { saveFile } = await import('@/lib/file-stroage')
// 각 파일 저장
for (const file of files) {
- const buffer = Buffer.from(await file.arrayBuffer())
- const filename = `${Date.now()}-${crypto.randomBytes(8).toString("hex")}-${file.name.replace(/[^a-zA-Z0-9.-]/g, "_")}`
- const filePath = join(uploadDir, filename)
-
- // 파일 쓰기
- await writeFile(filePath, buffer)
+ const saveResult = await saveFile({
+ file,
+ directory: `tech-sales-rfq-${rfqId}/vendor-${vendorId}/comment-${comment.id}`,
+ originalName: file.name
+ })
+
+ if (!saveResult.success) {
+ throw new Error(saveResult.error || '파일 저장에 실패했습니다.')
+ }
// DB에 첨부파일 정보 저장
const [attachment] = await db
@@ -204,10 +204,11 @@ export async function POST(
.values({
rfqId,
commentId: comment.id,
- fileName: file.name,
+ fileName: saveResult.fileName!, // 해시된 파일명 (저장용)
+ originalFileName: saveResult.originalName!, // 원본 파일명 (표시용)
fileSize: file.size,
fileType: file.type,
- filePath: `/tech-sales-rfq-${rfqId}/vendor-${vendorId}/comment-${comment.id}/${filename}`,
+ filePath: saveResult.publicPath!,
isVendorUpload: isVendorComment,
uploadedBy: parseInt(session.user.id),
vendorId,
@@ -218,6 +219,7 @@ export async function POST(
attachments.push({
id: attachment.id,
fileName: attachment.fileName,
+ originalFileName: attachment.originalFileName,
fileSize: attachment.fileSize,
fileType: attachment.fileType,
filePath: attachment.filePath,
diff --git a/components/signup/tech-vendor-item-selector-dialog.tsx b/components/signup/tech-vendor-item-selector-dialog.tsx
new file mode 100644
index 00000000..a69dec5d
--- /dev/null
+++ b/components/signup/tech-vendor-item-selector-dialog.tsx
@@ -0,0 +1,254 @@
+"use client"
+
+import * as React from "react"
+import { useState, useEffect } from "react"
+import { Search, X } from "lucide-react"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Input } from "@/components/ui/input"
+import { Badge } from "@/components/ui/badge"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { Checkbox } from "@/components/ui/checkbox"
+
+interface Item {
+ itemCode: string
+ itemList: string
+ subItemList?: string
+ workType?: string
+ shipTypes?: string
+}
+
+interface TechVendorItemSelectorDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ vendorType: string | string[]
+ onItemsSelected: (selectedItems: string[]) => void
+}
+
+export function TechVendorItemSelectorDialog({
+ open,
+ onOpenChange,
+ vendorType,
+ onItemsSelected,
+}: TechVendorItemSelectorDialogProps) {
+ const [items, setItems] = useState<Item[]>([])
+ const [filteredItems, setFilteredItems] = useState<Item[]>([])
+ const [searchTerm, setSearchTerm] = useState("")
+ const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set())
+ const [isLoading, setIsLoading] = useState(false)
+
+ // 벤더 타입에 따른 아이템 조회
+ useEffect(() => {
+ if (open && vendorType) {
+ loadItemsByVendorType()
+ }
+ }, [open, vendorType])
+
+ // 검색 필터링
+ useEffect(() => {
+ if (searchTerm.trim() === "") {
+ setFilteredItems(items)
+ } else {
+ const filtered = items.filter(
+ (item) =>
+ item.itemList.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ (item.subItemList && item.subItemList.toLowerCase().includes(searchTerm.toLowerCase())) ||
+ item.itemCode.toLowerCase().includes(searchTerm.toLowerCase())
+ )
+ setFilteredItems(filtered)
+ }
+ }, [searchTerm, items])
+
+ const loadItemsByVendorType = async () => {
+ setIsLoading(true)
+ try {
+ // 서버 액션으로 아이템 조회
+ const { getItemsByVendorType } = await import("@/lib/tech-vendors/service")
+
+ let allItems: any[] = []
+
+ // 여러 벤더 타입인 경우 각각 조회하여 합치기
+ if (Array.isArray(vendorType)) {
+ for (const type of vendorType) {
+ const result = await getItemsByVendorType(type, "")
+ if (result && result.data && result.data.length > 0) {
+ allItems = [...allItems, ...result.data]
+ }
+ }
+ } else {
+ // 단일 벤더 타입인 경우
+ const result = await getItemsByVendorType(vendorType, "")
+ if (result && result.data && result.data.length > 0) {
+ allItems = result.data
+ }
+ }
+
+ if (allItems.length > 0) {
+ // 중복 제거 (itemCode 기준)
+ const uniqueItems = allItems.filter((item, index, self) =>
+ index === self.findIndex(t => t.itemCode === item.itemCode)
+ )
+
+ const itemsData = uniqueItems.map((item: any) => ({
+ itemCode: item.itemCode || "",
+ itemList: item.itemList || "",
+ subItemList: item.subItemList || "",
+ workType: item.workType || "",
+ shipTypes: item.shipTypes || "",
+ }))
+ setItems(itemsData)
+ setFilteredItems(itemsData)
+ } else {
+ setItems([])
+ setFilteredItems([])
+ }
+ } catch (error) {
+ console.error("아이템 조회 실패:", error)
+ setItems([])
+ setFilteredItems([])
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const handleItemToggle = (itemCode: string) => {
+ const newSelected = new Set(selectedItems)
+ if (newSelected.has(itemCode)) {
+ newSelected.delete(itemCode)
+ } else {
+ newSelected.add(itemCode)
+ }
+ setSelectedItems(newSelected)
+ }
+
+ const handleConfirm = () => {
+ const selectedItemCodes = Array.from(selectedItems)
+ onItemsSelected(selectedItemCodes)
+ onOpenChange(false)
+ // 상태 초기화
+ setSelectedItems(new Set())
+ setSearchTerm("")
+ }
+
+ const handleCancel = () => {
+ onOpenChange(false)
+ // 상태 초기화
+ setSelectedItems(new Set())
+ setSearchTerm("")
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl max-h-[80vh] flex flex-col">
+ <DialogHeader>
+ <DialogTitle>공급가능품목 선택</DialogTitle>
+ <DialogDescription>
+ {Array.isArray(vendorType) ? vendorType.join(", ") : vendorType} 관련 아이템 중에서 공급 가능한 품목을 선택해주세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="flex-1 space-y-4">
+ {/* 검색바 */}
+ <div className="relative">
+ <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
+ <Input
+ placeholder="아이템명, 서브아이템명, 아이템코드로 검색..."
+ value={searchTerm}
+ onChange={(e) => setSearchTerm(e.target.value)}
+ className="pl-10"
+ />
+ </div>
+
+ {/* 선택된 아이템 표시 */}
+ {selectedItems.size > 0 && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium">선택된 아이템 ({selectedItems.size}개)</div>
+ <div className="flex flex-wrap gap-2">
+ {Array.from(selectedItems).map((itemCode) => {
+ const item = items.find((i) => i.itemCode === itemCode)
+ return (
+ <Badge key={itemCode} variant="secondary" className="gap-1">
+ {item?.itemList || itemCode}
+ <button
+ onClick={() => handleItemToggle(itemCode)}
+ className="ml-1 hover:bg-muted rounded-full p-0.5"
+ >
+ <X className="h-3 w-3" />
+ </button>
+ </Badge>
+ )
+ })}
+ </div>
+ </div>
+ )}
+
+ {/* 아이템 목록 */}
+ <div className="border rounded-md">
+ <ScrollArea className="h-96">
+ {isLoading ? (
+ <div className="p-4 text-center text-muted-foreground">로딩 중...</div>
+ ) : filteredItems.length === 0 ? (
+ <div className="p-4 text-center text-muted-foreground">
+ {searchTerm ? "검색 결과가 없습니다." : "아이템이 없습니다."}
+ </div>
+ ) : (
+ <div className="p-4 space-y-2">
+ {filteredItems.map((item) => (
+ <div
+ key={item.itemCode}
+ className="flex items-start space-x-3 p-3 border rounded-lg hover:bg-muted/50"
+ >
+ <Checkbox
+ checked={selectedItems.has(item.itemCode)}
+ onCheckedChange={() => handleItemToggle(item.itemCode)}
+ className="mt-1"
+ />
+ <div className="flex-1 space-y-1">
+ <div className="flex items-center space-x-2">
+ <span className="font-medium">{item.itemList}</span>
+ <Badge variant="outline" className="text-xs">
+ {item.itemCode}
+ </Badge>
+ </div>
+ {item.subItemList && (
+ <div className="text-sm text-muted-foreground">
+ {item.subItemList}
+ </div>
+ )}
+ <div className="flex space-x-2 text-xs text-muted-foreground">
+ {item.workType && (
+ <span>공종: {item.workType}</span>
+ )}
+ {item.shipTypes && (
+ <span>선종: {item.shipTypes}</span>
+ )}
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
+ </ScrollArea>
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button variant="outline" onClick={handleCancel}>
+ 취소
+ </Button>
+ <Button onClick={handleConfirm} disabled={selectedItems.size === 0}>
+ 선택 완료 ({selectedItems.size}개)
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/components/signup/tech-vendor-join-form.tsx b/components/signup/tech-vendor-join-form.tsx
index db81b88c..efdee322 100644
--- a/components/signup/tech-vendor-join-form.tsx
+++ b/components/signup/tech-vendor-join-form.tsx
@@ -38,6 +38,7 @@ import { Check, ChevronsUpDown, Loader2, Plus, X } from "lucide-react"
import { cn } from "@/lib/utils"
import { createTechVendorFromSignup } from "@/lib/tech-vendors/service"
+import { TechVendorItemSelectorDialog } from "./tech-vendor-item-selector-dialog"
import { createTechVendorSchema, CreateTechVendorSchema } from "@/lib/tech-vendors/validations"
import { VENDOR_TYPES } from "@/db/schema/techVendors"
import { verifyTechVendorInvitationToken } from "@/lib/tech-vendor-invitation-token"
@@ -144,6 +145,8 @@ export function TechVendorJoinForm() {
const [isSubmitting, setIsSubmitting] = React.useState(false)
const [isLoading, setIsLoading] = React.useState(false)
const [hasValidToken, setHasValidToken] = React.useState<boolean | null>(null)
+ const [isItemSelectorOpen, setIsItemSelectorOpen] = React.useState(false)
+ const [selectedItemCodes, setSelectedItemCodes] = React.useState<string[]>([])
// React Hook Form (항상 최상위에서 호출)
const form = useForm<CreateTechVendorSchema>({
@@ -158,7 +161,7 @@ export function TechVendorJoinForm() {
phone: "",
country: "",
website: "",
- techVendorType: ["조선"],
+ techVendorType: [],
representativeName: "",
representativeBirth: "",
representativeEmail: "",
@@ -200,13 +203,15 @@ export function TechVendorJoinForm() {
if (tokenPayload) {
setHasValidToken(true);
+ console.log("tokenPayload", tokenPayload);
// 토큰에서 가져온 정보로 폼 미리 채우기
form.setValue("vendorName", tokenPayload.vendorName);
form.setValue("email", tokenPayload.email);
+ form.setValue("techVendorType", tokenPayload.vendorType as "조선" | "해양TOP" | "해양HULL" | ("조선" | "해양TOP" | "해양HULL")[]);
- // 연락처 정보도 미리 채우기
- form.setValue("contacts.0.contactName", tokenPayload.vendorName);
- form.setValue("contacts.0.contactEmail", tokenPayload.email);
+ // // 연락처 정보도 미리 채우기
+ // form.setValue("contacts.0.contactName", tokenPayload.vendorName);
+ // form.setValue("contacts.0.contactEmail", tokenPayload.email);
toast({
title: "초대 정보 로드 완료",
@@ -292,6 +297,13 @@ export function TechVendorJoinForm() {
form.setValue("files", updated, { shouldValidate: true })
}
+ const handleItemsSelected = (itemCodes: string[]) => {
+ setSelectedItemCodes(itemCodes)
+ // 선택된 아이템 코드들을 콤마로 구분하여 items 필드에 설정
+ const itemsString = itemCodes.join(", ")
+ form.setValue("items", itemsString)
+ }
+
// Submit
async function onSubmit(values: CreateTechVendorSchema) {
setIsSubmitting(true)
@@ -310,7 +322,7 @@ export function TechVendorJoinForm() {
email: values.email,
phone: values.phone,
country: values.country,
- techVendorType: Array.isArray(values.techVendorType) ? values.techVendorType[0] : values.techVendorType,
+ techVendorType: values.techVendorType as "조선" | "해양TOP" | "해양HULL" | ("조선" | "해양TOP" | "해양HULL")[],
representativeName: values.representativeName || "",
representativeBirth: values.representativeBirth || "",
representativeEmail: values.representativeEmail || "",
@@ -323,6 +335,7 @@ export function TechVendorJoinForm() {
vendorData: techVendorData,
files: mainFiles,
contacts: values.contacts,
+ selectedItemCodes: selectedItemCodes,
invitationToken: invitationToken || undefined,
})
@@ -413,7 +426,7 @@ export function TechVendorJoinForm() {
id={`techVendorType-${type}`}
checked={field.value?.includes(type) || false}
onChange={(e) => {
- const currentValues = field.value || [];
+ const currentValues = Array.isArray(field.value) ? field.value : [];
if (e.target.checked) {
field.onChange([...currentValues, type]);
} else {
@@ -557,7 +570,7 @@ export function TechVendorJoinForm() {
)}
/>
- {/* 이메일 */}
+ {/* 이메일 (수정 불가, 뷰 전용) */}
<FormField
control={form.control}
name="email"
@@ -567,7 +580,13 @@ export function TechVendorJoinForm() {
이메일
</FormLabel>
<FormControl>
- <Input placeholder="example@company.com" {...field} />
+ <Input
+ placeholder="example@company.com"
+ {...field}
+ readOnly
+ tabIndex={-1}
+ className="bg-muted cursor-not-allowed pointer-events-none"
+ />
</FormControl>
<FormMessage />
</FormItem>
@@ -616,11 +635,29 @@ export function TechVendorJoinForm() {
<FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500">
주요 품목
</FormLabel>
- <FormControl>
- <Input placeholder="주요 품목을 입력하세요" {...field} />
- </FormControl>
+ <div className="space-y-2">
+ <FormControl>
+ <Input placeholder="주요 품목을 입력하세요" {...field} />
+ </FormControl>
+ <div className="flex items-center space-x-2">
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={() => setIsItemSelectorOpen(true)}
+ disabled={!form.watch("techVendorType") || (Array.isArray(form.watch("techVendorType")) && form.watch("techVendorType").length === 0)}
+ >
+ 공급가능품목 선택
+ </Button>
+ {selectedItemCodes.length > 0 && (
+ <span className="text-sm text-muted-foreground">
+ {selectedItemCodes.length}개 아이템 선택됨
+ </span>
+ )}
+ </div>
+ </div>
<FormDescription>
- 회사에서 주로 다루는 품목들을 쉼표로 구분하여 입력하세요.
+ 공급가능품목 선택 버튼을 클릭하여 아이템을 선택하세요. 원하는 아이템이 없다면 텍스트로 입력하세요.
</FormDescription>
<FormMessage />
</FormItem>
@@ -902,6 +939,14 @@ export function TechVendorJoinForm() {
</Form>
</div>
</section>
+
+ {/* 공급가능품목 선택 다이얼로그 */}
+ <TechVendorItemSelectorDialog
+ open={isItemSelectorOpen}
+ onOpenChange={setIsItemSelectorOpen}
+ vendorType={form.watch("techVendorType")}
+ onItemsSelected={handleItemsSelected}
+ />
</div>
)
} \ No newline at end of file
diff --git a/components/tech-vendors/tech-vendor-container.tsx b/components/tech-vendors/tech-vendor-container.tsx
index af5169b8..94536702 100644
--- a/components/tech-vendors/tech-vendor-container.tsx
+++ b/components/tech-vendors/tech-vendor-container.tsx
@@ -40,20 +40,20 @@ export function TechVendorContainer({
// URL에서 현재 선택된 벤더 타입 가져오기
const vendorType = searchParams.get("vendorType") || "all"
- // 선택한 벤더 타입에 해당하는 이름 찾기
- const selectedVendor = vendorTypes.find((vendor) => vendor.id === vendorType)?.name || "전체"
+ // // 선택한 벤더 타입에 해당하는 이름 찾기
+ // 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)
- }
+ // // 벤더 타입 변경 핸들러
+ // 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])
+ // router.push(`${pathname}?${params.toString()}`)
+ // }, [router, pathname, searchParams])
return (
<>
@@ -62,7 +62,7 @@ export function TechVendorContainer({
{/* 왼쪽: 타이틀 & 설명 */}
<div>
<div className="flex items-center gap-2">
- <h2 className="text-2xl font-bold tracking-tight">기술영업 벤더 관리</h2>
+ <h2 className="text-2xl font-bold tracking-tight">기술영업 벤더 리스트</h2>
<InformationButton pagePath="evcp/tech-vendors" />
</div>
{/* <p className="text-muted-foreground">
@@ -70,7 +70,7 @@ export function TechVendorContainer({
</p> */}
</div>
- {/* 오른쪽: 벤더 타입 드롭다운 */}
+ {/* 오른쪽: 벤더 타입 드롭다운
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="min-w-[150px]">
@@ -89,7 +89,7 @@ export function TechVendorContainer({
</DropdownMenuItem>
))}
</DropdownMenuContent>
- </DropdownMenu>
+ </DropdownMenu> */}
</div>
{/* 컨텐츠 영역 */}
diff --git a/components/tech-vendors/tech-vendor-items-container.tsx b/components/tech-vendors/tech-vendor-items-container.tsx
deleted file mode 100644
index 49a9d4ee..00000000
--- a/components/tech-vendors/tech-vendor-items-container.tsx
+++ /dev/null
@@ -1,121 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useRouter, usePathname, useSearchParams } from "next/navigation"
-import { ChevronDown } from "lucide-react"
-
-import { type TechVendor } from "@/db/schema/techVendors"
-
-import { Button } from "@/components/ui/button"
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu"
-import { TechVendorItemsTable } from "@/lib/tech-vendors/items-table/item-table"
-import { getVendorItemsByType } from "@/lib/tech-vendors/service"
-
-interface ItemType {
- id: string
- name: string
- vendorType: string
-}
-
-interface TechVendorItemsContainerProps {
- vendorId: number
- vendor: TechVendor
- itemTypes: ItemType[]
-}
-
-export function TechVendorItemsContainer({
- vendorId,
- vendor,
- itemTypes,
-}: TechVendorItemsContainerProps) {
- const router = useRouter()
- const pathname = usePathname()
- const searchParamsObj = useSearchParams()
-
- // useSearchParams를 메모이제이션하여 안정적인 참조 생성
- const currentSearchParams = React.useMemo(
- () => searchParamsObj || new URLSearchParams(),
- [searchParamsObj]
- )
-
- // URL에서 현재 선택된 아이템 타입 가져오기 (기본값은 첫 번째 타입)
- const itemType = currentSearchParams.get("type") || itemTypes[0]?.id || "ship"
-
- // 선택한 아이템 타입에 해당하는 정보 찾기
- const selectedItemType = itemTypes.find((item) => item.id === itemType) || itemTypes[0]
-
- // 아이템 타입 변경 핸들러
- const handleItemTypeChange = React.useCallback((value: string) => {
- const params = new URLSearchParams(currentSearchParams.toString())
- params.set("type", value)
-
- router.push(`${pathname}?${params.toString()}`)
- }, [router, pathname, currentSearchParams])
-
- // 현재 선택된 벤더 타입에 대한 아이템 데이터 가져오기
- const promises = React.useMemo(() => {
- if (selectedItemType) {
- return getVendorItemsByType(vendorId, selectedItemType.vendorType)
- }
- return Promise.resolve({ data: [] })
- }, [vendorId, selectedItemType])
-
- // 벤더 타입이 하나뿐인 경우 드롭다운 숨기기
- const showDropdown = itemTypes.length > 1
-
- return (
- <>
- {/* 상단 영역: 제목 왼쪽 / 아이템 타입 선택기 오른쪽 */}
- <div className="flex items-center justify-between">
- {/* 왼쪽: 타이틀 & 설명 */}
- <div>
- <h4 className="text-lg font-medium">자재 목록</h4>
- <p className="text-sm text-muted-foreground">
- {vendor.vendorName}의 공급 가능한 자재 목록입니다.
- </p>
- </div>
-
- {/* 오른쪽: 아이템 타입 드롭다운 (타입이 여러 개인 경우에만 표시) */}
- {showDropdown && (
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button variant="outline" className="min-w-[150px]">
- {selectedItemType?.name || "타입 선택"}
- <ChevronDown className="ml-2 h-4 w-4" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end" className="w-[200px]">
- {itemTypes.map((item) => (
- <DropdownMenuItem
- key={item.id}
- onClick={() => handleItemTypeChange(item.id)}
- className={item.id === itemType ? "bg-muted" : ""}
- >
- {item.name}
- </DropdownMenuItem>
- ))}
- </DropdownMenuContent>
- </DropdownMenu>
- )}
- </div>
-
- {/* 컨텐츠 영역 */}
- <section className="overflow-hidden">
- <div>
- {selectedItemType && (
- <TechVendorItemsTable
- promises={promises}
- vendorId={vendorId}
- vendorType={selectedItemType.vendorType}
- />
- )}
- </div>
- </section>
- </>
- )
-} \ No newline at end of file
diff --git a/config/techVendorColumnsConfig.ts b/config/techVendorColumnsConfig.ts
index bbb2586a..e6ae2eaa 100644
--- a/config/techVendorColumnsConfig.ts
+++ b/config/techVendorColumnsConfig.ts
@@ -76,6 +76,18 @@ export const techVendorColumnsConfig: VendorColumnConfig[] = [
},
{
+ id: "countryEng",
+ label: "국가(영문)",
+ excelHeader: "국가(영문)",
+ },
+
+ {
+ id: "countryFab",
+ label: "제조국가",
+ excelHeader: "제조국가",
+ },
+
+ {
id: "phone",
label: "전화번호",
excelHeader: "전화번호",
@@ -92,6 +104,52 @@ export const techVendorColumnsConfig: VendorColumnConfig[] = [
label: "웹사이트",
excelHeader: "웹사이트",
// group: "Metadata",
- },
+ },
+
+ // 에이전트 정보
+ {
+ id: "agentName",
+ label: "에이전트명",
+ excelHeader: "에이전트명",
+ group: "에이전트 정보",
+ },
+ {
+ id: "agentEmail",
+ label: "에이전트 이메일",
+ excelHeader: "에이전트 이메일",
+ group: "에이전트 정보",
+ },
+ {
+ id: "agentPhone",
+ label: "에이전트 번호",
+ excelHeader: "에이전트 번호",
+ group: "에이전트 정보",
+ },
+
+ // 대표자 정보
+ {
+ id: "representativeName",
+ label: "대표자명",
+ excelHeader: "대표자명",
+ group: "대표자 정보",
+ },
+ {
+ id: "representativeEmail",
+ label: "대표자 이메일",
+ excelHeader: "대표자 이메일",
+ group: "대표자 정보",
+ },
+ {
+ id: "representativePhone",
+ label: "대표자 전화번호",
+ excelHeader: "대표자 전화번호",
+ group: "대표자 정보",
+ },
+ {
+ id: "representativeBirth",
+ label: "대표자 생년월일",
+ excelHeader: "대표자 생년월일",
+ group: "대표자 정보",
+ },
]; \ No newline at end of file
diff --git a/config/techVendorContactsColumnsConfig.ts b/config/techVendorContactsColumnsConfig.ts
index e1afe200..4de48c81 100644
--- a/config/techVendorContactsColumnsConfig.ts
+++ b/config/techVendorContactsColumnsConfig.ts
@@ -1,70 +1,70 @@
-import { TechVendorContact } from "@/db/schema/techVendors";
+import { TechVendorContact } from "@/db/schema/techVendors"
-/**
- * 테이블/엑셀에 보여줄 컬럼 한 칸을 어떻게 렌더링할지 결정하는 설정
- */
-export interface TechVendorColumnConfig {
- /**
- * TechVendorContact 객체의 어느 필드를 표시할지
- */
- id: keyof TechVendorContact;
-
- /** 화면·엑셀에서 보여줄 컬럼명 */
- label: string;
-
- /** (선택) 그룹핑/카테고리 */
- group?: string;
-
- /** (선택) Excel에서의 헤더 */
- excelHeader?: string;
-
- /** (선택) 데이터 타입(예: date, string, number 등), 포맷 지정용 */
- type?: string;
+export interface TechVendorContactColumnConfig {
+ id: keyof TechVendorContact
+ label: string
+ group?: string
+ excelHeader?: string
+ type?: string
}
-/**
- * Tech Vendor Contacts 테이블에서
- * 어떤 컬럼들을 어떤 순서로 표시할 것인지 정의.
- */
-export const techVendorContactsColumnsConfig: TechVendorColumnConfig[] = [
+export const techVendorContactsColumnsConfig: TechVendorContactColumnConfig[] = [
+ // 기본 정보
{
id: "contactName",
- label: "Contact Name",
- excelHeader: "Contact Name",
+ label: "담당자명",
+ type: "text",
+ group: "기본 정보",
+ excelHeader: "담당자명",
},
{
id: "contactPosition",
- label: "Contact Position",
- excelHeader: "Contact Position",
+ label: "직책",
+ type: "text",
+ group: "기본 정보",
+ excelHeader: "직책",
},
{
id: "contactEmail",
- label: "Contact Email",
- excelHeader: "Contact Email",
+ label: "이메일",
+ type: "email",
+ group: "연락처",
+ excelHeader: "이메일",
},
{
id: "contactPhone",
- label: "Contact Phone",
- excelHeader: "Contact Phone",
+ label: "전화번호",
+ type: "text",
+ group: "연락처",
+ excelHeader: "전화번호",
},
{
- id: "country",
- label: "Country",
- excelHeader: "Country",
+ id: "contactCountry",
+ label: "국가",
+ type: "text",
+ group: "기본 정보",
+ excelHeader: "국가",
},
{
id: "isPrimary",
- label: "isPrimary",
- excelHeader: "isPrimary",
+ label: "주담당자",
+ type: "boolean",
+ group: "기본 정보",
+ excelHeader: "주담당자",
},
+ // 시스템 정보
{
id: "createdAt",
- label: "Created At",
- excelHeader: "Created At",
+ label: "생성일",
+ type: "date",
+ group: "시스템 정보",
+ excelHeader: "생성일",
},
{
id: "updatedAt",
- label: "Updated At",
- excelHeader: "Updated At",
+ label: "수정일",
+ type: "date",
+ group: "시스템 정보",
+ excelHeader: "수정일",
},
-]; \ No newline at end of file
+] \ No newline at end of file
diff --git a/lib/contact-possible-items/service.ts b/lib/contact-possible-items/service.ts
new file mode 100644
index 00000000..f4b89368
--- /dev/null
+++ b/lib/contact-possible-items/service.ts
@@ -0,0 +1,190 @@
+"use server"
+
+import db from "@/db/db"
+import { techSalesContactPossibleItems } from "@/db/schema/techSales"
+import { techVendors, techVendorContacts, techVendorPossibleItems } from "@/db/schema/techVendors"
+import { eq, desc, ilike, count, or } from "drizzle-orm"
+import { revalidatePath } from "next/cache"
+import { unstable_noStore } from "next/cache"
+import { GetContactPossibleItemsSchema } from "./validations"
+
+// 담당자별 아이템 상세 타입 정의 (뷰 기반)
+export interface ContactPossibleItemDetail {
+ id: number
+ contactId: number
+ vendorPossibleItemId: number
+ createdAt: Date
+ updatedAt: Date
+
+ // 벤더 정보
+ vendorId: number
+ vendorName: string | null
+ vendorCode: string | null
+ vendorEmail: string | null
+ vendorPhone: string | null
+ vendorCountry: string | null
+ vendorStatus: string | null
+ techVendorType: string | null
+
+ // 연락처 정보
+ contactName: string | null
+ contactPosition: string | null
+ contactEmail: string | null
+ contactPhone: string | null
+ contactCountry: string | null
+ isPrimary: boolean | null
+
+ // 아이템 정보
+ itemCode: string | null
+ workType: string | null
+ shipTypes: string | null
+ itemList: string | null
+ subItemList: string | null
+}
+
+/**
+ * 담당자별 아이템 목록 조회 (뷰 사용)
+ */
+export async function getContactPossibleItems(input: GetContactPossibleItemsSchema) {
+ unstable_noStore()
+
+ try {
+ const offset = (input.page - 1) * input.per_page
+
+ console.log("=== getContactPossibleItems DEBUG ===")
+ console.log("Input:", input)
+ console.log("Offset:", offset)
+
+ // 검색 조건
+ let whereCondition
+ if (input.search) {
+ const searchTerm = `%${input.search}%`
+ whereCondition = or(
+ ilike(techVendorPossibleItems.itemCode, searchTerm),
+ ilike(techVendorPossibleItems.itemList, searchTerm),
+ ilike(techVendors.vendorName, searchTerm),
+ ilike(techVendorContacts.contactName, searchTerm)
+ )
+ console.log("Search term:", searchTerm)
+ } else {
+ console.log("No search condition")
+ }
+
+ // 원본 테이블들을 직접 조인해서 데이터 조회
+ console.log("Executing data query...")
+ const items = await db
+ .select({
+ // 기본 매핑 정보
+ id: techSalesContactPossibleItems.id,
+ contactId: techSalesContactPossibleItems.contactId,
+ vendorPossibleItemId: techSalesContactPossibleItems.vendorPossibleItemId,
+ createdAt: techSalesContactPossibleItems.createdAt,
+ updatedAt: techSalesContactPossibleItems.updatedAt,
+
+ // 벤더 정보
+ vendorId: techVendors.id,
+ vendorName: techVendors.vendorName,
+ vendorCode: techVendors.vendorCode,
+ vendorEmail: techVendors.email,
+ vendorPhone: techVendors.phone,
+ vendorCountry: techVendors.country,
+ vendorStatus: techVendors.status,
+ techVendorType: techVendors.techVendorType,
+
+ // 연락처 정보
+ contactName: techVendorContacts.contactName,
+ contactPosition: techVendorContacts.contactPosition,
+ contactEmail: techVendorContacts.contactEmail,
+ contactPhone: techVendorContacts.contactPhone,
+ contactCountry: techVendorContacts.contactCountry,
+ isPrimary: techVendorContacts.isPrimary,
+
+ // 벤더 가능 아이템 정보
+ itemCode: techVendorPossibleItems.itemCode,
+ workType: techVendorPossibleItems.workType,
+ shipTypes: techVendorPossibleItems.shipTypes,
+ itemList: techVendorPossibleItems.itemList,
+ subItemList: techVendorPossibleItems.subItemList,
+ })
+ .from(techSalesContactPossibleItems)
+ .leftJoin(techVendorContacts, eq(techSalesContactPossibleItems.contactId, techVendorContacts.id))
+ .leftJoin(techVendorPossibleItems, eq(techSalesContactPossibleItems.vendorPossibleItemId, techVendorPossibleItems.id))
+ .leftJoin(techVendors, eq(techVendorContacts.vendorId, techVendors.id))
+ .where(whereCondition)
+ .orderBy(desc(techSalesContactPossibleItems.createdAt))
+ .offset(offset)
+ .limit(input.per_page)
+
+ console.log("Items found:", items.length)
+ console.log("First 3 items:", items.slice(0, 3))
+
+ // 전체 개수 조회 (동일한 조인과 검색 조건 적용)
+ console.log("Executing count query...")
+ const [{ count: total }] = await db
+ .select({ count: count() })
+ .from(techSalesContactPossibleItems)
+ .leftJoin(techVendorContacts, eq(techSalesContactPossibleItems.contactId, techVendorContacts.id))
+ .leftJoin(techVendorPossibleItems, eq(techSalesContactPossibleItems.vendorPossibleItemId, techVendorPossibleItems.id))
+ .leftJoin(techVendors, eq(techVendorContacts.vendorId, techVendors.id))
+ .where(whereCondition)
+
+ console.log("Total count:", total)
+
+ const pageCount = Math.ceil(total / input.per_page)
+
+ console.log("Final result:", { dataLength: items.length, pageCount, total })
+ console.log("=== END DEBUG ===")
+
+ return {
+ data: items as ContactPossibleItemDetail[],
+ pageCount,
+ total,
+ }
+ } catch (err) {
+ console.error("=== ERROR in getContactPossibleItems ===")
+ console.error("Error fetching contact possible items:", err)
+ console.error("Input was:", input)
+ console.error("=== END ERROR ===")
+ return {
+ data: [],
+ pageCount: 0,
+ total: 0,
+ }
+ }
+}
+
+/**
+ * 담당자별 아이템 삭제
+ */
+export async function deleteContactPossibleItem(id: number) {
+ try {
+ await db
+ .delete(techSalesContactPossibleItems)
+ .where(eq(techSalesContactPossibleItems.id, id))
+
+ revalidatePath("/evcp/contact-possible-items")
+ return { success: true }
+ } catch (error) {
+ console.error("담당자별 아이템 삭제 오류:", error)
+ return { success: false, error: "담당자별 아이템 삭제에 실패했습니다." }
+ }
+}
+
+/**
+ * 여러 담당자별 아이템 삭제
+ */
+export async function deleteContactPossibleItems(ids: number[]) {
+ try {
+ await db
+ .delete(techSalesContactPossibleItems)
+ .where(
+ or(...ids.map(id => eq(techSalesContactPossibleItems.id, id)))
+ )
+
+ revalidatePath("/evcp/contact-possible-items")
+ return { success: true }
+ } catch (error) {
+ console.error("담당자별 아이템 일괄 삭제 오류:", error)
+ return { success: false, error: "담당자별 아이템 삭제에 실패했습니다." }
+ }
+} \ No newline at end of file
diff --git a/lib/contact-possible-items/table/contact-possible-items-table-columns.tsx b/lib/contact-possible-items/table/contact-possible-items-table-columns.tsx
new file mode 100644
index 00000000..a3b198ae
--- /dev/null
+++ b/lib/contact-possible-items/table/contact-possible-items-table-columns.tsx
@@ -0,0 +1,301 @@
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Ellipsis } from "lucide-react"
+
+import { formatDate } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuShortcut,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+
+import { ContactPossibleItemDetail } from "../service"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<ContactPossibleItemDetail> | null>>;
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<ContactPossibleItemDetail>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<ContactPossibleItemDetail> = {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+ // ----------------------------------------------------------------
+ // 2) actions 컬럼 (Dropdown 메뉴)
+ // ----------------------------------------------------------------
+ const actionsColumn: ColumnDef<ContactPossibleItemDetail> = {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-40">
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "delete" })}
+ >
+ 삭제
+ <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 40,
+ }
+
+ // ----------------------------------------------------------------
+ // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
+ // ----------------------------------------------------------------
+ const baseColumns: ColumnDef<ContactPossibleItemDetail>[] = [
+ // 벤더 정보
+ {
+ id: "vendorInfo",
+ header: "벤더 정보",
+ columns: [
+ {
+ accessorKey: "vendorCode",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="벤더 코드" />
+ ),
+ cell: ({ row }) => row.original.vendorCode ?? "",
+ },
+ {
+ accessorKey: "vendorName",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="벤더명" />
+ ),
+ cell: ({ row }) => row.original.vendorName ?? "",
+ },
+ {
+ accessorKey: "vendorCountry",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="벤더 국가" />
+ ),
+ cell: ({ row }) => {
+ const country = row.original.vendorCountry
+ return country || <span className="text-muted-foreground">-</span>
+ },
+ },
+ {
+ accessorKey: "techVendorType",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="벤더 타입" />
+ ),
+ cell: ({ row }) => {
+ const type = row.original.techVendorType
+ return type || <span className="text-muted-foreground">-</span>
+ },
+ },
+ ]
+ },
+ // 담당자 정보
+ {
+ id: "contactInfo",
+ header: "담당자 정보",
+ columns: [
+ {
+ accessorKey: "contactName",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="담당자명" />
+ ),
+ cell: ({ row }) => {
+ const contactName = row.original.contactName
+ return contactName || <span className="text-muted-foreground">-</span>
+ },
+ },
+ {
+ accessorKey: "contactPosition",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="직책" />
+ ),
+ cell: ({ row }) => {
+ const position = row.original.contactPosition
+ return position || <span className="text-muted-foreground">-</span>
+ },
+ },
+ {
+ accessorKey: "contactEmail",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="담당자 이메일" />
+ ),
+ cell: ({ row }) => {
+ const contactEmail = row.original.contactEmail
+ return contactEmail || <span className="text-muted-foreground">-</span>
+ },
+ },
+ {
+ accessorKey: "contactPhone",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="담당자 전화번호" />
+ ),
+ cell: ({ row }) => {
+ const contactPhone = row.original.contactPhone
+ return contactPhone || <span className="text-muted-foreground">-</span>
+ },
+ },
+ {
+ accessorKey: "contactCountry",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="담당자 국가" />
+ ),
+ cell: ({ row }) => {
+ const contactCountry = row.original.contactCountry
+ return contactCountry || <span className="text-muted-foreground">-</span>
+ },
+ },
+ {
+ accessorKey: "isPrimary",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="주담당자" />
+ ),
+ cell: ({ row }) => {
+ const isPrimary = row.original.isPrimary
+ return isPrimary ? "예" : "아니오"
+ },
+ },
+ ]
+ },
+ // 아이템 정보
+ {
+ id: "itemInfo",
+ header: "아이템 정보",
+ columns: [
+ {
+ accessorKey: "itemCode",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="아이템 코드" />
+ ),
+ cell: ({ row }) => row.original.itemCode ?? "",
+ },
+ {
+ accessorKey: "itemList",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="아이템 리스트" />
+ ),
+ cell: ({ row }) => row.original.itemList ?? "",
+ },
+ {
+ accessorKey: "workType",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="공종" />
+ ),
+ cell: ({ row }) => row.original.workType ?? "",
+ },
+ {
+ accessorKey: "shipTypes",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="선종" />
+ ),
+ cell: ({ row }) => row.original.shipTypes ?? "",
+ },
+ {
+ accessorKey: "subItemList",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="서브아이템 리스트" />
+ ),
+ cell: ({ row }) => row.original.subItemList ?? "",
+ },
+ ]
+ },
+
+ // 시스템 정보
+ {
+ id: "systemInfo",
+ header: "시스템 정보",
+ columns: [
+ {
+ accessorKey: "createdAt",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="생성일" />
+ ),
+ cell: ({ row }) => {
+ const dateVal = row.getValue("createdAt") as Date
+ return formatDate(dateVal)
+ },
+ },
+ {
+ accessorKey: "updatedAt",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="수정일" />
+ ),
+ cell: ({ row }) => {
+ const dateVal = row.getValue("updatedAt") as Date
+ return formatDate(dateVal)
+ },
+ },
+ ]
+ },
+ ]
+
+ // ----------------------------------------------------------------
+ // 4) 최종 컬럼 배열: select, baseColumns, actions
+ // ----------------------------------------------------------------
+ return [
+ selectColumn,
+ ...baseColumns,
+ actionsColumn,
+ ]
+} \ No newline at end of file
diff --git a/lib/contact-possible-items/table/contact-possible-items-table-toolbar-actions.tsx b/lib/contact-possible-items/table/contact-possible-items-table-toolbar-actions.tsx
new file mode 100644
index 00000000..4125399b
--- /dev/null
+++ b/lib/contact-possible-items/table/contact-possible-items-table-toolbar-actions.tsx
@@ -0,0 +1,92 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Download } from "lucide-react"
+import * as ExcelJS from 'exceljs'
+
+import { Button } from "@/components/ui/button"
+import { ContactPossibleItemDetail } from "../service"
+
+interface ContactPossibleItemsTableToolbarActionsProps {
+ table: Table<ContactPossibleItemDetail>
+}
+
+export function ContactPossibleItemsTableToolbarActions({
+ table,
+}: ContactPossibleItemsTableToolbarActionsProps) {
+
+ const handleExport = () => {
+ // 현재 테이블의 모든 데이터를 Excel로 내보내기
+ const data = table.getFilteredRowModel().rows.map(row => ({
+ "벤더 코드": row.original.vendorCode,
+ "벤더명": row.original.vendorName,
+ "벤더 국가": row.original.vendorCountry,
+ "벤더 타입": row.original.techVendorType,
+ "담당자명": row.original.contactName,
+ "담당자 직책": row.original.contactPosition,
+ "담당자 이메일": row.original.contactEmail,
+ "담당자 전화번호": row.original.contactPhone,
+ "담당자 국가": row.original.contactCountry,
+ "주담당자": row.original.isPrimary ? "예" : "아니오",
+ "아이템 코드": row.original.itemCode,
+ "아이템명": row.original.itemList,
+ "공종": row.original.workType,
+ "선종": row.original.shipTypes,
+ "서브아이템": row.original.subItemList,
+ "생성일": new Date(row.original.createdAt).toLocaleDateString("ko-KR"),
+ "수정일": new Date(row.original.updatedAt).toLocaleDateString("ko-KR"),
+ }))
+
+ downloadExcel(data, "contact_possible_items.xlsx")
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleExport}
+ >
+ <Download className="mr-2 h-4 w-4" />
+ Excel 내보내기
+ </Button>
+ </div>
+ )
+}
+
+// Excel 파일 다운로드
+function downloadExcel(data: Array<Record<string, unknown>>, filename: string) {
+ const workbook = new ExcelJS.Workbook()
+ const worksheet = workbook.addWorksheet("Data")
+
+ if (data.length > 0) {
+ // 헤더 추가
+ const headers = Object.keys(data[0])
+ worksheet.addRow(headers)
+
+ // 데이터 추가
+ data.forEach(row => {
+ worksheet.addRow(Object.values(row))
+ })
+
+ // 스타일 적용
+ worksheet.getRow(1).font = { bold: true }
+ worksheet.columns.forEach(column => {
+ column.width = 15
+ })
+ }
+
+ // 파일 다운로드
+ workbook.xlsx.writeBuffer().then(buffer => {
+ const blob = new Blob([buffer], {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
+ })
+ const url = window.URL.createObjectURL(blob)
+ const a = document.createElement("a")
+ a.href = url
+ a.download = filename
+ a.click()
+ window.URL.revokeObjectURL(url)
+ })
+} \ No newline at end of file
diff --git a/lib/contact-possible-items/table/contact-possible-items-table.tsx b/lib/contact-possible-items/table/contact-possible-items-table.tsx
new file mode 100644
index 00000000..3828e26c
--- /dev/null
+++ b/lib/contact-possible-items/table/contact-possible-items-table.tsx
@@ -0,0 +1,102 @@
+"use client"
+
+import React from "react"
+import { DataTable } from "@/components/data-table/data-table"
+import { ContactPossibleItemsTableToolbarActions } from "./contact-possible-items-table-toolbar-actions"
+import { getColumns } from "./contact-possible-items-table-columns"
+import { ContactPossibleItemDetail } from "../service"
+import { DeleteContactPossibleItemsDialog } from "./delete-contact-possible-items-dialog"
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import { type DataTableAdvancedFilterField } from "@/types/table"
+
+// 필터 필드 정의
+const advancedFilterFields: DataTableAdvancedFilterField<ContactPossibleItemDetail>[] = [
+ {
+ id: "contactName",
+ label: "담당자명",
+ type: "text",
+ placeholder: "담당자명으로 검색...",
+ },
+ {
+ id: "vendorName",
+ label: "벤더명",
+ type: "text",
+ placeholder: "벤더명으로 검색...",
+ },
+ {
+ id: "vendorCode",
+ label: "벤더코드",
+ type: "text",
+ placeholder: "벤더코드로 검색...",
+ },
+ {
+ id: "itemCode",
+ label: "아이템코드",
+ type: "text",
+ placeholder: "아이템코드로 검색...",
+ },
+ {
+ id: "workType",
+ label: "공종",
+ type: "text",
+ placeholder: "공종으로 검색...",
+ },
+]
+
+interface ContactPossibleItemsTableProps {
+ contactPossibleItemsPromise: Promise<{
+ data: ContactPossibleItemDetail[]
+ pageCount: number
+ total: number
+ }>
+}
+
+export function ContactPossibleItemsTable({
+ contactPossibleItemsPromise,
+}: ContactPossibleItemsTableProps) {
+ const { data, pageCount, total } = React.use(contactPossibleItemsPromise)
+
+ const [rowAction, setRowAction] = React.useState<any | null>(null)
+
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ rowCount: total,
+ })
+
+ return (
+ <div className="w-full space-y-2.5 overflow-auto">
+ {/* 메인 테이블 */}
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <ContactPossibleItemsTableToolbarActions
+ table={table}
+ />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ <DeleteContactPossibleItemsDialog
+ open={rowAction?.type === "delete"}
+ onOpenChange={() => setRowAction(null)}
+ contactPossibleItems={
+ rowAction?.type === "delete" && rowAction.row
+ ? [rowAction.row.original]
+ : []
+ }
+ showTrigger={false}
+ onSuccess={() => setRowAction(null)}
+ />
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/contact-possible-items/table/delete-contact-possible-items-dialog.tsx b/lib/contact-possible-items/table/delete-contact-possible-items-dialog.tsx
new file mode 100644
index 00000000..7c2fc459
--- /dev/null
+++ b/lib/contact-possible-items/table/delete-contact-possible-items-dialog.tsx
@@ -0,0 +1,111 @@
+"use client"
+
+import * as React from "react"
+import { Loader2, Trash2Icon } from "lucide-react"
+
+import {
+ AlertDialog,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@/components/ui/alert-dialog"
+import { Button } from "@/components/ui/button"
+import { toast } from "@/hooks/use-toast"
+
+import { deleteContactPossibleItems, type ContactPossibleItemDetail } from "../service"
+
+interface DeleteContactPossibleItemsDialogProps {
+ contactPossibleItems: ContactPossibleItemDetail[]
+ showTrigger?: boolean
+ trigger?: React.ReactNode
+ open?: boolean
+ onOpenChange?: (open: boolean) => void
+ onSuccess?: () => void
+}
+
+export function DeleteContactPossibleItemsDialog({
+ contactPossibleItems,
+ showTrigger = true,
+ trigger,
+ open,
+ onOpenChange,
+ onSuccess,
+}: DeleteContactPossibleItemsDialogProps) {
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+
+ function onDelete() {
+ startDeleteTransition(async () => {
+ try {
+ const ids = contactPossibleItems.map((item) => item.id)
+ const result = await deleteContactPossibleItems(ids)
+
+ if (result.success) {
+ toast({
+ title: "성공",
+ description: `${contactPossibleItems.length}개의 담당자별 아이템이 삭제되었습니다.`,
+ })
+ onSuccess?.()
+ onOpenChange?.(false)
+ } else {
+ toast({
+ title: "오류",
+ description: result.error || "담당자별 아이템 삭제에 실패했습니다.",
+ variant: "destructive",
+ })
+ }
+ } catch {
+ toast({
+ title: "오류",
+ description: "담당자별 아이템 삭제 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ }
+ })
+ }
+
+ const isMultiple = contactPossibleItems.length > 1
+
+ return (
+ <AlertDialog open={open} onOpenChange={onOpenChange}>
+ {showTrigger && (
+ <AlertDialogTrigger asChild>
+ {trigger ?? (
+ <Button variant="outline" size="sm">
+ <Trash2Icon className="mr-2 size-4" aria-hidden="true" />
+ 삭제 ({contactPossibleItems.length})
+ </Button>
+ )}
+ </AlertDialogTrigger>
+ )}
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle>
+ {isMultiple
+ ? `${contactPossibleItems.length}개의 담당자별 아이템을 삭제하시겠습니까?`
+ : "담당자별 아이템을 삭제하시겠습니까?"}
+ </AlertDialogTitle>
+ <AlertDialogDescription>
+ 이 작업은 되돌릴 수 없습니다. 선택한 담당자별 아이템{isMultiple ? "들이" : "이"} 영구적으로 삭제됩니다.
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel disabled={isDeletePending}>취소</AlertDialogCancel>
+ <Button
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader2 className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ 삭제
+ </Button>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+ )
+} \ No newline at end of file
diff --git a/lib/contact-possible-items/validations.ts b/lib/contact-possible-items/validations.ts
new file mode 100644
index 00000000..609be0df
--- /dev/null
+++ b/lib/contact-possible-items/validations.ts
@@ -0,0 +1,59 @@
+import { createSearchParamsCache, parseAsInteger, parseAsString } from "nuqs/server"
+import { z } from "zod"
+
+// 검색 파라미터 스키마 (뷰 기반으로 수정)
+export const searchParamsSchema = z.object({
+ page: z.coerce.number().default(1),
+ per_page: z.coerce.number().default(10),
+ sort: z.string().optional(),
+ search: z.string().optional(), // 통합 검색
+ contactName: z.string().optional(),
+ vendorName: z.string().optional(),
+ itemCode: z.string().optional(),
+ vendorCode: z.string().optional(),
+ workType: z.string().optional(),
+ from: z.string().optional(),
+ to: z.string().optional(),
+})
+
+// searchParams 캐시 생성
+export const searchParamsCache = createSearchParamsCache({
+ page: parseAsInteger.withDefault(1),
+ per_page: parseAsInteger.withDefault(10),
+ sort: parseAsString.withDefault(""),
+ search: parseAsString.withDefault(""), // 통합 검색 추가
+ contactName: parseAsString.withDefault(""),
+ vendorName: parseAsString.withDefault(""),
+ itemCode: parseAsString.withDefault(""),
+ vendorCode: parseAsString.withDefault(""),
+ workType: parseAsString.withDefault(""),
+ from: parseAsString.withDefault(""),
+ to: parseAsString.withDefault(""),
+})
+
+export type SearchParamsCache = typeof searchParamsCache
+
+// 담당자별 아이템 생성용 스키마 (FK만 사용)
+export const contactPossibleItemSchema = z.object({
+ contactId: z.number().min(1, "담당자를 선택해주세요"),
+ vendorPossibleItemId: z.number().min(1, "벤더 가능 아이템을 선택해주세요"),
+})
+
+export type ContactPossibleItemSchema = z.infer<typeof contactPossibleItemSchema>
+
+// 조회용 스키마 (searchParamsCache와 일치하도록 수정)
+export const getContactPossibleItemsSchema = z.object({
+ page: z.number().default(1),
+ per_page: z.number().default(10),
+ sort: z.string().optional(),
+ search: z.string().optional(),
+ contactName: z.string().optional(),
+ vendorName: z.string().optional(),
+ itemCode: z.string().optional(),
+ vendorCode: z.string().optional(),
+ workType: z.string().optional(),
+ from: z.string().optional(),
+ to: z.string().optional(),
+})
+
+export type GetContactPossibleItemsSchema = z.infer<typeof getContactPossibleItemsSchema> \ No newline at end of file
diff --git a/lib/items-tech/repository.ts b/lib/items-tech/repository.ts
index 1f4f7933..10ae2dab 100644
--- a/lib/items-tech/repository.ts
+++ b/lib/items-tech/repository.ts
@@ -1,124 +1,124 @@
-// src/lib/items/repository.ts
-import db from "@/db/db";
-import { Item, ItemOffshoreTop, ItemOffshoreHull, itemOffshoreHull, itemOffshoreTop, items } from "@/db/schema/items";
-import {
- eq,
- inArray,
- asc,
- desc,
- count,
-} from "drizzle-orm";
-import { PgTransaction } from "drizzle-orm/pg-core";
-export type NewItem = typeof items.$inferInsert
-
-/**
- * 단건/복수 조회 시 공통으로 사용 가능한 SELECT 함수 예시
- * - 트랜잭션(tx)을 받아서 사용하도록 구현
- */
-export async function selectItems(
- tx: PgTransaction<any, any, any>,
- params: {
- where?: any; // drizzle-orm의 조건식 (and, eq...) 등
- orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[];
- offset?: number;
- limit?: number;
- }
-) {
- const { where, orderBy, offset = 0, limit = 10 } = params;
-
- return tx
- .select()
- .from(items)
- .where(where)
- .orderBy(...(orderBy ?? []))
- .offset(offset)
- .limit(limit);
-}
-/** 총 개수 count */
-export async function countItems(
- tx: PgTransaction<any, any, any>,
- where?: any
-) {
- const res = await tx.select({ count: count() }).from(items).where(where);
- return res[0]?.count ?? 0;
-}
-
-/** 단건 Insert 예시 */
-export async function insertItem(
- tx: PgTransaction<any, any, any>,
- data: NewItem // DB와 동일한 insert 가능한 타입
-) {
- // returning() 사용 시 배열로 돌아오므로 [0]만 리턴
- return tx
- .insert(items)
- .values(data)
- .returning({ id: items.id, createdAt: items.createdAt });
-}
-
-/** 복수 Insert 예시 */
-export async function insertItems(
- tx: PgTransaction<any, any, any>,
- data: Item[]
-) {
- return tx.insert(items).values(data).onConflictDoNothing();
-}
-
-
-
-/** 단건 삭제 */
-export async function deleteItemById(
- tx: PgTransaction<any, any, any>,
- itemId: number
-) {
- return tx.delete(items).where(eq(items.id, itemId));
-}
-
-/** 복수 삭제 */
-export async function deleteItemsByIds(
- tx: PgTransaction<any, any, any>,
- ids: number[]
-) {
- return tx.delete(items).where(inArray(items.id, ids));
-}
-
-/** 전체 삭제 */
-export async function deleteAllItems(
- tx: PgTransaction<any, any, any>,
-) {
- return tx.delete(items);
-}
-
-/** 단건 업데이트 */
-export async function updateItem(
- tx: PgTransaction<any, any, any>,
- itemId: number,
- data: Partial<Item>
-) {
- return tx
- .update(items)
- .set(data)
- .where(eq(items.id, itemId))
- .returning({ id: items.id, createdAt: items.createdAt });
-}
-
-/** 복수 업데이트 */
-export async function updateItems(
- tx: PgTransaction<any, any, any>,
- ids: number[],
- data: Partial<Item>
-) {
- return tx
- .update(items)
- .set(data)
- .where(inArray(items.id, ids))
- .returning({ id: items.id, createdAt: items.createdAt });
-}
-
-export async function findAllItems(): Promise<Item[]> {
- return db.select().from(items).orderBy(asc(items.itemCode));
-}
-export async function findAllOffshoreItems(): Promise<(ItemOffshoreHull | ItemOffshoreTop)[]> {
- const hullItems = await db.select().from(itemOffshoreHull);
- const topItems = await db.select().from(itemOffshoreTop);
- return [...hullItems, ...topItems];
-}
+// src/lib/items/repository.ts
+import db from "@/db/db";
+import { Item, ItemOffshoreTop, ItemOffshoreHull, itemOffshoreHull, itemOffshoreTop, items } from "@/db/schema/items";
+import {
+ eq,
+ inArray,
+ asc,
+ desc,
+ count,
+} from "drizzle-orm";
+import { PgTransaction } from "drizzle-orm/pg-core";
+export type NewItem = typeof items.$inferInsert
+
+/**
+ * 단건/복수 조회 시 공통으로 사용 가능한 SELECT 함수 예시
+ * - 트랜잭션(tx)을 받아서 사용하도록 구현
+ */
+export async function selectItems(
+ tx: PgTransaction<any, any, any>,
+ params: {
+ where?: any; // drizzle-orm의 조건식 (and, eq...) 등
+ orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[];
+ offset?: number;
+ limit?: number;
+ }
+) {
+ const { where, orderBy, offset = 0, limit = 10 } = params;
+
+ return tx
+ .select()
+ .from(items)
+ .where(where)
+ .orderBy(...(orderBy ?? []))
+ .offset(offset)
+ .limit(limit);
+}
+/** 총 개수 count */
+export async function countItems(
+ tx: PgTransaction<any, any, any>,
+ where?: any
+) {
+ const res = await tx.select({ count: count() }).from(items).where(where);
+ return res[0]?.count ?? 0;
+}
+
+/** 단건 Insert 예시 */
+export async function insertItem(
+ tx: PgTransaction<any, any, any>,
+ data: NewItem // DB와 동일한 insert 가능한 타입
+) {
+ // returning() 사용 시 배열로 돌아오므로 [0]만 리턴
+ return tx
+ .insert(items)
+ .values(data)
+ .returning({ id: items.id, createdAt: items.createdAt });
+}
+
+/** 복수 Insert 예시 */
+export async function insertItems(
+ tx: PgTransaction<any, any, any>,
+ data: Item[]
+) {
+ return tx.insert(items).values(data).onConflictDoNothing();
+}
+
+
+
+/** 단건 삭제 */
+export async function deleteItemById(
+ tx: PgTransaction<any, any, any>,
+ itemId: number
+) {
+ return tx.delete(items).where(eq(items.id, itemId));
+}
+
+/** 복수 삭제 */
+export async function deleteItemsByIds(
+ tx: PgTransaction<any, any, any>,
+ ids: number[]
+) {
+ return tx.delete(items).where(inArray(items.id, ids));
+}
+
+/** 전체 삭제 */
+export async function deleteAllItems(
+ tx: PgTransaction<any, any, any>,
+) {
+ return tx.delete(items);
+}
+
+/** 단건 업데이트 */
+export async function updateItem(
+ tx: PgTransaction<any, any, any>,
+ itemId: number,
+ data: Partial<Item>
+) {
+ return tx
+ .update(items)
+ .set(data)
+ .where(eq(items.id, itemId))
+ .returning({ id: items.id, createdAt: items.createdAt });
+}
+
+/** 복수 업데이트 */
+export async function updateItems(
+ tx: PgTransaction<any, any, any>,
+ ids: number[],
+ data: Partial<Item>
+) {
+ return tx
+ .update(items)
+ .set(data)
+ .where(inArray(items.id, ids))
+ .returning({ id: items.id, createdAt: items.createdAt });
+}
+
+export async function findAllItems(): Promise<Item[]> {
+ return db.select().from(items).orderBy(asc(items.itemCode));
+}
+export async function findAllOffshoreItems(): Promise<(ItemOffshoreHull | ItemOffshoreTop)[]> {
+ const hullItems = await db.select().from(itemOffshoreHull);
+ const topItems = await db.select().from(itemOffshoreTop);
+ return [...hullItems, ...topItems];
+}
diff --git a/lib/items-tech/service.ts b/lib/items-tech/service.ts
index bf2684d7..d93c5f96 100644
--- a/lib/items-tech/service.ts
+++ b/lib/items-tech/service.ts
@@ -405,7 +405,14 @@ export async function createShipbuildingItem(input: TypedItemCreateData) {
unstable_noStore()
try {
- // itemCode는 nullable하게 변경
+ if (!input.itemCode) {
+ return {
+ success: false,
+ message: "아이템 코드는 필수입니다",
+ data: null,
+ error: "필수 필드 누락"
+ }
+ }
const shipData = input as ShipbuildingItemCreateData;
const result = await db.insert(itemShipbuilding).values({
@@ -459,7 +466,14 @@ export async function createShipbuildingImportItem(input: {
unstable_noStore();
try {
- // itemCode는 nullable하게 변경
+ if (!input.itemCode) {
+ return {
+ success: false,
+ message: "아이템 코드는 필수입니다",
+ data: null,
+ error: "필수 필드 누락"
+ }
+ }
// 기존 아이템 및 선종 확인 (itemCode가 있을 경우에만)
if (input.itemCode) {
@@ -525,6 +539,14 @@ export async function createOffshoreTopItem(data: OffshoreTopItemCreateData) {
unstable_noStore();
try {
+ if (!data.itemCode) {
+ return {
+ success: false,
+ message: "아이템 코드는 필수입니다",
+ data: null,
+ error: "필수 필드 누락"
+ }
+ }
// itemCode가 있는 경우 중복 체크
if (data.itemCode && data.itemCode.trim() !== "") {
const existingItem = await db
@@ -586,6 +608,14 @@ export async function createOffshoreHullItem(data: OffshoreHullItemCreateData) {
unstable_noStore();
try {
+ if (!data.itemCode) {
+ return {
+ success: false,
+ message: "아이템 코드는 필수입니다",
+ data: null,
+ error: "필수 필드 누락"
+ }
+ }
// itemCode가 있는 경우 중복 체크
if (data.itemCode && data.itemCode.trim() !== "") {
const existingItem = await db
diff --git a/lib/items-tech/table/delete-items-dialog.tsx b/lib/items-tech/table/delete-items-dialog.tsx
index b94a2333..6ec4b4c7 100644
--- a/lib/items-tech/table/delete-items-dialog.tsx
+++ b/lib/items-tech/table/delete-items-dialog.tsx
@@ -1,194 +1,194 @@
-"use client"
-
-import * as React from "react"
-import { type Row } from "@tanstack/react-table"
-import { Loader, Trash } from "lucide-react"
-import { toast } from "sonner"
-
-import { useMediaQuery } from "@/hooks/use-media-query"
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogClose,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog"
-import {
- Drawer,
- DrawerClose,
- DrawerContent,
- DrawerDescription,
- DrawerFooter,
- DrawerHeader,
- DrawerTitle,
- DrawerTrigger,
-} from "@/components/ui/drawer"
-
-import { Item } from "@/db/schema/items"
-import {
- removeShipbuildingItems,
- removeOffshoreTopItems,
- removeOffshoreHullItems
-} from "../service"
-
-export type ItemType = 'shipbuilding' | 'offshoreTop' | 'offshoreHull';
-
-interface DeleteItemsDialogProps
- extends React.ComponentPropsWithoutRef<typeof Dialog> {
- items: Row<Item>["original"][]
- showTrigger?: boolean
- onSuccess?: () => void
- itemType: ItemType
-}
-
-export function DeleteItemsDialog({
- items,
- showTrigger = true,
- onSuccess,
- itemType,
- ...props
-}: DeleteItemsDialogProps) {
- const [isDeletePending, startDeleteTransition] = React.useTransition()
- const isDesktop = useMediaQuery("(min-width: 640px)")
-
- const getItemTypeLabel = () => {
- switch (itemType) {
- case 'shipbuilding':
- return '조선 아이템';
- case 'offshoreTop':
- return '해양 TOP 아이템';
- case 'offshoreHull':
- return '해양 HULL 아이템';
- default:
- return '아이템';
- }
- }
-
- async function onDelete() {
- try {
- startDeleteTransition(async () => {
- let result;
-
- switch (itemType) {
- case 'shipbuilding':
- result = await removeShipbuildingItems({
- ids: items.map((item) => item.id),
- });
- break;
- case 'offshoreTop':
- result = await removeOffshoreTopItems({
- ids: items.map((item) => item.id),
- });
- break;
- case 'offshoreHull':
- result = await removeOffshoreHullItems({
- ids: items.map((item) => item.id),
- });
- break;
- default:
- toast.error("지원하지 않는 아이템 타입입니다");
- return;
- }
-
- if (result.error) {
- toast.error(result.error)
- return
- }
-
- props.onOpenChange?.(false)
- toast.success("아이템 삭제 완료")
- onSuccess?.()
- })
- } catch (error) {
- toast.error("오류가 발생했습니다.")
- console.error(error)
- }
- }
-
- if (isDesktop) {
- return (
- <Dialog {...props}>
- {showTrigger ? (
- <DialogTrigger asChild>
- <Button variant="outline" size="sm">
- <Trash className="mr-2 size-4" aria-hidden="true" />
- 삭제 ({items.length})
- </Button>
- </DialogTrigger>
- ) : null}
- <DialogContent>
- <DialogHeader>
- <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle>
- <DialogDescription>
- 이 작업은 되돌릴 수 없습니다. 선택한{" "}
- <span className="font-medium">{items.length}</span>
- 개의 {getItemTypeLabel()}이(가) 영구적으로 삭제됩니다.
- </DialogDescription>
- </DialogHeader>
- <DialogFooter className="gap-2 sm:space-x-0">
- <DialogClose asChild>
- <Button variant="outline">취소</Button>
- </DialogClose>
- <Button
- aria-label="삭제 확인"
- variant="destructive"
- onClick={onDelete}
- disabled={isDeletePending}
- >
- {isDeletePending && (
- <Loader
- className="mr-2 size-4 animate-spin"
- aria-hidden="true"
- />
- )}
- 삭제
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
- }
-
- return (
- <Drawer {...props}>
- {showTrigger ? (
- <DrawerTrigger asChild>
- <Button variant="outline" size="sm">
- <Trash className="mr-2 size-4" aria-hidden="true" />
- 삭제 ({items.length})
- </Button>
- </DrawerTrigger>
- ) : null}
- <DrawerContent>
- <DrawerHeader>
- <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle>
- <DrawerDescription>
- 이 작업은 되돌릴 수 없습니다. 선택한{" "}
- <span className="font-medium">{items.length}</span>
- 개의 {getItemTypeLabel()}이(가) 영구적으로 삭제됩니다.
- </DrawerDescription>
- </DrawerHeader>
- <DrawerFooter className="gap-2 sm:space-x-0">
- <DrawerClose asChild>
- <Button variant="outline">취소</Button>
- </DrawerClose>
- <Button
- aria-label="삭제 확인"
- variant="destructive"
- onClick={onDelete}
- disabled={isDeletePending}
- >
- {isDeletePending && (
- <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
- )}
- 삭제
- </Button>
- </DrawerFooter>
- </DrawerContent>
- </Drawer>
- )
-}
+"use client"
+
+import * as React from "react"
+import { type Row } from "@tanstack/react-table"
+import { Loader, Trash } from "lucide-react"
+import { toast } from "sonner"
+
+import { useMediaQuery } from "@/hooks/use-media-query"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+
+import { Item } from "@/db/schema/items"
+import {
+ removeShipbuildingItems,
+ removeOffshoreTopItems,
+ removeOffshoreHullItems
+} from "../service"
+
+export type ItemType = 'shipbuilding' | 'offshoreTop' | 'offshoreHull';
+
+interface DeleteItemsDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ items: Row<Item>["original"][]
+ showTrigger?: boolean
+ onSuccess?: () => void
+ itemType: ItemType
+}
+
+export function DeleteItemsDialog({
+ items,
+ showTrigger = true,
+ onSuccess,
+ itemType,
+ ...props
+}: DeleteItemsDialogProps) {
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ const getItemTypeLabel = () => {
+ switch (itemType) {
+ case 'shipbuilding':
+ return '조선 아이템';
+ case 'offshoreTop':
+ return '해양 TOP 아이템';
+ case 'offshoreHull':
+ return '해양 HULL 아이템';
+ default:
+ return '아이템';
+ }
+ }
+
+ async function onDelete() {
+ try {
+ startDeleteTransition(async () => {
+ let result;
+
+ switch (itemType) {
+ case 'shipbuilding':
+ result = await removeShipbuildingItems({
+ ids: items.map((item) => item.id),
+ });
+ break;
+ case 'offshoreTop':
+ result = await removeOffshoreTopItems({
+ ids: items.map((item) => item.id),
+ });
+ break;
+ case 'offshoreHull':
+ result = await removeOffshoreHullItems({
+ ids: items.map((item) => item.id),
+ });
+ break;
+ default:
+ toast.error("지원하지 않는 아이템 타입입니다");
+ return;
+ }
+
+ if (result.error) {
+ toast.error(result.error)
+ return
+ }
+
+ props.onOpenChange?.(false)
+ toast.success("아이템 삭제 완료")
+ onSuccess?.()
+ })
+ } catch (error) {
+ toast.error("오류가 발생했습니다.")
+ console.error(error)
+ }
+ }
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ 삭제 ({items.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle>
+ <DialogDescription>
+ 이 작업은 되돌릴 수 없습니다. 선택한{" "}
+ <span className="font-medium">{items.length}</span>
+ 개의 {getItemTypeLabel()}이(가) 영구적으로 삭제됩니다.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">취소</Button>
+ </DialogClose>
+ <Button
+ aria-label="삭제 확인"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 삭제
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ 삭제 ({items.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle>
+ <DrawerDescription>
+ 이 작업은 되돌릴 수 없습니다. 선택한{" "}
+ <span className="font-medium">{items.length}</span>
+ 개의 {getItemTypeLabel()}이(가) 영구적으로 삭제됩니다.
+ </DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">취소</Button>
+ </DrawerClose>
+ <Button
+ aria-label="삭제 확인"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ 삭제
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+}
diff --git a/lib/items-tech/table/feature-flags.tsx b/lib/items-tech/table/feature-flags.tsx
index aaae6af2..cc5093ca 100644
--- a/lib/items-tech/table/feature-flags.tsx
+++ b/lib/items-tech/table/feature-flags.tsx
@@ -1,96 +1,96 @@
-"use client"
-
-import * as React from "react"
-import { useQueryState } from "nuqs"
-
-import { dataTableConfig, type DataTableConfig } from "@/config/data-table"
-import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/ui/tooltip"
-
-type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
-
-interface TasksTableContextProps {
- featureFlags: FeatureFlagValue[]
- setFeatureFlags: (value: FeatureFlagValue[]) => void
-}
-
-const TasksTableContext = React.createContext<TasksTableContextProps>({
- featureFlags: [],
- setFeatureFlags: () => {},
-})
-
-export function useTasksTable() {
- const context = React.useContext(TasksTableContext)
- if (!context) {
- throw new Error("useTasksTable must be used within a TasksTableProvider")
- }
- return context
-}
-
-export function TasksTableProvider({ children }: React.PropsWithChildren) {
- const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
- "featureFlags",
- {
- defaultValue: [],
- parse: (value) => value.split(",") as FeatureFlagValue[],
- serialize: (value) => value.join(","),
- eq: (a, b) =>
- a.length === b.length && a.every((value, index) => value === b[index]),
- clearOnDefault: true,
- }
- )
-
- return (
- <TasksTableContext.Provider
- value={{
- featureFlags,
- setFeatureFlags: (value) => void setFeatureFlags(value),
- }}
- >
- <div className="w-full overflow-x-auto">
- <ToggleGroup
- type="multiple"
- variant="outline"
- size="sm"
- value={featureFlags}
- onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
- className="w-fit"
- >
- {dataTableConfig.featureFlags.map((flag) => (
- <Tooltip key={flag.value}>
- <ToggleGroupItem
- value={flag.value}
- className="whitespace-nowrap px-3 text-xs"
- asChild
- >
- <TooltipTrigger>
- <flag.icon
- className="mr-2 size-3.5 shrink-0"
- aria-hidden="true"
- />
- {flag.label}
- </TooltipTrigger>
- </ToggleGroupItem>
- <TooltipContent
- align="start"
- side="bottom"
- sideOffset={6}
- className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
- >
- <div>{flag.tooltipTitle}</div>
- <div className="text-xs text-muted-foreground">
- {flag.tooltipDescription}
- </div>
- </TooltipContent>
- </Tooltip>
- ))}
- </ToggleGroup>
- </div>
- {children}
- </TasksTableContext.Provider>
- )
-}
+"use client"
+
+import * as React from "react"
+import { useQueryState } from "nuqs"
+
+import { dataTableConfig, type DataTableConfig } from "@/config/data-table"
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
+
+interface TasksTableContextProps {
+ featureFlags: FeatureFlagValue[]
+ setFeatureFlags: (value: FeatureFlagValue[]) => void
+}
+
+const TasksTableContext = React.createContext<TasksTableContextProps>({
+ featureFlags: [],
+ setFeatureFlags: () => {},
+})
+
+export function useTasksTable() {
+ const context = React.useContext(TasksTableContext)
+ if (!context) {
+ throw new Error("useTasksTable must be used within a TasksTableProvider")
+ }
+ return context
+}
+
+export function TasksTableProvider({ children }: React.PropsWithChildren) {
+ const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
+ "featureFlags",
+ {
+ defaultValue: [],
+ parse: (value) => value.split(",") as FeatureFlagValue[],
+ serialize: (value) => value.join(","),
+ eq: (a, b) =>
+ a.length === b.length && a.every((value, index) => value === b[index]),
+ clearOnDefault: true,
+ }
+ )
+
+ return (
+ <TasksTableContext.Provider
+ value={{
+ featureFlags,
+ setFeatureFlags: (value) => void setFeatureFlags(value),
+ }}
+ >
+ <div className="w-full overflow-x-auto">
+ <ToggleGroup
+ type="multiple"
+ variant="outline"
+ size="sm"
+ value={featureFlags}
+ onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
+ className="w-fit"
+ >
+ {dataTableConfig.featureFlags.map((flag) => (
+ <Tooltip key={flag.value}>
+ <ToggleGroupItem
+ value={flag.value}
+ className="whitespace-nowrap px-3 text-xs"
+ asChild
+ >
+ <TooltipTrigger>
+ <flag.icon
+ className="mr-2 size-3.5 shrink-0"
+ aria-hidden="true"
+ />
+ {flag.label}
+ </TooltipTrigger>
+ </ToggleGroupItem>
+ <TooltipContent
+ align="start"
+ side="bottom"
+ sideOffset={6}
+ className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
+ >
+ <div>{flag.tooltipTitle}</div>
+ <div className="text-xs text-muted-foreground">
+ {flag.tooltipDescription}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ ))}
+ </ToggleGroup>
+ </div>
+ {children}
+ </TasksTableContext.Provider>
+ )
+}
diff --git a/lib/items-tech/table/hull/import-item-handler.tsx b/lib/items-tech/table/hull/import-item-handler.tsx
index 8c8fc57d..9090dab1 100644
--- a/lib/items-tech/table/hull/import-item-handler.tsx
+++ b/lib/items-tech/table/hull/import-item-handler.tsx
@@ -1,127 +1,127 @@
-"use client"
-
-import { z } from "zod"
-import { createOffshoreHullItem } from "../../service"
-
-// 해양 HULL 기능(공종) 유형 enum
-const HULL_WORK_TYPES = ["HA", "HE", "HH", "HM", "HO", "HP", "NC"] as const;
-
-// 아이템 데이터 검증을 위한 Zod 스키마
-const itemSchema = z.object({
- itemCode: z.string().optional(),
- workType: z.enum(HULL_WORK_TYPES, {
- required_error: "기능(공종)은 필수입니다",
- }),
- itemList: z.string().nullable().optional(),
- subItemList: z.string().nullable().optional(),
-});
-
-interface ProcessResult {
- successCount: number;
- errorCount: number;
- errors: Array<{ row: number; message: string; itemCode?: string; workType?: string }>;
-}
-
-/**
- * Excel 파일에서 가져온 해양 HULL 아이템 데이터 처리하는 함수
- */
-export async function processHullFileImport(
- jsonData: Record<string, unknown>[],
- progressCallback?: (current: number, total: number) => void
-): Promise<ProcessResult> {
- // 결과 카운터 초기화
- let successCount = 0;
- let errorCount = 0;
- const errors: Array<{ row: number; message: string }> = [];
-
- // 빈 행 등 필터링
- const dataRows = jsonData.filter(row => {
- // 빈 행 건너뛰기
- if (Object.values(row).every(val => !val)) {
- return false;
- }
- return true;
- });
-
- // 데이터 행이 없으면 빈 결과 반환
- if (dataRows.length === 0) {
- return { successCount: 0, errorCount: 0, errors: [] };
- }
-
- // 각 행에 대해 처리
- for (let i = 0; i < dataRows.length; i++) {
- const row = dataRows[i];
- const rowIndex = i + 1; // 사용자에게 표시할 행 번호는 1부터 시작
-
- // 진행 상황 콜백 호출
- if (progressCallback) {
- progressCallback(i + 1, dataRows.length);
- }
-
- try {
- // 필드 매핑 (한글/영문 필드명 모두 지원)
- const itemCode = row["자재 그룹"] || row["itemCode"] || "";
- const workType = row["기능(공종)"] || row["workType"] || "";
- const itemList = row["자재명"] || row["itemList"] || null;
- const subItemList = row["자재명(상세)"] || row["subItemList"] || null;
-
- // 데이터 정제
- const cleanedRow = {
- itemCode: typeof itemCode === 'string' ? itemCode.trim() : String(itemCode).trim(),
- workType: typeof workType === 'string' ? workType.trim() : String(workType).trim(),
- itemList: itemList ? (typeof itemList === 'string' ? itemList : String(itemList)) : null,
- subItemList: subItemList ? (typeof subItemList === 'string' ? subItemList : String(subItemList)) : null,
- };
-
- // 데이터 유효성 검사
- const validationResult = itemSchema.safeParse(cleanedRow);
-
- if (!validationResult.success) {
- const errorMessage = validationResult.error.errors.map(
- err => `${err.path.join('.')}: ${err.message}`
- ).join(', ');
-
- errors.push({ row: rowIndex, message: errorMessage });
- errorCount++;
- continue;
- }
-
- // 해양 HULL 아이템 생성
- const result = await createOffshoreHullItem({
- itemCode: cleanedRow.itemCode,
- workType: cleanedRow.workType as "HA" | "HE" | "HH" | "HM" | "HO" | "HP" | "NC",
- itemList: cleanedRow.itemList,
- subItemList: cleanedRow.subItemList,
- });
-
- if (result.success) {
- successCount++;
- } else {
- errors.push({
- row: rowIndex,
- message: result.message || result.error || "알 수 없는 오류"
- });
- errorCount++;
- }
- } catch (error) {
- console.error(`${rowIndex}행 처리 오류:`, error);
- errors.push({
- row: rowIndex,
- message: error instanceof Error ? error.message : "알 수 없는 오류"
- });
- errorCount++;
- }
-
- // 비동기 작업 쓰로틀링
- if (i % 5 === 0) {
- await new Promise(resolve => setTimeout(resolve, 10));
- }
- }
-
- // 처리 결과 반환
- return {
- successCount,
- errorCount,
- errors: errors.length > 0 ? errors : undefined
- };
-}
+"use client"
+
+import { z } from "zod"
+import { createOffshoreHullItem } from "../../service"
+
+// 해양 HULL 기능(공종) 유형 enum
+const HULL_WORK_TYPES = ["HA", "HE", "HH", "HM", "HO", "HP", "NC"] as const;
+
+// 아이템 데이터 검증을 위한 Zod 스키마
+const itemSchema = z.object({
+ itemCode: z.string().optional(),
+ workType: z.enum(HULL_WORK_TYPES, {
+ required_error: "기능(공종)은 필수입니다",
+ }),
+ itemList: z.string().nullable().optional(),
+ subItemList: z.string().nullable().optional(),
+});
+
+interface ProcessResult {
+ successCount: number;
+ errorCount: number;
+ errors: Array<{ row: number; message: string }>;
+}
+
+/**
+ * Excel 파일에서 가져온 해양 HULL 아이템 데이터 처리하는 함수
+ */
+export async function processHullFileImport(
+ jsonData: Record<string, unknown>[],
+ progressCallback?: (current: number, total: number) => void
+): Promise<ProcessResult> {
+ // 결과 카운터 초기화
+ let successCount = 0;
+ let errorCount = 0;
+ const errors: Array<{ row: number; message: string }> = [];
+
+ // 빈 행 등 필터링
+ const dataRows = jsonData.filter(row => {
+ // 빈 행 건너뛰기
+ if (Object.values(row).every(val => !val)) {
+ return false;
+ }
+ return true;
+ });
+
+ // 데이터 행이 없으면 빈 결과 반환
+ if (dataRows.length === 0) {
+ return { successCount: 0, errorCount: 0, errors: [] };
+ }
+
+ // 각 행에 대해 처리
+ for (let i = 0; i < dataRows.length; i++) {
+ const row = dataRows[i];
+ const rowIndex = i + 1; // 사용자에게 표시할 행 번호는 1부터 시작
+
+ // 진행 상황 콜백 호출
+ if (progressCallback) {
+ progressCallback(i + 1, dataRows.length);
+ }
+
+ try {
+ // 필드 매핑 (한글/영문 필드명 모두 지원)
+ const itemCode = row["자재 그룹"] || row["itemCode"] || "";
+ const workType = row["기능(공종)"] || row["workType"] || "";
+ const itemList = row["자재명"] || row["itemList"] || null;
+ const subItemList = row["자재명(상세)"] || row["subItemList"] || null;
+
+ // 데이터 정제
+ const cleanedRow = {
+ itemCode: typeof itemCode === 'string' ? itemCode.trim() : String(itemCode).trim(),
+ workType: typeof workType === 'string' ? workType.trim() : String(workType).trim(),
+ itemList: itemList ? (typeof itemList === 'string' ? itemList : String(itemList)) : null,
+ subItemList: subItemList ? (typeof subItemList === 'string' ? subItemList : String(subItemList)) : null,
+ };
+
+ // 데이터 유효성 검사
+ const validationResult = itemSchema.safeParse(cleanedRow);
+
+ if (!validationResult.success) {
+ const errorMessage = validationResult.error.errors.map(
+ err => `${err.path.join('.')}: ${err.message}`
+ ).join(', ');
+
+ errors.push({ row: rowIndex, message: errorMessage });
+ errorCount++;
+ continue;
+ }
+
+ // 해양 HULL 아이템 생성
+ const result = await createOffshoreHullItem({
+ itemCode: cleanedRow.itemCode,
+ workType: cleanedRow.workType as "HA" | "HE" | "HH" | "HM" | "HO" | "HP" | "NC",
+ itemList: cleanedRow.itemList,
+ subItemList: cleanedRow.subItemList,
+ });
+
+ if (result.success) {
+ successCount++;
+ } else {
+ errors.push({
+ row: rowIndex,
+ message: result.message || result.error || "알 수 없는 오류"
+ });
+ errorCount++;
+ }
+ } catch (error) {
+ console.error(`${rowIndex}행 처리 오류:`, error);
+ errors.push({
+ row: rowIndex,
+ message: error instanceof Error ? error.message : "알 수 없는 오류"
+ });
+ errorCount++;
+ }
+
+ // 비동기 작업 쓰로틀링
+ if (i % 5 === 0) {
+ await new Promise(resolve => setTimeout(resolve, 10));
+ }
+ }
+
+ // 처리 결과 반환
+ return {
+ successCount,
+ errorCount,
+ errors: errors.length > 0 ? errors : []
+ };
+}
diff --git a/lib/items-tech/table/hull/item-excel-template.tsx b/lib/items-tech/table/hull/item-excel-template.tsx
index 79512b9b..2e5196e1 100644
--- a/lib/items-tech/table/hull/item-excel-template.tsx
+++ b/lib/items-tech/table/hull/item-excel-template.tsx
@@ -1,105 +1,105 @@
-import * as ExcelJS from 'exceljs';
-import { saveAs } from "file-saver";
-
-/**
- * 해양 HULL 아이템 데이터 가져오기를 위한 Excel 템플릿 파일 생성 및 다운로드
- */
-export async function exportHullItemTemplate() {
- // 워크북 생성
- const workbook = new ExcelJS.Workbook();
- workbook.creator = 'Offshore HULL Item Management System';
- workbook.created = new Date();
-
- // 워크시트 생성
- const worksheet = workbook.addWorksheet('해양 HULL 아이템');
-
- // 컬럼 헤더 정의 및 스타일 적용
- worksheet.columns = [
- { header: '자재 그룹', key: 'itemCode', width: 15 },
- { header: '기능(공종)', key: 'workType', width: 15 },
- { header: '자재명', key: 'itemList', width: 20 },
- { header: '자재명(상세)', key: 'subItemList', width: 20 },
- ];
-
- // 헤더 스타일 적용
- const headerRow = worksheet.getRow(1);
- headerRow.font = { bold: true };
- headerRow.fill = {
- type: 'pattern',
- pattern: 'solid',
- fgColor: { argb: 'FFE0E0E0' }
- };
- headerRow.alignment = { vertical: 'middle', horizontal: 'center' };
-
- // 테두리 스타일 적용
- headerRow.eachCell((cell) => {
- cell.border = {
- top: { style: 'thin' },
- left: { style: 'thin' },
- bottom: { style: 'thin' },
- right: { style: 'thin' }
- };
- });
-
- // 샘플 데이터 추가
- const sampleData = [
- {
- itemCode: 'HULL001',
- workType: 'HA',
- itemList: '항목1 샘플 데이터',
- subItemList: '항목2 샘플 데이터',
- },
- {
- itemCode: 'HULL002',
- workType: 'HE',
- itemList: '항목1 샘플 데이터',
- subItemList: '항목2 샘플 데이터',
- }
- ];
-
- // 데이터 행 추가
- sampleData.forEach(item => {
- worksheet.addRow(item);
- });
-
- // 데이터 행 스타일 적용
- worksheet.eachRow((row, rowNumber) => {
- if (rowNumber > 1) { // 헤더를 제외한 데이터 행
- row.eachCell((cell) => {
- cell.border = {
- top: { style: 'thin' },
- left: { style: 'thin' },
- bottom: { style: 'thin' },
- right: { style: 'thin' }
- };
- });
- }
- });
-
- // 워크시트 보호 (선택적)
- worksheet.protect('', {
- selectLockedCells: true,
- selectUnlockedCells: true,
- formatColumns: true,
- formatRows: true,
- insertColumns: false,
- insertRows: true,
- insertHyperlinks: false,
- deleteColumns: false,
- deleteRows: true,
- sort: true,
- autoFilter: true,
- pivotTables: false
- });
-
- try {
- // 워크북을 Blob으로 변환
- const buffer = await workbook.xlsx.writeBuffer();
- const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
- saveAs(blob, 'offshore-hull-item-template.xlsx');
- return true;
- } catch (error) {
- console.error('Excel 템플릿 생성 오류:', error);
- throw error;
- }
-}
+import * as ExcelJS from 'exceljs';
+import { saveAs } from "file-saver";
+
+/**
+ * 해양 HULL 아이템 데이터 가져오기를 위한 Excel 템플릿 파일 생성 및 다운로드
+ */
+export async function exportHullItemTemplate() {
+ // 워크북 생성
+ const workbook = new ExcelJS.Workbook();
+ workbook.creator = 'Offshore HULL Item Management System';
+ workbook.created = new Date();
+
+ // 워크시트 생성
+ const worksheet = workbook.addWorksheet('해양 HULL 아이템');
+
+ // 컬럼 헤더 정의 및 스타일 적용
+ worksheet.columns = [
+ { header: '자재 그룹', key: 'itemCode', width: 15 },
+ { header: '기능(공종)', key: 'workType', width: 15 },
+ { header: '자재명', key: 'itemList', width: 20 },
+ { header: '자재명(상세)', key: 'subItemList', width: 20 },
+ ];
+
+ // 헤더 스타일 적용
+ const headerRow = worksheet.getRow(1);
+ headerRow.font = { bold: true };
+ headerRow.fill = {
+ type: 'pattern',
+ pattern: 'solid',
+ fgColor: { argb: 'FFE0E0E0' }
+ };
+ headerRow.alignment = { vertical: 'middle', horizontal: 'center' };
+
+ // 테두리 스타일 적용
+ headerRow.eachCell((cell) => {
+ cell.border = {
+ top: { style: 'thin' },
+ left: { style: 'thin' },
+ bottom: { style: 'thin' },
+ right: { style: 'thin' }
+ };
+ });
+
+ // 샘플 데이터 추가
+ const sampleData = [
+ {
+ itemCode: 'HULL001',
+ workType: 'HA',
+ itemList: '항목1 샘플 데이터',
+ subItemList: '항목2 샘플 데이터',
+ },
+ {
+ itemCode: 'HULL002',
+ workType: 'HE',
+ itemList: '항목1 샘플 데이터',
+ subItemList: '항목2 샘플 데이터',
+ }
+ ];
+
+ // 데이터 행 추가
+ sampleData.forEach(item => {
+ worksheet.addRow(item);
+ });
+
+ // 데이터 행 스타일 적용
+ worksheet.eachRow((row, rowNumber) => {
+ if (rowNumber > 1) { // 헤더를 제외한 데이터 행
+ row.eachCell((cell) => {
+ cell.border = {
+ top: { style: 'thin' },
+ left: { style: 'thin' },
+ bottom: { style: 'thin' },
+ right: { style: 'thin' }
+ };
+ });
+ }
+ });
+
+ // 워크시트 보호 (선택적)
+ worksheet.protect('', {
+ selectLockedCells: true,
+ selectUnlockedCells: true,
+ formatColumns: true,
+ formatRows: true,
+ insertColumns: false,
+ insertRows: true,
+ insertHyperlinks: false,
+ deleteColumns: false,
+ deleteRows: true,
+ sort: true,
+ autoFilter: true,
+ pivotTables: false
+ });
+
+ try {
+ // 워크북을 Blob으로 변환
+ const buffer = await workbook.xlsx.writeBuffer();
+ const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
+ saveAs(blob, 'offshore-hull-item-template.xlsx');
+ return true;
+ } catch (error) {
+ console.error('Excel 템플릿 생성 오류:', error);
+ throw error;
+ }
+}
diff --git a/lib/items-tech/table/import-excel-button.tsx b/lib/items-tech/table/import-excel-button.tsx
index 4565c365..f8ba9f6d 100644
--- a/lib/items-tech/table/import-excel-button.tsx
+++ b/lib/items-tech/table/import-excel-button.tsx
@@ -1,304 +1,304 @@
-"use client"
-
-import * as React from "react"
-import { Upload } from "lucide-react"
-import { toast } from "sonner"
-import * as ExcelJS from 'exceljs'
-
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
-import { Progress } from "@/components/ui/progress"
-import { processFileImport } from "./ship/import-item-handler"
-import { processTopFileImport } from "./top/import-item-handler"
-import { processHullFileImport } from "./hull/import-item-handler"
-import { decryptWithServerAction } from "@/components/drm/drmUtils"
-
-
-// 선박 아이템 타입
-type ItemType = "ship" | "top" | "hull";
-
-const ITEM_TYPE_NAMES = {
- ship: "조선 아이템",
- top: "해양 TOP 아이템",
- hull: "해양 HULL 아이템",
-};
-
-interface ImportItemButtonProps {
- itemType: ItemType;
- onSuccess?: () => void;
-}
-
-export function ImportItemButton({ itemType, onSuccess }: ImportItemButtonProps) {
- const [open, setOpen] = React.useState(false);
- const [file, setFile] = React.useState<File | null>(null);
- const [isUploading, setIsUploading] = React.useState(false);
- const [progress, setProgress] = React.useState(0);
- const [error, setError] = React.useState<string | null>(null);
-
- const fileInputRef = React.useRef<HTMLInputElement>(null);
-
- // 파일 선택 처리
- const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
- const selectedFile = e.target.files?.[0];
- if (!selectedFile) return;
-
- if (!selectedFile.name.endsWith('.xlsx') && !selectedFile.name.endsWith('.xls')) {
- setError("Excel 파일(.xlsx 또는 .xls)만 가능합니다.");
- return;
- }
-
- setFile(selectedFile);
- setError(null);
- };
-
-
- // 데이터 가져오기 처리
- const handleImport = async () => {
- if (!file) {
- setError("가져올 파일을 선택해주세요.");
- return;
- }
-
- try {
- setIsUploading(true);
- setProgress(0);
- setError(null);
-
- // DRM 복호화 처리 - 서버 액션 직접 호출
- let arrayBuffer: ArrayBuffer;
- try {
- setProgress(10);
- toast.info("파일 복호화 중...");
- arrayBuffer = await decryptWithServerAction(file);
- setProgress(30);
- } catch (decryptError) {
- console.error("파일 복호화 실패, 원본 파일 사용:", decryptError);
- toast.warning("파일 복호화에 실패하여 원본 파일을 사용합니다.");
- // 복호화 실패 시 원본 파일 사용
- arrayBuffer = await file.arrayBuffer();
- }
- // ExcelJS 워크북 로드
- const workbook = new ExcelJS.Workbook();
- await workbook.xlsx.load(arrayBuffer);
-
- // 첫 번째 워크시트 가져오기
- const worksheet = workbook.worksheets[0];
- if (!worksheet) {
- throw new Error("Excel 파일에 워크시트가 없습니다.");
- }
- // 헤더 행 찾기
- let headerRowIndex = 1;
- let headerRow: ExcelJS.Row | undefined;
- let headerValues: (string | null)[] = [];
-
-
- worksheet.eachRow((row, rowNumber) => {
- const values = row.values as (string | null)[];
- if (!headerRow && values.some(v => v === "자재 그룹" || v === "자재 코드" || v === "itemCode" || v === "item_code")) {
- headerRowIndex = rowNumber;
- headerRow = row;
- headerValues = [...values];
- }
- });
-
- if (!headerRow) {
- throw new Error("Excel 파일에서 헤더 행을 찾을 수 없습니다.");
- }
-
- // 헤더를 기반으로 인덱스 매핑 생성
- const headerMapping: Record<string, number> = {};
- headerValues.forEach((value, index) => {
- if (typeof value === 'string') {
- headerMapping[value] = index;
- }
- });
-
- // 필수 헤더 확인 (타입별 구분)
- const requiredHeaders: string[] = ["자재 그룹", "기능(공종)"];
-
- const alternativeHeaders = {
- "자재 그룹": ["itemCode", "item_code"],
- "기능(공종)": ["workType"],
- "자재명": ["itemList"],
- "자재명(상세)": ["subItemList"]
- };
-
- // 헤더 매핑 확인 (대체 이름 포함)
- const missingHeaders = requiredHeaders.filter(header => {
- const alternatives = alternativeHeaders[header as keyof typeof alternativeHeaders] || [];
- return !(header in headerMapping) &&
- !alternatives.some(alt => alt in headerMapping);
- });
-
- if (missingHeaders.length > 0) {
- throw new Error(`다음 필수 헤더가 누락되었습니다: ${missingHeaders.join(", ")}`);
- }
-
- // 데이터 행 추출 (헤더 이후 행부터)
- const dataRows: Record<string, any>[] = [];
-
- worksheet.eachRow((row, rowNumber) => {
- if (rowNumber > headerRowIndex) {
- const rowData: Record<string, any> = {};
- const values = row.values as (string | null | undefined)[];
-
- // 헤더 매핑에 따라 데이터 추출
- Object.entries(headerMapping).forEach(([header, index]) => {
- rowData[header] = values[index] || "";
- });
-
- // 빈 행이 아닌 경우만 추가
- if (Object.values(rowData).some(value => value && value.toString().trim() !== "")) {
- dataRows.push(rowData);
- }
- }
- });
-
- if (dataRows.length === 0) {
- throw new Error("Excel 파일에 가져올 데이터가 없습니다.");
- }
-
- // 진행 상황 업데이트를 위한 콜백
- const updateProgress = (current: number, total: number) => {
- const percentage = Math.round((current / total) * 100);
- setProgress(percentage);
- };
-
- // 선택된 타입에 따라 적절한 프로세스 함수 호출
- let result: { successCount: number; errorCount: number; errors?: Array<{ row: number; message: string; itemCode?: string; workType?: string }> };
- if (itemType === "top") {
- result = await processTopFileImport(dataRows, updateProgress);
- } else if (itemType === "hull") {
- result = await processHullFileImport(dataRows, updateProgress);
- } else {
- result = await processFileImport(dataRows, updateProgress);
- }
-
- toast.success(`${result.successCount}개의 ${ITEM_TYPE_NAMES[itemType]}이(가) 성공적으로 가져와졌습니다.`);
-
- if (result.errorCount > 0) {
- const errorDetails = result.errors?.map((error: { row: number; message: string; itemCode?: string; workType?: string }) =>
- `행 ${error.row}: ${error.itemCode || '알 수 없음'} (${error.workType || '알 수 없음'}) - ${error.message}`
- ).join('\n') || '오류 정보를 가져올 수 없습니다.';
-
- console.error('Import 오류 상세:', errorDetails);
- toast.error(`${result.errorCount}개의 항목 처리 실패. 콘솔에서 상세 내용을 확인하세요.`);
- }
-
- // 상태 초기화 및 다이얼로그 닫기
- setFile(null);
- setOpen(false);
-
- // 성공 콜백 호출
- if (onSuccess) {
- onSuccess();
- }
- } catch (error) {
- console.error("Excel 파일 처리 중 오류 발생:", error);
- setError(error instanceof Error ? error.message : "파일 처리 중 오류가 발생했습니다.");
- } finally {
- setIsUploading(false);
- }
- };
-
-
-
- // 다이얼로그 열기/닫기 핸들러
- const handleOpenChange = (newOpen: boolean) => {
- if (!newOpen) {
- // 닫을 때 상태 초기화
- setFile(null);
- setError(null);
- setProgress(0);
- if (fileInputRef.current) {
- fileInputRef.current.value = "";
- }
- }
- setOpen(newOpen);
- };
-
- return (
- <>
- <Button
- variant="outline"
- size="sm"
- className="gap-2"
- onClick={() => setOpen(true)}
- disabled={isUploading}
- >
- <Upload className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">Import</span>
- </Button>
-
- <Dialog open={open} onOpenChange={handleOpenChange}>
- <DialogContent className="sm:max-w-[500px]">
- <DialogHeader>
- <DialogTitle>{ITEM_TYPE_NAMES[itemType]} 가져오기</DialogTitle>
- <DialogDescription>
- {ITEM_TYPE_NAMES[itemType]}을 Excel 파일에서 가져옵니다.
- <br />
- 올바른 형식의 Excel 파일(.xlsx)을 업로드하세요.
- </DialogDescription>
- </DialogHeader>
-
- <div className="space-y-4 py-4">
- <div className="flex items-center gap-4">
- <input
- type="file"
- ref={fileInputRef}
- className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-foreground file:font-medium"
- accept=".xlsx,.xls"
- onChange={handleFileChange}
- disabled={isUploading}
- />
- </div>
-
- {file && (
- <div className="text-sm text-muted-foreground">
- 선택된 파일: <span className="font-medium">{file.name}</span> ({(file.size / 1024).toFixed(1)} KB)
- </div>
- )}
-
- {isUploading && (
- <div className="space-y-2">
- <Progress value={progress} />
- <p className="text-sm text-muted-foreground text-center">
- {progress}% 완료
- </p>
- </div>
- )}
-
- {error && (
- <div className="text-sm font-medium text-destructive">
- {error}
- </div>
- )}
- </div>
-
- <DialogFooter>
- <Button
- variant="outline"
- onClick={() => setOpen(false)}
- disabled={isUploading}
- >
- 취소
- </Button>
- <Button
- onClick={handleImport}
- disabled={!file || isUploading}
- >
- {isUploading ? "처리 중..." : "가져오기"}
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- </>
- );
+"use client"
+
+import * as React from "react"
+import { Upload } from "lucide-react"
+import { toast } from "sonner"
+import * as ExcelJS from 'exceljs'
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Progress } from "@/components/ui/progress"
+import { processFileImport } from "./ship/import-item-handler"
+import { processTopFileImport } from "./top/import-item-handler"
+import { processHullFileImport } from "./hull/import-item-handler"
+import { decryptWithServerAction } from "@/components/drm/drmUtils"
+
+
+// 선박 아이템 타입
+type ItemType = "ship" | "top" | "hull";
+
+const ITEM_TYPE_NAMES = {
+ ship: "조선 아이템",
+ top: "해양 TOP 아이템",
+ hull: "해양 HULL 아이템",
+};
+
+interface ImportItemButtonProps {
+ itemType: ItemType;
+ onSuccess?: () => void;
+}
+
+export function ImportItemButton({ itemType, onSuccess }: ImportItemButtonProps) {
+ const [open, setOpen] = React.useState(false);
+ const [file, setFile] = React.useState<File | null>(null);
+ const [isUploading, setIsUploading] = React.useState(false);
+ const [progress, setProgress] = React.useState(0);
+ const [error, setError] = React.useState<string | null>(null);
+
+ const fileInputRef = React.useRef<HTMLInputElement>(null);
+
+ // 파일 선택 처리
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ const selectedFile = e.target.files?.[0];
+ if (!selectedFile) return;
+
+ if (!selectedFile.name.endsWith('.xlsx') && !selectedFile.name.endsWith('.xls')) {
+ setError("Excel 파일(.xlsx 또는 .xls)만 가능합니다.");
+ return;
+ }
+
+ setFile(selectedFile);
+ setError(null);
+ };
+
+
+ // 데이터 가져오기 처리
+ const handleImport = async () => {
+ if (!file) {
+ setError("가져올 파일을 선택해주세요.");
+ return;
+ }
+
+ try {
+ setIsUploading(true);
+ setProgress(0);
+ setError(null);
+
+ // DRM 복호화 처리 - 서버 액션 직접 호출
+ let arrayBuffer: ArrayBuffer;
+ try {
+ setProgress(10);
+ toast.info("파일 복호화 중...");
+ arrayBuffer = await decryptWithServerAction(file);
+ setProgress(30);
+ } catch (decryptError) {
+ console.error("파일 복호화 실패, 원본 파일 사용:", decryptError);
+ toast.warning("파일 복호화에 실패하여 원본 파일을 사용합니다.");
+ // 복호화 실패 시 원본 파일 사용
+ arrayBuffer = await file.arrayBuffer();
+ }
+ // ExcelJS 워크북 로드
+ const workbook = new ExcelJS.Workbook();
+ await workbook.xlsx.load(arrayBuffer);
+
+ // 첫 번째 워크시트 가져오기
+ const worksheet = workbook.worksheets[0];
+ if (!worksheet) {
+ throw new Error("Excel 파일에 워크시트가 없습니다.");
+ }
+ // 헤더 행 찾기
+ let headerRowIndex = 1;
+ let headerRow: ExcelJS.Row | undefined;
+ let headerValues: (string | null)[] = [];
+
+
+ worksheet.eachRow((row, rowNumber) => {
+ const values = row.values as (string | null)[];
+ if (!headerRow && values.some(v => v === "자재 그룹" || v === "자재 코드" || v === "itemCode" || v === "item_code")) {
+ headerRowIndex = rowNumber;
+ headerRow = row;
+ headerValues = [...values];
+ }
+ });
+
+ if (!headerRow) {
+ throw new Error("Excel 파일에서 헤더 행을 찾을 수 없습니다.");
+ }
+
+ // 헤더를 기반으로 인덱스 매핑 생성
+ const headerMapping: Record<string, number> = {};
+ headerValues.forEach((value, index) => {
+ if (typeof value === 'string') {
+ headerMapping[value] = index;
+ }
+ });
+
+ // 필수 헤더 확인 (타입별 구분)
+ const requiredHeaders: string[] = ["자재 그룹", "기능(공종)"];
+
+ const alternativeHeaders = {
+ "자재 그룹": ["itemCode", "item_code"],
+ "기능(공종)": ["workType"],
+ "자재명": ["itemList"],
+ "자재명(상세)": ["subItemList"]
+ };
+
+ // 헤더 매핑 확인 (대체 이름 포함)
+ const missingHeaders = requiredHeaders.filter(header => {
+ const alternatives = alternativeHeaders[header as keyof typeof alternativeHeaders] || [];
+ return !(header in headerMapping) &&
+ !alternatives.some(alt => alt in headerMapping);
+ });
+
+ if (missingHeaders.length > 0) {
+ throw new Error(`다음 필수 헤더가 누락되었습니다: ${missingHeaders.join(", ")}`);
+ }
+
+ // 데이터 행 추출 (헤더 이후 행부터)
+ const dataRows: Record<string, any>[] = [];
+
+ worksheet.eachRow((row, rowNumber) => {
+ if (rowNumber > headerRowIndex) {
+ const rowData: Record<string, any> = {};
+ const values = row.values as (string | null | undefined)[];
+
+ // 헤더 매핑에 따라 데이터 추출
+ Object.entries(headerMapping).forEach(([header, index]) => {
+ rowData[header] = values[index] || "";
+ });
+
+ // 빈 행이 아닌 경우만 추가
+ if (Object.values(rowData).some(value => value && value.toString().trim() !== "")) {
+ dataRows.push(rowData);
+ }
+ }
+ });
+
+ if (dataRows.length === 0) {
+ throw new Error("Excel 파일에 가져올 데이터가 없습니다.");
+ }
+
+ // 진행 상황 업데이트를 위한 콜백
+ const updateProgress = (current: number, total: number) => {
+ const percentage = Math.round((current / total) * 100);
+ setProgress(percentage);
+ };
+
+ // 선택된 타입에 따라 적절한 프로세스 함수 호출
+ let result: { successCount: number; errorCount: number; errors?: Array<{ row: number; message: string }> };
+ if (itemType === "top") {
+ result = await processTopFileImport(dataRows, updateProgress);
+ } else if (itemType === "hull") {
+ result = await processHullFileImport(dataRows, updateProgress);
+ } else {
+ result = await processFileImport(dataRows, updateProgress);
+ }
+
+ toast.success(`${result.successCount}개의 ${ITEM_TYPE_NAMES[itemType]}이(가) 성공적으로 가져와졌습니다.`);
+
+ if (result.errorCount > 0) {
+ const errorDetails = result.errors?.map((error: { row: number; message: string; itemCode?: string; workType?: string }) =>
+ `행 ${error.row}: ${error.itemCode || '알 수 없음'} (${error.workType || '알 수 없음'}) - ${error.message}`
+ ).join('\n') || '오류 정보를 가져올 수 없습니다.';
+
+ console.error('Import 오류 상세:', errorDetails);
+ toast.error(`${result.errorCount}개의 항목 처리 실패. 콘솔에서 상세 내용을 확인하세요.`);
+ }
+
+ // 상태 초기화 및 다이얼로그 닫기
+ setFile(null);
+ setOpen(false);
+
+ // 성공 콜백 호출
+ if (onSuccess) {
+ onSuccess();
+ }
+ } catch (error) {
+ console.error("Excel 파일 처리 중 오류 발생:", error);
+ setError(error instanceof Error ? error.message : "파일 처리 중 오류가 발생했습니다.");
+ } finally {
+ setIsUploading(false);
+ }
+ };
+
+
+
+ // 다이얼로그 열기/닫기 핸들러
+ const handleOpenChange = (newOpen: boolean) => {
+ if (!newOpen) {
+ // 닫을 때 상태 초기화
+ setFile(null);
+ setError(null);
+ setProgress(0);
+ if (fileInputRef.current) {
+ fileInputRef.current.value = "";
+ }
+ }
+ setOpen(newOpen);
+ };
+
+ return (
+ <>
+ <Button
+ variant="outline"
+ size="sm"
+ className="gap-2"
+ onClick={() => setOpen(true)}
+ disabled={isUploading}
+ >
+ <Upload className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Import</span>
+ </Button>
+
+ <Dialog open={open} onOpenChange={handleOpenChange}>
+ <DialogContent className="sm:max-w-[500px]">
+ <DialogHeader>
+ <DialogTitle>{ITEM_TYPE_NAMES[itemType]} 가져오기</DialogTitle>
+ <DialogDescription>
+ {ITEM_TYPE_NAMES[itemType]}을 Excel 파일에서 가져옵니다.
+ <br />
+ 올바른 형식의 Excel 파일(.xlsx)을 업로드하세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4 py-4">
+ <div className="flex items-center gap-4">
+ <input
+ type="file"
+ ref={fileInputRef}
+ className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-foreground file:font-medium"
+ accept=".xlsx,.xls"
+ onChange={handleFileChange}
+ disabled={isUploading}
+ />
+ </div>
+
+ {file && (
+ <div className="text-sm text-muted-foreground">
+ 선택된 파일: <span className="font-medium">{file.name}</span> ({(file.size / 1024).toFixed(1)} KB)
+ </div>
+ )}
+
+ {isUploading && (
+ <div className="space-y-2">
+ <Progress value={progress} />
+ <p className="text-sm text-muted-foreground text-center">
+ {progress}% 완료
+ </p>
+ </div>
+ )}
+
+ {error && (
+ <div className="text-sm font-medium text-destructive">
+ {error}
+ </div>
+ )}
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => setOpen(false)}
+ disabled={isUploading}
+ >
+ 취소
+ </Button>
+ <Button
+ onClick={handleImport}
+ disabled={!file || isUploading}
+ >
+ {isUploading ? "처리 중..." : "가져오기"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </>
+ );
} \ No newline at end of file
diff --git a/lib/items-tech/table/ship/import-item-handler.tsx b/lib/items-tech/table/ship/import-item-handler.tsx
index 57546cc6..b0f475ff 100644
--- a/lib/items-tech/table/ship/import-item-handler.tsx
+++ b/lib/items-tech/table/ship/import-item-handler.tsx
@@ -1,139 +1,129 @@
-"use client"
-
-import { z } from "zod"
-import { createShipbuildingImportItem } from "../../service" // 아이템 생성 서버 액션
-
-// 아이템 데이터 검증을 위한 Zod 스키마
-const itemSchema = z.object({
- itemCode: z.string().optional(),
- workType: z.enum(["기장", "전장", "선실", "배관", "철의", "선체"], {
- required_error: "기능(공종)은 필수입니다",
- }),
- shipTypes: z.string().nullable().optional(),
- itemList: z.string().nullable().optional(),
-});
-
-interface ProcessResult {
- successCount: number;
- errorCount: number;
- errors: Array<{ row: number; message: string; itemCode?: string; workType?: string }>;
-}
-
-/**
- * Excel 파일에서 가져온 조선 아이템 데이터 처리하는 함수
- */
-export async function processFileImport(
- jsonData: Record<string, unknown>[],
- progressCallback?: (current: number, total: number) => void
-): Promise<ProcessResult> {
- // 결과 카운터 초기화
- let successCount = 0;
- let errorCount = 0;
- const errors: Array<{ row: number; message: string }> = [];
-
- // 빈 행 등 필터링
- const dataRows = jsonData.filter(row => {
- // 빈 행 건너뛰기
- if (Object.values(row).every(val => !val)) {
- return false;
- }
- return true;
- });
-
- // 데이터 행이 없으면 빈 결과 반환
- if (dataRows.length === 0) {
- return { successCount: 0, errorCount: 0, errors: [] };
- }
-
- // 각 행에 대해 처리
- for (let i = 0; i < dataRows.length; i++) {
- const row = dataRows[i];
- const rowIndex = i + 1; // 사용자에게 표시할 행 번호는 1부터 시작
-
- // 진행 상황 콜백 호출
- if (progressCallback) {
- progressCallback(i + 1, dataRows.length);
- }
-
- try {
- // 필드 매핑 (한글/영문 필드명 모두 지원)
- const itemCode = row["자재 그룹"] || row["itemCode"] || "";
- const workType = row["기능(공종)"] || row["workType"] || "";
- const shipTypes = row["선종"] || row["shipTypes"] || null;
- const itemList = row["자재명"] || row["itemList"] || null;
-
- // 데이터 정제
- const cleanedRow = {
- itemCode: typeof itemCode === 'string' ? itemCode.trim() : String(itemCode).trim(),
- workType: typeof workType === 'string' ? workType.trim() : String(workType).trim(),
- shipTypes: shipTypes ? (typeof shipTypes === 'string' ? shipTypes.trim() : String(shipTypes).trim()) : null,
- itemList: itemList ? (typeof itemList === 'string' ? itemList : String(itemList)) : null,
- };
-
- // 데이터 유효성 검사
- const validationResult = itemSchema.safeParse(cleanedRow);
-
- if (!validationResult.success) {
- const errorMessage = validationResult.error.errors.map(
- err => `${err.path.join('.')}: ${err.message}`
- ).join(', ');
-
- errors.push({
- row: rowIndex,
- message: errorMessage,
- itemCode: cleanedRow.itemCode,
- workType: cleanedRow.workType
- });
- errorCount++;
- continue;
- }
-
- // 아이템 생성
- const result = await createShipbuildingImportItem({
- itemCode: cleanedRow.itemCode,
- workType: cleanedRow.workType as "기장" | "전장" | "선실" | "배관" | "철의" | "선체",
- shipTypes: cleanedRow.shipTypes,
- itemList: cleanedRow.itemList,
- });
-
- if (result.success || !result.error) {
- successCount++;
- } else {
- errors.push({
- row: rowIndex,
- message: result.message || result.error || "알 수 없는 오류",
- itemCode: cleanedRow.itemCode,
- workType: cleanedRow.workType
- });
- errorCount++;
- }
-
- } catch (error) {
- console.error(`${rowIndex}행 처리 오류:`, error);
-
- // cleanedRow가 정의되지 않은 경우를 처리
- const itemCode = row["자재 그룹"] || row["itemCode"] || "";
- const workType = row["기능(공종)"] || row["workType"] || "";
-
- errors.push({
- row: rowIndex,
- message: error instanceof Error ? error.message : "알 수 없는 오류",
- itemCode: typeof itemCode === 'string' ? itemCode.trim() : String(itemCode).trim(),
- workType: typeof workType === 'string' ? workType.trim() : String(workType).trim()
- });
- errorCount++;
- }
-
- // 비동기 작업 쓰로틀링
- if (i % 5 === 0) {
- await new Promise(resolve => setTimeout(resolve, 10));
- }
- }
-
- // 처리 결과 반환
- return {
- successCount,
- errorCount,
- errors
- };
+"use client"
+
+import { z } from "zod"
+import { createShipbuildingImportItem } from "../../service" // 아이템 생성 서버 액션
+
+// 아이템 데이터 검증을 위한 Zod 스키마
+const itemSchema = z.object({
+ itemCode: z.string().optional(),
+ workType: z.enum(["기장", "전장", "선실", "배관", "철의", "선체"], {
+ required_error: "기능(공종)은 필수입니다",
+ }),
+ shipTypes: z.string().nullable().optional(),
+ itemList: z.string().nullable().optional(),
+});
+
+interface ProcessResult {
+ successCount: number;
+ errorCount: number;
+ errors: Array<{ row: number; message: string }>;
+}
+
+/**
+ * Excel 파일에서 가져온 조선 아이템 데이터 처리하는 함수
+ */
+export async function processFileImport(
+ jsonData: Record<string, unknown>[],
+ progressCallback?: (current: number, total: number) => void
+): Promise<ProcessResult> {
+ // 결과 카운터 초기화
+ let successCount = 0;
+ let errorCount = 0;
+ const errors: Array<{ row: number; message: string }> = [];
+
+ // 빈 행 등 필터링
+ const dataRows = jsonData.filter(row => {
+ // 빈 행 건너뛰기
+ if (Object.values(row).every(val => !val)) {
+ return false;
+ }
+ return true;
+ });
+
+ // 데이터 행이 없으면 빈 결과 반환
+ if (dataRows.length === 0) {
+ return { successCount: 0, errorCount: 0, errors: [] };
+ }
+
+ // 각 행에 대해 처리
+ for (let i = 0; i < dataRows.length; i++) {
+ const row = dataRows[i];
+ const rowIndex = i + 1; // 사용자에게 표시할 행 번호는 1부터 시작
+
+ // 진행 상황 콜백 호출
+ if (progressCallback) {
+ progressCallback(i + 1, dataRows.length);
+ }
+
+ try {
+ // 필드 매핑 (한글/영문 필드명 모두 지원)
+ const itemCode = row["자재 그룹"] || row["itemCode"] || "";
+ const workType = row["기능(공종)"] || row["workType"] || "";
+ const shipTypes = row["선종"] || row["shipTypes"] || null;
+ const itemList = row["자재명"] || row["itemList"] || null;
+
+ // 데이터 정제
+ const cleanedRow = {
+ itemCode: typeof itemCode === 'string' ? itemCode.trim() : String(itemCode).trim(),
+ workType: typeof workType === 'string' ? workType.trim() : String(workType).trim(),
+ shipTypes: shipTypes ? (typeof shipTypes === 'string' ? shipTypes.trim() : String(shipTypes).trim()) : null,
+ itemList: itemList ? (typeof itemList === 'string' ? itemList : String(itemList)) : null,
+ };
+
+ // 데이터 유효성 검사
+ const validationResult = itemSchema.safeParse(cleanedRow);
+
+ if (!validationResult.success) {
+ const errorMessage = validationResult.error.errors.map(
+ err => `${err.path.join('.')}: ${err.message}`
+ ).join(', ');
+
+ errors.push({
+ row: rowIndex,
+ message: errorMessage,
+ });
+ errorCount++;
+ continue;
+ }
+
+ // 아이템 생성
+ const result = await createShipbuildingImportItem({
+ itemCode: cleanedRow.itemCode,
+ workType: cleanedRow.workType as "기장" | "전장" | "선실" | "배관" | "철의" | "선체",
+ shipTypes: cleanedRow.shipTypes,
+ itemList: cleanedRow.itemList,
+ });
+
+ if (result.success || !result.error) {
+ successCount++;
+ } else {
+ errors.push({
+ row: rowIndex,
+ message: result.message || result.error || "알 수 없는 오류",
+ });
+ errorCount++;
+ }
+
+ } catch (error) {
+ console.error(`${rowIndex}행 처리 오류:`, error);
+
+ errors.push({
+ row: rowIndex,
+ message: error instanceof Error ? error.message : "알 수 없는 오류",
+ });
+ errorCount++;
+ }
+
+ // 비동기 작업 쓰로틀링
+ if (i % 5 === 0) {
+ await new Promise(resolve => setTimeout(resolve, 10));
+ }
+ }
+
+ // 처리 결과 반환
+ return {
+ successCount,
+ errorCount,
+ errors
+ };
} \ No newline at end of file
diff --git a/lib/items-tech/table/ship/item-excel-template.tsx b/lib/items-tech/table/ship/item-excel-template.tsx
index 401fb911..fdff0de0 100644
--- a/lib/items-tech/table/ship/item-excel-template.tsx
+++ b/lib/items-tech/table/ship/item-excel-template.tsx
@@ -1,111 +1,111 @@
-import * as ExcelJS from 'exceljs';
-import { saveAs } from "file-saver";
-
-/**
- * 조선 아이템 데이터 가져오기를 위한 Excel 템플릿 파일 생성 및 다운로드
- */
-export async function exportItemTemplate() {
- // 워크북 생성
- const workbook = new ExcelJS.Workbook();
- workbook.creator = 'Shipbuilding Item Management System';
- workbook.created = new Date();
-
- // 워크시트 생성
- const worksheet = workbook.addWorksheet('조선 아이템');
-
- // 컬럼 헤더 정의 및 스타일 적용
- worksheet.columns = [
- { header: '자재 그룹', key: 'itemCode', width: 15 },
- { header: '기능(공종)', key: 'workType', width: 15 },
- { header: '선종', key: 'shipTypes', width: 15 },
- { header: '자재명', key: 'itemList', width: 30 },
- ];
-
- // 헤더 스타일 적용
- const headerRow = worksheet.getRow(1);
- headerRow.font = { bold: true };
- headerRow.fill = {
- type: 'pattern',
- pattern: 'solid',
- fgColor: { argb: 'FFE0E0E0' }
- };
- headerRow.alignment = { vertical: 'middle', horizontal: 'center' };
-
- // 테두리 스타일 적용
- headerRow.eachCell((cell) => {
- cell.border = {
- top: { style: 'thin' },
- left: { style: 'thin' },
- bottom: { style: 'thin' },
- right: { style: 'thin' }
- };
- });
-
- // 샘플 데이터 추가
- const sampleData = [
- {
- itemCode: 'BG0001',
- workType: '기장',
- shipTypes: 'A-MAX',
- itemList: '자재명',
- },
- {
- itemCode: 'BG0002',
- workType: '전장',
- shipTypes: 'LNGC',
- itemList: '자재명',
- },
- {
- itemCode: 'BG0003',
- workType: '선실',
- shipTypes: 'VLCC',
- itemList: '자재명',
- }
- ];
-
- // 데이터 행 추가
- sampleData.forEach(item => {
- worksheet.addRow(item);
- });
-
- // 데이터 행 스타일 적용
- worksheet.eachRow((row, rowNumber) => {
- if (rowNumber > 1) { // 헤더를 제외한 데이터 행
- row.eachCell((cell) => {
- cell.border = {
- top: { style: 'thin' },
- left: { style: 'thin' },
- bottom: { style: 'thin' },
- right: { style: 'thin' }
- };
- });
- }
- });
-
- // 워크시트 보호 (선택적)
- worksheet.protect('', {
- selectLockedCells: true,
- selectUnlockedCells: true,
- formatColumns: true,
- formatRows: true,
- insertColumns: false,
- insertRows: true,
- insertHyperlinks: false,
- deleteColumns: false,
- deleteRows: true,
- sort: true,
- autoFilter: true,
- pivotTables: false
- });
-
- try {
- // 워크북을 Blob으로 변환
- const buffer = await workbook.xlsx.writeBuffer();
- const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
- saveAs(blob, 'shipbuilding-item-template.xlsx');
- return true;
- } catch (error) {
- console.error('Excel 템플릿 생성 오류:', error);
- throw error;
- }
+import * as ExcelJS from 'exceljs';
+import { saveAs } from "file-saver";
+
+/**
+ * 조선 아이템 데이터 가져오기를 위한 Excel 템플릿 파일 생성 및 다운로드
+ */
+export async function exportItemTemplate() {
+ // 워크북 생성
+ const workbook = new ExcelJS.Workbook();
+ workbook.creator = 'Shipbuilding Item Management System';
+ workbook.created = new Date();
+
+ // 워크시트 생성
+ const worksheet = workbook.addWorksheet('조선 아이템');
+
+ // 컬럼 헤더 정의 및 스타일 적용
+ worksheet.columns = [
+ { header: '자재 그룹', key: 'itemCode', width: 15 },
+ { header: '기능(공종)', key: 'workType', width: 15 },
+ { header: '선종', key: 'shipTypes', width: 15 },
+ { header: '자재명', key: 'itemList', width: 30 },
+ ];
+
+ // 헤더 스타일 적용
+ const headerRow = worksheet.getRow(1);
+ headerRow.font = { bold: true };
+ headerRow.fill = {
+ type: 'pattern',
+ pattern: 'solid',
+ fgColor: { argb: 'FFE0E0E0' }
+ };
+ headerRow.alignment = { vertical: 'middle', horizontal: 'center' };
+
+ // 테두리 스타일 적용
+ headerRow.eachCell((cell) => {
+ cell.border = {
+ top: { style: 'thin' },
+ left: { style: 'thin' },
+ bottom: { style: 'thin' },
+ right: { style: 'thin' }
+ };
+ });
+
+ // 샘플 데이터 추가
+ const sampleData = [
+ {
+ itemCode: 'BG0001',
+ workType: '기장',
+ shipTypes: 'A-MAX',
+ itemList: '자재명',
+ },
+ {
+ itemCode: 'BG0002',
+ workType: '전장',
+ shipTypes: 'LNGC',
+ itemList: '자재명',
+ },
+ {
+ itemCode: 'BG0003',
+ workType: '선실',
+ shipTypes: 'VLCC',
+ itemList: '자재명',
+ }
+ ];
+
+ // 데이터 행 추가
+ sampleData.forEach(item => {
+ worksheet.addRow(item);
+ });
+
+ // 데이터 행 스타일 적용
+ worksheet.eachRow((row, rowNumber) => {
+ if (rowNumber > 1) { // 헤더를 제외한 데이터 행
+ row.eachCell((cell) => {
+ cell.border = {
+ top: { style: 'thin' },
+ left: { style: 'thin' },
+ bottom: { style: 'thin' },
+ right: { style: 'thin' }
+ };
+ });
+ }
+ });
+
+ // 워크시트 보호 (선택적)
+ worksheet.protect('', {
+ selectLockedCells: true,
+ selectUnlockedCells: true,
+ formatColumns: true,
+ formatRows: true,
+ insertColumns: false,
+ insertRows: true,
+ insertHyperlinks: false,
+ deleteColumns: false,
+ deleteRows: true,
+ sort: true,
+ autoFilter: true,
+ pivotTables: false
+ });
+
+ try {
+ // 워크북을 Blob으로 변환
+ const buffer = await workbook.xlsx.writeBuffer();
+ const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
+ saveAs(blob, 'shipbuilding-item-template.xlsx');
+ return true;
+ } catch (error) {
+ console.error('Excel 템플릿 생성 오류:', error);
+ throw error;
+ }
} \ No newline at end of file
diff --git a/lib/items-tech/table/ship/items-table-toolbar-actions.tsx b/lib/items-tech/table/ship/items-table-toolbar-actions.tsx
index 29995327..82ceb298 100644
--- a/lib/items-tech/table/ship/items-table-toolbar-actions.tsx
+++ b/lib/items-tech/table/ship/items-table-toolbar-actions.tsx
@@ -1,177 +1,177 @@
-"use client"
-
-import * as React from "react"
-import { type Table } from "@tanstack/react-table"
-import { Download, FileDown } from "lucide-react"
-import * as ExcelJS from 'exceljs'
-import { saveAs } from "file-saver"
-
-import { Button } from "@/components/ui/button"
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu"
-
-import { DeleteItemsDialog } from "../delete-items-dialog"
-import { AddItemDialog } from "../add-items-dialog"
-import { exportItemTemplate } from "./item-excel-template"
-import { ImportItemButton } from "../import-excel-button"
-
-// 조선 아이템 타입 정의
-interface ShipbuildingItem {
- id: number;
- itemId: number;
- workType: "기장" | "전장" | "선실" | "배관" | "철의" | "선체";
- shipTypes: string;
- itemCode: string;
- itemName: string;
- itemList: string | null;
- description: string | null;
- createdAt: Date;
- updatedAt: Date;
-}
-
-interface ItemsTableToolbarActionsProps {
- table: Table<ShipbuildingItem>
-}
-
-export function ItemsTableToolbarActions({ table }: ItemsTableToolbarActionsProps) {
- const [refreshKey, setRefreshKey] = React.useState(0)
-
- // 가져오기 성공 후 테이블 갱신
- const handleImportSuccess = () => {
- setRefreshKey(prev => prev + 1)
- }
-
- // Excel 내보내기 함수
- const exportTableToExcel = async (
- table: Table<ShipbuildingItem>,
- options: {
- filename: string;
- excludeColumns?: string[];
- sheetName?: string;
- }
- ) => {
- const { filename, excludeColumns = [], sheetName = "조선 아이템 목록" } = options;
-
- // 워크북 생성
- const workbook = new ExcelJS.Workbook();
- workbook.creator = 'Shipbuilding Item Management System';
- workbook.created = new Date();
-
- // 워크시트 생성
- const worksheet = workbook.addWorksheet(sheetName);
-
- // 테이블 데이터 가져오기
- const data = table.getFilteredRowModel().rows.map(row => row.original);
- console.log("내보내기 데이터:", data);
-
- // 필요한 헤더 직접 정의 (필터링 문제 해결)
- const headers = [
- { key: 'itemCode', header: '자재 그룹' },
- { key: 'workType', header: '기능(공종)' },
- { key: 'shipTypes', header: '선종' },
- { key: 'itemList', header: '자재명' },
- { key: 'subItemList', header: '자재명(상세)' },
- ].filter(header => !excludeColumns.includes(header.key));
-
- console.log("내보내기 헤더:", headers);
- // 컬럼 정의
- worksheet.columns = headers.map(header => ({
- header: header.header,
- key: header.key,
- width: 20 // 기본 너비
- }));
-
- // 스타일 적용
- const headerRow = worksheet.getRow(1);
- headerRow.font = { bold: true };
- headerRow.fill = {
- type: 'pattern',
- pattern: 'solid',
- fgColor: { argb: 'FFE0E0E0' }
- };
- headerRow.alignment = { vertical: 'middle', horizontal: 'center' };
-
- // 데이터 행 추가
- data.forEach(item => {
- const row: Record<string, any> = {};
- headers.forEach(header => {
- row[header.key] = item[header.key as keyof ShipbuildingItem];
- });
- worksheet.addRow(row);
- });
-
- // 전체 셀에 테두리 추가
- worksheet.eachRow((row) => {
- row.eachCell((cell) => {
- cell.border = {
- top: { style: 'thin' },
- left: { style: 'thin' },
- bottom: { style: 'thin' },
- right: { style: 'thin' }
- };
- });
- });
-
- try {
- // 워크북을 Blob으로 변환
- const buffer = await workbook.xlsx.writeBuffer();
- const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
- saveAs(blob, `${filename}.xlsx`);
- return true;
- } catch (error) {
- console.error("Excel 내보내기 오류:", error);
- return false;
- }
- }
-
- return (
- <div className="flex items-center gap-2">
- {/* 선택된 로우가 있으면 삭제 다이얼로그 */}
- {table.getFilteredSelectedRowModel().rows.length > 0 ? (
- <DeleteItemsDialog
- items={table.getFilteredSelectedRowModel().rows.map((row) => row.original) as any}
- onSuccess={() => table.toggleAllRowsSelected(false)}
- itemType="shipbuilding"
- />
- ) : null}
-
- {/* 새 아이템 추가 다이얼로그 */}
- <AddItemDialog itemType="shipbuilding" />
-
- {/* Import 버튼 */}
- <ImportItemButton itemType="ship" onSuccess={handleImportSuccess} />
-
- {/* Export 드롭다운 메뉴 */}
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button variant="outline" size="sm" className="gap-2">
- <Download className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">Export</span>
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end">
- <DropdownMenuItem
- onClick={() =>
- exportTableToExcel(table, {
- filename: "shipbuilding_items",
- excludeColumns: ["select", "actions"],
- sheetName: "조선 아이템 목록"
- })
- }
- >
- <FileDown className="mr-2 h-4 w-4" />
- <span>현재 데이터 내보내기</span>
- </DropdownMenuItem>
- <DropdownMenuItem onClick={() => exportItemTemplate()}>
- <FileDown className="mr-2 h-4 w-4" />
- <span>템플릿 다운로드</span>
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
- </div>
- )
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Download, FileDown } from "lucide-react"
+import * as ExcelJS from 'exceljs'
+import { saveAs } from "file-saver"
+
+import { Button } from "@/components/ui/button"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+
+import { DeleteItemsDialog } from "../delete-items-dialog"
+import { AddItemDialog } from "../add-items-dialog"
+import { exportItemTemplate } from "./item-excel-template"
+import { ImportItemButton } from "../import-excel-button"
+
+// 조선 아이템 타입 정의
+interface ShipbuildingItem {
+ id: number;
+ itemId: number;
+ workType: "기장" | "전장" | "선실" | "배관" | "철의" | "선체";
+ shipTypes: string;
+ itemCode: string;
+ itemName: string;
+ itemList: string | null;
+ description: string | null;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+interface ItemsTableToolbarActionsProps {
+ table: Table<ShipbuildingItem>
+}
+
+export function ItemsTableToolbarActions({ table }: ItemsTableToolbarActionsProps) {
+ const [refreshKey, setRefreshKey] = React.useState(0)
+
+ // 가져오기 성공 후 테이블 갱신
+ const handleImportSuccess = () => {
+ setRefreshKey(prev => prev + 1)
+ }
+
+ // Excel 내보내기 함수
+ const exportTableToExcel = async (
+ table: Table<ShipbuildingItem>,
+ options: {
+ filename: string;
+ excludeColumns?: string[];
+ sheetName?: string;
+ }
+ ) => {
+ const { filename, excludeColumns = [], sheetName = "조선 아이템 목록" } = options;
+
+ // 워크북 생성
+ const workbook = new ExcelJS.Workbook();
+ workbook.creator = 'Shipbuilding Item Management System';
+ workbook.created = new Date();
+
+ // 워크시트 생성
+ const worksheet = workbook.addWorksheet(sheetName);
+
+ // 테이블 데이터 가져오기
+ const data = table.getFilteredRowModel().rows.map(row => row.original);
+ console.log("내보내기 데이터:", data);
+
+ // 필요한 헤더 직접 정의 (필터링 문제 해결)
+ const headers = [
+ { key: 'itemCode', header: '자재 그룹' },
+ { key: 'workType', header: '기능(공종)' },
+ { key: 'shipTypes', header: '선종' },
+ { key: 'itemList', header: '자재명' },
+ { key: 'subItemList', header: '자재명(상세)' },
+ ].filter(header => !excludeColumns.includes(header.key));
+
+ console.log("내보내기 헤더:", headers);
+ // 컬럼 정의
+ worksheet.columns = headers.map(header => ({
+ header: header.header,
+ key: header.key,
+ width: 20 // 기본 너비
+ }));
+
+ // 스타일 적용
+ const headerRow = worksheet.getRow(1);
+ headerRow.font = { bold: true };
+ headerRow.fill = {
+ type: 'pattern',
+ pattern: 'solid',
+ fgColor: { argb: 'FFE0E0E0' }
+ };
+ headerRow.alignment = { vertical: 'middle', horizontal: 'center' };
+
+ // 데이터 행 추가
+ data.forEach(item => {
+ const row: Record<string, any> = {};
+ headers.forEach(header => {
+ row[header.key] = item[header.key as keyof ShipbuildingItem];
+ });
+ worksheet.addRow(row);
+ });
+
+ // 전체 셀에 테두리 추가
+ worksheet.eachRow((row) => {
+ row.eachCell((cell) => {
+ cell.border = {
+ top: { style: 'thin' },
+ left: { style: 'thin' },
+ bottom: { style: 'thin' },
+ right: { style: 'thin' }
+ };
+ });
+ });
+
+ try {
+ // 워크북을 Blob으로 변환
+ const buffer = await workbook.xlsx.writeBuffer();
+ const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
+ saveAs(blob, `${filename}.xlsx`);
+ return true;
+ } catch (error) {
+ console.error("Excel 내보내기 오류:", error);
+ return false;
+ }
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+ {/* 선택된 로우가 있으면 삭제 다이얼로그 */}
+ {table.getFilteredSelectedRowModel().rows.length > 0 ? (
+ <DeleteItemsDialog
+ items={table.getFilteredSelectedRowModel().rows.map((row) => row.original) as any}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+ itemType="shipbuilding"
+ />
+ ) : null}
+
+ {/* 새 아이템 추가 다이얼로그 */}
+ <AddItemDialog itemType="shipbuilding" />
+
+ {/* Import 버튼 */}
+ <ImportItemButton itemType="ship" onSuccess={handleImportSuccess} />
+
+ {/* Export 드롭다운 메뉴 */}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Export</span>
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuItem
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "shipbuilding_items",
+ excludeColumns: ["select", "actions"],
+ sheetName: "조선 아이템 목록"
+ })
+ }
+ >
+ <FileDown className="mr-2 h-4 w-4" />
+ <span>현재 데이터 내보내기</span>
+ </DropdownMenuItem>
+ <DropdownMenuItem onClick={() => exportItemTemplate()}>
+ <FileDown className="mr-2 h-4 w-4" />
+ <span>템플릿 다운로드</span>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+ )
} \ No newline at end of file
diff --git a/lib/items-tech/table/top/import-item-handler.tsx b/lib/items-tech/table/top/import-item-handler.tsx
index 0a163791..4f34cff2 100644
--- a/lib/items-tech/table/top/import-item-handler.tsx
+++ b/lib/items-tech/table/top/import-item-handler.tsx
@@ -1,141 +1,130 @@
-"use client"
-
-import { z } from "zod"
-import { createOffshoreTopItem } from "../../service"
-
-// 해양 TOP 기능(공종) 유형 enum
-const TOP_WORK_TYPES = ["TM", "TS", "TE", "TP"] as const;
-
-// 아이템 데이터 검증을 위한 Zod 스키마
-const itemSchema = z.object({
- itemCode: z.string().optional(),
- workType: z.enum(TOP_WORK_TYPES, {
- required_error: "기능(공종)은 필수입니다",
- }),
- itemList: z.string().nullable().optional(),
- subItemList: z.string().nullable().optional(),
-});
-
-interface ProcessResult {
- successCount: number;
- errorCount: number;
- errors: Array<{ row: number; message: string; itemCode?: string; workType?: string }>;
-}
-
-/**
- * Excel 파일에서 가져온 해양 TOP 아이템 데이터 처리하는 함수
- */
-export async function processTopFileImport(
- jsonData: Record<string, unknown>[],
- progressCallback?: (current: number, total: number) => void
-): Promise<ProcessResult> {
- // 결과 카운터 초기화
- let successCount = 0;
- let errorCount = 0;
- const errors: Array<{ row: number; message: string }> = [];
-
- // 빈 행 등 필터링
- const dataRows = jsonData.filter(row => {
- // 빈 행 건너뛰기
- if (Object.values(row).every(val => !val)) {
- return false;
- }
- return true;
- });
-
- // 데이터 행이 없으면 빈 결과 반환
- if (dataRows.length === 0) {
- return { successCount: 0, errorCount: 0, errors: [] };
- }
-
- // 각 행에 대해 처리
- for (let i = 0; i < dataRows.length; i++) {
- const row = dataRows[i];
- const rowIndex = i + 1; // 사용자에게 표시할 행 번호는 1부터 시작
-
- // 진행 상황 콜백 호출
- if (progressCallback) {
- progressCallback(i + 1, dataRows.length);
- }
-
- try {
- // 필드 매핑 (한글/영문 필드명 모두 지원)
- const itemCode = row["자재 그룹"] || row["itemCode"] || "";
- const workType = row["기능(공종)"] || row["workType"] || "";
- const itemList = row["자재명"] || row["itemList"] || null;
- const subItemList = row["자재명(상세)"] || row["subItemList"] || null;
-
- // 데이터 정제
- const cleanedRow = {
- itemCode: typeof itemCode === 'string' ? itemCode.trim() : String(itemCode).trim(),
- workType: typeof workType === 'string' ? workType.trim() : String(workType).trim(),
- itemList: itemList ? (typeof itemList === 'string' ? itemList : String(itemList)) : null,
- subItemList: subItemList ? (typeof subItemList === 'string' ? subItemList : String(subItemList)) : null,
- };
-
- // 데이터 유효성 검사
- const validationResult = itemSchema.safeParse(cleanedRow);
-
- if (!validationResult.success) {
- const errorMessage = validationResult.error.errors.map(
- err => `${err.path.join('.')}: ${err.message}`
- ).join(', ');
-
- errors.push({
- row: rowIndex,
- message: errorMessage,
- itemCode: cleanedRow.itemCode,
- workType: cleanedRow.workType
- });
- errorCount++;
- continue;
- }
-
- // 해양 TOP 아이템 생성
- const result = await createOffshoreTopItem({
- itemCode: cleanedRow.itemCode,
- workType: cleanedRow.workType as "TM" | "TS" | "TE" | "TP",
- itemList: cleanedRow.itemList,
- subItemList: cleanedRow.subItemList,
- });
-
- if (result.success) {
- successCount++;
- } else {
- errors.push({
- row: rowIndex,
- message: result.message || result.error || "알 수 없는 오류",
- itemCode: cleanedRow.itemCode,
- workType: cleanedRow.workType
- });
- errorCount++;
- }
- } catch (error) {
- console.error(`${rowIndex}행 처리 오류:`, error);
-
- // cleanedRow가 정의되지 않은 경우를 처리
- const itemCode = row["자재 그룹"] || row["itemCode"] || "";
- const workType = row["기능(공종)"] || row["workType"] || "";
-
- errors.push({
- row: rowIndex,
- message: error instanceof Error ? error.message : "알 수 없는 오류",
- itemCode: typeof itemCode === 'string' ? itemCode.trim() : String(itemCode).trim(),
- workType: typeof workType === 'string' ? workType.trim() : String(workType).trim()
- });
- errorCount++;
- }
-
- // 비동기 작업 쓰로틀링
- if (i % 5 === 0) {
- await new Promise(resolve => setTimeout(resolve, 10));
- }
- }
-
- // 처리 결과 반환
- return {
- successCount,
- errorCount,
- errors
- };
-}
+"use client"
+
+import { z } from "zod"
+import { createOffshoreTopItem } from "../../service"
+
+// 해양 TOP 기능(공종) 유형 enum
+const TOP_WORK_TYPES = ["TM", "TS", "TE", "TP"] as const;
+
+// 아이템 데이터 검증을 위한 Zod 스키마
+const itemSchema = z.object({
+ itemCode: z.string().optional(),
+ workType: z.enum(TOP_WORK_TYPES, {
+ required_error: "기능(공종)은 필수입니다",
+ }),
+ itemList: z.string().nullable().optional(),
+ subItemList: z.string().nullable().optional(),
+});
+
+interface ProcessResult {
+ successCount: number;
+ errorCount: number;
+ errors: Array<{ row: number; message: string }>;
+}
+
+/**
+ * Excel 파일에서 가져온 해양 TOP 아이템 데이터 처리하는 함수
+ */
+export async function processTopFileImport(
+ jsonData: Record<string, unknown>[],
+ progressCallback?: (current: number, total: number) => void
+): Promise<ProcessResult> {
+ // 결과 카운터 초기화
+ let successCount = 0;
+ let errorCount = 0;
+ const errors: Array<{ row: number; message: string }> = [];
+
+ // 빈 행 등 필터링
+ const dataRows = jsonData.filter(row => {
+ // 빈 행 건너뛰기
+ if (Object.values(row).every(val => !val)) {
+ return false;
+ }
+ return true;
+ });
+
+ // 데이터 행이 없으면 빈 결과 반환
+ if (dataRows.length === 0) {
+ return { successCount: 0, errorCount: 0, errors: [] };
+ }
+
+ // 각 행에 대해 처리
+ for (let i = 0; i < dataRows.length; i++) {
+ const row = dataRows[i];
+ const rowIndex = i + 1; // 사용자에게 표시할 행 번호는 1부터 시작
+
+ // 진행 상황 콜백 호출
+ if (progressCallback) {
+ progressCallback(i + 1, dataRows.length);
+ }
+
+ try {
+ // 필드 매핑 (한글/영문 필드명 모두 지원)
+ const itemCode = row["자재 그룹"] || row["itemCode"] || "";
+ const workType = row["기능(공종)"] || row["workType"] || "";
+ const itemList = row["자재명"] || row["itemList"] || null;
+ const subItemList = row["자재명(상세)"] || row["subItemList"] || null;
+
+ // 데이터 정제
+ const cleanedRow = {
+ itemCode: typeof itemCode === 'string' ? itemCode.trim() : String(itemCode).trim(),
+ workType: typeof workType === 'string' ? workType.trim() : String(workType).trim(),
+ itemList: itemList ? (typeof itemList === 'string' ? itemList : String(itemList)) : null,
+ subItemList: subItemList ? (typeof subItemList === 'string' ? subItemList : String(subItemList)) : null,
+ };
+
+ // 데이터 유효성 검사
+ const validationResult = itemSchema.safeParse(cleanedRow);
+
+ if (!validationResult.success) {
+ const errorMessage = validationResult.error.errors.map(
+ err => `${err.path.join('.')}: ${err.message}`
+ ).join(', ');
+
+ errors.push({
+ row: rowIndex,
+ message: errorMessage,
+ });
+ errorCount++;
+ continue;
+ }
+
+ // 해양 TOP 아이템 생성
+ const result = await createOffshoreTopItem({
+ itemCode: cleanedRow.itemCode,
+ workType: cleanedRow.workType as "TM" | "TS" | "TE" | "TP",
+ itemList: cleanedRow.itemList,
+ subItemList: cleanedRow.subItemList,
+ });
+
+ if (result.success) {
+ successCount++;
+ } else {
+ errors.push({
+ row: rowIndex,
+ message: result.message || result.error || "알 수 없는 오류",
+ });
+ errorCount++;
+ }
+ } catch (error) {
+ console.error(`${rowIndex}행 처리 오류:`, error);
+ errors.push({
+ row: rowIndex,
+ message: error instanceof Error ? error.message : "알 수 없는 오류",
+ });
+ errorCount++;
+ }
+
+ // 비동기 작업 쓰로틀링
+ if (i % 5 === 0) {
+ await new Promise(resolve => setTimeout(resolve, 10));
+ }
+ }
+
+ // 처리 결과 반환
+ return {
+ successCount,
+ errorCount,
+ errors
+ };
+}
diff --git a/lib/items-tech/table/top/item-excel-template.tsx b/lib/items-tech/table/top/item-excel-template.tsx
index b67d91be..9121d70f 100644
--- a/lib/items-tech/table/top/item-excel-template.tsx
+++ b/lib/items-tech/table/top/item-excel-template.tsx
@@ -1,109 +1,109 @@
-import * as ExcelJS from 'exceljs';
-import { saveAs } from "file-saver";
-
-
-/**
- * 해양 TOP 아이템 데이터 가져오기를 위한 Excel 템플릿 파일 생성 및 다운로드
- */
-export async function exportTopItemTemplate() {
- // 워크북 생성
- const workbook = new ExcelJS.Workbook();
- workbook.creator = 'Offshore TOP Item Management System';
- workbook.created = new Date();
-
- // 워크시트 생성
- const worksheet = workbook.addWorksheet('해양 TOP 아이템');
-
- // 컬럼 헤더 정의 및 스타일 적용
- worksheet.columns = [
- { header: '자재 그룹', key: 'itemCode', width: 15 },
- { header: '기능(공종)', key: 'workType', width: 15 },
- { header: '자재명', key: 'itemList', width: 20 },
- { header: '자재명(상세)', key: 'subItemList', width: 20 },
-
- ];
-
- // 헤더 스타일 적용
- const headerRow = worksheet.getRow(1);
- headerRow.font = { bold: true };
- headerRow.fill = {
- type: 'pattern',
- pattern: 'solid',
- fgColor: { argb: 'FFE0E0E0' }
- };
- headerRow.alignment = { vertical: 'middle', horizontal: 'center' };
-
- // 테두리 스타일 적용
- headerRow.eachCell((cell) => {
- cell.border = {
- top: { style: 'thin' },
- left: { style: 'thin' },
- bottom: { style: 'thin' },
- right: { style: 'thin' }
- };
- });
-
- // 샘플 데이터 추가
- const sampleData = [
- {
- itemCode: 'TOP001',
- workType: 'TM',
- itemList: '항목1 샘플 데이터',
- subItemList: '항목2 샘플 데이터',
- },
- {
- itemCode: 'TOP002',
- workType: 'TS',
- itemList: '항목1 샘플 데이터',
- subItemList: '항목2 샘플 데이터',
- }
-
- ];
-
- // 데이터 행 추가
- sampleData.forEach(item => {
- worksheet.addRow(item);
- });
-
- // 데이터 행 스타일 적용
- worksheet.eachRow((row, rowNumber) => {
- if (rowNumber > 1) { // 헤더를 제외한 데이터 행
- row.eachCell((cell) => {
- cell.border = {
- top: { style: 'thin' },
- left: { style: 'thin' },
- bottom: { style: 'thin' },
- right: { style: 'thin' }
- };
- });
- }
- });
-
-
- // 워크시트 보호 (선택적)
- worksheet.protect('', {
- selectLockedCells: true,
- selectUnlockedCells: true,
- formatColumns: true,
- formatRows: true,
- insertColumns: false,
- insertRows: true,
- insertHyperlinks: false,
- deleteColumns: false,
- deleteRows: true,
- sort: true,
- autoFilter: true,
- pivotTables: false
- });
-
- try {
- // 워크북을 Blob으로 변환
- const buffer = await workbook.xlsx.writeBuffer();
- const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
- saveAs(blob, 'offshore-top-item-template.xlsx');
- return true;
- } catch (error) {
- console.error('Excel 템플릿 생성 오류:', error);
- throw error;
- }
-}
+import * as ExcelJS from 'exceljs';
+import { saveAs } from "file-saver";
+
+
+/**
+ * 해양 TOP 아이템 데이터 가져오기를 위한 Excel 템플릿 파일 생성 및 다운로드
+ */
+export async function exportTopItemTemplate() {
+ // 워크북 생성
+ const workbook = new ExcelJS.Workbook();
+ workbook.creator = 'Offshore TOP Item Management System';
+ workbook.created = new Date();
+
+ // 워크시트 생성
+ const worksheet = workbook.addWorksheet('해양 TOP 아이템');
+
+ // 컬럼 헤더 정의 및 스타일 적용
+ worksheet.columns = [
+ { header: '자재 그룹', key: 'itemCode', width: 15 },
+ { header: '기능(공종)', key: 'workType', width: 15 },
+ { header: '자재명', key: 'itemList', width: 20 },
+ { header: '자재명(상세)', key: 'subItemList', width: 20 },
+
+ ];
+
+ // 헤더 스타일 적용
+ const headerRow = worksheet.getRow(1);
+ headerRow.font = { bold: true };
+ headerRow.fill = {
+ type: 'pattern',
+ pattern: 'solid',
+ fgColor: { argb: 'FFE0E0E0' }
+ };
+ headerRow.alignment = { vertical: 'middle', horizontal: 'center' };
+
+ // 테두리 스타일 적용
+ headerRow.eachCell((cell) => {
+ cell.border = {
+ top: { style: 'thin' },
+ left: { style: 'thin' },
+ bottom: { style: 'thin' },
+ right: { style: 'thin' }
+ };
+ });
+
+ // 샘플 데이터 추가
+ const sampleData = [
+ {
+ itemCode: 'TOP001',
+ workType: 'TM',
+ itemList: '항목1 샘플 데이터',
+ subItemList: '항목2 샘플 데이터',
+ },
+ {
+ itemCode: 'TOP002',
+ workType: 'TS',
+ itemList: '항목1 샘플 데이터',
+ subItemList: '항목2 샘플 데이터',
+ }
+
+ ];
+
+ // 데이터 행 추가
+ sampleData.forEach(item => {
+ worksheet.addRow(item);
+ });
+
+ // 데이터 행 스타일 적용
+ worksheet.eachRow((row, rowNumber) => {
+ if (rowNumber > 1) { // 헤더를 제외한 데이터 행
+ row.eachCell((cell) => {
+ cell.border = {
+ top: { style: 'thin' },
+ left: { style: 'thin' },
+ bottom: { style: 'thin' },
+ right: { style: 'thin' }
+ };
+ });
+ }
+ });
+
+
+ // 워크시트 보호 (선택적)
+ worksheet.protect('', {
+ selectLockedCells: true,
+ selectUnlockedCells: true,
+ formatColumns: true,
+ formatRows: true,
+ insertColumns: false,
+ insertRows: true,
+ insertHyperlinks: false,
+ deleteColumns: false,
+ deleteRows: true,
+ sort: true,
+ autoFilter: true,
+ pivotTables: false
+ });
+
+ try {
+ // 워크북을 Blob으로 변환
+ const buffer = await workbook.xlsx.writeBuffer();
+ const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
+ saveAs(blob, 'offshore-top-item-template.xlsx');
+ return true;
+ } catch (error) {
+ console.error('Excel 템플릿 생성 오류:', error);
+ throw error;
+ }
+}
diff --git a/lib/tech-vendor-invitation-token.ts b/lib/tech-vendor-invitation-token.ts
index 83c82448..04a31bcc 100644
--- a/lib/tech-vendor-invitation-token.ts
+++ b/lib/tech-vendor-invitation-token.ts
@@ -15,6 +15,7 @@ export interface TechVendorInvitationPayload {
vendorId: number;
vendorName: string;
email: string;
+ vendorType: "조선" | "해양TOP" | "해양HULL" | ("조선" | "해양TOP" | "해양HULL")[];
type: "tech-vendor-invitation";
expiresAt: number;
}
@@ -26,6 +27,7 @@ export async function createTechVendorInvitationToken(payload: {
vendorId: number;
vendorName: string;
email: string;
+ vendorType: "조선" | "해양TOP" | "해양HULL" | ("조선" | "해양TOP" | "해양HULL")[];
}): Promise<string> {
const expiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000; // 7일
@@ -34,6 +36,7 @@ export async function createTechVendorInvitationToken(payload: {
vendorName: payload.vendorName,
email: payload.email,
type: "tech-vendor-invitation",
+ vendorType: payload.vendorType,
expiresAt,
})
.setProtectedHeader({ alg: "HS256" })
@@ -78,6 +81,6 @@ export async function verifyTechVendorInvitationToken(
* 초대 토큰을 포함한 가입 URL 생성
*/
export async function createTechVendorSignupUrl(token: string): Promise<string> {
- const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000";
- return `${baseUrl}/ko/auth/tech-signup?token=${token}`;
+ const baseUrl = process.env.NEXT_PUBLIC_URL || "http://localhost:3000";
+ return `${baseUrl}/partners/tech-signup?token=${token}`;
} \ No newline at end of file
diff --git a/lib/tech-vendor-possible-items/repository.ts b/lib/tech-vendor-possible-items/repository.ts
index b2588395..5c1487b5 100644
--- a/lib/tech-vendor-possible-items/repository.ts
+++ b/lib/tech-vendor-possible-items/repository.ts
@@ -1,16 +1,17 @@
-import { eq, desc, count } from "drizzle-orm";
+import { eq, desc, count, SQL, sql, and, or, ilike } from "drizzle-orm";
import {
techVendors,
techVendorPossibleItems
} from "@/db/schema/techVendors";
+import type { PgTransaction } from "drizzle-orm/pg-core";
/**
* 기술영업 벤더 가능 아이템 목록 조회 (조인 포함)
*/
export async function selectTechVendorPossibleItemsWithJoin(
- tx: any,
- where: any,
- orderBy: any[],
+ tx: PgTransaction<any, any, any>,
+ where: SQL | undefined,
+ orderBy: SQL[],
offset: number,
limit: number
) {
@@ -18,10 +19,17 @@ export async function selectTechVendorPossibleItemsWithJoin(
.select({
id: techVendorPossibleItems.id,
vendorId: techVendorPossibleItems.vendorId,
- vendorCode: techVendors.vendorCode,
+ vendorCode: techVendorPossibleItems.vendorCode, // 테이블에서 직접 조회
vendorName: techVendors.vendorName,
+ vendorEmail: techVendorPossibleItems.vendorEmail, // 테이블에서 직접 조회
techVendorType: techVendors.techVendorType,
+ vendorStatus: techVendors.status,
itemCode: techVendorPossibleItems.itemCode,
+ // 새로운 스키마: 테이블에서 직접 조회
+ workType: techVendorPossibleItems.workType,
+ shipTypes: techVendorPossibleItems.shipTypes,
+ itemList: techVendorPossibleItems.itemList,
+ subItemList: techVendorPossibleItems.subItemList,
createdAt: techVendorPossibleItems.createdAt,
updatedAt: techVendorPossibleItems.updatedAt,
})
@@ -36,7 +44,10 @@ export async function selectTechVendorPossibleItemsWithJoin(
/**
* 기술영업 벤더 가능 아이템 총 개수 조회 (조인 포함)
*/
-export async function countTechVendorPossibleItemsWithJoin(tx: any, where?: any) {
+export async function countTechVendorPossibleItemsWithJoin(
+ tx: PgTransaction<any, any, any>,
+ where?: SQL | undefined
+) {
const [result] = await tx
.select({ count: count() })
.from(techVendorPossibleItems)
@@ -44,4 +55,102 @@ export async function countTechVendorPossibleItemsWithJoin(tx: any, where?: any)
.where(where);
return result.count;
-} \ No newline at end of file
+}
+
+/**
+ * 새로운 필드들을 위한 그룹별 통계 조회
+ */
+export async function getTechVendorPossibleItemsGroupStats(
+ tx: PgTransaction<any, any, any>,
+ groupBy: 'workType' | 'shipTypes' | 'vendorCode' | 'vendorEmail',
+ where?: SQL | undefined
+) {
+ const groupField = techVendorPossibleItems[groupBy];
+
+ return await tx
+ .select({
+ groupValue: groupField,
+ count: count(),
+ vendorCount: sql<number>`COUNT(DISTINCT ${techVendorPossibleItems.vendorId})`.as('vendorCount'),
+ itemCount: sql<number>`COUNT(DISTINCT ${techVendorPossibleItems.itemCode})`.as('itemCount'),
+ })
+ .from(techVendorPossibleItems)
+ .innerJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id))
+ .where(where)
+ .groupBy(groupField)
+ .orderBy(desc(count()));
+}
+
+/**
+ * 공종별 통계 조회
+ */
+export async function getWorkTypeStats(
+ tx: PgTransaction<any, any, any>,
+ where?: SQL | undefined
+) {
+ return await tx
+ .select({
+ workType: techVendorPossibleItems.workType,
+ count: count(),
+ vendorCount: sql<number>`COUNT(DISTINCT ${techVendorPossibleItems.vendorId})`.as('vendorCount'),
+ itemCount: sql<number>`COUNT(DISTINCT ${techVendorPossibleItems.itemCode})`.as('itemCount'),
+ })
+ .from(techVendorPossibleItems)
+ .innerJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id))
+ .where(where)
+ .groupBy(techVendorPossibleItems.workType)
+ .orderBy(desc(count()));
+}
+
+/**
+ * 선종별 통계 조회
+ */
+export async function getShipTypeStats(
+ tx: PgTransaction<any, any, any>,
+ where?: SQL | undefined
+) {
+ return await tx
+ .select({
+ shipTypes: techVendorPossibleItems.shipTypes,
+ count: count(),
+ vendorCount: sql<number>`COUNT(DISTINCT ${techVendorPossibleItems.vendorId})`.as('vendorCount'),
+ itemCount: sql<number>`COUNT(DISTINCT ${techVendorPossibleItems.itemCode})`.as('itemCount'),
+ })
+ .from(techVendorPossibleItems)
+ .innerJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id))
+ .where(where)
+ .groupBy(techVendorPossibleItems.shipTypes)
+ .orderBy(desc(count()));
+}
+
+/**
+ * 벤더별 통계 조회
+ */
+export async function getVendorStats(
+ tx: PgTransaction<any, any, any>,
+ where?: SQL | undefined
+) {
+ return await tx
+ .select({
+ vendorId: techVendorPossibleItems.vendorId,
+ vendorCode: techVendorPossibleItems.vendorCode,
+ vendorName: techVendors.vendorName,
+ vendorEmail: techVendorPossibleItems.vendorEmail,
+ itemCount: count(),
+ distinctItemCount: sql<number>`COUNT(DISTINCT ${techVendorPossibleItems.itemCode})`.as('distinctItemCount'),
+ workTypeCount: sql<number>`COUNT(DISTINCT ${techVendorPossibleItems.workType})`.as('workTypeCount'),
+ shipTypeCount: sql<number>`COUNT(DISTINCT ${techVendorPossibleItems.shipTypes})`.as('shipTypeCount'),
+ latestUpdate: sql<Date>`MAX(${techVendorPossibleItems.updatedAt})`.as('latestUpdate'),
+ })
+ .from(techVendorPossibleItems)
+ .innerJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id))
+ .where(where)
+ .groupBy(
+ techVendorPossibleItems.vendorId,
+ techVendorPossibleItems.vendorCode,
+ techVendors.vendorName,
+ techVendorPossibleItems.vendorEmail
+ )
+ .orderBy(desc(count()));
+}
+
diff --git a/lib/tech-vendor-possible-items/service.ts b/lib/tech-vendor-possible-items/service.ts
index efe9be51..c630e33a 100644
--- a/lib/tech-vendor-possible-items/service.ts
+++ b/lib/tech-vendor-possible-items/service.ts
@@ -1,5 +1,5 @@
"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택)
-import { eq, and, inArray, desc, asc, or, ilike } from "drizzle-orm";
+import { eq, and, inArray, desc, asc, or, ilike, isNull } from "drizzle-orm";
import db from "@/db/db";
import {
techVendors,
@@ -9,9 +9,9 @@ import { itemShipbuilding, itemOffshoreTop, itemOffshoreHull } from "@/db/schema
import { unstable_cache } from "@/lib/unstable-cache";
import { filterColumns } from "@/lib/filter-columns";
import type { GetTechVendorPossibleItemsSchema } from "./validations";
-import {
- selectTechVendorPossibleItemsWithJoin,
- countTechVendorPossibleItemsWithJoin
+import {
+ selectTechVendorPossibleItemsWithJoin,
+ countTechVendorPossibleItemsWithJoin,
} from "./repository";
export interface TechVendorPossibleItemsData {
@@ -19,21 +19,34 @@ export interface TechVendorPossibleItemsData {
vendorId: number;
vendorCode: string | null;
vendorName: string;
+ vendorEmail: string | null;
techVendorType: string;
itemCode: string;
+ workType: string | null;
+ shipTypes: string | null;
+ itemList: string | null;
+ subItemList: string | null;
createdAt: Date;
updatedAt: Date;
}
export interface CreateTechVendorPossibleItemData {
- vendorId: number;
- itemCode: string;
+ vendorId: number; // 필수: 벤더 ID (Add Dialog에서 벤더 선택 시 사용)
+ itemCode: string; // 필수: 아이템 코드
+ workType?: string | null; // 공종 (아이템에서 가져온 정보)
+ shipTypes?: string | null; // 선종 (아이템에서 가져온 정보)
+ itemList?: string | null; // 아이템리스트 (아이템에서 가져온 정보)
+ subItemList?: string | null; // 서브아이템리스트 (아이템에서 가져온 정보)
}
export interface ImportTechVendorPossibleItemData {
- vendorCode: string;
- vendorEmail?: string;
- itemCode: string;
+ vendorCode?: string;
+ vendorEmail: string; // 필수: 벤더 이메일
+ itemCode: string; // 필수: 아이템 코드
+ workType?: string;
+ shipTypes?: string;
+ itemList?: string;
+ subItemList?: string;
}
export interface ImportResult {
@@ -45,7 +58,11 @@ export interface ImportResult {
error: string;
vendorCode?: string;
vendorEmail?: string;
- itemCode?: string;
+ itemCode?: string;
+ workType?: string;
+ shipTypes?: string;
+ itemList?: string;
+ subItemList?: string;
}[];
}
@@ -74,14 +91,19 @@ export async function getTechVendorPossibleItems(input: GetTechVendorPossibleIte
globalWhere = or(
ilike(techVendors.vendorCode, s),
ilike(techVendors.vendorName, s),
+ ilike(techVendorPossibleItems.vendorEmail, s),
ilike(techVendorPossibleItems.itemCode, s),
+ ilike(techVendorPossibleItems.workType, s),
+ ilike(techVendorPossibleItems.shipTypes, s),
+ ilike(techVendorPossibleItems.itemList, s),
+ ilike(techVendorPossibleItems.subItemList, s),
);
}
// 기존 호환성을 위한 개별 필터들
const legacyFilters = [];
if (input.vendorCode) {
- legacyFilters.push(ilike(techVendors.vendorCode, `%${input.vendorCode}%`));
+ legacyFilters.push(ilike(techVendorPossibleItems.vendorCode, `%${input.vendorCode}%`));
}
if (input.vendorName) {
legacyFilters.push(ilike(techVendors.vendorName, `%${input.vendorName}%`));
@@ -225,13 +247,13 @@ export async function getTechVendorPossibleItems(input: GetTechVendorPossibleIte
// }
/**
- * tech vendor possible item 생성 (간단 버전)
+ * tech vendor possible item 생성 (Add Dialog용 - vendorId 기반)
*/
export async function createTechVendorPossibleItem(
data: CreateTechVendorPossibleItemData
): Promise<{ success: boolean; error?: string }> {
try {
- // 벤더 존재 여부만 확인
+ // 벤더 ID로 벤더 조회
const vendor = await db
.select()
.from(techVendors)
@@ -242,14 +264,20 @@ export async function createTechVendorPossibleItem(
return { success: false, error: "벤더를 찾을 수 없습니다." };
}
- // 중복 체크
+ // 중복 체크 (벤더 + 아이템코드 + 공종 + 선종 조합)
const existing = await db
.select()
.from(techVendorPossibleItems)
.where(
and(
eq(techVendorPossibleItems.vendorId, data.vendorId),
- eq(techVendorPossibleItems.itemCode, data.itemCode)
+ eq(techVendorPossibleItems.itemCode, data.itemCode),
+ data.workType
+ ? eq(techVendorPossibleItems.workType, data.workType)
+ : isNull(techVendorPossibleItems.workType),
+ data.shipTypes
+ ? eq(techVendorPossibleItems.shipTypes, data.shipTypes)
+ : isNull(techVendorPossibleItems.shipTypes)
)
)
.limit(1);
@@ -258,10 +286,16 @@ export async function createTechVendorPossibleItem(
return { success: false, error: "이미 존재하는 벤더-아이템 조합입니다." };
}
- // 아이템 코드 검증 없이 바로 삽입
+ // 새로운 아이템 생성 (선택한 아이템의 정보를 그대로 저장)
await db.insert(techVendorPossibleItems).values({
- vendorId: data.vendorId,
+ vendorId: vendor[0].id,
+ vendorCode: vendor[0].vendorCode,
+ vendorEmail: vendor[0].email,
itemCode: data.itemCode,
+ workType: data.workType,
+ shipTypes: data.shipTypes,
+ itemList: data.itemList,
+ subItemList: data.subItemList,
});
return { success: true };
@@ -419,7 +453,7 @@ export async function getItemByCode(itemCode: string) {
}
/**
- * Import 기능: 벤더코드와 아이템코드를 통한 batch insert (간단 버전)
+ * Import 기능: 벤더이메일과 아이템정보를 통한 batch insert (새로운 스키마 버전)
*/
export async function importTechVendorPossibleItems(
data: ImportTechVendorPossibleItemData[]
@@ -436,39 +470,55 @@ export async function importTechVendorPossibleItems(
const rowNumber = i + 1;
try {
- // 벤더 코드 또는 이메일로 벤더 찾기
+ // 벤더 이메일로 벤더 찾기 (필수)
let vendor = null;
- if (row.vendorCode && row.vendorCode.trim()) {
- // 벤더 코드가 있으면 먼저 벤더 코드로 검색
- vendor = await getTechVendorByCode(row.vendorCode);
- } else if (row.vendorEmail && row.vendorEmail.trim()) {
- // 벤더 코드가 없으면 이메일로 검색
+ if (row.vendorEmail && row.vendorEmail.trim()) {
vendor = await getTechVendorByEmail(row.vendorEmail);
+ } else {
+ result.failedRows.push({
+ row: rowNumber,
+ error: "벤더 이메일은 필수입니다.",
+ vendorCode: row.vendorCode,
+ vendorEmail: row.vendorEmail,
+ itemCode: row.itemCode,
+ workType: row.workType,
+ shipTypes: row.shipTypes,
+ itemList: row.itemList,
+ subItemList: row.subItemList,
+ });
+ continue;
}
if (!vendor) {
- const identifier = row.vendorCode ? `벤더 코드 '${row.vendorCode}'` :
- row.vendorEmail ? `벤더 이메일 '${row.vendorEmail}'` :
- '벤더 코드 또는 이메일';
result.failedRows.push({
row: rowNumber,
- error: `${identifier}을(를) 찾을 수 없습니다.`,
+ error: `벤더 이메일 '${row.vendorEmail}'을(를) 찾을 수 없습니다.`,
vendorCode: row.vendorCode,
vendorEmail: row.vendorEmail,
itemCode: row.itemCode,
+ workType: row.workType,
+ shipTypes: row.shipTypes,
+ itemList: row.itemList,
+ subItemList: row.subItemList,
});
continue;
}
- // 중복 체크
+ // 중복 체크 (벤더 + 아이템코드 + 공종 + 선종 조합)
const existing = await db
.select()
.from(techVendorPossibleItems)
.where(
and(
eq(techVendorPossibleItems.vendorId, vendor.id),
- eq(techVendorPossibleItems.itemCode, row.itemCode)
+ eq(techVendorPossibleItems.itemCode, row.itemCode),
+ row.workType
+ ? eq(techVendorPossibleItems.workType, row.workType)
+ : isNull(techVendorPossibleItems.workType),
+ row.shipTypes
+ ? eq(techVendorPossibleItems.shipTypes, row.shipTypes)
+ : isNull(techVendorPossibleItems.shipTypes)
)
)
.limit(1);
@@ -480,14 +530,24 @@ export async function importTechVendorPossibleItems(
vendorCode: row.vendorCode,
vendorEmail: row.vendorEmail,
itemCode: row.itemCode,
+ workType: row.workType,
+ shipTypes: row.shipTypes,
+ itemList: row.itemList,
+ subItemList: row.subItemList,
});
continue;
}
- // 아이템 코드 검증 없이 바로 삽입
+ // 새로운 아이템 생성
await db.insert(techVendorPossibleItems).values({
vendorId: vendor.id,
+ vendorCode: vendor.vendorCode,
+ vendorEmail: vendor.email,
itemCode: row.itemCode,
+ workType: row.workType || null,
+ shipTypes: row.shipTypes || null,
+ itemList: row.itemList || null,
+ subItemList: row.subItemList || null,
});
result.successCount++;
@@ -498,6 +558,10 @@ export async function importTechVendorPossibleItems(
vendorCode: row.vendorCode,
vendorEmail: row.vendorEmail,
itemCode: row.itemCode,
+ workType: row.workType,
+ shipTypes: row.shipTypes,
+ itemList: row.itemList,
+ subItemList: row.subItemList,
});
}
}
@@ -580,4 +644,174 @@ export async function getUniqueTechVendorTypes(): Promise<string[]> {
// 오류 발생시 기본 벤더 타입 반환
return ["조선", "해양TOP", "해양HULL"];
}
-} \ No newline at end of file
+}
+
+/**
+ * 벤더 타입에 따른 아이템 목록 조회
+ */
+export async function getItemsByVendorType(vendorTypes: string): Promise<{
+ itemCode: string;
+ itemList: string | null;
+ workType: string | null;
+ shipTypes?: string | null;
+ subItemList?: string | null;
+}[]> {
+ try {
+ // 벤더 타입 파싱 개선
+ let types: string[] = [];
+ if (!vendorTypes) {
+ return [];
+ }
+
+ if (vendorTypes.startsWith('[') && vendorTypes.endsWith(']')) {
+ // JSON 배열 형태
+ try {
+ const parsed = JSON.parse(vendorTypes);
+ types = Array.isArray(parsed) ? parsed.filter(Boolean) : [vendorTypes];
+ } catch {
+ types = [vendorTypes];
+ }
+ } else if (vendorTypes.includes(',')) {
+ // 콤마로 구분된 문자열
+ types = vendorTypes.split(',').map(t => t.trim()).filter(Boolean);
+ } else {
+ // 단일 문자열
+ types = [vendorTypes.trim()].filter(Boolean);
+ }
+ // 벤더 타입 정렬 - 조선 > 해양TOP > 해양HULL 순
+ const typeOrder = ["조선", "해양TOP", "해양HULL"];
+ types.sort((a, b) => {
+ const indexA = typeOrder.indexOf(a);
+ const indexB = typeOrder.indexOf(b);
+
+ // 정의된 순서에 있는 경우 우선순위 적용
+ if (indexA !== -1 && indexB !== -1) {
+ return indexA - indexB;
+ }
+ // 정의된 순서에 없는 경우 마지막에 배치하고 알파벳 순으로 정렬
+ if (indexA !== -1) return -1;
+ if (indexB !== -1) return 1;
+ return a.localeCompare(b);
+ });
+
+ const allItems: any[] = [];
+
+ // 각 벤더 타입에 따라 해당 아이템 테이블에서 조회
+ for (const type of types) {
+ switch (type) {
+ case "조선":
+ const shipItems = await db
+ .select({
+ itemCode: itemShipbuilding.itemCode,
+ itemList: itemShipbuilding.itemList,
+ workType: itemShipbuilding.workType,
+ shipTypes: itemShipbuilding.shipTypes,
+ })
+ .from(itemShipbuilding);
+ allItems.push(...shipItems);
+ break;
+
+ case "해양TOP":
+ const topItems = await db
+ .select({
+ itemCode: itemOffshoreTop.itemCode,
+ itemList: itemOffshoreTop.itemList,
+ workType: itemOffshoreTop.workType,
+ subItemList: itemOffshoreTop.subItemList,
+ })
+ .from(itemOffshoreTop);
+ allItems.push(...topItems);
+ break;
+
+ case "해양HULL":
+ const hullItems = await db
+ .select({
+ itemCode: itemOffshoreHull.itemCode,
+ itemList: itemOffshoreHull.itemList,
+ workType: itemOffshoreHull.workType,
+ subItemList: itemOffshoreHull.subItemList,
+ })
+ .from(itemOffshoreHull);
+ allItems.push(...hullItems);
+ break;
+ }
+ }
+ // // 중복 제거 (itemCode 기준)
+ // const uniqueItems = allItems.filter((item, index, self) =>
+ // index === self.findIndex(i => i.itemCode === item.itemCode)
+ // );
+
+ // const finalItems = uniqueItems.filter(item => item.itemCode); // itemCode가 있는 것만 반환
+ // console.log("Final items after deduplication and filtering:", finalItems.length);
+
+ return allItems;
+ } catch (error) {
+ console.error("Error fetching items by vendor type:", error);
+ return [];
+ }
+}
+
+/**
+ * Excel Export 기능: 기술영업 벤더 가능 아이템 목록 내보내기
+ */
+export async function exportTechVendorPossibleItemsToExcel(): Promise<{
+ success: boolean;
+ data?: Array<{
+ 벤더코드: string | null;
+ 벤더명: string;
+ 벤더이메일: string | null;
+ 벤더타입: string;
+ 아이템코드: string;
+ 공종: string | null;
+ 선종: string | null;
+ 아이템리스트: string | null;
+ 서브아이템리스트: string | null;
+ 생성일: string;
+ }>;
+ error?: string;
+}> {
+ try {
+ // 모든 데이터 조회 (페이지네이션 없이)
+ const allData = await db
+ .select({
+ vendorCode: techVendorPossibleItems.vendorCode,
+ vendorName: techVendors.vendorName,
+ vendorEmail: techVendorPossibleItems.vendorEmail,
+ techVendorType: techVendors.techVendorType,
+ itemCode: techVendorPossibleItems.itemCode,
+ workType: techVendorPossibleItems.workType,
+ shipTypes: techVendorPossibleItems.shipTypes,
+ itemList: techVendorPossibleItems.itemList,
+ subItemList: techVendorPossibleItems.subItemList,
+ createdAt: techVendorPossibleItems.createdAt,
+ })
+ .from(techVendorPossibleItems)
+ .innerJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id))
+ .orderBy(desc(techVendorPossibleItems.createdAt));
+
+ // Excel 형태로 변환
+ const excelData = allData.map(item => ({
+ 벤더코드: item.vendorCode,
+ 벤더명: item.vendorName,
+ 벤더이메일: item.vendorEmail,
+ 벤더타입: item.techVendorType,
+ 아이템코드: item.itemCode,
+ 공종: item.workType,
+ 선종: item.shipTypes,
+ 아이템리스트: item.itemList,
+ 서브아이템리스트: item.subItemList,
+ 생성일: item.createdAt.toISOString().split('T')[0], // YYYY-MM-DD 형식
+ }));
+
+ return {
+ success: true,
+ data: excelData,
+ };
+ } catch (error) {
+ console.error("Error exporting tech vendor possible items:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "내보내기 중 오류가 발생했습니다.",
+ };
+ }
+}
diff --git a/lib/tech-vendor-possible-items/table/add-possible-item-dialog.tsx b/lib/tech-vendor-possible-items/table/add-possible-item-dialog.tsx
new file mode 100644
index 00000000..cdce60af
--- /dev/null
+++ b/lib/tech-vendor-possible-items/table/add-possible-item-dialog.tsx
@@ -0,0 +1,450 @@
+"use client";
+
+import * as React from "react";
+import { Search, Plus, X } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Badge } from "@/components/ui/badge";
+import { useToast } from "@/hooks/use-toast";
+import {
+ getAllTechVendors,
+ createTechVendorPossibleItem,
+ getItemsByVendorType
+} from "@/lib/tech-vendor-possible-items/service";
+
+interface TechVendor {
+ id: number;
+ vendorCode: string | null;
+ vendorName: string;
+ techVendorType: string;
+}
+
+interface ItemData {
+ itemCode: string;
+ itemList: string | null;
+ workType: string | null;
+ shipTypes?: string | null;
+ subItemList?: string | null;
+}
+
+interface AddPossibleItemDialogProps {
+ children?: React.ReactNode;
+ onSuccess?: () => void;
+}
+
+export function AddPossibleItemDialog({
+ children,
+ onSuccess
+}: AddPossibleItemDialogProps) {
+ const { toast } = useToast();
+ const [open, setOpen] = React.useState(false);
+
+ // 벤더 관련 상태
+ const [vendors, setVendors] = React.useState<TechVendor[]>([]);
+ const [filteredVendors, setFilteredVendors] = React.useState<TechVendor[]>([]);
+ const [vendorSearch, setVendorSearch] = React.useState("");
+ const [selectedVendor, setSelectedVendor] = React.useState<TechVendor | null>(null);
+
+ // 아이템 관련 상태
+ const [items, setItems] = React.useState<ItemData[]>([]);
+ const [filteredItems, setFilteredItems] = React.useState<ItemData[]>([]);
+ const [itemSearch, setItemSearch] = React.useState("");
+ const [selectedItems, setSelectedItems] = React.useState<ItemData[]>([]);
+
+ const [isLoading, setIsLoading] = React.useState(false);
+
+ // 벤더 목록 로드
+ React.useEffect(() => {
+ if (open) {
+ loadVendors();
+ }
+ }, [open]);
+
+ // 벤더 검색 필터링
+ React.useEffect(() => {
+ if (!vendorSearch) {
+ setFilteredVendors(vendors);
+ } else {
+ const filtered = vendors.filter(vendor =>
+ vendor.vendorName.toLowerCase().includes(vendorSearch.toLowerCase()) ||
+ vendor.vendorCode?.toLowerCase().includes(vendorSearch.toLowerCase())
+ );
+ setFilteredVendors(filtered);
+ }
+ }, [vendors, vendorSearch]);
+
+ // 아이템 검색 필터링
+ React.useEffect(() => {
+ if (!itemSearch) {
+ setFilteredItems(items);
+ } else {
+ const filtered = items.filter(item =>
+ item.itemCode.toLowerCase().includes(itemSearch.toLowerCase()) ||
+ item.itemList?.toLowerCase().includes(itemSearch.toLowerCase()) ||
+ item.workType?.toLowerCase().includes(itemSearch.toLowerCase())
+ );
+ setFilteredItems(filtered);
+ }
+ }, [items, itemSearch]);
+
+ const loadVendors = async () => {
+ try {
+ setIsLoading(true);
+ const vendorData = await getAllTechVendors();
+ setVendors(vendorData);
+ } catch (error) {
+ console.error("Failed to load vendors:", error);
+ toast({
+ title: "오류",
+ description: "벤더 목록을 불러오는데 실패했습니다.",
+ variant: "destructive",
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const loadItemsByVendorType = async (vendorTypes: string) => {
+ try {
+ setIsLoading(true);
+ console.log("Loading items for vendor types:", vendorTypes);
+ const itemData = await getItemsByVendorType(vendorTypes);
+ console.log("Loaded items:", itemData.length, itemData);
+ setItems(itemData);
+ } catch (error) {
+ console.error("Failed to load items:", error);
+ toast({
+ title: "오류",
+ description: "아이템 목록을 불러오는데 실패했습니다.",
+ variant: "destructive",
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleVendorSelect = (vendor: TechVendor) => {
+ setSelectedVendor(vendor);
+ setSelectedItems([]); // 벤더 변경시 선택된 아이템 초기화
+ loadItemsByVendorType(vendor.techVendorType);
+ };
+
+ const handleItemToggle = (item: ItemData) => {
+ setSelectedItems(prev => {
+ const isSelected = prev.some(i => i.itemCode === item.itemCode);
+ if (isSelected) {
+ return prev.filter(i => i.itemCode !== item.itemCode);
+ } else {
+ return [...prev, item];
+ }
+ });
+ };
+
+ const handleSubmit = async () => {
+ if (!selectedVendor || selectedItems.length === 0) return;
+
+ try {
+ setIsLoading(true);
+ let successCount = 0;
+ let errorCount = 0;
+
+ for (const item of selectedItems) {
+ const result = await createTechVendorPossibleItem({
+ vendorId: selectedVendor.id,
+ itemCode: item.itemCode,
+ workType: item.workType,
+ shipTypes: item.shipTypes,
+ itemList: item.itemList,
+ subItemList: item.subItemList,
+ });
+
+ if (result.success) {
+ successCount++;
+ } else {
+ errorCount++;
+ }
+ }
+
+ if (successCount > 0) {
+ toast({
+ title: "성공",
+ description: `${successCount}개의 아이템이 추가되었습니다.${errorCount > 0 ? ` (${errorCount}개 실패)` : ""}`,
+ });
+
+ handleClose();
+ onSuccess?.();
+ } else {
+ toast({
+ title: "오류",
+ description: "아이템 추가에 실패했습니다.",
+ variant: "destructive",
+ });
+ }
+ } catch (error) {
+ console.error("Failed to add items:", error);
+ toast({
+ title: "오류",
+ description: "아이템 추가 중 오류가 발생했습니다.",
+ variant: "destructive",
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleClose = () => {
+ setOpen(false);
+ setTimeout(() => {
+ setSelectedVendor(null);
+ setSelectedItems([]);
+ setVendorSearch("");
+ setItemSearch("");
+ setVendors([]);
+ setItems([]);
+ setFilteredVendors([]);
+ setFilteredItems([]);
+ }, 200);
+ };
+
+ const parseVendorTypes = (vendorType: string): string[] => {
+ if (!vendorType) return [];
+
+ // JSON 배열 형태인지 확인
+ if (vendorType.startsWith('[') && vendorType.endsWith(']')) {
+ try {
+ const parsed = JSON.parse(vendorType);
+ return Array.isArray(parsed) ? parsed.filter(Boolean) : [vendorType];
+ } catch {
+ return [vendorType];
+ }
+ }
+
+ // 콤마로 구분된 문자열인지 확인
+ if (vendorType.includes(',')) {
+ return vendorType.split(',').map(t => t.trim()).filter(Boolean);
+ }
+
+ // 단일 문자열
+ return [vendorType.trim()].filter(Boolean);
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={setOpen}>
+ <DialogTrigger asChild>
+ {children || (
+ <Button size="sm">
+ <Plus className="mr-2 h-4 w-4" />
+ 추가
+ </Button>
+ )}
+ </DialogTrigger>
+ <DialogContent className="max-w-6xl max-h-[90vh] flex flex-col">
+ <DialogHeader>
+ <DialogTitle>
+ 벤더별 아이템 추가
+ </DialogTitle>
+ <DialogDescription>
+ 왼쪽에서 벤더를 선택하고, 오른쪽에서 아이템을 선택하세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="flex-1 min-h-0">
+ <div className="grid grid-cols-2 gap-4 h-[500px]">
+ {/* 왼쪽: 벤더 선택/표시 */}
+ <div className="space-y-4 h-full flex flex-col">
+ {!selectedVendor ? (
+ <>
+ <div className="space-y-2">
+ <Label htmlFor="vendor-search">벤더 검색</Label>
+ <div className="relative">
+ <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" /> <Input
+ id="vendor-search"
+ placeholder="벤더명 또는 벤더코드로 검색..."
+ value={vendorSearch}
+ onChange={(e) => setVendorSearch(e.target.value)}
+ className="pl-10"
+ />
+ </div>
+ </div>
+
+ <div className="max-h-96 overflow-y-auto border rounded-lg bg-gray-50 p-2">
+ <div className="space-y-2">
+ {isLoading ? (
+ <div className="text-center py-4">로딩 중...</div>
+ ) : filteredVendors.length === 0 ? (
+ <div className="text-center py-4 text-muted-foreground">
+ 검색 결과가 없습니다.
+ </div>
+ ) : (
+ filteredVendors.map((vendor) => (
+ <div
+ key={vendor.id}
+ className="p-3 bg-white border rounded-lg cursor-pointer transition-colors hover:bg-gray-50"
+ onClick={() => handleVendorSelect(vendor)}
+ >
+ <div className="font-medium">{vendor.vendorName}</div>
+ <div className="text-sm text-muted-foreground">
+ {vendor.vendorCode}
+ </div>
+ <div className="flex flex-wrap gap-1 mt-2">
+ {parseVendorTypes(vendor.techVendorType).map((type, index) => (
+ <Badge key={`${vendor.id}-${type}-${index}`} variant="secondary" className="text-xs">
+ {type}
+ </Badge>
+ ))}
+ </div>
+ </div>
+ ))
+ )}
+ </div>
+ </div>
+ </>
+ ) : (
+ <div className="space-y-4">
+ <div className="flex items-center justify-between">
+ <Label>선택된 벤더</Label>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ setSelectedVendor(null);
+ setSelectedItems([]);
+ setItems([]);
+ setFilteredItems([]);
+ }}
+ >
+ 변경
+ </Button>
+ </div>
+ <div className="p-4 border rounded-md bg-muted/20">
+ <div className="font-medium">{selectedVendor?.vendorName}</div>
+ <div className="text-sm text-muted-foreground">
+ {selectedVendor?.vendorCode}
+ </div>
+ <div className="flex flex-wrap gap-1 mt-2">
+ {selectedVendor && parseVendorTypes(selectedVendor.techVendorType).map((type, index) => (
+ <Badge key={`selected-${type}-${index}`} variant="outline" className="text-xs">
+ {type}
+ </Badge>
+ ))}
+ </div>
+ </div>
+ </div>
+ )}
+ </div>
+
+
+
+ {/* 오른쪽: 아이템 선택 */}
+ <div className="space-y-4 h-full flex flex-col">
+ {selectedVendor ? (
+ <>
+
+
+ <Label htmlFor="item-search">아이템 검색</Label>
+ <div className="relative">
+ <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" /> <Input
+ id="item-search"
+ placeholder="아이템코드, 아이템리스트, 공종으로 검색..."
+ value={itemSearch}
+ onChange={(e) => setItemSearch(e.target.value)}
+ className="pl-10"
+ />
+ </div>
+
+
+ {selectedItems.length > 0 && (
+ <div className="space-y-2">
+ <Label>선택된 아이템 ({selectedItems.length}개)</Label>
+ <div className="flex flex-wrap gap-1 p-2 border rounded-md bg-muted/50 max-h-20 overflow-y-auto">
+ {selectedItems.map((item) => (
+ <Badge key={`selected-${item.itemCode}`} variant="default" className="text-xs">
+ {item.itemCode}
+ <X
+ className="ml-1 h-3 w-3 cursor-pointer"
+ onClick={(e) => {
+ e.stopPropagation();
+ handleItemToggle(item);
+ }}
+ />
+ </Badge>
+ ))}
+ </div>
+ </div>
+ )}
+
+ <div className="max-h-80 overflow-y-auto border rounded-lg bg-gray-50 p-2">
+ <div className="space-y-2">
+ {isLoading ? (
+ <div className="text-center py-4">아이템 로딩 중...</div>
+ ) : filteredItems.length === 0 && items.length === 0 ? (
+ <div className="text-center py-4 text-muted-foreground">
+ 해당 벤더 타입에 대한 아이템이 없습니다.
+ </div>
+ ) : filteredItems.length === 0 ? (
+ <div className="text-center py-4 text-muted-foreground">
+ 검색 결과가 없습니다.
+ </div>
+ ) : (
+ filteredItems.map((item) => {
+ const isSelected = selectedItems.some(i => i.itemCode === item.itemCode);
+ return (
+ <div
+ key={`item-${item.itemCode}`}
+ className={`p-3 bg-white border rounded-lg cursor-pointer transition-colors ${
+ isSelected
+ ? "bg-primary/10 border-primary hover:bg-primary/20"
+ : "hover:bg-gray-50"
+ }`}
+ onClick={() => handleItemToggle(item)}
+ >
+ <div className="font-medium">{item.itemCode}</div>
+ <div className="text-sm text-muted-foreground">
+ {item.itemList || "-"}
+ </div>
+ <div className="flex gap-2 mt-1 text-xs">
+ <span>공종: {item.workType || "-"}</span>
+ {item.shipTypes && <span>선종: {item.shipTypes}</span>}
+ {item.subItemList && <span>서브아이템: {item.subItemList}</span>}
+ </div>
+ </div>
+ );
+ })
+ )}
+ </div>
+ </div>
+ </>
+ ) : (
+ <div className="flex-1 flex items-center justify-center text-muted-foreground">
+ 왼쪽에서 벤더를 선택하세요.
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+
+ <div className="flex justify-end gap-2 pt-4 border-t">
+ <Button variant="outline" onClick={handleClose}>
+ 취소
+ </Button>
+ <Button
+ onClick={handleSubmit}
+ disabled={!selectedVendor || selectedItems.length === 0 || isLoading}
+ >
+ {isLoading ? "추가 중..." : `추가 (${selectedItems.length})`}
+ </Button>
+ </div>
+ </DialogContent>
+ </Dialog>
+ );
+} \ No newline at end of file
diff --git a/lib/tech-vendor-possible-items/table/delete-possible-items-dialog.tsx b/lib/tech-vendor-possible-items/table/delete-possible-items-dialog.tsx
new file mode 100644
index 00000000..6b1c7775
--- /dev/null
+++ b/lib/tech-vendor-possible-items/table/delete-possible-items-dialog.tsx
@@ -0,0 +1,175 @@
+"use client";
+
+import * as React from "react";
+import { Trash2, AlertTriangle } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { Badge } from "@/components/ui/badge";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { useToast } from "@/hooks/use-toast";
+import { deleteTechVendorPossibleItems } from "@/lib/tech-vendor-possible-items/service";
+
+interface TechVendorPossibleItemsData {
+ id: number;
+ vendorId: number;
+ vendorCode: string | null;
+ vendorName: string;
+ techVendorType: string;
+ itemCode: string;
+ itemList: string | null;
+ workType: string | null;
+ shipTypes: string | null;
+ subItemList: string | null;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+interface DeletePossibleItemsDialogProps {
+ selectedItems: TechVendorPossibleItemsData[];
+ children?: React.ReactNode;
+ onSuccess?: () => void;
+}
+
+export function DeletePossibleItemsDialog({
+ selectedItems,
+ children,
+ onSuccess
+}: DeletePossibleItemsDialogProps) {
+ const { toast } = useToast();
+ const [open, setOpen] = React.useState(false);
+ const [isLoading, setIsLoading] = React.useState(false);
+
+ const handleDelete = async () => {
+ if (selectedItems.length === 0) return;
+
+ try {
+ setIsLoading(true);
+ const selectedIds = selectedItems.map(item => item.id);
+
+ const result = await deleteTechVendorPossibleItems(selectedIds);
+
+ if (result.success) {
+ toast({
+ title: "성공",
+ description: `${selectedIds.length}개의 아이템이 삭제되었습니다.`,
+ });
+
+ setOpen(false);
+ onSuccess?.();
+ } else {
+ toast({
+ title: "오류",
+ description: result.error || "삭제 중 오류가 발생했습니다.",
+ variant: "destructive",
+ });
+ }
+ } catch (error) {
+ console.error("Delete error:", error);
+ toast({
+ title: "오류",
+ description: "삭제 중 오류가 발생했습니다.",
+ variant: "destructive",
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const parseVendorTypes = (vendorType: string): string[] => {
+ try {
+ return JSON.parse(vendorType);
+ } catch {
+ return vendorType.split(',').map(t => t.trim());
+ }
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={setOpen}>
+ <DialogTrigger asChild>
+ {children || (
+ <Button
+ variant="destructive"
+ size="sm"
+ disabled={selectedItems.length === 0}
+ >
+ <Trash2 className="mr-2 h-4 w-4" />
+ 삭제 ({selectedItems.length})
+ </Button>
+ )}
+ </DialogTrigger>
+ <DialogContent className="max-w-2xl">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <AlertTriangle className="h-5 w-5 text-destructive" />
+ 아이템 삭제 확인
+ </DialogTitle>
+ <DialogDescription>
+ 선택한 {selectedItems.length}개의 벤더-아이템 조합을 삭제하시겠습니까?
+ 이 작업은 되돌릴 수 없습니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="py-4">
+ <div className="text-sm font-medium mb-3">삭제될 아이템 목록:</div>
+ <ScrollArea className="max-h-[300px] border rounded-md">
+ <div className="p-4 space-y-3">
+ {selectedItems.map((item) => (
+ <div key={item.id} className="border rounded-md p-3 bg-muted/50">
+ <div className="flex justify-between items-start">
+ <div className="space-y-1">
+ <div className="font-medium text-sm">
+ {item.vendorName} ({item.vendorCode})
+ </div>
+ <div className="text-sm text-muted-foreground">
+ 아이템코드: {item.itemCode}
+ </div>
+ {item.itemList && (
+ <div className="text-xs text-muted-foreground">
+ 아이템리스트: {item.itemList}
+ </div>
+ )}
+ {item.workType && (
+ <div className="text-xs text-muted-foreground">
+ 공종: {item.workType}
+ </div>
+ )}
+ </div>
+ <div className="flex flex-wrap gap-1">
+ {parseVendorTypes(item.techVendorType).map((type, index) => (
+ <Badge key={index} variant="secondary" className="text-xs">
+ {type}
+ </Badge>
+ ))}
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ </ScrollArea>
+ </div>
+
+ <DialogFooter>
+ <Button variant="outline" onClick={() => setOpen(false)}>
+ 취소
+ </Button>
+ <Button
+ variant="destructive"
+ onClick={handleDelete}
+ disabled={isLoading}
+ >
+ {isLoading ? "삭제 중..." : `삭제 (${selectedItems.length})`}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+} \ No newline at end of file
diff --git a/lib/tech-vendor-possible-items/table/excel-export.tsx b/lib/tech-vendor-possible-items/table/excel-export.tsx
index d3c4dea5..e6fcceed 100644
--- a/lib/tech-vendor-possible-items/table/excel-export.tsx
+++ b/lib/tech-vendor-possible-items/table/excel-export.tsx
@@ -5,7 +5,7 @@ import { format } from 'date-fns';
import { ko } from 'date-fns/locale';
/**
- * 기술영업 벤더 가능 아이템 데이터를 Excel 파일로 내보내기
+ * 기술영업 벤더 가능 아이템 데이터를 Excel 파일로 내보내기 (새로운 스키마 버전)
*/
export async function exportTechVendorPossibleItemsToExcel(
data: TechVendorPossibleItemsData[]
@@ -19,13 +19,18 @@ export async function exportTechVendorPossibleItemsToExcel(
// 워크시트 생성
const worksheet = workbook.addWorksheet('기술영업 벤더 가능 아이템');
- // 컬럼 헤더 정의 및 스타일 적용
+ // 컬럼 헤더 정의 및 스타일 적용 (새로운 스키마에 맞춰)
worksheet.columns = [
{ header: '번호', key: 'id', width: 10 },
{ header: '벤더코드', key: 'vendorCode', width: 15 },
{ header: '벤더명', key: 'vendorName', width: 25 },
+ { header: '벤더이메일', key: 'vendorEmail', width: 30 },
{ header: '벤더타입', key: 'techVendorType', width: 20 },
{ header: '아이템코드', key: 'itemCode', width: 20 },
+ { header: '공종', key: 'workType', width: 15 },
+ { header: '선종', key: 'shipTypes', width: 20 },
+ { header: '아이템리스트', key: 'itemList', width: 30 },
+ { header: '서브아이템리스트', key: 'subItemList', width: 30 },
{ header: '생성일시', key: 'createdAt', width: 20 },
];
@@ -53,7 +58,7 @@ export async function exportTechVendorPossibleItemsToExcel(
};
});
- // 데이터 추가
+ // 데이터 추가 (새로운 스키마 필드들 포함)
data.forEach((item, index) => {
// 벤더 타입 파싱
let vendorTypes = '';
@@ -68,8 +73,13 @@ export async function exportTechVendorPossibleItemsToExcel(
id: item.id,
vendorCode: item.vendorCode || '-',
vendorName: item.vendorName,
+ vendorEmail: item.vendorEmail || '-',
techVendorType: vendorTypes,
itemCode: item.itemCode,
+ workType: item.workType || '-',
+ shipTypes: item.shipTypes || '-',
+ itemList: item.itemList || '-',
+ subItemList: item.subItemList || '-',
createdAt: format(item.createdAt, 'yyyy-MM-dd HH:mm', { locale: ko }),
});
@@ -89,6 +99,15 @@ export async function exportTechVendorPossibleItemsToExcel(
// 나머지 컬럼 왼쪽 정렬
cell.alignment = { vertical: 'middle', horizontal: 'left' };
}
+
+ // 텍스트 줄바꿈 처리 (긴 텍스트 필드들)
+ if (colNumber >= 9 && colNumber <= 10) { // itemList, subItemList 컬럼
+ cell.alignment = {
+ vertical: 'top',
+ horizontal: 'left',
+ wrapText: true
+ };
+ }
});
// 홀수 행 배경색
@@ -103,19 +122,29 @@ export async function exportTechVendorPossibleItemsToExcel(
}
});
- // 요약 정보 워크시트 생성
+ // 요약 정보 워크시트 생성 (새로운 스키마 통계 포함)
const summarySheet = workbook.addWorksheet('요약 정보');
const summaryData = [
- ['기술영업 벤더 가능 아이템 현황', ''],
+ ['기술영업 벤더 가능 아이템 현황 (새로운 스키마)', ''],
['', ''],
+ ['📊 기본 통계:', ''],
['총 항목 수:', data.length.toLocaleString()],
['고유 벤더 수:', new Set(data.map(item => item.vendorId)).size.toLocaleString()],
['고유 아이템 수:', new Set(data.map(item => item.itemCode)).size.toLocaleString()],
['', ''],
- ['벤더 타입별 분포:', ''],
+ ['🏢 벤더 타입별 분포:', ''],
...getVendorTypeDistribution(data),
['', ''],
+ ['⚙️ 공종별 분포:', ''],
+ ...getWorkTypeDistribution(data),
+ ['', ''],
+ ['🚢 선종별 분포:', ''],
+ ...getShipTypeDistribution(data),
+ ['', ''],
+ ['📈 데이터 완성도:', ''],
+ ...getDataCompleteness(data),
+ ['', ''],
['내보내기 일시:', format(new Date(), 'yyyy-MM-dd HH:mm:ss', { locale: ko })],
];
@@ -127,10 +156,13 @@ export async function exportTechVendorPossibleItemsToExcel(
} else if (typeof rowData[0] === 'string' && rowData[0].includes(':') && rowData[1] === '') {
// 섹션 제목 스타일
row.getCell(1).font = { bold: true, color: { argb: 'FF1F4E79' } };
+ } else if (typeof rowData[0] === 'string' && rowData[0].includes('📊') || rowData[0].includes('🏢') || rowData[0].includes('⚙️') || rowData[0].includes('🚢') || rowData[0].includes('📈')) {
+ // 이모지 섹션 제목 스타일
+ row.getCell(1).font = { bold: true, color: { argb: 'FF1F4E79' } };
}
});
- summarySheet.getColumn(1).width = 30;
+ summarySheet.getColumn(1).width = 40;
summarySheet.getColumn(2).width = 20;
// 파일 생성 및 다운로드
@@ -178,4 +210,64 @@ function getVendorTypeDistribution(data: TechVendorPossibleItemsData[]): [string
return Array.from(typeCount.entries())
.sort((a, b) => b[1] - a[1])
.map(([type, count]) => [` - ${type}`, count.toLocaleString()]);
+}
+
+/**
+ * 공종별 분포 계산
+ */
+function getWorkTypeDistribution(data: TechVendorPossibleItemsData[]): [string, string][] {
+ const workTypeCount = new Map<string, number>();
+
+ data.forEach(item => {
+ const workType = item.workType || '미분류';
+ workTypeCount.set(workType, (workTypeCount.get(workType) || 0) + 1);
+ });
+
+ return Array.from(workTypeCount.entries())
+ .sort((a, b) => b[1] - a[1])
+ .slice(0, 10) // 상위 10개만 표시
+ .map(([type, count]) => [` - ${type}`, count.toLocaleString()]);
+}
+
+/**
+ * 선종별 분포 계산
+ */
+function getShipTypeDistribution(data: TechVendorPossibleItemsData[]): [string, string][] {
+ const shipTypeCount = new Map<string, number>();
+
+ data.forEach(item => {
+ if (item.shipTypes) {
+ // 여러 선종이 콤마로 구분되어 있을 수 있음
+ const shipTypes = item.shipTypes.split(',').map(s => s.trim());
+ shipTypes.forEach(shipType => {
+ if (shipType) {
+ shipTypeCount.set(shipType, (shipTypeCount.get(shipType) || 0) + 1);
+ }
+ });
+ } else {
+ shipTypeCount.set('미분류', (shipTypeCount.get('미분류') || 0) + 1);
+ }
+ });
+
+ return Array.from(shipTypeCount.entries())
+ .sort((a, b) => b[1] - a[1])
+ .slice(0, 10) // 상위 10개만 표시
+ .map(([type, count]) => [` - ${type}`, count.toLocaleString()]);
+}
+
+/**
+ * 데이터 완성도 계산
+ */
+function getDataCompleteness(data: TechVendorPossibleItemsData[]): [string, string][] {
+ const total = data.length;
+
+ const completeness = [
+ ['벤더이메일 있음', `${data.filter(item => item.vendorEmail).length}/${total} (${((data.filter(item => item.vendorEmail).length / total) * 100).toFixed(1)}%)`],
+ ['공종 있음', `${data.filter(item => item.workType).length}/${total} (${((data.filter(item => item.workType).length / total) * 100).toFixed(1)}%)`],
+ ['선종 있음', `${data.filter(item => item.shipTypes).length}/${total} (${((data.filter(item => item.shipTypes).length / total) * 100).toFixed(1)}%)`],
+ ['아이템리스트 있음', `${data.filter(item => item.itemList).length}/${total} (${((data.filter(item => item.itemList).length / total) * 100).toFixed(1)}%)`],
+ ['서브아이템리스트 있음', `${data.filter(item => item.subItemList).length}/${total} (${((data.filter(item => item.subItemList).length / total) * 100).toFixed(1)}%)`],
+ ];
+
+ return completeness.map(([label, stat]) => [` - ${label}`, stat]);
} \ No newline at end of file
diff --git a/lib/tech-vendor-possible-items/table/excel-import.tsx b/lib/tech-vendor-possible-items/table/excel-import.tsx
index fbf984dd..743879b3 100644
--- a/lib/tech-vendor-possible-items/table/excel-import.tsx
+++ b/lib/tech-vendor-possible-items/table/excel-import.tsx
@@ -3,21 +3,35 @@
import * as ExcelJS from 'exceljs';
import { ImportTechVendorPossibleItemData, ImportResult, importTechVendorPossibleItems } from '../service';
import { saveAs } from "file-saver";
+import { decryptWithServerAction } from "@/components/drm/drmUtils"
+import { toast } from 'sonner';
export interface ExcelImportResult extends ImportResult {
errorFileUrl?: string;
}
/**
- * Excel 파일에서 tech vendor possible items 데이터를 읽고 import
+ * Excel 파일에서 tech vendor possible items 데이터를 읽고 import (새로운 스키마 버전)
*/
export async function importTechVendorPossibleItemsFromExcel(
file: File
): Promise<ExcelImportResult> {
+
try {
- const buffer = await file.arrayBuffer();
- const workbook = new ExcelJS.Workbook();
- await workbook.xlsx.load(buffer);
+ // DRM 복호화 처리 - 서버 액션 직접 호출
+ let arrayBuffer: ArrayBuffer;
+ try {
+ toast.info("파일 복호화 중...");
+ arrayBuffer = await decryptWithServerAction(file);
+ } catch (decryptError) {
+ console.error("파일 복호화 실패, 원본 파일 사용:", decryptError);
+ toast.warning("파일 복호화에 실패하여 원본 파일을 사용합니다.");
+ // 복호화 실패 시 원본 파일 사용
+ arrayBuffer = await file.arrayBuffer();
+ }
+ // ExcelJS 워크북 로드
+ const workbook = new ExcelJS.Workbook();
+ await workbook.xlsx.load(arrayBuffer);
// 첫 번째 워크시트에서 데이터 읽기
const worksheet = workbook.getWorksheet(1);
@@ -33,31 +47,48 @@ export async function importTechVendorPossibleItemsFromExcel(
const data: ImportTechVendorPossibleItemData[] = [];
// 데이터 행 읽기 (헤더 제외)
+ // 새로운 스키마: 벤더이메일, 아이템코드, 공종, 선종, 아이템리스트, 서브아이템리스트
worksheet.eachRow((row, rowNumber) => {
if (rowNumber === 1) return; // 헤더 건너뛰기
- const itemCode = row.getCell(1).value?.toString()?.trim();
- const vendorCode = row.getCell(2).value?.toString()?.trim();
- const vendorEmail = row.getCell(3).value?.toString()?.trim();
+ const vendorEmail = row.getCell(1).value?.toString()?.trim(); // 필수
+ const itemCode = row.getCell(2).value?.toString()?.trim(); // 필수
+ const workType = row.getCell(3).value?.toString()?.trim(); // 선택
+ const shipTypes = row.getCell(4).value?.toString()?.trim(); // 선택
+ const itemList = row.getCell(5).value?.toString()?.trim(); // 선택
+ const subItemList = row.getCell(6).value?.toString()?.trim(); // 선택
+ const vendorCode = row.getCell(7).value?.toString()?.trim(); // 선택 (호환성)
// 빈 행 건너뛰기
- if (!itemCode && !vendorCode && !vendorEmail) return;
+ if (!vendorEmail && !itemCode && !workType && !shipTypes && !itemList && !subItemList && !vendorCode) {
+ return;
+ }
- // 벤더 코드 또는 이메일 중 하나는 있어야 함
- if (itemCode && (vendorCode || vendorEmail)) {
+ // 필수 필드 체크: 벤더이메일, 아이템코드
+ if (!vendorEmail || !itemCode) {
+ // 불완전한 데이터도 포함하여 에러 처리
data.push({
- vendorCode: vendorCode || '',
- vendorEmail: vendorEmail || '',
- itemCode,
- });
- } else {
- // 불완전한 데이터 처리
- data.push({
- vendorCode: vendorCode || '',
vendorEmail: vendorEmail || '',
itemCode: itemCode || '',
+ workType: workType || undefined,
+ shipTypes: shipTypes || undefined,
+ itemList: itemList || undefined,
+ subItemList: subItemList || undefined,
+ vendorCode: vendorCode || undefined,
});
+ return;
}
+
+ // 완전한 데이터 추가
+ data.push({
+ vendorEmail,
+ itemCode,
+ workType: workType || undefined,
+ shipTypes: shipTypes || undefined,
+ itemList: itemList || undefined,
+ subItemList: subItemList || undefined,
+ vendorCode: vendorCode || undefined,
+ });
});
if (data.length === 0) {
@@ -99,7 +130,7 @@ export async function importTechVendorPossibleItemsFromExcel(
}
/**
- * 실패한 항목들을 포함한 오류 Excel 파일 생성
+ * 실패한 항목들을 포함한 오류 Excel 파일 생성 (새로운 스키마 버전)
*/
async function createErrorExcelFile(
failedRows: ImportResult['failedRows']
@@ -108,12 +139,16 @@ async function createErrorExcelFile(
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet('Import 오류 목록');
- // 헤더 설정
+ // 헤더 설정 (새로운 스키마에 맞춰)
worksheet.columns = [
{ header: '행 번호', key: 'row', width: 10 },
+ { header: '벤더이메일', key: 'vendorEmail', width: 30 },
{ header: '아이템코드', key: 'itemCode', width: 20 },
+ { header: '공종', key: 'workType', width: 15 },
+ { header: '선종', key: 'shipTypes', width: 20 },
+ { header: '아이템리스트', key: 'itemList', width: 30 },
+ { header: '서브아이템리스트', key: 'subItemList', width: 30 },
{ header: '벤더코드', key: 'vendorCode', width: 15 },
- { header: '벤더이메일', key: 'vendorEmail', width: 30 },
{ header: '오류 내용', key: 'error', width: 60 },
{ header: '해결 방법', key: 'solution', width: 40 },
];
@@ -142,19 +177,25 @@ async function createErrorExcelFile(
failedRows.forEach((item) => {
let solution = '시스템 관리자에게 문의하세요';
- if (item.error.includes('벤더 코드') || item.error.includes('벤더 이메일')) {
- solution = '등록된 벤더 코드 또는 이메일인지 확인하세요';
+ if (item.error.includes('벤더 이메일')) {
+ solution = '올바른 이메일 형식으로 등록된 벤더 이메일인지 확인하세요';
} else if (item.error.includes('아이템 코드')) {
- solution = '벤더 타입에 맞는 아이템 코드인지 확인하세요';
+ solution = '아이템 코드가 누락되었거나 잘못된 형식입니다';
} else if (item.error.includes('이미 존재')) {
- solution = '중복된 조합입니다. 제거하거나 건너뛰세요';
+ solution = '중복된 조합입니다. 기존 데이터를 확인하세요';
+ } else if (item.error.includes('찾을 수 없습니다')) {
+ solution = '벤더 이메일이 시스템에 등록되어 있는지 확인하세요';
}
const row = worksheet.addRow({
row: item.row,
- itemCode: item.itemCode || '누락',
- vendorCode: item.vendorCode || '누락',
vendorEmail: item.vendorEmail || '누락',
+ itemCode: item.itemCode || '누락',
+ workType: item.workType || '',
+ shipTypes: item.shipTypes || '',
+ itemList: item.itemList || '',
+ subItemList: item.subItemList || '',
+ vendorCode: item.vendorCode || '',
error: item.error,
solution: solution,
});
@@ -169,24 +210,35 @@ async function createErrorExcelFile(
});
});
- // 안내사항 추가
+ // 안내사항 추가 (새로운 스키마에 맞춰)
const instructionSheet = workbook.addWorksheet('오류 해결 가이드');
const instructions = [
- ['📋 오류 유형별 해결 방법', ''],
+ ['📋 새로운 스키마 Import 가이드', ''],
['', ''],
- ['1. 벤더 코드/이메일 오류:', ''],
- [' • 시스템에 등록된 벤더 코드 또는 이메일인지 확인', ''],
+ ['📌 필수 필드:', ''],
+ [' • 벤더이메일: 시스템에 등록된 벤더의 이메일 주소', ''],
+ [' • 아이템코드: 처리할 아이템의 코드', ''],
+ ['', ''],
+ ['📌 선택 필드:', ''],
+ [' • 공종: 작업 유형 (예: 용접, 도장, 기계 등)', ''],
+ [' • 선종: 선박 유형 (예: 컨테이너선, 벌크선, 탱커 등)', ''],
+ [' • 아이템리스트: 아이템에 대한 상세 설명', ''],
+ [' • 서브아이템리스트: 세부 아이템들에 대한 설명', ''],
+ [' • 벤더코드: 호환성을 위한 선택 필드', ''],
+ ['', ''],
+ ['🔍 오류 유형별 해결 방법:', ''],
+ ['', ''],
+ ['1. 벤더 이메일 오류:', ''],
+ [' • 올바른 이메일 형식 확인 (예: vendor@example.com)', ''],
+ [' • 시스템에 등록된 벤더 이메일인지 확인', ''],
[' • 벤더 관리 메뉴에서 등록 상태 확인', ''],
- [' • 벤더 코드가 없으면 벤더 이메일로 대체 가능', ''],
['', ''],
['2. 아이템 코드 오류:', ''],
- [' • 벤더 타입과 일치하는 아이템인지 확인', ''],
- [' • 조선 벤더 → item_shipbuilding 테이블', ''],
- [' • 해양TOP 벤더 → item_offshore_top 테이블', ''],
- [' • 해양HULL 벤더 → item_offshore_hull 테이블', ''],
+ [' • 아이템 코드가 누락되지 않았는지 확인', ''],
+ [' • 특수문자나 공백이 포함되지 않았는지 확인', ''],
['', ''],
['3. 중복 오류:', ''],
- [' • 이미 등록된 벤더-아이템 조합', ''],
+ [' • 동일한 벤더 + 아이템코드 + 공종 + 선종 조합', ''],
[' • 기존 데이터 확인 후 중복 제거', ''],
['', ''],
['📞 추가 문의: 시스템 관리자', ''],
@@ -196,12 +248,14 @@ async function createErrorExcelFile(
const row = instructionSheet.addRow(rowData);
if (index === 0) {
row.getCell(1).font = { bold: true, size: 14, color: { argb: 'FF1F4E79' } };
+ } else if (rowData[0]?.includes('📌') || rowData[0]?.includes('🔍')) {
+ row.getCell(1).font = { bold: true, color: { argb: 'FF1F4E79' } };
} else if (rowData[0]?.includes(':')) {
row.getCell(1).font = { bold: true, color: { argb: 'FF1F4E79' } };
}
});
- instructionSheet.getColumn(1).width = 50;
+ instructionSheet.getColumn(1).width = 60;
// 파일 생성 및 다운로드
const buffer = await workbook.xlsx.writeBuffer();
diff --git a/lib/tech-vendor-possible-items/table/excel-template.tsx b/lib/tech-vendor-possible-items/table/excel-template.tsx
index 70a7eddf..20880350 100644
--- a/lib/tech-vendor-possible-items/table/excel-template.tsx
+++ b/lib/tech-vendor-possible-items/table/excel-template.tsx
@@ -2,7 +2,7 @@ import * as ExcelJS from 'exceljs';
import { saveAs } from "file-saver";
/**
- * 기술영업 벤더 가능 아이템 Import를 위한 Excel 템플릿 파일 생성 및 다운로드
+ * 기술영업 벤더 가능 아이템 Import를 위한 Excel 템플릿 파일 생성 및 다운로드 (새로운 스키마 버전)
*/
export async function exportTechVendorPossibleItemsTemplate() {
// 워크북 생성
@@ -13,11 +13,15 @@ export async function exportTechVendorPossibleItemsTemplate() {
// 워크시트 생성
const worksheet = workbook.addWorksheet('기술영업 벤더 가능 아이템');
- // 컬럼 헤더 정의 및 스타일 적용
+ // 컬럼 헤더 정의 및 스타일 적용 (새로운 스키마에 맞춰)
worksheet.columns = [
- { header: '아이템코드', key: 'itemCode', width: 20 },
- { header: '벤더코드', key: 'vendorCode', width: 15 },
- { header: '벤더이메일', key: 'vendorEmail', width: 30 },
+ { header: '벤더이메일 (필수)', key: 'vendorEmail', width: 30 },
+ { header: '아이템코드 (필수)', key: 'itemCode', width: 20 },
+ { header: '공종 (선택)', key: 'workType', width: 15 },
+ { header: '선종 (선택)', key: 'shipTypes', width: 20 },
+ { header: '아이템리스트 (선택)', key: 'itemList', width: 35 },
+ { header: '서브아이템리스트 (선택)', key: 'subItemList', width: 35 },
+ { header: '벤더코드 (호환성)', key: 'vendorCode', width: 15 },
];
// 헤더 스타일 적용
@@ -44,18 +48,58 @@ export async function exportTechVendorPossibleItemsTemplate() {
};
});
- // 샘플 데이터 추가
+ // 샘플 데이터 추가 (새로운 스키마에 맞춰)
const sampleData = [
- { itemCode: 'ITEM001', vendorCode: 'V001', vendorEmail: '' },
- { itemCode: 'ITEM001', vendorCode: 'V002', vendorEmail: '' },
- { itemCode: 'ITEM002', vendorCode: '', vendorEmail: 'vendor@example.com' },
- { itemCode: 'ITEM002', vendorCode: 'V002', vendorEmail: '' },
- { itemCode: 'ITEM004', vendorCode: '', vendorEmail: 'vendor2@example.com' },
+ {
+ vendorEmail: 'vendor1@example.com',
+ itemCode: 'ITEM001',
+ workType: '용접',
+ shipTypes: '컨테이너선',
+ itemList: '선체 용접 작업',
+ subItemList: '외판 용접, 내부 구조 용접',
+ vendorCode: 'V001'
+ },
+ {
+ vendorEmail: 'vendor2@example.com',
+ itemCode: 'ITEM002',
+ workType: '도장',
+ shipTypes: '벌크선',
+ itemList: '선체 도장 작업',
+ subItemList: '프라이머, 탑코트',
+ vendorCode: ''
+ },
+ {
+ vendorEmail: 'vendor3@example.com',
+ itemCode: 'ITEM003',
+ workType: '기계',
+ shipTypes: '탱커',
+ itemList: '기계 설비 설치',
+ subItemList: '엔진, 펌프, 배관',
+ vendorCode: ''
+ },
+ {
+ vendorEmail: 'vendor1@example.com',
+ itemCode: 'ITEM004',
+ workType: '용접',
+ shipTypes: '컨테이너선, 벌크선',
+ itemList: '특수 용접 작업',
+ subItemList: '',
+ vendorCode: 'V001'
+ },
+ {
+ vendorEmail: 'vendor4@example.com',
+ itemCode: 'ITEM005',
+ workType: '',
+ shipTypes: '',
+ itemList: '',
+ subItemList: '',
+ vendorCode: 'V004'
+ },
];
sampleData.forEach((data) => {
const row = worksheet.addRow(data);
- row.eachCell((cell) => {
+ row.eachCell((cell, colNumber) => {
cell.border = {
top: { style: 'thin' },
left: { style: 'thin' },
@@ -66,35 +110,75 @@ export async function exportTechVendorPossibleItemsTemplate() {
vertical: 'middle',
horizontal: 'left'
};
+
+ // 긴 텍스트 필드는 줄바꿈 허용
+ if (colNumber >= 5 && colNumber <= 6) { // itemList, subItemList
+ cell.alignment = {
+ vertical: 'top',
+ horizontal: 'left',
+ wrapText: true
+ };
+ }
});
});
- // 안내사항 워크시트 생성
+ // 안내사항 워크시트 생성 (새로운 스키마에 맞춰)
const guideSheet = workbook.addWorksheet('사용 가이드');
const guideData = [
- ['기술영업 벤더 가능 아이템 Import 템플릿', ''],
+ ['기술영업 벤더 가능 아이템 Import 템플릿 (새로운 스키마)', ''],
['', ''],
- ['📋 사용 방법:', ''],
+ ['📋 새로운 스키마 특징:', ''],
+ ['- 더욱 구체적인 아이템 정보 관리', ''],
+ ['- 공종과 선종으로 세분화된 분류', ''],
+ ['- 아이템 상세 설명 및 서브 아이템 정보', ''],
+ ['- 중복 아이템도 공종/선종이 다르면 별도 관리', ''],
+ ['', ''],
+ ['📌 필수 입력 필드:', ''],
+ ['1. 벤더이메일: 시스템에 등록된 벤더의 이메일 주소', ''],
+ [' 예: vendor@company.com', ''],
+ ['2. 아이템코드: 처리할 아이템의 고유 코드', ''],
+ [' 예: ITEM001, WELD_001, PAINT_002', ''],
+ ['', ''],
+ ['📝 선택 입력 필드:', ''],
+ ['3. 공종: 작업 유형 분류', ''],
+ [' 예: 용접, 도장, 기계, 전기, 배관 등', ''],
+ ['4. 선종: 적용 가능한 선박 유형', ''],
+ [' 예: 컨테이너선, 벌크선, 탱커, LNG선 등', ''],
+ [' - 여러 선종은 콤마로 구분: "컨테이너선, 벌크선"', ''],
+ ['5. 아이템리스트: 아이템에 대한 상세 설명', ''],
+ [' 예: "선체 용접 작업", "외판 도장 및 마감"', ''],
+ ['6. 서브아이템리스트: 세부 작업 항목들', ''],
+ [' 예: "외판 용접, 내부 구조 용접, 배관 용접"', ''],
+ ['7. 벤더코드: 기존 호환성을 위한 선택 필드', ''],
+ ['', ''],
+ ['🔍 중복 처리 로직:', ''],
+ ['- 동일한 벤더 + 아이템코드 + 공종 + 선종 = 중복으로 처리', ''],
+ ['- 아이템코드가 같아도 공종이나 선종이 다르면 별도 항목', ''],
+ ['- 예: ITEM001 + 용접 + 컨테이너선 ≠ ITEM001 + 도장 + 컨테이너선', ''],
+ ['', ''],
+ ['💡 사용 방법:', ''],
['1. "기술영업 벤더 가능 아이템" 시트에 데이터를 입력하세요', ''],
- ['2. 벤더 식별: 벤더코드 또는 벤더이메일 중 하나는 반드시 입력', ''],
- [' • 벤더코드가 있으면 벤더코드를 우선 사용', ''],
- [' • 벤더코드가 없으면 벤더이메일로 벤더 검색', ''],
- ['3. 아이템코드는 실제 존재하는 아이템코드를 사용하세요', ''],
- ['4. 한 아이템코드에 여러 벤더를 매핑할 수 있습니다 (1:N 관계)', ''],
- ['5. 중복된 벤더-아이템 조합은 무시됩니다', ''],
+ ['2. 필수 필드(벤더이메일, 아이템코드)는 반드시 입력', ''],
+ ['3. 선택 필드는 필요에 따라 입력 (빈 칸으로 두어도 됨)', ''],
+ ['4. 한 벤더가 여러 아이템을 담당할 수 있습니다 (1:N 관계)', ''],
+ ['5. 한 아이템에 여러 벤더를 배정할 수 있습니다 (N:M 관계)', ''],
['6. 파일 저장 후 시스템에서 업로드하세요', ''],
['', ''],
- ['⚠️ 중요 사항:', ''],
- ['- 벤더코드 또는 벤더이메일 중 하나는 반드시 필요', ''],
- ['- 벤더코드가 우선, 없으면 벤더이메일로 검색', ''],
- ['- 중복된 벤더-아이템 조합은 건너뜁니다', ''],
+ ['⚠️ 주의사항:', ''],
+ ['- 벤더이메일은 시스템에 이미 등록된 이메일이어야 함', ''],
+ ['- 이메일 형식 확인: @를 포함한 올바른 이메일 형식', ''],
+ ['- 아이템코드는 특수문자나 공백 주의', ''],
+ ['- 긴 텍스트 필드(아이템리스트, 서브아이템리스트)는 줄바꿈 가능', ''],
['- 오류가 있는 항목은 별도 파일로 다운로드됩니다', ''],
- ['- 빈 셀이 있으면 해당 행은 무시됩니다', ''],
['', ''],
- ['💡 팁:', ''],
- ['- 벤더코드만 존재하면 어떤 아이템코드든 입력 가능합니다', ''],
- ['- 아이템코드는 그대로 시스템에 저장됩니다', ''],
+ ['📊 데이터 예시:', ''],
+ ['벤더이메일: welding@company.com', ''],
+ ['아이템코드: WELD_HULL_001', ''],
+ ['공종: 용접', ''],
+ ['선종: 컨테이너선, 벌크선', ''],
+ ['아이템리스트: 선체 구조 용접 작업', ''],
+ ['서브아이템리스트: 외판 용접, 격벽 용접, 갑판 용접', ''],
['', ''],
['📞 문의사항이 있으시면 시스템 관리자에게 연락하세요.', ''],
];
@@ -104,16 +188,19 @@ export async function exportTechVendorPossibleItemsTemplate() {
if (index === 0) {
// 제목 스타일
row.getCell(1).font = { bold: true, size: 16, color: { argb: 'FF1F4E79' } };
+ } else if (rowData[0]?.includes('📋') || rowData[0]?.includes('📌') || rowData[0]?.includes('📝') || rowData[0]?.includes('🔍') || rowData[0]?.includes('💡') || rowData[0]?.includes('⚠️') || rowData[0]?.includes('📊')) {
+ // 섹션 제목 스타일 (이모지 포함)
+ row.getCell(1).font = { bold: true, color: { argb: 'FF1F4E79' } };
} else if (rowData[0]?.includes(':')) {
- // 섹션 제목 스타일
+ // 일반 제목 스타일
row.getCell(1).font = { bold: true, color: { argb: 'FF1F4E79' } };
- } else if (rowData[0]?.includes('•') || rowData[0]?.includes('-')) {
+ } else if (rowData[0]?.includes('-') || rowData[0]?.includes('•') || rowData[0]?.includes('예:')) {
// 리스트 아이템 스타일
row.getCell(1).font = { color: { argb: 'FF333333' } };
}
});
- guideSheet.getColumn(1).width = 70;
+ guideSheet.getColumn(1).width = 80;
guideSheet.getColumn(2).width = 20;
// 파일 생성 및 다운로드
diff --git a/lib/tech-vendor-possible-items/table/possible-items-data-table.tsx b/lib/tech-vendor-possible-items/table/possible-items-data-table.tsx
index 5252684b..28b9774f 100644
--- a/lib/tech-vendor-possible-items/table/possible-items-data-table.tsx
+++ b/lib/tech-vendor-possible-items/table/possible-items-data-table.tsx
@@ -17,6 +17,10 @@ type TechVendorPossibleItemsData = {
vendorName: string;
techVendorType: string;
itemCode: string;
+ itemList: string | null;
+ workType: string | null;
+ shipTypes: string | null;
+ subItemList: string | null;
createdAt: Date;
updatedAt: Date;
};
@@ -52,6 +56,26 @@ export function PossibleItemsDataTable({ promises }: PossibleItemsDataTableProps
type: "text",
},
{
+ id: "itemList",
+ label: "아이템리스트",
+ type: "text",
+ },
+ {
+ id: "workType",
+ label: "공종",
+ type: "text",
+ },
+ {
+ id: "shipTypes",
+ label: "선종",
+ type: "text",
+ },
+ {
+ id: "subItemList",
+ label: "서브아이템리스트",
+ type: "text",
+ },
+ {
id: "techVendorType",
label: "벤더타입",
type: "multi-select",
@@ -73,7 +97,7 @@ export function PossibleItemsDataTable({ promises }: PossibleItemsDataTableProps
sorting: [{ id: "createdAt", desc: true }],
pagination: { pageIndex: 0, pageSize: 10 },
},
- getRowId: (originalRow) => String(originalRow.id),
+ getRowId: (originalRow) => `${originalRow.vendorId}-${originalRow.itemCode}-${originalRow.id}`,
shallow: false,
clearOnDefault: true,
});
diff --git a/lib/tech-vendor-possible-items/table/possible-items-table-columns.tsx b/lib/tech-vendor-possible-items/table/possible-items-table-columns.tsx
index 520c089e..7fdcc900 100644
--- a/lib/tech-vendor-possible-items/table/possible-items-table-columns.tsx
+++ b/lib/tech-vendor-possible-items/table/possible-items-table-columns.tsx
@@ -11,7 +11,12 @@ type TechVendorPossibleItemsData = {
vendorCode: string | null;
vendorName: string;
techVendorType: string;
+ vendorStatus: string;
itemCode: string;
+ itemList: string | null;
+ workType: string | null;
+ shipTypes: string | null;
+ subItemList: string | null;
createdAt: Date;
updatedAt: Date;
};
@@ -56,6 +61,70 @@ export function getColumns(): ColumnDef<TechVendorPossibleItemsData>[] {
},
},
{
+ accessorKey: "itemList",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="아이템리스트" />
+ ),
+ cell: ({ row }) => {
+ const itemList = row.getValue("itemList") as string | null;
+ return <div className="max-w-[200px] truncate">{itemList || "-"}</div>;
+ },
+ filterFn: (row, id, value) => {
+ const itemList = row.getValue(id) as string | null;
+ if (!value) return true;
+ if (!itemList) return false;
+ return itemList.toLowerCase().includes(value.toLowerCase());
+ },
+ },
+ {
+ accessorKey: "workType",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="공종" />
+ ),
+ cell: ({ row }) => {
+ const workType = row.getValue("workType") as string | null;
+ return <div className="font-medium">{workType || "-"}</div>;
+ },
+ filterFn: (row, id, value) => {
+ const workType = row.getValue(id) as string | null;
+ if (!value) return true;
+ if (!workType) return false;
+ return workType.toLowerCase().includes(value.toLowerCase());
+ },
+ },
+ {
+ accessorKey: "shipTypes",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="선종" />
+ ),
+ cell: ({ row }) => {
+ const shipTypes = row.getValue("shipTypes") as string | null;
+ return <div className="font-medium">{shipTypes || "-"}</div>;
+ },
+ filterFn: (row, id, value) => {
+ const shipTypes = row.getValue(id) as string | null;
+ if (!value) return true;
+ if (!shipTypes) return false;
+ return shipTypes.toLowerCase().includes(value.toLowerCase());
+ },
+ },
+ {
+ accessorKey: "subItemList",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="서브아이템리스트" />
+ ),
+ cell: ({ row }) => {
+ const subItemList = row.getValue("subItemList") as string | null;
+ return <div className="max-w-[200px] truncate">{subItemList || "-"}</div>;
+ },
+ filterFn: (row, id, value) => {
+ const subItemList = row.getValue(id) as string | null;
+ if (!value) return true;
+ if (!subItemList) return false;
+ return subItemList.toLowerCase().includes(value.toLowerCase());
+ },
+ },
+ {
accessorKey: "vendorCode",
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="벤더코드" />
@@ -91,38 +160,86 @@ export function getColumns(): ColumnDef<TechVendorPossibleItemsData>[] {
cell: ({ row }) => {
const techVendorType = row.getValue("techVendorType") as string;
- // JSON 배열인지 확인하고 파싱
+ // 벤더 타입 파싱 개선
let types: string[] = [];
- try {
- const parsed = JSON.parse(techVendorType || "[]");
- types = Array.isArray(parsed) ? parsed : [techVendorType];
- } catch {
- types = [techVendorType];
+ if (!techVendorType) {
+ types = [];
+ } else if (techVendorType.startsWith('[') && techVendorType.endsWith(']')) {
+ // JSON 배열 형태
+ try {
+ const parsed = JSON.parse(techVendorType);
+ types = Array.isArray(parsed) ? parsed.filter(Boolean) : [techVendorType];
+ } catch {
+ types = [techVendorType];
+ }
+ } else if (techVendorType.includes(',')) {
+ // 콤마로 구분된 문자열
+ types = techVendorType.split(',').map(t => t.trim()).filter(Boolean);
+ } else {
+ // 단일 문자열
+ types = [techVendorType.trim()].filter(Boolean);
}
return (
<div className="flex flex-wrap gap-1">
- {types.map((type, index) => (
- <Badge key={index} variant="secondary" className="text-xs">
+ {types.length > 0 ? types.map((type, index) => (
+ <Badge key={`${type}-${index}`} variant="secondary" className="text-xs">
{type}
</Badge>
- ))}
+ )) : (
+ <span className="text-muted-foreground">-</span>
+ )}
</div>
);
},
filterFn: (row, id, value) => {
const techVendorType = row.getValue(id) as string;
- try {
- const parsed = JSON.parse(techVendorType || "[]");
- const types = Array.isArray(parsed) ? parsed : [techVendorType];
- return types.some(type => type.includes(value));
- } catch {
- return techVendorType?.includes(value) || false;
+ if (!techVendorType || !value) return false;
+
+ let types: string[] = [];
+ if (techVendorType.startsWith('[') && techVendorType.endsWith(']')) {
+ try {
+ const parsed = JSON.parse(techVendorType);
+ types = Array.isArray(parsed) ? parsed : [techVendorType];
+ } catch {
+ types = [techVendorType];
+ }
+ } else if (techVendorType.includes(',')) {
+ types = techVendorType.split(',').map(t => t.trim());
+ } else {
+ types = [techVendorType.trim()];
}
+
+ return types.some(type =>
+ type.toLowerCase().includes(value.toLowerCase())
+ );
},
},
{
+ accessorKey: "vendorStatus",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="벤더상태" />
+ ),
+ cell: ({ row }) => {
+ const vendorStatus = row.getValue("vendorStatus") as string;
+ const getStatusColor = (status: string) => {
+ switch (status) {
+ case "ACTIVE": return "bg-green-100 text-green-800";
+ case "PENDING_INVITE": return "bg-yellow-100 text-yellow-800";
+ case "PENDING_REVIEW": return "bg-blue-100 text-blue-800";
+ case "INACTIVE": return "bg-gray-100 text-gray-800";
+ default: return "bg-gray-100 text-gray-800";
+ }
+ };
+ return (
+ <Badge className={getStatusColor(vendorStatus)}>
+ {vendorStatus}
+ </Badge>
+ );
+ },
+ },
+ {
accessorKey: "createdAt",
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="생성일시" />
diff --git a/lib/tech-vendor-possible-items/table/possible-items-table-toolbar-actions.tsx b/lib/tech-vendor-possible-items/table/possible-items-table-toolbar-actions.tsx
index 3628f87e..dc67221f 100644
--- a/lib/tech-vendor-possible-items/table/possible-items-table-toolbar-actions.tsx
+++ b/lib/tech-vendor-possible-items/table/possible-items-table-toolbar-actions.tsx
@@ -2,12 +2,13 @@
import * as React from "react";
import { type Table } from "@tanstack/react-table";
-import { Download, Upload, FileSpreadsheet, Trash2 } from "lucide-react";
+import { Download, Upload, FileSpreadsheet, Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useToast } from "@/hooks/use-toast";
-import { deleteTechVendorPossibleItems } from "@/lib/tech-vendor-possible-items/service";
+import { AddPossibleItemDialog } from "./add-possible-item-dialog";
+import { DeletePossibleItemsDialog } from "./delete-possible-items-dialog";
// Excel 함수들을 동적 import로만 사용하기 위해 타입만 import
type TechVendorPossibleItemsData = {
id: number;
@@ -16,6 +17,10 @@ type TechVendorPossibleItemsData = {
vendorName: string;
techVendorType: string;
itemCode: string;
+ itemList: string | null;
+ workType: string | null;
+ shipTypes: string | null;
+ subItemList: string | null;
createdAt: Date;
updatedAt: Date;
};
@@ -28,44 +33,15 @@ export function PossibleItemsTableToolbarActions({
table,
}: PossibleItemsTableToolbarActionsProps) {
const { toast } = useToast();
- const [isPending, startTransition] = React.useTransition();
const selectedRows = table.getFilteredSelectedRowModel().rows;
const hasSelection = selectedRows.length > 0;
+ const selectedItems = selectedRows.map(row => row.original);
- const handleDelete = () => {
- if (!hasSelection) return;
-
- startTransition(async () => {
- const selectedIds = selectedRows.map((row) => row.original.id);
-
- try {
- const result = await deleteTechVendorPossibleItems(selectedIds);
-
- if (result.success) {
- toast({
- title: "성공",
- description: `${selectedIds.length}개의 아이템이 삭제되었습니다.`,
- });
- table.toggleAllRowsSelected(false);
- // 페이지 새로고침이나 데이터 다시 로드 필요
- window.location.reload();
- } else {
- toast({
- title: "오류",
- description: result.error || "삭제 중 오류가 발생했습니다.",
- variant: "destructive",
- });
- }
- } catch (error) {
- console.error("Delete error:", error);
- toast({
- title: "오류",
- description: "삭제 중 오류가 발생했습니다.",
- variant: "destructive",
- });
- }
- });
+ const handleSuccess = () => {
+ table.toggleAllRowsSelected(false);
+ // 페이지 새로고침이나 데이터 다시 로드 필요
+ window.location.reload();
};
const handleExport = async () => {
@@ -158,17 +134,27 @@ export function PossibleItemsTableToolbarActions({
return (
<div className="flex items-center gap-2">
- {hasSelection && (
- <Button
- variant="destructive"
- size="sm"
- onClick={handleDelete}
- disabled={isPending}
- >
- <Trash2 className="mr-2 h-4 w-4" />
- 삭제 ({selectedRows.length})
- </Button>
+ {hasSelection && (
+ <DeletePossibleItemsDialog
+ selectedItems={selectedItems}
+ onSuccess={handleSuccess}
+ />
)}
+ <AddPossibleItemDialog onSuccess={handleSuccess}>
+ <Button size="sm">
+ <Plus className="mr-2 h-4 w-4" />
+ 추가
+ </Button>
+ </AddPossibleItemDialog>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => document.getElementById("import-file")?.click()}
+ >
+ <Upload className="mr-2 h-4 w-4" />
+ Import
+ </Button>
<Button variant="outline" size="sm" onClick={handleExport}>
<Download className="mr-2 h-4 w-4" />
@@ -183,14 +169,6 @@ export function PossibleItemsTableToolbarActions({
className="hidden"
/>
- <Button
- variant="outline"
- size="sm"
- onClick={() => document.getElementById("import-file")?.click()}
- >
- <Upload className="mr-2 h-4 w-4" />
- Import
- </Button>
<Button variant="outline" size="sm" onClick={handleDownloadTemplate}>
<FileSpreadsheet className="mr-2 h-4 w-4" />
diff --git a/lib/tech-vendor-possible-items/validations.ts b/lib/tech-vendor-possible-items/validations.ts
index 1e42264b..6e930bb1 100644
--- a/lib/tech-vendor-possible-items/validations.ts
+++ b/lib/tech-vendor-possible-items/validations.ts
@@ -31,14 +31,28 @@ export const searchParamsTechVendorPossibleItemsCache = createSearchParamsCache(
})
export const createTechVendorPossibleItemSchema = z.object({
- vendorId: z.number().min(1, "벤더를 선택해주세요"),
+ vendorId: z.number().min(1, "벤더 ID는 필수입니다"),
itemCode: z.string().min(1, "아이템 코드를 입력해주세요"),
+ workType: z.string().nullable().optional(),
+ shipTypes: z.string().nullable().optional(),
+ itemList: z.string().nullable().optional(),
+ subItemList: z.string().nullable().optional(),
})
export const updateTechVendorPossibleItemSchema = createTechVendorPossibleItemSchema.extend({
id: z.number(),
})
+export const importTechVendorPossibleItemSchema = z.object({
+ vendorCode: z.string().optional(),
+ vendorEmail: z.string().email("올바른 이메일 형식을 입력해주세요").min(1, "벤더 이메일을 입력해주세요"),
+ itemCode: z.string().min(1, "아이템 코드를 입력해주세요"),
+ workType: z.string().optional(),
+ shipTypes: z.string().optional(),
+ itemList: z.string().optional(),
+ subItemList: z.string().optional(),
+})
+
export type CreateTechVendorPossibleItemSchema = z.infer<typeof createTechVendorPossibleItemSchema>
export type UpdateTechVendorPossibleItemSchema = z.infer<typeof updateTechVendorPossibleItemSchema>
diff --git a/lib/tech-vendors/contacts-table/add-contact-dialog.tsx b/lib/tech-vendors/contacts-table/add-contact-dialog.tsx
index ff845e20..93ea6761 100644
--- a/lib/tech-vendors/contacts-table/add-contact-dialog.tsx
+++ b/lib/tech-vendors/contacts-table/add-contact-dialog.tsx
@@ -1,196 +1,196 @@
-"use client"
-
-import * as React from "react"
-import { useForm } from "react-hook-form"
-import { zodResolver } from "@hookform/resolvers/zod"
-
-import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"
-import { Button } from "@/components/ui/button"
-import { Input } from "@/components/ui/input"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-
-import {
- createTechVendorContactSchema,
- type CreateTechVendorContactSchema,
-} from "@/lib/tech-vendors/validations"
-import { createTechVendorContact } from "@/lib/tech-vendors/service"
-
-interface AddContactDialogProps {
- vendorId: number
-}
-
-export function AddContactDialog({ vendorId }: AddContactDialogProps) {
- const [open, setOpen] = React.useState(false)
-
- // react-hook-form 세팅
- const form = useForm<CreateTechVendorContactSchema>({
- resolver: zodResolver(createTechVendorContactSchema),
- defaultValues: {
- // vendorId는 form에 표시할 필요가 없다면 hidden으로 관리하거나, submit 시 추가
- vendorId,
- contactName: "",
- contactPosition: "",
- contactEmail: "",
- contactPhone: "",
- country: "",
- isPrimary: false,
- },
- })
-
- async function onSubmit(data: CreateTechVendorContactSchema) {
- // 혹은 여기서 data.vendorId = vendorId; 해줘도 됨
- const result = await createTechVendorContact(data)
- if (result.error) {
- alert(`에러: ${result.error}`)
- return
- }
-
- // 성공 시 메시지 표시
- if (result.data?.message) {
- alert(result.data.message)
- }
-
- // 성공 시 모달 닫고 폼 리셋
- form.reset()
- setOpen(false)
- }
-
- function handleDialogOpenChange(nextOpen: boolean) {
- if (!nextOpen) {
- form.reset()
- }
- setOpen(nextOpen)
- }
-
- return (
- <Dialog open={open} onOpenChange={handleDialogOpenChange}>
- {/* 모달을 열기 위한 버튼 */}
- <DialogTrigger asChild>
- <Button variant="default" size="sm">
- Add Contact
- </Button>
- </DialogTrigger>
-
- <DialogContent>
- <DialogHeader>
- <DialogTitle>Create New Contact</DialogTitle>
- <DialogDescription>
- 새 Contact 정보를 입력하고 <b>Create</b> 버튼을 누르세요.
- </DialogDescription>
- </DialogHeader>
-
- {/* shadcn/ui Form을 이용해 react-hook-form과 연결 */}
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)}>
- <div className="space-y-4 py-4">
- <FormField
- control={form.control}
- name="contactName"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Contact Name</FormLabel>
- <FormControl>
- <Input placeholder="예: 홍길동" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="contactPosition"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Position / Title</FormLabel>
- <FormControl>
- <Input placeholder="예: 과장" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="contactEmail"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Email</FormLabel>
- <FormControl>
- <Input placeholder="name@company.com" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="contactPhone"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Phone</FormLabel>
- <FormControl>
- <Input placeholder="010-1234-5678" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="country"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Country</FormLabel>
- <FormControl>
- <Input placeholder="예: Korea" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 단순 checkbox */}
- <FormField
- control={form.control}
- name="isPrimary"
- render={({ field }) => (
- <FormItem>
- <div className="flex items-center space-x-2 mt-2">
- <input
- type="checkbox"
- checked={field.value}
- onChange={(e) => field.onChange(e.target.checked)}
- />
- <FormLabel>Is Primary?</FormLabel>
- </div>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
-
- <DialogFooter>
- <Button type="button" variant="outline" onClick={() => setOpen(false)}>
- Cancel
- </Button>
- <Button type="submit" disabled={form.formState.isSubmitting}>
- Create
- </Button>
- </DialogFooter>
- </form>
- </Form>
- </DialogContent>
- </Dialog>
- )
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+
+import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+
+import {
+ createTechVendorContactSchema,
+ type CreateTechVendorContactSchema,
+} from "@/lib/tech-vendors/validations"
+import { createTechVendorContact } from "@/lib/tech-vendors/service"
+
+interface AddContactDialogProps {
+ vendorId: number
+}
+
+export function AddContactDialog({ vendorId }: AddContactDialogProps) {
+ const [open, setOpen] = React.useState(false)
+
+ // react-hook-form 세팅
+ const form = useForm<CreateTechVendorContactSchema>({
+ resolver: zodResolver(createTechVendorContactSchema),
+ defaultValues: {
+ // vendorId는 form에 표시할 필요가 없다면 hidden으로 관리하거나, submit 시 추가
+ vendorId,
+ contactName: "",
+ contactPosition: "",
+ contactEmail: "",
+ contactPhone: "",
+ contactCountry: "",
+ isPrimary: false,
+ },
+ })
+
+ async function onSubmit(data: CreateTechVendorContactSchema) {
+ // 혹은 여기서 data.vendorId = vendorId; 해줘도 됨
+ const result = await createTechVendorContact(data)
+ if (result.error) {
+ alert(`에러: ${result.error}`)
+ return
+ }
+
+ // 성공 시 메시지 표시
+ if (result.data?.message) {
+ alert(result.data.message)
+ }
+
+ // 성공 시 모달 닫고 폼 리셋
+ form.reset()
+ setOpen(false)
+ }
+
+ function handleDialogOpenChange(nextOpen: boolean) {
+ if (!nextOpen) {
+ form.reset()
+ }
+ setOpen(nextOpen)
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={handleDialogOpenChange}>
+ {/* 모달을 열기 위한 버튼 */}
+ <DialogTrigger asChild>
+ <Button variant="default" size="sm">
+ Add Contact
+ </Button>
+ </DialogTrigger>
+
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Create New Contact</DialogTitle>
+ <DialogDescription>
+ 새 Contact 정보를 입력하고 <b>Create</b> 버튼을 누르세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* shadcn/ui Form을 이용해 react-hook-form과 연결 */}
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)}>
+ <div className="space-y-4 py-4">
+ <FormField
+ control={form.control}
+ name="contactName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Contact Name</FormLabel>
+ <FormControl>
+ <Input placeholder="예: 홍길동" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="contactPosition"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Position / Title</FormLabel>
+ <FormControl>
+ <Input placeholder="예: 과장" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="contactEmail"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Email</FormLabel>
+ <FormControl>
+ <Input placeholder="name@company.com" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="contactPhone"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Phone</FormLabel>
+ <FormControl>
+ <Input placeholder="010-1234-5678" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="contactCountry"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Contact Country</FormLabel>
+ <FormControl>
+ <Input placeholder="예: Korea" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 단순 checkbox */}
+ <FormField
+ control={form.control}
+ name="isPrimary"
+ render={({ field }) => (
+ <FormItem>
+ <div className="flex items-center space-x-2 mt-2">
+ <input
+ type="checkbox"
+ checked={field.value}
+ onChange={(e) => field.onChange(e.target.checked)}
+ />
+ <FormLabel>Is Primary?</FormLabel>
+ </div>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <DialogFooter>
+ <Button type="button" variant="outline" onClick={() => setOpen(false)}>
+ Cancel
+ </Button>
+ <Button type="submit" disabled={form.formState.isSubmitting}>
+ Create
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
} \ No newline at end of file
diff --git a/lib/tech-vendors/contacts-table/contact-table-columns.tsx b/lib/tech-vendors/contacts-table/contact-table-columns.tsx
index b8f4e7a2..1a65a58c 100644
--- a/lib/tech-vendors/contacts-table/contact-table-columns.tsx
+++ b/lib/tech-vendors/contacts-table/contact-table-columns.tsx
@@ -1,176 +1,176 @@
-"use client"
-
-import * as React from "react"
-import { type DataTableRowAction } from "@/types/table"
-import { type ColumnDef } from "@tanstack/react-table"
-import { Ellipsis } from "lucide-react"
-
-import { formatDate } from "@/lib/utils"
-import { Button } from "@/components/ui/button"
-import { Checkbox } from "@/components/ui/checkbox"
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuShortcut,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu"
-
-import { TechVendorContact } from "@/db/schema/techVendors"
-import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
-import { techVendorContactsColumnsConfig } from "@/config/techVendorContactsColumnsConfig"
-
-interface GetColumnsProps {
- setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<TechVendorContact> | null>>;
-}
-
-/**
- * tanstack table 컬럼 정의 (중첩 헤더 버전)
- */
-export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<TechVendorContact>[] {
- // ----------------------------------------------------------------
- // 1) select 컬럼 (체크박스)
- // ----------------------------------------------------------------
- const selectColumn: ColumnDef<TechVendorContact> = {
- id: "select",
- header: ({ table }) => (
- <Checkbox
- checked={
- table.getIsAllPageRowsSelected() ||
- (table.getIsSomePageRowsSelected() && "indeterminate")
- }
- onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
- aria-label="Select all"
- className="translate-y-0.5"
- />
- ),
- cell: ({ row }) => (
- <Checkbox
- checked={row.getIsSelected()}
- onCheckedChange={(value) => row.toggleSelected(!!value)}
- aria-label="Select row"
- className="translate-y-0.5"
- />
- ),
- size:40,
- enableSorting: false,
- enableHiding: false,
- }
-
- // ----------------------------------------------------------------
- // 2) actions 컬럼 (Dropdown 메뉴)
- // ----------------------------------------------------------------
- const actionsColumn: ColumnDef<TechVendorContact> = {
- id: "actions",
- enableHiding: false,
- cell: function Cell({ row }) {
- return (
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- aria-label="Open menu"
- variant="ghost"
- className="flex size-8 p-0 data-[state=open]:bg-muted"
- >
- <Ellipsis className="size-4" aria-hidden="true" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end" className="w-40">
- <DropdownMenuItem
- onSelect={() => {
- setRowAction({ row, type: "update" })
- }}
- >
- Edit
- </DropdownMenuItem>
-
- <DropdownMenuSeparator />
- <DropdownMenuItem
- onSelect={() => setRowAction({ row, type: "delete" })}
- >
- Delete
- <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
- )
- },
- size: 40,
- }
-
- // ----------------------------------------------------------------
- // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
- // ----------------------------------------------------------------
- // 3-1) groupMap: { [groupName]: ColumnDef<TechVendorContact>[] }
- const groupMap: Record<string, ColumnDef<TechVendorContact>[]> = {}
-
- techVendorContactsColumnsConfig.forEach((cfg) => {
- // 만약 group가 없으면 "_noGroup" 처리
- const groupName = cfg.group || "_noGroup"
-
- if (!groupMap[groupName]) {
- groupMap[groupName] = []
- }
-
- // child column 정의
- const childCol: ColumnDef<TechVendorContact> = {
- accessorKey: cfg.id,
- enableResizing: true,
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title={cfg.label} />
- ),
- meta: {
- excelHeader: cfg.excelHeader,
- group: cfg.group,
- type: cfg.type,
- },
- cell: ({ row, cell }) => {
- if (cfg.id === "createdAt") {
- const dateVal = cell.getValue() as Date
- return formatDate(dateVal)
- }
-
- if (cfg.id === "updatedAt") {
- const dateVal = cell.getValue() as Date
- return formatDate(dateVal)
- }
-
- // code etc...
- return row.getValue(cfg.id) ?? ""
- },
- }
-
- groupMap[groupName].push(childCol)
- })
-
- // ----------------------------------------------------------------
- // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기
- // ----------------------------------------------------------------
- const nestedColumns: ColumnDef<TechVendorContact>[] = []
-
- // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함
- // 여기서는 그냥 Object.entries 순서
- Object.entries(groupMap).forEach(([groupName, colDefs]) => {
- if (groupName === "_noGroup") {
- // 그룹 없음 → 그냥 최상위 레벨 컬럼
- nestedColumns.push(...colDefs)
- } else {
- // 상위 컬럼
- nestedColumns.push({
- id: groupName,
- header: groupName, // "Basic Info", "Metadata" 등
- columns: colDefs,
- })
- }
- })
-
- // ----------------------------------------------------------------
- // 4) 최종 컬럼 배열: select, nestedColumns, actions
- // ----------------------------------------------------------------
- return [
- selectColumn,
- ...nestedColumns,
- actionsColumn,
- ]
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Ellipsis } from "lucide-react"
+
+import { formatDate } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+
+import { TechVendorContact } from "@/db/schema/techVendors"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { techVendorContactsColumnsConfig } from "@/config/techVendorContactsColumnsConfig"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<TechVendorContact> | null>>;
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<TechVendorContact>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<TechVendorContact> = {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size:40,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+ // ----------------------------------------------------------------
+ // 2) actions 컬럼 (Dropdown 메뉴)
+ // ----------------------------------------------------------------
+ const actionsColumn: ColumnDef<TechVendorContact> = {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-40">
+ <DropdownMenuItem
+ onSelect={() => {
+ setRowAction({ row, type: "update" })
+ }}
+ >
+ Edit
+ </DropdownMenuItem>
+
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "delete" })}
+ >
+ Delete
+ <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 40,
+ }
+
+ // ----------------------------------------------------------------
+ // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
+ // ----------------------------------------------------------------
+ // 3-1) groupMap: { [groupName]: ColumnDef<TechVendorContact>[] }
+ const groupMap: Record<string, ColumnDef<TechVendorContact>[]> = {}
+
+ techVendorContactsColumnsConfig.forEach((cfg) => {
+ // 만약 group가 없으면 "_noGroup" 처리
+ const groupName = cfg.group || "_noGroup"
+
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // child column 정의
+ const childCol: ColumnDef<TechVendorContact> = {
+ accessorKey: cfg.id,
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title={cfg.label} />
+ ),
+ meta: {
+ excelHeader: cfg.excelHeader,
+ group: cfg.group,
+ type: cfg.type,
+ },
+ cell: ({ row, cell }) => {
+ if (cfg.id === "createdAt") {
+ const dateVal = cell.getValue() as Date
+ return formatDate(dateVal)
+ }
+
+ if (cfg.id === "updatedAt") {
+ const dateVal = cell.getValue() as Date
+ return formatDate(dateVal)
+ }
+
+ // code etc...
+ return row.getValue(cfg.id) ?? ""
+ },
+ }
+
+ groupMap[groupName].push(childCol)
+ })
+
+ // ----------------------------------------------------------------
+ // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기
+ // ----------------------------------------------------------------
+ const nestedColumns: ColumnDef<TechVendorContact>[] = []
+
+ // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함
+ // 여기서는 그냥 Object.entries 순서
+ Object.entries(groupMap).forEach(([groupName, colDefs]) => {
+ if (groupName === "_noGroup") {
+ // 그룹 없음 → 그냥 최상위 레벨 컬럼
+ nestedColumns.push(...colDefs)
+ } else {
+ // 상위 컬럼
+ nestedColumns.push({
+ id: groupName,
+ header: groupName, // "Basic Info", "Metadata" 등
+ columns: colDefs,
+ })
+ }
+ })
+
+ // ----------------------------------------------------------------
+ // 4) 최종 컬럼 배열: select, nestedColumns, actions
+ // ----------------------------------------------------------------
+ return [
+ selectColumn,
+ ...nestedColumns,
+ actionsColumn,
+ ]
} \ No newline at end of file
diff --git a/lib/tech-vendors/contacts-table/contact-table-toolbar-actions.tsx b/lib/tech-vendors/contacts-table/contact-table-toolbar-actions.tsx
index 7622c6d6..84228a54 100644
--- a/lib/tech-vendors/contacts-table/contact-table-toolbar-actions.tsx
+++ b/lib/tech-vendors/contacts-table/contact-table-toolbar-actions.tsx
@@ -1,103 +1,163 @@
-"use client"
-
-import * as React from "react"
-import { type Table } from "@tanstack/react-table"
-import { Download, Upload } from "lucide-react"
-import { toast } from "sonner"
-
-import { exportTableToExcel } from "@/lib/export"
-import { Button } from "@/components/ui/button"
-import { TechVendorContact } from "@/db/schema/techVendors"
-import { AddContactDialog } from "./add-contact-dialog"
-import { importTasksExcel } from "@/lib/tasks/service"
-
-interface TechVendorContactsTableToolbarActionsProps {
- table: Table<TechVendorContact>
- vendorId: number
-}
-
-export function TechVendorContactsTableToolbarActions({ table, vendorId }: TechVendorContactsTableToolbarActionsProps) {
- // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
- const fileInputRef = React.useRef<HTMLInputElement>(null)
-
- // 파일이 선택되었을 때 처리
- async function onFileChange(event: React.ChangeEvent<HTMLInputElement>) {
- const file = event.target.files?.[0]
- if (!file) return
-
- // 파일 초기화 (동일 파일 재업로드 시에도 onChange가 트리거되도록)
- event.target.value = ""
-
- // 서버 액션 or API 호출
- try {
- // 예: 서버 액션 호출
- const { errorFile, errorMessage } = await importTasksExcel(file)
-
- if (errorMessage) {
- toast.error(errorMessage)
- }
- if (errorFile) {
- // 에러 엑셀을 다운로드
- const url = URL.createObjectURL(errorFile)
- const link = document.createElement("a")
- link.href = url
- link.download = "errors.xlsx"
- link.click()
- URL.revokeObjectURL(url)
- } else {
- // 성공
- toast.success("Import success")
- // 필요 시 revalidateTag("tasks") 등
- }
-
- } catch (error) {
- toast.error("파일 업로드 중 오류가 발생했습니다.")
-
- }
- }
-
- function handleImportClick() {
- // 숨겨진 <input type="file" /> 요소를 클릭
- fileInputRef.current?.click()
- }
-
- return (
- <div className="flex items-center gap-2">
-
- <AddContactDialog vendorId={vendorId}/>
-
- {/** 3) Import 버튼 (파일 업로드) */}
- <Button variant="outline" size="sm" className="gap-2" onClick={handleImportClick}>
- <Upload className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">Import</span>
- </Button>
- {/*
- 실제로는 숨겨진 input과 연결:
- - accept=".xlsx,.xls" 등으로 Excel 파일만 업로드 허용
- */}
- <input
- ref={fileInputRef}
- type="file"
- accept=".xlsx,.xls"
- className="hidden"
- onChange={onFileChange}
- />
-
- {/** 4) Export 버튼 */}
- <Button
- variant="outline"
- size="sm"
- onClick={() =>
- exportTableToExcel(table, {
- filename: "tech-vendor-contacts",
- excludeColumns: ["select", "actions"],
- })
- }
- className="gap-2"
- >
- <Download className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">Export</span>
- </Button>
- </div>
- )
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Download, Upload } from "lucide-react"
+import { toast } from "sonner"
+import ExcelJS from "exceljs"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import { TechVendorContact } from "@/db/schema/techVendors"
+import { AddContactDialog } from "./add-contact-dialog"
+import {
+ importTechVendorContacts,
+ generateContactImportTemplate,
+ parseContactImportFile
+} from "@/lib/tech-vendors/service"
+
+interface TechVendorContactsTableToolbarActionsProps {
+ table: Table<TechVendorContact>
+ vendorId: number
+}
+
+export function TechVendorContactsTableToolbarActions({ table, vendorId }: TechVendorContactsTableToolbarActionsProps) {
+ // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
+
+ // 파일이 선택되었을 때 처리
+ async function onFileChange(event: React.ChangeEvent<HTMLInputElement>) {
+ const file = event.target.files?.[0]
+ if (!file) return
+
+ // 파일 초기화 (동일 파일 재업로드 시에도 onChange가 트리거되도록)
+ event.target.value = ""
+
+ try {
+ // Excel 파일 파싱
+ const contactData = await parseContactImportFile(file)
+
+ if (contactData.length === 0) {
+ toast.error("유효한 데이터가 없습니다. 템플릿 형식을 확인해주세요.")
+ return
+ }
+
+ // 서버로 데이터 전송
+ const result = await importTechVendorContacts(contactData)
+
+ if (result.successCount > 0) {
+ toast.success(`${result.successCount}개 연락처가 성공적으로 추가되었습니다.`)
+ }
+
+ if (result.failedRows.length > 0) {
+ toast.error(`${result.failedRows.length}개 행에서 오류가 발생했습니다.`)
+
+ // 에러 데이터를 Excel로 다운로드
+ const errorWorkbook = new ExcelJS.Workbook()
+ const errorWorksheet = errorWorkbook.addWorksheet("오류내역")
+
+ // 헤더 추가
+ errorWorksheet.columns = [
+ { header: "행번호", key: "row", width: 10 },
+ { header: "벤더이메일", key: "vendorEmail", width: 25 },
+ { header: "담당자명", key: "contactName", width: 20 },
+ { header: "담당자이메일", key: "contactEmail", width: 25 },
+ { header: "오류내용", key: "error", width: 80, style: { alignment: { wrapText: true } , font: { color: { argb: "FFFF0000" } } } },
+ ]
+
+ // 오류 데이터 추가
+ result.failedRows.forEach(failedRow => {
+ errorWorksheet.addRow({
+ row: failedRow.row,
+ error: failedRow.error,
+ vendorEmail: failedRow.vendorEmail,
+ contactName: failedRow.contactName,
+ contactEmail: failedRow.contactEmail,
+ })
+ })
+
+ const buffer = await errorWorkbook.xlsx.writeBuffer()
+ const blob = new Blob([buffer], {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ })
+ const url = URL.createObjectURL(blob)
+ const link = document.createElement("a")
+ link.href = url
+ link.download = "contact-import-errors.xlsx"
+ link.click()
+ URL.revokeObjectURL(url)
+ }
+
+ } catch (error) {
+ toast.error("파일 업로드 중 오류가 발생했습니다.")
+ console.error("Import error:", error)
+ }
+ }
+
+ function handleImportClick() {
+ // 숨겨진 <input type="file" /> 요소를 클릭
+ fileInputRef.current?.click()
+ }
+
+ async function handleTemplateDownload() {
+ try {
+ const templateBlob = await generateContactImportTemplate()
+ const url = URL.createObjectURL(templateBlob)
+ const link = document.createElement("a")
+ link.href = url
+ link.download = "tech-vendor-contacts-template.xlsx"
+ link.click()
+ URL.revokeObjectURL(url)
+ toast.success("템플릿이 다운로드되었습니다.")
+ } catch (error) {
+ toast.error("템플릿 다운로드 중 오류가 발생했습니다.")
+ console.error("Template download error:", error)
+ }
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+
+ <AddContactDialog vendorId={vendorId}/>
+
+ {/** 템플릿 다운로드 버튼 */}
+ <Button variant="outline" size="sm" className="gap-2" onClick={handleTemplateDownload}>
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">템플릿</span>
+ </Button>
+
+ {/** Import 버튼 (파일 업로드) */}
+ <Button variant="outline" size="sm" className="gap-2" onClick={handleImportClick}>
+ <Upload className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Import</span>
+ </Button>
+ {/*
+ 실제로는 숨겨진 input과 연결:
+ - accept=".xlsx,.xls" 등으로 Excel 파일만 업로드 허용
+ */}
+ <input
+ ref={fileInputRef}
+ type="file"
+ accept=".xlsx,.xls"
+ className="hidden"
+ onChange={onFileChange}
+ />
+
+ {/** Export 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "tech-vendor-contacts",
+ excludeColumns: ["select", "actions"],
+ })
+ }
+ className="gap-2"
+ >
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Export</span>
+ </Button>
+ </div>
+ )
} \ No newline at end of file
diff --git a/lib/tech-vendors/contacts-table/contact-table.tsx b/lib/tech-vendors/contacts-table/contact-table.tsx
index cccf490c..6029fe16 100644
--- a/lib/tech-vendors/contacts-table/contact-table.tsx
+++ b/lib/tech-vendors/contacts-table/contact-table.tsx
@@ -1,87 +1,93 @@
-"use client"
-
-import * as React from "react"
-import type {
- DataTableAdvancedFilterField,
- DataTableFilterField,
- DataTableRowAction,
-} from "@/types/table"
-
-import { toSentenceCase } from "@/lib/utils"
-import { useDataTable } from "@/hooks/use-data-table"
-import { DataTable } from "@/components/data-table/data-table"
-import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
-import { useFeatureFlags } from "./feature-flags-provider"
-import { getColumns } from "./contact-table-columns"
-import { getTechVendorContacts } from "../service"
-import { TechVendorContact } from "@/db/schema/techVendors"
-import { TechVendorContactsTableToolbarActions } from "./contact-table-toolbar-actions"
-
-interface TechVendorContactsTableProps {
- promises: Promise<
- [
- Awaited<ReturnType<typeof getTechVendorContacts>>,
- ]
- >,
- vendorId:number
-}
-
-export function TechVendorContactsTable({ promises , vendorId}: TechVendorContactsTableProps) {
- const { featureFlags } = useFeatureFlags()
-
- // Suspense로 받아온 데이터
- const [{ data, pageCount }] = React.use(promises)
-
- const [rowAction, setRowAction] = React.useState<DataTableRowAction<TechVendorContact> | null>(null)
-
- // getColumns() 호출 시, router를 주입
- const columns = React.useMemo(
- () => getColumns({ setRowAction }),
- [setRowAction]
- )
-
- const filterFields: DataTableFilterField<TechVendorContact>[] = [
-
- ]
-
- const advancedFilterFields: DataTableAdvancedFilterField<TechVendorContact>[] = [
- { id: "contactName", label: "Contact Name", type: "text" },
- { id: "contactPosition", label: "Contact Position", type: "text" },
- { id: "contactEmail", label: "Contact Email", type: "text" },
- { id: "contactPhone", label: "Contact Phone", type: "text" },
- { id: "createdAt", label: "Created at", type: "date" },
- { id: "updatedAt", label: "Updated at", type: "date" },
- ]
-
- const { table } = useDataTable({
- data,
- columns,
- pageCount,
- filterFields,
- enablePinning: true,
- enableAdvancedFilter: true,
- initialState: {
- sorting: [{ id: "createdAt", desc: true }],
- columnPinning: { right: ["actions"] },
- },
- getRowId: (originalRow) => String(originalRow.id),
- shallow: false,
- clearOnDefault: true,
- })
-
- return (
- <>
- <DataTable
- table={table}
- >
- <DataTableAdvancedToolbar
- table={table}
- filterFields={advancedFilterFields}
- shallow={false}
- >
- <TechVendorContactsTableToolbarActions table={table} vendorId={vendorId} />
- </DataTableAdvancedToolbar>
- </DataTable>
- </>
- )
+"use client"
+
+import * as React from "react"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import { getColumns } from "./contact-table-columns"
+import { getTechVendorContacts } from "../service"
+import { TechVendorContact } from "@/db/schema/techVendors"
+import { TechVendorContactsTableToolbarActions } from "./contact-table-toolbar-actions"
+import { UpdateContactSheet } from "./update-contact-sheet"
+
+interface TechVendorContactsTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getTechVendorContacts>>,
+ ]
+ >,
+ vendorId:number
+}
+
+export function TechVendorContactsTable({ promises , vendorId}: TechVendorContactsTableProps) {
+
+ // Suspense로 받아온 데이터
+ const [{ data, pageCount }] = React.use(promises)
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<TechVendorContact> | null>(null)
+
+ // getColumns() 호출 시, router를 주입
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ const filterFields: DataTableFilterField<TechVendorContact>[] = [
+
+ ]
+
+ const advancedFilterFields: DataTableAdvancedFilterField<TechVendorContact>[] = [
+ { id: "contactName", label: "Contact Name", type: "text" },
+ { id: "contactPosition", label: "Contact Position", type: "text" },
+ { id: "contactEmail", label: "Contact Email", type: "text" },
+ { id: "contactPhone", label: "Contact Phone", type: "text" },
+ { id: "country", label: "Country", type: "text" },
+ { id: "createdAt", label: "Created at", type: "date" },
+ { id: "updatedAt", label: "Updated at", type: "date" },
+ ]
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "createdAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <DataTable
+ table={table}
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <TechVendorContactsTableToolbarActions table={table} vendorId={vendorId} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ <UpdateContactSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ contact={rowAction?.type === "update" ? rowAction.row.original : null}
+ vendorId={vendorId}
+ />
+ </>
+ )
} \ No newline at end of file
diff --git a/lib/tech-vendors/contacts-table/feature-flags-provider.tsx b/lib/tech-vendors/contacts-table/feature-flags-provider.tsx
index 81131894..615377d6 100644
--- a/lib/tech-vendors/contacts-table/feature-flags-provider.tsx
+++ b/lib/tech-vendors/contacts-table/feature-flags-provider.tsx
@@ -1,108 +1,108 @@
-"use client"
-
-import * as React from "react"
-import { useQueryState } from "nuqs"
-
-import { dataTableConfig, type DataTableConfig } from "@/config/data-table"
-import { cn } from "@/lib/utils"
-import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/ui/tooltip"
-
-type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
-
-interface FeatureFlagsContextProps {
- featureFlags: FeatureFlagValue[]
- setFeatureFlags: (value: FeatureFlagValue[]) => void
-}
-
-const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
- featureFlags: [],
- setFeatureFlags: () => {},
-})
-
-export function useFeatureFlags() {
- const context = React.useContext(FeatureFlagsContext)
- if (!context) {
- throw new Error(
- "useFeatureFlags must be used within a FeatureFlagsProvider"
- )
- }
- return context
-}
-
-interface FeatureFlagsProviderProps {
- children: React.ReactNode
-}
-
-export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
- const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
- "flags",
- {
- defaultValue: [],
- parse: (value) => value.split(",") as FeatureFlagValue[],
- serialize: (value) => value.join(","),
- eq: (a, b) =>
- a.length === b.length && a.every((value, index) => value === b[index]),
- clearOnDefault: true,
- shallow: false,
- }
- )
-
- return (
- <FeatureFlagsContext.Provider
- value={{
- featureFlags,
- setFeatureFlags: (value) => void setFeatureFlags(value),
- }}
- >
- <div className="w-full overflow-x-auto">
- <ToggleGroup
- type="multiple"
- variant="outline"
- size="sm"
- value={featureFlags}
- onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
- className="w-fit gap-0"
- >
- {dataTableConfig.featureFlags.map((flag, index) => (
- <Tooltip key={flag.value}>
- <ToggleGroupItem
- value={flag.value}
- className={cn(
- "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90",
- {
- "rounded-l-sm border-r-0": index === 0,
- "rounded-r-sm":
- index === dataTableConfig.featureFlags.length - 1,
- }
- )}
- asChild
- >
- <TooltipTrigger>
- <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
- {flag.label}
- </TooltipTrigger>
- </ToggleGroupItem>
- <TooltipContent
- align="start"
- side="bottom"
- sideOffset={6}
- className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
- >
- <div>{flag.tooltipTitle}</div>
- <div className="text-xs text-muted-foreground">
- {flag.tooltipDescription}
- </div>
- </TooltipContent>
- </Tooltip>
- ))}
- </ToggleGroup>
- </div>
- {children}
- </FeatureFlagsContext.Provider>
- )
-}
+"use client"
+
+import * as React from "react"
+import { useQueryState } from "nuqs"
+
+import { dataTableConfig, type DataTableConfig } from "@/config/data-table"
+import { cn } from "@/lib/utils"
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
+
+interface FeatureFlagsContextProps {
+ featureFlags: FeatureFlagValue[]
+ setFeatureFlags: (value: FeatureFlagValue[]) => void
+}
+
+const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
+ featureFlags: [],
+ setFeatureFlags: () => {},
+})
+
+export function useFeatureFlags() {
+ const context = React.useContext(FeatureFlagsContext)
+ if (!context) {
+ throw new Error(
+ "useFeatureFlags must be used within a FeatureFlagsProvider"
+ )
+ }
+ return context
+}
+
+interface FeatureFlagsProviderProps {
+ children: React.ReactNode
+}
+
+export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
+ const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
+ "flags",
+ {
+ defaultValue: [],
+ parse: (value) => value.split(",") as FeatureFlagValue[],
+ serialize: (value) => value.join(","),
+ eq: (a, b) =>
+ a.length === b.length && a.every((value, index) => value === b[index]),
+ clearOnDefault: true,
+ shallow: false,
+ }
+ )
+
+ return (
+ <FeatureFlagsContext.Provider
+ value={{
+ featureFlags,
+ setFeatureFlags: (value) => void setFeatureFlags(value),
+ }}
+ >
+ <div className="w-full overflow-x-auto">
+ <ToggleGroup
+ type="multiple"
+ variant="outline"
+ size="sm"
+ value={featureFlags}
+ onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
+ className="w-fit gap-0"
+ >
+ {dataTableConfig.featureFlags.map((flag, index) => (
+ <Tooltip key={flag.value}>
+ <ToggleGroupItem
+ value={flag.value}
+ className={cn(
+ "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90",
+ {
+ "rounded-l-sm border-r-0": index === 0,
+ "rounded-r-sm":
+ index === dataTableConfig.featureFlags.length - 1,
+ }
+ )}
+ asChild
+ >
+ <TooltipTrigger>
+ <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
+ {flag.label}
+ </TooltipTrigger>
+ </ToggleGroupItem>
+ <TooltipContent
+ align="start"
+ side="bottom"
+ sideOffset={6}
+ className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
+ >
+ <div>{flag.tooltipTitle}</div>
+ <div className="text-xs text-muted-foreground">
+ {flag.tooltipDescription}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ ))}
+ </ToggleGroup>
+ </div>
+ {children}
+ </FeatureFlagsContext.Provider>
+ )
+}
diff --git a/lib/tech-vendors/contacts-table/update-contact-sheet.tsx b/lib/tech-vendors/contacts-table/update-contact-sheet.tsx
new file mode 100644
index 00000000..b75ddd1e
--- /dev/null
+++ b/lib/tech-vendors/contacts-table/update-contact-sheet.tsx
@@ -0,0 +1,217 @@
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useForm } from "react-hook-form"
+import { toast } from "sonner"
+
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { Checkbox } from "@/components/ui/checkbox"
+import { Loader2 } from "lucide-react"
+
+import type { TechVendorContact } from "@/db/schema/techVendors"
+import { updateTechVendorContactSchema, type UpdateTechVendorContactSchema } from "../validations"
+import { updateTechVendorContact } from "../service"
+
+interface UpdateContactSheetProps
+ extends React.ComponentPropsWithoutRef<typeof Sheet> {
+ contact: TechVendorContact | null
+ vendorId: number
+}
+
+export function UpdateContactSheet({ contact, vendorId, ...props }: UpdateContactSheetProps) {
+ const [isPending, startTransition] = React.useTransition()
+
+ const form = useForm<UpdateTechVendorContactSchema>({
+ resolver: zodResolver(updateTechVendorContactSchema),
+ defaultValues: {
+ contactName: contact?.contactName ?? "",
+ contactPosition: contact?.contactPosition ?? "",
+ contactEmail: contact?.contactEmail ?? "",
+ contactPhone: contact?.contactPhone ?? "",
+ contactCountry: contact?.contactCountry ?? "",
+ isPrimary: contact?.isPrimary ?? false,
+ },
+ })
+
+ React.useEffect(() => {
+ if (contact) {
+ form.reset({
+ contactName: contact.contactName,
+ contactPosition: contact.contactPosition ?? "",
+ contactEmail: contact.contactEmail,
+ contactPhone: contact.contactPhone ?? "",
+ contactCountry: contact.contactCountry ?? "",
+ isPrimary: contact.isPrimary,
+ })
+ }
+ }, [contact, form])
+
+ async function onSubmit(data: UpdateTechVendorContactSchema) {
+ if (!contact) return
+
+ startTransition(async () => {
+ try {
+ const { error } = await updateTechVendorContact({
+ id: contact.id,
+ vendorId: vendorId,
+ ...data
+ })
+
+ if (error) throw new Error(error)
+
+ toast.success("연락처 정보가 업데이트되었습니다!")
+ form.reset()
+ props.onOpenChange?.(false)
+ } catch (err: unknown) {
+ toast.error(String(err))
+ }
+ })
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="space-y-4 w-[600px] sm:max-w-[600px]">
+ <SheetHeader>
+ <SheetTitle>연락처 수정</SheetTitle>
+ <SheetDescription>
+ 연락처 정보를 수정하세요. 완료되면 저장 버튼을 클릭하세요.
+ </SheetDescription>
+ </SheetHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+ <div className="grid grid-cols-1 gap-4">
+ <FormField
+ control={form.control}
+ name="contactName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>담당자명 *</FormLabel>
+ <FormControl>
+ <Input placeholder="담당자명을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="contactPosition"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>직책</FormLabel>
+ <FormControl>
+ <Input placeholder="직책을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="contactEmail"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>이메일</FormLabel>
+ <FormControl>
+ <Input
+ type="email"
+ placeholder="이메일을 입력하세요"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="contactPhone"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>전화번호</FormLabel>
+ <FormControl>
+ <Input placeholder="전화번호를 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="contactCountry"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>국가</FormLabel>
+ <FormControl>
+ <Input placeholder="국가를 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="isPrimary"
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-start space-x-3 space-y-0">
+ <FormControl>
+ <Checkbox
+ checked={field.value}
+ onCheckedChange={field.onChange}
+ />
+ </FormControl>
+ <div className="space-y-1 leading-none">
+ <FormLabel>주 담당자</FormLabel>
+ </div>
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <div className="flex justify-end space-x-2">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => props.onOpenChange?.(false)}
+ >
+ 취소
+ </Button>
+ <Button type="submit" disabled={isPending}>
+ {isPending && (
+ <Loader2
+ className="mr-2 h-4 w-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 저장
+ </Button>
+ </div>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/tech-vendors/possible-items/add-item-dialog.tsx b/lib/tech-vendors/possible-items/add-item-dialog.tsx
new file mode 100644
index 00000000..ef15a5ce
--- /dev/null
+++ b/lib/tech-vendors/possible-items/add-item-dialog.tsx
@@ -0,0 +1,284 @@
+"use client";
+
+import * as React from "react";
+import { Search, X } from "lucide-react";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Badge } from "@/components/ui/badge";
+import {
+ getItemsForTechVendor,
+ addTechVendorPossibleItem
+} from "../service";
+
+interface ItemData {
+ id: number;
+ itemCode: string | null;
+ itemList: string | null;
+ workType: string | null;
+ shipTypes?: string | null;
+ subItemList?: string | null;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+interface AddItemDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ vendorId: number;
+}
+
+export function AddItemDialog({ open, onOpenChange, vendorId }: AddItemDialogProps) {
+ // 아이템 관련 상태
+ const [items, setItems] = React.useState<ItemData[]>([]);
+ const [filteredItems, setFilteredItems] = React.useState<ItemData[]>([]);
+ const [itemSearch, setItemSearch] = React.useState("");
+ const [selectedItems, setSelectedItems] = React.useState<ItemData[]>([]);
+
+ const [isLoading, setIsLoading] = React.useState(false);
+
+ // 다이얼로그가 열릴 때 아이템 목록 로드
+ React.useEffect(() => {
+ if (open && vendorId) {
+ loadItems();
+ }
+ }, [open, vendorId]);
+
+ // 아이템 검색 필터링
+ React.useEffect(() => {
+ if (!itemSearch) {
+ setFilteredItems(items);
+ } else {
+ const filtered = items.filter(item =>
+ item.itemCode?.toLowerCase().includes(itemSearch.toLowerCase()) ||
+ item.itemList?.toLowerCase().includes(itemSearch.toLowerCase()) ||
+ item.workType?.toLowerCase().includes(itemSearch.toLowerCase())
+ );
+ setFilteredItems(filtered);
+ }
+ }, [items, itemSearch]);
+
+ const loadItems = async () => {
+ try {
+ setIsLoading(true);
+ console.log("Loading items for vendor:", vendorId);
+ const result = await getItemsForTechVendor(vendorId);
+
+ if (result.error) {
+ throw new Error(result.error);
+ }
+
+ console.log("Loaded items:", result.data.length, result.data);
+ // itemCode가 null이 아닌 항목만 필터링
+ const validItems = result.data.filter(item => item.itemCode != null);
+ setItems(validItems);
+ } catch (error) {
+ console.error("Failed to load items:", error);
+ toast.error("아이템 목록을 불러오는데 실패했습니다.");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleItemToggle = (item: ItemData) => {
+ if (!item.itemCode) return; // itemCode가 null인 경우 처리하지 않음
+
+ setSelectedItems(prev => {
+ // itemCode + shipTypes 조합으로 중복 체크
+ const isSelected = prev.some(i =>
+ i.itemCode === item.itemCode && i.shipTypes === item.shipTypes
+ );
+ if (isSelected) {
+ return prev.filter(i =>
+ !(i.itemCode === item.itemCode && i.shipTypes === item.shipTypes)
+ );
+ } else {
+ return [...prev, item];
+ }
+ });
+ };
+
+ const handleSubmit = async () => {
+ if (selectedItems.length === 0) return;
+
+ try {
+ setIsLoading(true);
+ let successCount = 0;
+ let errorCount = 0;
+
+ for (const item of selectedItems) {
+ if (!item.itemCode) continue; // itemCode가 null인 경우 건너뛰기
+
+ const result = await addTechVendorPossibleItem({
+ vendorId: vendorId,
+ itemCode: item.itemCode,
+ workType: item.workType || undefined,
+ shipTypes: item.shipTypes || undefined,
+ itemList: item.itemList || undefined,
+ subItemList: item.subItemList || undefined,
+ });
+
+ if (result.success) {
+ successCount++;
+ } else {
+ errorCount++;
+ console.error("Failed to add item:", item.itemCode, result.error);
+ }
+ }
+
+ if (successCount > 0) {
+ toast.success(
+ `${successCount}개의 아이템이 추가되었습니다.${
+ errorCount > 0 ? ` (${errorCount}개 실패)` : ""
+ }`
+ );
+
+ handleClose();
+ } else {
+ toast.error("아이템 추가에 실패했습니다.");
+ }
+ } catch (error) {
+ console.error("Failed to add items:", error);
+ toast.error("아이템 추가 중 오류가 발생했습니다.");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleClose = () => {
+ onOpenChange(false);
+ setTimeout(() => {
+ setSelectedItems([]);
+ setItemSearch("");
+ setItems([]);
+ setFilteredItems([]);
+ }, 200);
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
+ <DialogHeader>
+ <DialogTitle>아이템 추가</DialogTitle>
+ <DialogDescription>
+ 추가할 아이템을 선택하세요. 복수 선택이 가능합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="flex-1 min-h-0 space-y-4">
+ {/* 검색 */}
+ <div className="space-y-2">
+ <Label htmlFor="item-search">아이템 검색</Label>
+ <div className="relative">
+ <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
+ <Input
+ id="item-search"
+ placeholder="아이템코드, 아이템리스트, 공종으로 검색..."
+ value={itemSearch}
+ onChange={(e) => setItemSearch(e.target.value)}
+ className="pl-10"
+ />
+ </div>
+ </div>
+
+ {/* 선택된 아이템 표시 */}
+ {selectedItems.length > 0 && (
+ <div className="space-y-2">
+ <Label>선택된 아이템 ({selectedItems.length}개)</Label>
+ <div className="flex flex-wrap gap-1 p-2 border rounded-md bg-muted/50 max-h-20 overflow-y-auto">
+ {selectedItems.map((item) => {
+ if (!item.itemCode) return null;
+ const itemKey = `${item.itemCode}${item.shipTypes ? `-${item.shipTypes}` : ''}`;
+ return (
+ <Badge key={`selected-${itemKey}`} variant="default" className="text-xs">
+ {itemKey}
+ <X
+ className="ml-1 h-3 w-3 cursor-pointer"
+ onClick={(e) => {
+ e.stopPropagation();
+ handleItemToggle(item);
+ }}
+ />
+ </Badge>
+ );
+ })}
+ </div>
+ </div>
+ )}
+
+ {/* 아이템 목록 */}
+ <div className="max-h-96 overflow-y-auto border rounded-lg bg-gray-50 p-2">
+ <div className="space-y-2">
+ {isLoading ? (
+ <div className="text-center py-4">아이템 로딩 중...</div>
+ ) : filteredItems.length === 0 && items.length === 0 ? (
+ <div className="text-center py-4 text-muted-foreground">
+ 해당 벤더 타입에 대한 추가 가능한 아이템이 없습니다.
+ </div>
+ ) : filteredItems.length === 0 ? (
+ <div className="text-center py-4 text-muted-foreground">
+ 검색 결과가 없습니다.
+ </div>
+ ) : (
+ filteredItems.map((item) => {
+ if (!item.itemCode) return null; // itemCode가 null인 경우 렌더링하지 않음
+
+ // itemCode + shipTypes 조합으로 선택 여부 체크
+ const isSelected = selectedItems.some(i =>
+ i.itemCode === item.itemCode && i.shipTypes === item.shipTypes
+ );
+ const itemKey = `${item.itemCode}${item.shipTypes ? `-${item.shipTypes}` : ''}`;
+
+ return (
+ <div
+ key={`item-${itemKey}`}
+ className={`p-3 bg-white border rounded-lg cursor-pointer transition-colors ${
+ isSelected
+ ? "bg-primary/10 border-primary hover:bg-primary/20"
+ : "hover:bg-gray-50"
+ }`}
+ onClick={() => handleItemToggle(item)}
+ >
+ <div className="font-medium">
+ {itemKey}
+ </div>
+ <div className="text-sm text-muted-foreground">
+ {item.itemList || "-"}
+ </div>
+ <div className="flex gap-2 mt-1 text-xs">
+ <span>공종: {item.workType || "-"}</span>
+ {item.shipTypes && <span>선종: {item.shipTypes}</span>}
+ {item.subItemList && <span>서브아이템: {item.subItemList}</span>}
+ </div>
+ </div>
+ );
+ })
+ )}
+ </div>
+ </div>
+ </div>
+
+ <div className="flex justify-end gap-2 pt-4 border-t">
+ <Button variant="outline" onClick={handleClose}>
+ 취소
+ </Button>
+ <Button
+ onClick={handleSubmit}
+ disabled={selectedItems.length === 0 || isLoading}
+ >
+ {isLoading ? "추가 중..." : `추가 (${selectedItems.length})`}
+ </Button>
+ </div>
+ </DialogContent>
+ </Dialog>
+ );
+} \ No newline at end of file
diff --git a/lib/tech-vendors/possible-items/possible-items-columns.tsx b/lib/tech-vendors/possible-items/possible-items-columns.tsx
new file mode 100644
index 00000000..71bcb3b8
--- /dev/null
+++ b/lib/tech-vendors/possible-items/possible-items-columns.tsx
@@ -0,0 +1,206 @@
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Ellipsis } from "lucide-react"
+
+import { formatDate } from "@/lib/utils"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuShortcut,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import type { TechVendorPossibleItem } from "../validations"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<TechVendorPossibleItem> | null>>;
+}
+
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<TechVendorPossibleItem>[] {
+ return [
+ // 선택 체크박스
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ },
+
+ // 아이템 코드
+ {
+ accessorKey: "itemCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="아이템 코드" />
+ ),
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">
+ {row.getValue("itemCode")}
+ </div>
+ ),
+ size: 150,
+ },
+
+ // 공종
+ {
+ accessorKey: "workType",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="공종" />
+ ),
+ cell: ({ row }) => {
+ const workType = row.getValue("workType") as string | null
+ return workType ? (
+ <Badge variant="secondary" className="text-xs">
+ {workType}
+ </Badge>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )
+ },
+ size: 100,
+ },
+
+ // 아이템명
+ {
+ accessorKey: "itemList",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="아이템명" />
+ ),
+ cell: ({ row }) => {
+ const itemList = row.getValue("itemList") as string | null
+ return (
+ <div className="max-w-[300px]">
+ {itemList || <span className="text-muted-foreground">-</span>}
+ </div>
+ )
+ },
+ size: 300,
+ },
+
+ // 선종 (조선용)
+ {
+ accessorKey: "shipTypes",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="선종" />
+ ),
+ cell: ({ row }) => {
+ const shipTypes = row.getValue("shipTypes") as string | null
+ return shipTypes ? (
+ <Badge variant="outline" className="text-xs">
+ {shipTypes}
+ </Badge>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )
+ },
+ size: 120,
+ },
+
+ // 서브아이템 (해양용)
+ {
+ accessorKey: "subItemList",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="서브아이템" />
+ ),
+ cell: ({ row }) => {
+ const subItemList = row.getValue("subItemList") as string | null
+ return (
+ <div className="max-w-[200px]">
+ {subItemList || <span className="text-muted-foreground">-</span>}
+ </div>
+ )
+ },
+ size: 200,
+ },
+
+ // 등록일
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="등록일" />
+ ),
+ cell: ({ row }) => {
+ const date = row.getValue("createdAt") as Date
+ return (
+ <div className="text-sm text-muted-foreground">
+ {formatDate(date)}
+ </div>
+ )
+ },
+ size: 120,
+ },
+
+ // 수정일
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="수정일" />
+ ),
+ cell: ({ row }) => {
+ const date = row.getValue("updatedAt") as Date
+ return (
+ <div className="text-sm text-muted-foreground">
+ {formatDate(date)}
+ </div>
+ )
+ },
+ size: 120,
+ },
+
+ // 액션 메뉴
+ {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-40">
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "delete" })}
+ >
+ 삭제
+ <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 40,
+ },
+ ]
+} \ No newline at end of file
diff --git a/lib/tech-vendors/possible-items/possible-items-table.tsx b/lib/tech-vendors/possible-items/possible-items-table.tsx
new file mode 100644
index 00000000..9c024a93
--- /dev/null
+++ b/lib/tech-vendors/possible-items/possible-items-table.tsx
@@ -0,0 +1,171 @@
+"use client"
+
+import * as React from "react"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+import { toast } from "sonner"
+
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog"
+
+import { getColumns } from "./possible-items-columns"
+import {
+ getTechVendorPossibleItems,
+ deleteTechVendorPossibleItem,
+} from "../service"
+import type { TechVendorPossibleItem } from "../validations"
+import { PossibleItemsTableToolbarActions } from "./possible-items-toolbar-actions"
+import { AddItemDialog } from "./add-item-dialog"
+
+interface TechVendorPossibleItemsTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getTechVendorPossibleItems>>,
+ ]
+ >
+ vendorId: number
+}
+
+export function TechVendorPossibleItemsTable({
+ promises,
+ vendorId,
+}: TechVendorPossibleItemsTableProps) {
+ // Suspense로 받아온 데이터
+ const [{ data, pageCount }] = React.use(promises)
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<TechVendorPossibleItem> | null>(null)
+ const [showAddDialog, setShowAddDialog] = React.useState(false)
+ const [showDeleteAlert, setShowDeleteAlert] = React.useState(false)
+ const [isDeleting, setIsDeleting] = React.useState(false)
+
+ // getColumns() 호출 시, setRowAction을 주입
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ // 단일 아이템 삭제 핸들러
+ async function handleDeleteItem() {
+ if (!rowAction || rowAction.type !== "delete") return
+
+ setIsDeleting(true)
+ try {
+ const { success, error } = await deleteTechVendorPossibleItem(
+ rowAction.row.original.id,
+ vendorId
+ )
+
+ if (!success) {
+ throw new Error(error)
+ }
+
+ toast.success("아이템이 삭제되었습니다")
+ setShowDeleteAlert(false)
+ setRowAction(null)
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "아이템 삭제 중 오류가 발생했습니다")
+ } finally {
+ setIsDeleting(false)
+ }
+ }
+
+ const filterFields: DataTableFilterField<TechVendorPossibleItem>[] = [
+ { id: "itemCode", label: "아이템 코드" },
+ { id: "workType", label: "공종" },
+ ]
+
+ const advancedFilterFields: DataTableAdvancedFilterField<TechVendorPossibleItem>[] = [
+ { id: "itemCode", label: "아이템 코드", type: "text" },
+ { id: "workType", label: "공종", type: "text" },
+ { id: "itemList", label: "아이템명", type: "text" },
+ { id: "shipTypes", label: "선종", type: "text" },
+ { id: "subItemList", label: "서브아이템", type: "text" },
+ { id: "createdAt", label: "등록일", type: "date" },
+ { id: "updatedAt", label: "수정일", type: "date" },
+ ]
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "createdAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ // rowAction 상태 변경 감지
+ React.useEffect(() => {
+ if (rowAction?.type === "delete") {
+ setShowDeleteAlert(true)
+ }
+ }, [rowAction])
+
+ return (
+ <>
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <PossibleItemsTableToolbarActions
+ table={table}
+ vendorId={vendorId}
+ onAdd={() => setShowAddDialog(true)}
+ />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ {/* Add Item Dialog */}
+ <AddItemDialog
+ open={showAddDialog}
+ onOpenChange={setShowAddDialog}
+ vendorId={vendorId}
+ />
+
+ {/* Delete Confirmation Dialog */}
+ <AlertDialog open={showDeleteAlert} onOpenChange={setShowDeleteAlert}>
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle>아이템 삭제</AlertDialogTitle>
+ <AlertDialogDescription>
+ 이 아이템을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel onClick={() => setRowAction(null)}>
+ 취소
+ </AlertDialogCancel>
+ <AlertDialogAction
+ onClick={handleDeleteItem}
+ disabled={isDeleting}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ >
+ {isDeleting ? "삭제 중..." : "삭제"}
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx b/lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx
new file mode 100644
index 00000000..707d0513
--- /dev/null
+++ b/lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx
@@ -0,0 +1,119 @@
+"use client"
+
+import * as React from "react"
+import type { Table } from "@tanstack/react-table"
+import { Plus, Trash2 } from "lucide-react"
+import { toast } from "sonner"
+
+import { Button } from "@/components/ui/button"
+import { Separator } from "@/components/ui/separator"
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog"
+
+import type { TechVendorPossibleItem } from "../validations"
+import { deleteTechVendorPossibleItemsNew } from "../service"
+
+interface PossibleItemsTableToolbarActionsProps {
+ table: Table<TechVendorPossibleItem>
+ vendorId: number
+ onAdd: () => void
+}
+
+export function PossibleItemsTableToolbarActions({
+ table,
+ vendorId,
+ onAdd,
+}: PossibleItemsTableToolbarActionsProps) {
+ const [showDeleteAlert, setShowDeleteAlert] = React.useState(false)
+ const [isDeleting, setIsDeleting] = React.useState(false)
+
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+
+ async function handleDelete() {
+ setIsDeleting(true)
+ try {
+ const ids = selectedRows.map((row) => row.original.id)
+ const { error } = await deleteTechVendorPossibleItemsNew(ids, vendorId)
+
+ if (error) {
+ throw new Error(error)
+ }
+
+ toast.success(`${ids.length}개의 아이템이 삭제되었습니다`)
+ table.resetRowSelection()
+ setShowDeleteAlert(false)
+ } catch {
+ toast.error("아이템 삭제 중 오류가 발생했습니다")
+ } finally {
+ setIsDeleting(false)
+ }
+ }
+
+ return (
+ <>
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={onAdd}
+ >
+ <Plus className="mr-2 h-4 w-4" />
+ 아이템 추가
+ </Button>
+
+ {selectedRows.length > 0 && (
+ <>
+ <Separator orientation="vertical" className="mx-2 h-4" />
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setShowDeleteAlert(true)}
+ disabled={selectedRows.length === 0}
+ >
+ <Trash2 className="mr-2 h-4 w-4" />
+ 삭제 ({selectedRows.length})
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ 선택된 {selectedRows.length}개 아이템을 삭제합니다
+ </TooltipContent>
+ </Tooltip>
+ </>
+ )}
+ </div>
+
+ <AlertDialog open={showDeleteAlert} onOpenChange={setShowDeleteAlert}>
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle>아이템 삭제</AlertDialogTitle>
+ <AlertDialogDescription>
+ 선택된 {selectedRows.length}개의 아이템을 삭제하시겠습니까?
+ 이 작업은 되돌릴 수 없습니다.
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel>취소</AlertDialogCancel>
+ <AlertDialogAction
+ onClick={handleDelete}
+ disabled={isDeleting}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ >
+ {isDeleting ? "삭제 중..." : "삭제"}
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/tech-vendors/repository.ts b/lib/tech-vendors/repository.ts
index d3c6671c..a273bf50 100644
--- a/lib/tech-vendors/repository.ts
+++ b/lib/tech-vendors/repository.ts
@@ -1,389 +1,462 @@
-// src/lib/vendors/repository.ts
-
-import { eq, inArray, count, desc } from "drizzle-orm";
-import db from '@/db/db';
-import { SQL } from "drizzle-orm";
-import { techVendors, techVendorContacts, techVendorPossibleItems, techVendorItemsView, type TechVendor, type TechVendorContact, type TechVendorItem, type TechVendorWithAttachments, techVendorAttachments } from "@/db/schema/techVendors";
-import { itemShipbuilding, itemOffshoreTop, itemOffshoreHull } from "@/db/schema/items";
-
-export type NewTechVendorContact = typeof techVendorContacts.$inferInsert
-export type NewTechVendorItem = typeof techVendorPossibleItems.$inferInsert
-
-type PaginationParams = {
- offset: number;
- limit: number;
-};
-
-// 메인 벤더 목록 조회 (첨부파일 정보 포함)
-export async function selectTechVendorsWithAttachments(
- tx: any,
- params: {
- where?: SQL<unknown>;
- orderBy?: SQL<unknown>[];
- } & PaginationParams
-) {
- const query = tx
- .select({
- id: techVendors.id,
- vendorName: techVendors.vendorName,
- vendorCode: techVendors.vendorCode,
- taxId: techVendors.taxId,
- address: techVendors.address,
- country: techVendors.country,
- phone: techVendors.phone,
- email: techVendors.email,
- website: techVendors.website,
- status: techVendors.status,
- techVendorType: techVendors.techVendorType,
- representativeName: techVendors.representativeName,
- representativeEmail: techVendors.representativeEmail,
- representativePhone: techVendors.representativePhone,
- representativeBirth: techVendors.representativeBirth,
- countryEng: techVendors.countryEng,
- countryFab: techVendors.countryFab,
- agentName: techVendors.agentName,
- agentPhone: techVendors.agentPhone,
- agentEmail: techVendors.agentEmail,
- items: techVendors.items,
- createdAt: techVendors.createdAt,
- updatedAt: techVendors.updatedAt,
- })
- .from(techVendors);
-
- // where 조건이 있는 경우
- if (params.where) {
- query.where(params.where);
- }
-
- // 정렬 조건이 있는 경우
- if (params.orderBy && params.orderBy.length > 0) {
- query.orderBy(...params.orderBy);
- } else {
- // 기본 정렬: 생성일 기준 내림차순
- query.orderBy(desc(techVendors.createdAt));
- }
-
- // 페이지네이션 적용
- query.offset(params.offset).limit(params.limit);
-
- const vendors = await query;
-
- // 첨부파일 정보 가져오기
- const vendorsWithAttachments = await Promise.all(
- vendors.map(async (vendor: TechVendor) => {
- const attachments = await tx
- .select({
- id: techVendorAttachments.id,
- fileName: techVendorAttachments.fileName,
- filePath: techVendorAttachments.filePath,
- })
- .from(techVendorAttachments)
- .where(eq(techVendorAttachments.vendorId, vendor.id));
-
- // 벤더의 worktype 조회
- const workTypes = await getVendorWorkTypes(tx, vendor.id, vendor.techVendorType);
-
- return {
- ...vendor,
- hasAttachments: attachments.length > 0,
- attachmentsList: attachments,
- workTypes: workTypes.join(', '), // 콤마로 구분해서 저장
- } as TechVendorWithAttachments;
- })
- );
-
- return vendorsWithAttachments;
-}
-
-// 메인 벤더 목록 수 조회 (첨부파일 정보 포함)
-export async function countTechVendorsWithAttachments(
- tx: any,
- where?: SQL<unknown>
-) {
- const query = tx.select({ count: count() }).from(techVendors);
-
- if (where) {
- query.where(where);
- }
-
- const result = await query;
- return result[0].count;
-}
-
-// 기술영업 벤더 조회
-export async function selectTechVendors(
- tx: any,
- params: {
- where?: SQL<unknown>;
- orderBy?: SQL<unknown>[];
- } & PaginationParams
-) {
- const query = tx.select().from(techVendors);
-
- if (params.where) {
- query.where(params.where);
- }
-
- if (params.orderBy && params.orderBy.length > 0) {
- query.orderBy(...params.orderBy);
- } else {
- query.orderBy(desc(techVendors.createdAt));
- }
-
- query.offset(params.offset).limit(params.limit);
-
- return query;
-}
-
-// 기술영업 벤더 수 카운트
-export async function countTechVendors(tx: any, where?: SQL<unknown>) {
- const query = tx.select({ count: count() }).from(techVendors);
-
- if (where) {
- query.where(where);
- }
-
- const result = await query;
- return result[0].count;
-}
-
-// 벤더 상태별 카운트
-export async function groupByTechVendorStatus(tx: any) {
- const result = await tx
- .select({
- status: techVendors.status,
- count: count(),
- })
- .from(techVendors)
- .groupBy(techVendors.status);
-
- return result;
-}
-
-// 벤더 상세 정보 조회
-export async function getTechVendorById(id: number) {
- const result = await db
- .select()
- .from(techVendors)
- .where(eq(techVendors.id, id));
-
- return result.length > 0 ? result[0] : null;
-}
-
-// 벤더 연락처 정보 조회
-export async function getTechVendorContactsById(id: number) {
- const result = await db
- .select()
- .from(techVendorContacts)
- .where(eq(techVendorContacts.id, id));
-
- return result.length > 0 ? result[0] : null;
-}
-
-// 신규 벤더 생성
-export async function insertTechVendor(
- tx: any,
- data: Omit<TechVendor, "id" | "createdAt" | "updatedAt">
-) {
- return tx
- .insert(techVendors)
- .values({
- ...data,
- createdAt: new Date(),
- updatedAt: new Date(),
- })
- .returning();
-}
-
-// 벤더 정보 업데이트 (단일)
-export async function updateTechVendor(
- tx: any,
- id: string | number,
- data: Partial<TechVendor>
-) {
- return tx
- .update(techVendors)
- .set({
- ...data,
- updatedAt: new Date(),
- })
- .where(eq(techVendors.id, Number(id)))
- .returning();
-}
-
-// 벤더 정보 업데이트 (다수)
-export async function updateTechVendors(
- tx: any,
- ids: (string | number)[],
- data: Partial<TechVendor>
-) {
- return tx
- .update(techVendors)
- .set({
- ...data,
- updatedAt: new Date(),
- })
- .where(inArray(techVendors.id, ids.map(id => Number(id))))
- .returning();
-}
-
-// 벤더 연락처 조회
-export async function selectTechVendorContacts(
- tx: any,
- params: {
- where?: SQL<unknown>;
- orderBy?: SQL<unknown>[];
- } & PaginationParams
-) {
- const query = tx.select().from(techVendorContacts);
-
- if (params.where) {
- query.where(params.where);
- }
-
- if (params.orderBy && params.orderBy.length > 0) {
- query.orderBy(...params.orderBy);
- } else {
- query.orderBy(desc(techVendorContacts.createdAt));
- }
-
- query.offset(params.offset).limit(params.limit);
-
- return query;
-}
-
-// 벤더 연락처 수 카운트
-export async function countTechVendorContacts(tx: any, where?: SQL<unknown>) {
- const query = tx.select({ count: count() }).from(techVendorContacts);
-
- if (where) {
- query.where(where);
- }
-
- const result = await query;
- return result[0].count;
-}
-
-// 연락처 생성
-export async function insertTechVendorContact(
- tx: any,
- data: Omit<TechVendorContact, "id" | "createdAt" | "updatedAt">
-) {
- return tx
- .insert(techVendorContacts)
- .values({
- ...data,
- createdAt: new Date(),
- updatedAt: new Date(),
- })
- .returning();
-}
-
-// 아이템 목록 조회
-export async function selectTechVendorItems(
- tx: any,
- params: {
- where?: SQL<unknown>;
- orderBy?: SQL<unknown>[];
- } & PaginationParams
-) {
- const query = tx.select().from(techVendorItemsView);
-
- if (params.where) {
- query.where(params.where);
- }
-
- if (params.orderBy && params.orderBy.length > 0) {
- query.orderBy(...params.orderBy);
- } else {
- query.orderBy(desc(techVendorItemsView.createdAt));
- }
-
- query.offset(params.offset).limit(params.limit);
-
- return query;
-}
-
-// 아이템 수 카운트
-export async function countTechVendorItems(tx: any, where?: SQL<unknown>) {
- const query = tx.select({ count: count() }).from(techVendorItemsView);
-
- if (where) {
- query.where(where);
- }
-
- const result = await query;
- return result[0].count;
-}
-
-// 아이템 생성
-export async function insertTechVendorItem(
- tx: any,
- data: Omit<TechVendorItem, "id" | "createdAt" | "updatedAt">
-) {
- return tx
- .insert(techVendorPossibleItems)
- .values({
- ...data,
- createdAt: new Date(),
- updatedAt: new Date(),
- })
- .returning();
-}
-
-// 벤더의 worktype 조회
-export async function getVendorWorkTypes(
- tx: any,
- vendorId: number,
- vendorType: string
-): Promise<string[]> {
- try {
- // 벤더의 possible items 조회
- const possibleItems = await tx
- .select({ itemCode: techVendorPossibleItems.itemCode })
- .from(techVendorPossibleItems)
- .where(eq(techVendorPossibleItems.vendorId, vendorId));
-
- if (!possibleItems.length) {
- return [];
- }
-
- const itemCodes = possibleItems.map((item: { itemCode: string }) => item.itemCode);
- const workTypes: string[] = [];
-
- // 벤더 타입에 따라 해당하는 아이템 테이블에서 worktype 조회
- if (vendorType.includes('조선')) {
- const shipWorkTypes = await tx
- .select({ workType: itemShipbuilding.workType })
- .from(itemShipbuilding)
- .where(inArray(itemShipbuilding.itemCode, itemCodes));
-
- workTypes.push(...shipWorkTypes.map((item: { workType: string | null }) => item.workType).filter(Boolean));
- }
-
- if (vendorType.includes('해양TOP')) {
- const topWorkTypes = await tx
- .select({ workType: itemOffshoreTop.workType })
- .from(itemOffshoreTop)
- .where(inArray(itemOffshoreTop.itemCode, itemCodes));
-
- workTypes.push(...topWorkTypes.map((item: { workType: string | null }) => item.workType).filter(Boolean));
- }
-
- if (vendorType.includes('해양HULL')) {
- const hullWorkTypes = await tx
- .select({ workType: itemOffshoreHull.workType })
- .from(itemOffshoreHull)
- .where(inArray(itemOffshoreHull.itemCode, itemCodes));
-
- workTypes.push(...hullWorkTypes.map((item: { workType: string | null }) => item.workType).filter(Boolean));
- }
-
- // 중복 제거 후 반환
- const uniqueWorkTypes = [...new Set(workTypes)];
-
- return uniqueWorkTypes;
- } catch (error) {
- return [];
- }
-}
+// src/lib/vendors/repository.ts
+
+import { eq, inArray, count, desc } from "drizzle-orm";
+import db from '@/db/db';
+import { SQL } from "drizzle-orm";
+import { techVendors, techVendorContacts, techVendorPossibleItems, techVendorItemsView, type TechVendor, type TechVendorContact, type TechVendorItem, type TechVendorWithAttachments, techVendorAttachments } from "@/db/schema/techVendors";
+import { itemShipbuilding, itemOffshoreTop, itemOffshoreHull } from "@/db/schema/items";
+
+export type NewTechVendorContact = typeof techVendorContacts.$inferInsert
+export type NewTechVendorItem = typeof techVendorPossibleItems.$inferInsert
+
+type PaginationParams = {
+ offset: number;
+ limit: number;
+};
+
+// 메인 벤더 목록 조회 (첨부파일 정보 포함)
+export async function selectTechVendorsWithAttachments(
+ tx: any,
+ params: {
+ where?: SQL<unknown>;
+ orderBy?: SQL<unknown>[];
+ } & PaginationParams
+) {
+ const query = tx
+ .select({
+ id: techVendors.id,
+ vendorName: techVendors.vendorName,
+ vendorCode: techVendors.vendorCode,
+ taxId: techVendors.taxId,
+ address: techVendors.address,
+ country: techVendors.country,
+ phone: techVendors.phone,
+ email: techVendors.email,
+ website: techVendors.website,
+ status: techVendors.status,
+ techVendorType: techVendors.techVendorType,
+ representativeName: techVendors.representativeName,
+ representativeEmail: techVendors.representativeEmail,
+ representativePhone: techVendors.representativePhone,
+ representativeBirth: techVendors.representativeBirth,
+ countryEng: techVendors.countryEng,
+ countryFab: techVendors.countryFab,
+ agentName: techVendors.agentName,
+ agentPhone: techVendors.agentPhone,
+ agentEmail: techVendors.agentEmail,
+ items: techVendors.items,
+ createdAt: techVendors.createdAt,
+ updatedAt: techVendors.updatedAt,
+ })
+ .from(techVendors);
+
+ // where 조건이 있는 경우
+ if (params.where) {
+ query.where(params.where);
+ }
+
+ // 정렬 조건이 있는 경우
+ if (params.orderBy && params.orderBy.length > 0) {
+ query.orderBy(...params.orderBy);
+ } else {
+ // 기본 정렬: 생성일 기준 내림차순
+ query.orderBy(desc(techVendors.createdAt));
+ }
+
+ // 페이지네이션 적용
+ query.offset(params.offset).limit(params.limit);
+
+ const vendors = await query;
+
+ // 첨부파일 정보 가져오기
+ const vendorsWithAttachments = await Promise.all(
+ vendors.map(async (vendor: TechVendor) => {
+ const attachments = await tx
+ .select({
+ id: techVendorAttachments.id,
+ fileName: techVendorAttachments.fileName,
+ filePath: techVendorAttachments.filePath,
+ })
+ .from(techVendorAttachments)
+ .where(eq(techVendorAttachments.vendorId, vendor.id));
+
+ // 벤더의 worktype 조회
+ const workTypes = await getVendorWorkTypes(tx, vendor.id, vendor.techVendorType);
+
+ return {
+ ...vendor,
+ hasAttachments: attachments.length > 0,
+ attachmentsList: attachments,
+ workTypes: workTypes.join(', '), // 콤마로 구분해서 저장
+ } as TechVendorWithAttachments;
+ })
+ );
+
+ return vendorsWithAttachments;
+}
+
+// 메인 벤더 목록 수 조회 (첨부파일 정보 포함)
+export async function countTechVendorsWithAttachments(
+ tx: any,
+ where?: SQL<unknown>
+) {
+ const query = tx.select({ count: count() }).from(techVendors);
+
+ if (where) {
+ query.where(where);
+ }
+
+ const result = await query;
+ return result[0].count;
+}
+
+// 기술영업 벤더 조회
+export async function selectTechVendors(
+ tx: any,
+ params: {
+ where?: SQL<unknown>;
+ orderBy?: SQL<unknown>[];
+ } & PaginationParams
+) {
+ const query = tx.select().from(techVendors);
+
+ if (params.where) {
+ query.where(params.where);
+ }
+
+ if (params.orderBy && params.orderBy.length > 0) {
+ query.orderBy(...params.orderBy);
+ } else {
+ query.orderBy(desc(techVendors.createdAt));
+ }
+
+ query.offset(params.offset).limit(params.limit);
+
+ return query;
+}
+
+// 기술영업 벤더 수 카운트
+export async function countTechVendors(tx: any, where?: SQL<unknown>) {
+ const query = tx.select({ count: count() }).from(techVendors);
+
+ if (where) {
+ query.where(where);
+ }
+
+ const result = await query;
+ return result[0].count;
+}
+
+// 벤더 상태별 카운트
+export async function groupByTechVendorStatus(tx: any) {
+ const result = await tx
+ .select({
+ status: techVendors.status,
+ count: count(),
+ })
+ .from(techVendors)
+ .groupBy(techVendors.status);
+
+ return result;
+}
+
+// 벤더 상세 정보 조회
+export async function getTechVendorById(id: number) {
+ const result = await db
+ .select()
+ .from(techVendors)
+ .where(eq(techVendors.id, id));
+
+ return result.length > 0 ? result[0] : null;
+}
+
+// 벤더 연락처 정보 조회
+export async function getTechVendorContactsById(id: number) {
+ const result = await db
+ .select()
+ .from(techVendorContacts)
+ .where(eq(techVendorContacts.id, id));
+
+ return result.length > 0 ? result[0] : null;
+}
+
+// 신규 벤더 생성
+export async function insertTechVendor(
+ tx: any,
+ data: Omit<TechVendor, "id" | "createdAt" | "updatedAt">
+) {
+ return tx
+ .insert(techVendors)
+ .values({
+ ...data,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .returning();
+}
+
+// 벤더 정보 업데이트 (단일)
+export async function updateTechVendor(
+ tx: any,
+ id: string | number,
+ data: Partial<TechVendor>
+) {
+ return tx
+ .update(techVendors)
+ .set({
+ ...data,
+ updatedAt: new Date(),
+ })
+ .where(eq(techVendors.id, Number(id)))
+ .returning();
+}
+
+// 벤더 정보 업데이트 (다수)
+export async function updateTechVendors(
+ tx: any,
+ ids: (string | number)[],
+ data: Partial<TechVendor>
+) {
+ return tx
+ .update(techVendors)
+ .set({
+ ...data,
+ updatedAt: new Date(),
+ })
+ .where(inArray(techVendors.id, ids.map(id => Number(id))))
+ .returning();
+}
+
+// 벤더 연락처 조회
+export async function selectTechVendorContacts(
+ tx: any,
+ params: {
+ where?: SQL<unknown>;
+ orderBy?: SQL<unknown>[];
+ } & PaginationParams
+) {
+ const query = tx.select().from(techVendorContacts);
+
+ if (params.where) {
+ query.where(params.where);
+ }
+
+ if (params.orderBy && params.orderBy.length > 0) {
+ query.orderBy(...params.orderBy);
+ } else {
+ query.orderBy(desc(techVendorContacts.createdAt));
+ }
+
+ query.offset(params.offset).limit(params.limit);
+
+ return query;
+}
+
+// 벤더 연락처 수 카운트
+export async function countTechVendorContacts(tx: any, where?: SQL<unknown>) {
+ const query = tx.select({ count: count() }).from(techVendorContacts);
+
+ if (where) {
+ query.where(where);
+ }
+
+ const result = await query;
+ return result[0].count;
+}
+
+// 연락처 생성
+export async function insertTechVendorContact(
+ tx: any,
+ data: Omit<TechVendorContact, "id" | "createdAt" | "updatedAt">
+) {
+ return tx
+ .insert(techVendorContacts)
+ .values({
+ ...data,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .returning();
+}
+
+// 아이템 목록 조회
+export async function selectTechVendorItems(
+ tx: any,
+ params: {
+ where?: SQL<unknown>;
+ orderBy?: SQL<unknown>[];
+ } & PaginationParams
+) {
+ const query = tx.select().from(techVendorItemsView);
+
+ if (params.where) {
+ query.where(params.where);
+ }
+
+ if (params.orderBy && params.orderBy.length > 0) {
+ query.orderBy(...params.orderBy);
+ } else {
+ query.orderBy(desc(techVendorItemsView.createdAt));
+ }
+
+ query.offset(params.offset).limit(params.limit);
+
+ return query;
+}
+
+// 아이템 수 카운트
+export async function countTechVendorItems(tx: any, where?: SQL<unknown>) {
+ const query = tx.select({ count: count() }).from(techVendorItemsView);
+
+ if (where) {
+ query.where(where);
+ }
+
+ const result = await query;
+ return result[0].count;
+}
+
+// 아이템 생성
+export async function insertTechVendorItem(
+ tx: any,
+ data: Omit<TechVendorItem, "id" | "createdAt" | "updatedAt">
+) {
+ return tx
+ .insert(techVendorPossibleItems)
+ .values({
+ ...data,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .returning();
+}
+
+// 벤더의 worktype 조회
+export async function getVendorWorkTypes(
+ tx: any,
+ vendorId: number,
+ vendorType: string
+): Promise<string[]> {
+ try {
+ // 벤더의 possible items 조회 - 모든 필드 가져오기
+ const possibleItems = await tx
+ .select({
+ itemCode: techVendorPossibleItems.itemCode,
+ shipTypes: techVendorPossibleItems.shipTypes,
+ itemList: techVendorPossibleItems.itemList,
+ subItemList: techVendorPossibleItems.subItemList,
+ workType: techVendorPossibleItems.workType
+ })
+ .from(techVendorPossibleItems)
+ .where(eq(techVendorPossibleItems.vendorId, vendorId));
+ console.log("possibleItems", possibleItems);
+ if (!possibleItems.length) {
+ return [];
+ }
+
+ const workTypes: string[] = [];
+
+ // 벤더 타입에 따라 해당하는 아이템 테이블에서 worktype 조회
+ if (vendorType.includes('조선')) {
+ const itemCodes = possibleItems
+ .map((item: { itemCode?: string | null }) => item.itemCode)
+ .filter(Boolean);
+
+ if (itemCodes.length > 0) {
+ const shipWorkTypes = await tx
+ .select({ workType: itemShipbuilding.workType })
+ .from(itemShipbuilding)
+ .where(inArray(itemShipbuilding.itemCode, itemCodes));
+
+ workTypes.push(...shipWorkTypes.map((item: { workType: string | null }) => item.workType).filter(Boolean));
+ }
+ }
+
+ if (vendorType.includes('해양TOP')) {
+ // 1. 아이템코드가 있는 경우
+ const itemCodesTop = possibleItems
+ .map((item: { itemCode?: string | null }) => item.itemCode)
+ .filter(Boolean) as string[];
+
+ if (itemCodesTop.length > 0) {
+ const topWorkTypes = await tx
+ .select({ workType: itemOffshoreTop.workType })
+ .from(itemOffshoreTop)
+ .where(inArray(itemOffshoreTop.itemCode, itemCodesTop));
+
+ workTypes.push(
+ ...topWorkTypes
+ .map((item: { workType: string | null }) => item.workType)
+ .filter(Boolean) as string[]
+ );
+ }
+
+ // 2. 아이템코드가 없는 경우 서브아이템리스트로 매칭
+ const itemsWithoutCodeTop = possibleItems.filter(
+ (item: { itemCode?: string | null; subItemList?: string | null }) =>
+ !item.itemCode && item.subItemList
+ );
+ if (itemsWithoutCodeTop.length > 0) {
+ const subItemListsTop = itemsWithoutCodeTop
+ .map((item: { subItemList?: string | null }) => item.subItemList)
+ .filter(Boolean) as string[];
+
+ if (subItemListsTop.length > 0) {
+ const topWorkTypesBySubItem = await tx
+ .select({ workType: itemOffshoreTop.workType })
+ .from(itemOffshoreTop)
+ .where(inArray(itemOffshoreTop.subItemList, subItemListsTop));
+
+ workTypes.push(
+ ...topWorkTypesBySubItem
+ .map((item: { workType: string | null }) => item.workType)
+ .filter(Boolean) as string[]
+ );
+ }
+ }
+ }
+ if (vendorType.includes('해양HULL')) {
+ // 1. 아이템코드가 있는 경우
+ const itemCodes = possibleItems
+ .map((item: { itemCode?: string | null }) => item.itemCode)
+ .filter(Boolean);
+
+ if (itemCodes.length > 0) {
+ const hullWorkTypes = await tx
+ .select({ workType: itemOffshoreHull.workType })
+ .from(itemOffshoreHull)
+ .where(inArray(itemOffshoreHull.itemCode, itemCodes));
+
+ workTypes.push(...hullWorkTypes.map((item: { workType: string | null }) => item.workType).filter(Boolean));
+ }
+
+ // 2. 아이템코드가 없는 경우 서브아이템리스트로 매칭
+ const itemsWithoutCodeHull = possibleItems.filter(
+ (item: { itemCode?: string | null; subItemList?: string | null }) =>
+ !item.itemCode && item.subItemList
+ );
+
+ if (itemsWithoutCodeHull.length > 0) {
+ const subItemListsHull = itemsWithoutCodeHull
+ .map((item: { subItemList?: string | null }) => item.subItemList)
+ .filter(Boolean) as string[];
+
+ if (subItemListsHull.length > 0) {
+ const hullWorkTypesBySubItem = await tx
+ .select({ workType: itemOffshoreHull.workType })
+ .from(itemOffshoreHull)
+ .where(inArray(itemOffshoreHull.subItemList, subItemListsHull));
+
+ workTypes.push(...hullWorkTypesBySubItem.map((item: { workType: string | null }) => item.workType).filter(Boolean));
+ }
+ }
+ }
+ // 중복 제거 후 반환
+ const uniqueWorkTypes = [...new Set(workTypes)];
+
+ return uniqueWorkTypes;
+ } catch (error) {
+ console.error('getVendorWorkTypes 오류:', error);
+ return [];
+ }
+}
diff --git a/lib/tech-vendors/rfq-history-table/tech-vendor-rfq-history-table-columns.tsx b/lib/tech-vendors/rfq-history-table/tech-vendor-rfq-history-table-columns.tsx
index a7eed1d2..9a5c85c1 100644
--- a/lib/tech-vendors/rfq-history-table/tech-vendor-rfq-history-table-columns.tsx
+++ b/lib/tech-vendors/rfq-history-table/tech-vendor-rfq-history-table-columns.tsx
@@ -101,33 +101,33 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<TechVen
// ----------------------------------------------------------------
// 2) actions 컬럼 (Dropdown 메뉴)
// ----------------------------------------------------------------
- const actionsColumn: ColumnDef<TechVendorRfqHistoryRow> = {
- id: "actions",
- enableHiding: false,
- cell: function Cell({ row }) {
- return (
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- aria-label="Open menu"
- variant="ghost"
- className="flex size-8 p-0 data-[state=open]:bg-muted"
- >
- <Ellipsis className="size-4" aria-hidden="true" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end" className="w-40">
- <DropdownMenuItem
- onSelect={() => setRowAction({ row, type: "update" })}
- >
- View Details
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
- )
- },
- size: 40,
- }
+ // const actionsColumn: ColumnDef<TechVendorRfqHistoryRow> = {
+ // id: "actions",
+ // enableHiding: false,
+ // cell: function Cell({ row }) {
+ // return (
+ // <DropdownMenu>
+ // <DropdownMenuTrigger asChild>
+ // <Button
+ // aria-label="Open menu"
+ // variant="ghost"
+ // className="flex size-8 p-0 data-[state=open]:bg-muted"
+ // >
+ // <Ellipsis className="size-4" aria-hidden="true" />
+ // </Button>
+ // </DropdownMenuTrigger>
+ // <DropdownMenuContent align="end" className="w-40">
+ // <DropdownMenuItem
+ // onSelect={() => setRowAction({ row, type: "update" })}
+ // >
+ // View Details
+ // </DropdownMenuItem>
+ // </DropdownMenuContent>
+ // </DropdownMenu>
+ // )
+ // },
+ // size: 40,
+ // }
// ----------------------------------------------------------------
// 3) 일반 컬럼들
@@ -238,6 +238,6 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<TechVen
return [
selectColumn,
...basicColumns,
- actionsColumn,
+ // actionsColumn,
]
} \ No newline at end of file
diff --git a/lib/tech-vendors/service.ts b/lib/tech-vendors/service.ts
index cb5aa89f..a5881083 100644
--- a/lib/tech-vendors/service.ts
+++ b/lib/tech-vendors/service.ts
@@ -1,1890 +1,2616 @@
-"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택)
-
-import { revalidateTag, unstable_noStore } from "next/cache";
-import db from "@/db/db";
-import { techVendorAttachments, techVendorContacts, techVendorPossibleItems, techVendors, techVendorItemsView, type TechVendor, techVendorCandidates } from "@/db/schema/techVendors";
-import { items, itemShipbuilding, itemOffshoreTop, itemOffshoreHull } from "@/db/schema/items";
-import { users } from "@/db/schema/users";
-
-import { filterColumns } from "@/lib/filter-columns";
-import { unstable_cache } from "@/lib/unstable-cache";
-import { getErrorMessage } from "@/lib/handle-error";
-
-import {
- insertTechVendor,
- updateTechVendor,
- groupByTechVendorStatus,
- selectTechVendorContacts,
- countTechVendorContacts,
- insertTechVendorContact,
- selectTechVendorItems,
- countTechVendorItems,
- insertTechVendorItem,
- selectTechVendorsWithAttachments,
- countTechVendorsWithAttachments,
- updateTechVendors,
-} from "./repository";
-
-import type {
- CreateTechVendorSchema,
- UpdateTechVendorSchema,
- GetTechVendorsSchema,
- GetTechVendorContactsSchema,
- CreateTechVendorContactSchema,
- GetTechVendorItemsSchema,
- CreateTechVendorItemSchema,
- GetTechVendorRfqHistorySchema,
-} from "./validations";
-
-import { asc, desc, ilike, inArray, and, or, eq, isNull, not } from "drizzle-orm";
-import path from "path";
-import { sql } from "drizzle-orm";
-import { decryptWithServerAction } from "@/components/drm/drmUtils";
-import { deleteFile, saveDRMFile } from "../file-stroage";
-
-/* -----------------------------------------------------
- 1) 조회 관련
------------------------------------------------------ */
-
-/**
- * 복잡한 조건으로 기술영업 Vendor 목록을 조회 (+ pagination) 하고,
- * 총 개수에 따라 pageCount를 계산해서 리턴.
- * Next.js의 unstable_cache를 사용해 일정 시간 캐시.
- */
-export async function getTechVendors(input: GetTechVendorsSchema) {
- return unstable_cache(
- async () => {
- try {
- const offset = (input.page - 1) * input.perPage;
-
- // 1) 고급 필터 (workTypes와 techVendorType 제외 - 별도 처리)
- const filteredFilters = input.filters.filter(
- filter => filter.id !== "workTypes" && filter.id !== "techVendorType"
- );
-
- const advancedWhere = filterColumns({
- table: techVendors,
- filters: filteredFilters,
- joinOperator: input.joinOperator,
- });
-
- // 2) 글로벌 검색
- let globalWhere;
- if (input.search) {
- const s = `%${input.search}%`;
- globalWhere = or(
- ilike(techVendors.vendorName, s),
- ilike(techVendors.vendorCode, s),
- ilike(techVendors.email, s),
- ilike(techVendors.status, s)
- );
- }
-
- // 최종 where 결합
- const finalWhere = and(advancedWhere, globalWhere);
-
- // 벤더 타입 필터링 로직 추가
- let vendorTypeWhere;
- if (input.vendorType) {
- // URL의 vendorType 파라미터를 실제 벤더 타입으로 매핑
- const vendorTypeMap = {
- "ship": "조선",
- "top": "해양TOP",
- "hull": "해양HULL"
- };
-
- const actualVendorType = input.vendorType in vendorTypeMap
- ? vendorTypeMap[input.vendorType as keyof typeof vendorTypeMap]
- : undefined;
- if (actualVendorType) {
- // techVendorType 필드는 콤마로 구분된 문자열이므로 LIKE 사용
- vendorTypeWhere = ilike(techVendors.techVendorType, `%${actualVendorType}%`);
- }
- }
-
- // 간단 검색 (advancedTable=false) 시 예시
- const simpleWhere = and(
- input.vendorName
- ? ilike(techVendors.vendorName, `%${input.vendorName}%`)
- : undefined,
- input.status ? ilike(techVendors.status, input.status) : undefined,
- input.country
- ? ilike(techVendors.country, `%${input.country}%`)
- : undefined
- );
-
- // TechVendorType 필터링 로직 추가 (고급 필터에서)
- let techVendorTypeWhere;
- const techVendorTypeFilters = input.filters.filter(filter => filter.id === "techVendorType");
- if (techVendorTypeFilters.length > 0) {
- const typeFilter = techVendorTypeFilters[0];
- if (Array.isArray(typeFilter.value) && typeFilter.value.length > 0) {
- // 각 타입에 대해 LIKE 조건으로 OR 연결
- const typeConditions = typeFilter.value.map(type =>
- ilike(techVendors.techVendorType, `%${type}%`)
- );
- techVendorTypeWhere = or(...typeConditions);
- }
- }
-
- // WorkTypes 필터링 로직 추가
- let workTypesWhere;
- const workTypesFilters = input.filters.filter(filter => filter.id === "workTypes");
- if (workTypesFilters.length > 0) {
- const workTypeFilter = workTypesFilters[0];
- if (Array.isArray(workTypeFilter.value) && workTypeFilter.value.length > 0) {
- // workTypes에 해당하는 벤더 ID들을 서브쿼리로 찾음
- const vendorIdsWithWorkTypes = db
- .selectDistinct({ vendorId: techVendorPossibleItems.vendorId })
- .from(techVendorPossibleItems)
- .leftJoin(itemShipbuilding, eq(techVendorPossibleItems.itemCode, itemShipbuilding.itemCode))
- .leftJoin(itemOffshoreTop, eq(techVendorPossibleItems.itemCode, itemOffshoreTop.itemCode))
- .leftJoin(itemOffshoreHull, eq(techVendorPossibleItems.itemCode, itemOffshoreHull.itemCode))
- .where(
- or(
- inArray(itemShipbuilding.workType, workTypeFilter.value),
- inArray(itemOffshoreTop.workType, workTypeFilter.value),
- inArray(itemOffshoreHull.workType, workTypeFilter.value)
- )
- );
-
- workTypesWhere = inArray(techVendors.id, vendorIdsWithWorkTypes);
- }
- }
-
- // 실제 사용될 where (vendorType, techVendorType, workTypes 필터링 추가)
- const where = and(finalWhere, vendorTypeWhere, techVendorTypeWhere, workTypesWhere);
-
- // 정렬
- const orderBy =
- input.sort.length > 0
- ? input.sort.map((item) =>
- item.desc ? desc(techVendors[item.id]) : asc(techVendors[item.id])
- )
- : [asc(techVendors.createdAt)];
-
- // 트랜잭션 내에서 데이터 조회
- const { data, total } = await db.transaction(async (tx) => {
- // 1) vendor 목록 조회 (with attachments)
- const vendorsData = await selectTechVendorsWithAttachments(tx, {
- where,
- orderBy,
- offset,
- limit: input.perPage,
- });
-
- // 2) 전체 개수
- const total = await countTechVendorsWithAttachments(tx, where);
- return { data: vendorsData, total };
- });
-
- // 페이지 수
- const pageCount = Math.ceil(total / input.perPage);
-
- return { data, pageCount };
- } catch (err) {
- console.error("Error fetching tech vendors:", err);
- // 에러 발생 시
- return { data: [], pageCount: 0 };
- }
- },
- [JSON.stringify(input)], // 캐싱 키
- {
- revalidate: 3600,
- tags: ["tech-vendors"], // revalidateTag("tech-vendors") 호출 시 무효화
- }
- )();
-}
-
-/**
- * 기술영업 벤더 상태별 카운트 조회
- */
-export async function getTechVendorStatusCounts() {
- return unstable_cache(
- async () => {
- try {
- const initial: Record<TechVendor["status"], number> = {
- "PENDING_REVIEW": 0,
- "ACTIVE": 0,
- "INACTIVE": 0,
- "BLACKLISTED": 0,
- };
-
- const result = await db.transaction(async (tx) => {
- const rows = await groupByTechVendorStatus(tx);
- type StatusCountRow = { status: TechVendor["status"]; count: number };
- return (rows as StatusCountRow[]).reduce<Record<TechVendor["status"], number>>((acc, { status, count }) => {
- acc[status] = count;
- return acc;
- }, initial);
- });
-
- return result;
- } catch (err) {
- return {} as Record<TechVendor["status"], number>;
- }
- },
- ["tech-vendor-status-counts"], // 캐싱 키
- {
- revalidate: 3600,
- }
- )();
-}
-
-/**
- * 벤더 상세 정보 조회
- */
-export async function getTechVendorById(id: number) {
- return unstable_cache(
- async () => {
- try {
- const result = await getTechVendorDetailById(id);
- return { data: result };
- } catch (err) {
- console.error("기술영업 벤더 상세 조회 오류:", err);
- return { data: null };
- }
- },
- [`tech-vendor-${id}`],
- {
- revalidate: 3600,
- tags: ["tech-vendors", `tech-vendor-${id}`],
- }
- )();
-}
-
-/* -----------------------------------------------------
- 2) 생성(Create)
------------------------------------------------------ */
-
-/**
- * 첨부파일 저장 헬퍼 함수
- */
-async function storeTechVendorFiles(
- tx: any,
- vendorId: number,
- files: File[],
- attachmentType: string
-) {
-
- for (const file of files) {
-
- const saveResult = await saveDRMFile(file, decryptWithServerAction, `tech-vendors/${vendorId}`)
-
- // Insert attachment record
- await tx.insert(techVendorAttachments).values({
- vendorId,
- fileName: file.name,
- filePath: saveResult.publicPath,
- attachmentType,
- });
- }
-}
-
-/**
- * 신규 기술영업 벤더 생성
- */
-export async function createTechVendor(input: CreateTechVendorSchema) {
- unstable_noStore();
-
- try {
- // taxId 중복 검사
- const existingVendor = await db
- .select({ id: techVendors.id })
- .from(techVendors)
- .where(eq(techVendors.taxId, input.taxId))
- .limit(1);
-
- // 이미 동일한 taxId를 가진 업체가 존재하면 에러 반환
- if (existingVendor.length > 0) {
- return {
- success: false,
- data: null,
- error: `이미 등록된 사업자등록번호입니다. (Tax ID ${input.taxId} already exists in the system)`
- };
- }
-
- const result = await db.transaction(async (tx) => {
- // 1. 벤더 생성
- const [newVendor] = await insertTechVendor(tx, {
- vendorName: input.vendorName,
- vendorCode: input.vendorCode || null,
- taxId: input.taxId,
- address: input.address || null,
- country: input.country,
- countryEng: null,
- countryFab: null,
- agentName: null,
- agentPhone: null,
- agentEmail: null,
- phone: input.phone || null,
- email: input.email,
- website: input.website || null,
- techVendorType: Array.isArray(input.techVendorType) ? input.techVendorType.join(',') : input.techVendorType,
- representativeName: input.representativeName || null,
- representativeBirth: input.representativeBirth || null,
- representativeEmail: input.representativeEmail || null,
- representativePhone: input.representativePhone || null,
- items: input.items || null,
- status: "ACTIVE"
- });
-
- // 2. 연락처 정보 등록
- for (const contact of input.contacts) {
- await insertTechVendorContact(tx, {
- vendorId: newVendor.id,
- contactName: contact.contactName,
- contactPosition: contact.contactPosition || null,
- contactEmail: contact.contactEmail,
- contactPhone: contact.contactPhone || null,
- isPrimary: contact.isPrimary ?? false,
- });
- }
-
- // 3. 첨부파일 저장
- if (input.files && input.files.length > 0) {
- await storeTechVendorFiles(tx, newVendor.id, input.files, "GENERAL");
- }
-
- return newVendor;
- });
-
- revalidateTag("tech-vendors");
-
- return {
- success: true,
- data: result,
- error: null
- };
- } catch (err) {
- console.error("기술영업 벤더 생성 오류:", err);
-
- return {
- success: false,
- data: null,
- error: getErrorMessage(err)
- };
- }
-}
-
-/* -----------------------------------------------------
- 3) 업데이트 (단건/복수)
------------------------------------------------------ */
-
-/** 단건 업데이트 */
-export async function modifyTechVendor(
- input: UpdateTechVendorSchema & { id: string; }
-) {
- unstable_noStore();
- try {
- const updated = await db.transaction(async (tx) => {
- // 벤더 정보 업데이트
- const [res] = await updateTechVendor(tx, input.id, {
- vendorName: input.vendorName,
- vendorCode: input.vendorCode,
- address: input.address,
- country: input.country,
- phone: input.phone,
- email: input.email,
- website: input.website,
- status: input.status,
- });
-
- return res;
- });
-
- // 캐시 무효화
- revalidateTag("tech-vendors");
- revalidateTag(`tech-vendor-${input.id}`);
-
- return { data: updated, error: null };
- } catch (err) {
- return { data: null, error: getErrorMessage(err) };
- }
-}
-
-/** 복수 업데이트 */
-export async function modifyTechVendors(input: {
- ids: string[];
- status?: TechVendor["status"];
-}) {
- unstable_noStore();
- try {
- const data = await db.transaction(async (tx) => {
- // 여러 협력업체 일괄 업데이트
- const [updated] = await updateTechVendors(tx, input.ids, {
- // 예: 상태만 일괄 변경
- status: input.status,
- });
- return updated;
- });
-
- // 캐시 무효화
- revalidateTag("tech-vendors");
- revalidateTag("tech-vendor-status-counts");
-
- return { data: null, error: null };
- } catch (err) {
- return { data: null, error: getErrorMessage(err) };
- }
-}
-
-/* -----------------------------------------------------
- 4) 연락처 관리
------------------------------------------------------ */
-
-export async function getTechVendorContacts(input: GetTechVendorContactsSchema, id: number) {
- return unstable_cache(
- async () => {
- try {
- const offset = (input.page - 1) * input.perPage;
-
- // 필터링 설정
- const advancedWhere = filterColumns({
- table: techVendorContacts,
- filters: input.filters,
- joinOperator: input.joinOperator,
- });
-
- // 검색 조건
- let globalWhere;
- if (input.search) {
- const s = `%${input.search}%`;
- globalWhere = or(
- ilike(techVendorContacts.contactName, s),
- ilike(techVendorContacts.contactPosition, s),
- ilike(techVendorContacts.contactEmail, s),
- ilike(techVendorContacts.contactPhone, s)
- );
- }
-
- // 해당 벤더 조건
- const vendorWhere = eq(techVendorContacts.vendorId, id);
-
- // 최종 조건 결합
- const finalWhere = and(advancedWhere, globalWhere, vendorWhere);
-
- // 정렬 조건
- const orderBy =
- input.sort.length > 0
- ? input.sort.map((item) =>
- item.desc ? desc(techVendorContacts[item.id]) : asc(techVendorContacts[item.id])
- )
- : [asc(techVendorContacts.createdAt)];
-
- // 트랜잭션 내부에서 Repository 호출
- const { data, total } = await db.transaction(async (tx) => {
- const data = await selectTechVendorContacts(tx, {
- where: finalWhere,
- orderBy,
- offset,
- limit: input.perPage,
- });
- const total = await countTechVendorContacts(tx, finalWhere);
- return { data, total };
- });
-
- const pageCount = Math.ceil(total / input.perPage);
-
- return { data, pageCount };
- } catch (err) {
- // 에러 발생 시 디폴트
- return { data: [], pageCount: 0 };
- }
- },
- [JSON.stringify(input), String(id)], // 캐싱 키
- {
- revalidate: 3600,
- tags: [`tech-vendor-contacts-${id}`],
- }
- )();
-}
-
-export async function createTechVendorContact(input: CreateTechVendorContactSchema) {
- unstable_noStore();
- try {
- await db.transaction(async (tx) => {
- // DB Insert
- const [newContact] = await insertTechVendorContact(tx, {
- vendorId: input.vendorId,
- contactName: input.contactName,
- contactPosition: input.contactPosition || "",
- contactEmail: input.contactEmail,
- contactPhone: input.contactPhone || "",
- country: input.country || "",
- isPrimary: input.isPrimary || false,
- });
-
- return newContact;
- });
-
- // 캐시 무효화
- revalidateTag(`tech-vendor-contacts-${input.vendorId}`);
- revalidateTag("users");
-
- return { data: null, error: null };
- } catch (err) {
- return { data: null, error: getErrorMessage(err) };
- }
-}
-
-/* -----------------------------------------------------
- 5) 아이템 관리
------------------------------------------------------ */
-
-export async function getTechVendorItems(input: GetTechVendorItemsSchema, id: number) {
- return unstable_cache(
- async () => {
- try {
- const offset = (input.page - 1) * input.perPage;
-
- // 필터링 설정
- const advancedWhere = filterColumns({
- table: techVendorItemsView,
- filters: input.filters,
- joinOperator: input.joinOperator,
- });
-
- // 검색 조건
- let globalWhere;
- if (input.search) {
- const s = `%${input.search}%`;
- globalWhere = or(
- ilike(techVendorItemsView.itemCode, s)
- );
- }
-
- // 해당 벤더 조건
- const vendorWhere = eq(techVendorItemsView.vendorId, id);
-
- // 최종 조건 결합
- const finalWhere = and(advancedWhere, globalWhere, vendorWhere);
-
- // 정렬 조건
- const orderBy =
- input.sort.length > 0
- ? input.sort.map((item) =>
- item.desc ? desc(techVendorItemsView[item.id]) : asc(techVendorItemsView[item.id])
- )
- : [asc(techVendorItemsView.createdAt)];
-
- // 트랜잭션 내부에서 Repository 호출
- const { data, total } = await db.transaction(async (tx) => {
- const data = await selectTechVendorItems(tx, {
- where: finalWhere,
- orderBy,
- offset,
- limit: input.perPage,
- });
- const total = await countTechVendorItems(tx, finalWhere);
- return { data, total };
- });
-
- const pageCount = Math.ceil(total / input.perPage);
-
- return { data, pageCount };
- } catch (err) {
- // 에러 발생 시 디폴트
- return { data: [], pageCount: 0 };
- }
- },
- [JSON.stringify(input), String(id)], // 캐싱 키
- {
- revalidate: 3600,
- tags: [`tech-vendor-items-${id}`],
- }
- )();
-}
-
-export interface ItemDropdownOption {
- itemCode: string;
- itemList: string;
- workType: string | null;
- shipTypes: string | null;
- subItemList: string | null;
-}
-
-/**
- * Vendor Item 추가 시 사용할 아이템 목록 조회 (전체 목록 반환)
- * 아이템 코드, 이름, 설명만 간소화해서 반환
- */
-export async function getItemsForTechVendor(vendorId: number) {
- return unstable_cache(
- async () => {
- try {
- // 1. 벤더 정보 조회로 벤더 타입 확인
- const vendor = await db.query.techVendors.findFirst({
- where: eq(techVendors.id, vendorId),
- columns: {
- techVendorType: true
- }
- });
-
- if (!vendor) {
- return {
- data: [],
- error: "벤더를 찾을 수 없습니다.",
- };
- }
-
- // 2. 해당 벤더가 이미 가지고 있는 itemCode 목록 조회
- const existingItems = await db
- .select({
- itemCode: techVendorPossibleItems.itemCode,
- })
- .from(techVendorPossibleItems)
- .where(eq(techVendorPossibleItems.vendorId, vendorId));
-
- const existingItemCodes = existingItems.map(item => item.itemCode);
-
- // 3. 벤더 타입에 따라 해당 타입의 아이템만 조회
- // let availableItems: ItemDropdownOption[] = [];
- let availableItems: (typeof itemShipbuilding.$inferSelect | typeof itemOffshoreTop.$inferSelect | typeof itemOffshoreHull.$inferSelect)[] = [];
- switch (vendor.techVendorType) {
- case "조선":
- const shipbuildingItems = await db
- .select({
- id: itemShipbuilding.id,
- createdAt: itemShipbuilding.createdAt,
- updatedAt: itemShipbuilding.updatedAt,
- itemCode: itemShipbuilding.itemCode,
- itemList: itemShipbuilding.itemList,
- workType: itemShipbuilding.workType,
- shipTypes: itemShipbuilding.shipTypes,
- })
- .from(itemShipbuilding)
- .where(
- existingItemCodes.length > 0
- ? not(inArray(itemShipbuilding.itemCode, existingItemCodes))
- : undefined
- )
- .orderBy(asc(itemShipbuilding.itemCode));
-
- availableItems = shipbuildingItems
- .filter(item => item.itemCode != null)
- .map(item => ({
- id: item.id,
- createdAt: item.createdAt,
- updatedAt: item.updatedAt,
- itemCode: item.itemCode!,
- itemList: item.itemList || "조선 아이템",
- workType: item.workType || "조선 관련 아이템",
- shipTypes: item.shipTypes || "조선 관련 아이템"
- }));
- break;
-
- case "해양TOP":
- const offshoreTopItems = await db
- .select({
- id: itemOffshoreTop.id,
- createdAt: itemOffshoreTop.createdAt,
- updatedAt: itemOffshoreTop.updatedAt,
- itemCode: itemOffshoreTop.itemCode,
- itemList: itemOffshoreTop.itemList,
- workType: itemOffshoreTop.workType,
- subItemList: itemOffshoreTop.subItemList,
- })
- .from(itemOffshoreTop)
- .where(
- existingItemCodes.length > 0
- ? not(inArray(itemOffshoreTop.itemCode, existingItemCodes))
- : undefined
- )
- .orderBy(asc(itemOffshoreTop.itemCode));
-
- availableItems = offshoreTopItems
- .filter(item => item.itemCode != null)
- .map(item => ({
- id: item.id,
- createdAt: item.createdAt,
- updatedAt: item.updatedAt,
- itemCode: item.itemCode!,
- itemList: item.itemList || "해양TOP 아이템",
- workType: item.workType || "해양TOP 관련 아이템",
- subItemList: item.subItemList || "해양TOP 관련 아이템"
- }));
- break;
-
- case "해양HULL":
- const offshoreHullItems = await db
- .select({
- id: itemOffshoreHull.id,
- createdAt: itemOffshoreHull.createdAt,
- updatedAt: itemOffshoreHull.updatedAt,
- itemCode: itemOffshoreHull.itemCode,
- itemList: itemOffshoreHull.itemList,
- workType: itemOffshoreHull.workType,
- subItemList: itemOffshoreHull.subItemList,
- })
- .from(itemOffshoreHull)
- .where(
- existingItemCodes.length > 0
- ? not(inArray(itemOffshoreHull.itemCode, existingItemCodes))
- : undefined
- )
- .orderBy(asc(itemOffshoreHull.itemCode));
-
- availableItems = offshoreHullItems
- .filter(item => item.itemCode != null)
- .map(item => ({
- id: item.id,
- createdAt: item.createdAt,
- updatedAt: item.updatedAt,
- itemCode: item.itemCode!,
- itemList: item.itemList || "해양HULL 아이템",
- workType: item.workType || "해양HULL 관련 아이템",
- subItemList: item.subItemList || "해양HULL 관련 아이템"
- }));
- break;
-
- default:
- return {
- data: [],
- error: `지원하지 않는 벤더 타입입니다: ${vendor.techVendorType}`,
- };
- }
-
- return {
- data: availableItems,
- error: null
- };
- } catch (err) {
- console.error("Failed to fetch items for tech vendor dropdown:", err);
- return {
- data: [],
- error: "아이템 목록을 불러오는데 실패했습니다.",
- };
- }
- },
- // 캐시 키를 vendorId 별로 달리 해야 한다.
- ["items-for-tech-vendor", String(vendorId)],
- {
- revalidate: 3600, // 1시간 캐싱
- tags: ["items"], // revalidateTag("items") 호출 시 무효화
- }
- )();
-}
-
-/**
- * 벤더 타입과 아이템 코드에 따른 아이템 조회
- */
-export async function getItemsByVendorType(vendorType: string, itemCode: string) {
- try {
- let items: (typeof itemShipbuilding.$inferSelect | typeof itemOffshoreTop.$inferSelect | typeof itemOffshoreHull.$inferSelect)[] = [];
-
- switch (vendorType) {
- case "조선":
- const shipbuildingResults = await db
- .select({
- id: itemShipbuilding.id,
- itemCode: itemShipbuilding.itemCode,
- workType: itemShipbuilding.workType,
- shipTypes: itemShipbuilding.shipTypes,
- itemList: itemShipbuilding.itemList,
- createdAt: itemShipbuilding.createdAt,
- updatedAt: itemShipbuilding.updatedAt,
- })
- .from(itemShipbuilding)
- .where(itemCode ? eq(itemShipbuilding.itemCode, itemCode) : undefined);
- items = shipbuildingResults;
- break;
-
- case "해양TOP":
- const offshoreTopResults = await db
- .select({
- id: itemOffshoreTop.id,
- itemCode: itemOffshoreTop.itemCode,
- workType: itemOffshoreTop.workType,
- itemList: itemOffshoreTop.itemList,
- subItemList: itemOffshoreTop.subItemList,
- createdAt: itemOffshoreTop.createdAt,
- updatedAt: itemOffshoreTop.updatedAt,
- })
- .from(itemOffshoreTop)
- .where(itemCode ? eq(itemOffshoreTop.itemCode, itemCode) : undefined);
- items = offshoreTopResults;
- break;
-
- case "해양HULL":
- const offshoreHullResults = await db
- .select({
- id: itemOffshoreHull.id,
- itemCode: itemOffshoreHull.itemCode,
- workType: itemOffshoreHull.workType,
- itemList: itemOffshoreHull.itemList,
- subItemList: itemOffshoreHull.subItemList,
- createdAt: itemOffshoreHull.createdAt,
- updatedAt: itemOffshoreHull.updatedAt,
- })
- .from(itemOffshoreHull)
- .where(itemCode ? eq(itemOffshoreHull.itemCode, itemCode) : undefined);
- items = offshoreHullResults;
- break;
-
- default:
- items = [];
- }
-
- const result = items.map(item => ({
- ...item,
- techVendorType: vendorType
- }));
-
- return { data: result, error: null };
- } catch (err) {
- console.error("Error fetching items by vendor type:", err);
- return { data: [], error: "Failed to fetch items" };
- }
-}
-
-/**
- * 벤더의 possible_items를 조회하고 해당 아이템 코드로 각 타입별 테이블을 조회
- * 벤더 타입이 콤마로 구분된 경우 (예: "조선,해양TOP,해양HULL") 모든 타입의 아이템을 조회
- */
-export async function getVendorItemsByType(vendorId: number, vendorType: string) {
- try {
- // 벤더의 possible_items 조회
- const possibleItems = await db.query.techVendorPossibleItems.findMany({
- where: eq(techVendorPossibleItems.vendorId, vendorId),
- columns: {
- itemCode: true
- }
- })
-
- const itemCodes = possibleItems.map(item => item.itemCode)
-
- if (itemCodes.length === 0) {
- return { data: [] }
- }
-
- // 벤더 타입을 콤마로 분리
- const vendorTypes = vendorType.split(',').map(type => type.trim())
- const allItems: Array<Record<string, any> & { techVendorType: "조선" | "해양TOP" | "해양HULL" }> = []
-
- // 각 벤더 타입에 따라 해당하는 테이블에서 아이템 조회
- for (const singleType of vendorTypes) {
- switch (singleType) {
- case "조선":
- const shipbuildingItems = await db.query.itemShipbuilding.findMany({
- where: inArray(itemShipbuilding.itemCode, itemCodes)
- })
- allItems.push(...shipbuildingItems.map(item => ({
- ...item,
- techVendorType: "조선" as const
- })))
- break
-
- case "해양TOP":
- const offshoreTopItems = await db.query.itemOffshoreTop.findMany({
- where: inArray(itemOffshoreTop.itemCode, itemCodes)
- })
- allItems.push(...offshoreTopItems.map(item => ({
- ...item,
- techVendorType: "해양TOP" as const
- })))
- break
-
- case "해양HULL":
- const offshoreHullItems = await db.query.itemOffshoreHull.findMany({
- where: inArray(itemOffshoreHull.itemCode, itemCodes)
- })
- allItems.push(...offshoreHullItems.map(item => ({
- ...item,
- techVendorType: "해양HULL" as const
- })))
- break
-
- default:
- console.warn(`Unknown vendor type: ${singleType}`)
- break
- }
- }
-
- // 중복 허용 - 모든 아이템을 그대로 반환
- return {
- data: allItems.sort((a, b) => a.itemCode.localeCompare(b.itemCode))
- }
- } catch (err) {
- console.error("Error getting vendor items by type:", err)
- return { data: [] }
- }
-}
-
-export async function createTechVendorItem(input: CreateTechVendorItemSchema) {
- unstable_noStore();
- try {
- // DB에 이미 존재하는지 확인
- const existingItem = await db
- .select({ id: techVendorPossibleItems.id })
- .from(techVendorPossibleItems)
- .where(
- and(
- eq(techVendorPossibleItems.vendorId, input.vendorId),
- eq(techVendorPossibleItems.itemCode, input.itemCode)
- )
- )
- .limit(1);
-
- if (existingItem.length > 0) {
- return { data: null, error: "이미 추가된 아이템입니다." };
- }
-
- await db.transaction(async (tx) => {
- // DB Insert
- const [newItem] = await tx
- .insert(techVendorPossibleItems)
- .values({
- vendorId: input.vendorId,
- itemCode: input.itemCode,
- })
- .returning();
- return newItem;
- });
-
- // 캐시 무효화
- revalidateTag(`tech-vendor-items-${input.vendorId}`);
-
- return { data: null, error: null };
- } catch (err) {
- return { data: null, error: getErrorMessage(err) };
- }
-}
-
-/* -----------------------------------------------------
- 6) 기술영업 벤더 승인/거부
------------------------------------------------------ */
-
-interface ApproveTechVendorsInput {
- ids: string[];
-}
-
-/**
- * 기술영업 벤더 승인 (상태를 ACTIVE로 변경)
- */
-export async function approveTechVendors(input: ApproveTechVendorsInput) {
- unstable_noStore();
-
- try {
- // 트랜잭션 내에서 협력업체 상태 업데이트
- const result = await db.transaction(async (tx) => {
- // 협력업체 상태 업데이트
- const [updated] = await tx
- .update(techVendors)
- .set({
- status: "ACTIVE",
- updatedAt: new Date()
- })
- .where(inArray(techVendors.id, input.ids.map(id => parseInt(id))))
- .returning();
-
- return updated;
- });
-
- // 캐시 무효화
- revalidateTag("tech-vendors");
- revalidateTag("tech-vendor-status-counts");
-
- return { data: result, error: null };
- } catch (err) {
- console.error("Error approving tech vendors:", err);
- return { data: null, error: getErrorMessage(err) };
- }
-}
-
-/**
- * 기술영업 벤더 거부 (상태를 REJECTED로 변경)
- */
-export async function rejectTechVendors(input: ApproveTechVendorsInput) {
- unstable_noStore();
-
- try {
- // 트랜잭션 내에서 협력업체 상태 업데이트
- const result = await db.transaction(async (tx) => {
- // 협력업체 상태 업데이트
- const [updated] = await tx
- .update(techVendors)
- .set({
- status: "INACTIVE",
- updatedAt: new Date()
- })
- .where(inArray(techVendors.id, input.ids.map(id => parseInt(id))))
- .returning();
-
- return updated;
- });
-
- // 캐시 무효화
- revalidateTag("tech-vendors");
- revalidateTag("tech-vendor-status-counts");
-
- return { data: result, error: null };
- } catch (err) {
- console.error("Error rejecting tech vendors:", err);
- return { data: null, error: getErrorMessage(err) };
- }
-}
-
-/* -----------------------------------------------------
- 7) 엑셀 내보내기
------------------------------------------------------ */
-
-/**
- * 벤더 연락처 목록 엑셀 내보내기
- */
-export async function exportTechVendorContacts(vendorId: number) {
- try {
- const contacts = await db
- .select()
- .from(techVendorContacts)
- .where(eq(techVendorContacts.vendorId, vendorId))
- .orderBy(techVendorContacts.isPrimary, techVendorContacts.contactName);
-
- return contacts;
- } catch (err) {
- console.error("기술영업 벤더 연락처 내보내기 오류:", err);
- return [];
- }
-}
-
-/**
- * 벤더 아이템 목록 엑셀 내보내기
- */
-export async function exportTechVendorItems(vendorId: number) {
- try {
- const items = await db
- .select({
- id: techVendorItemsView.vendorItemId,
- vendorId: techVendorItemsView.vendorId,
- itemCode: techVendorItemsView.itemCode,
- createdAt: techVendorItemsView.createdAt,
- updatedAt: techVendorItemsView.updatedAt,
- })
- .from(techVendorItemsView)
- .where(eq(techVendorItemsView.vendorId, vendorId))
-
- return items;
- } catch (err) {
- console.error("기술영업 벤더 아이템 내보내기 오류:", err);
- return [];
- }
-}
-
-/**
- * 벤더 정보 엑셀 내보내기
- */
-export async function exportTechVendorDetails(vendorIds: number[]) {
- try {
- if (!vendorIds.length) return [];
-
- // 벤더 기본 정보 조회
- const vendorsData = await db
- .select({
- id: techVendors.id,
- vendorName: techVendors.vendorName,
- vendorCode: techVendors.vendorCode,
- taxId: techVendors.taxId,
- address: techVendors.address,
- country: techVendors.country,
- phone: techVendors.phone,
- email: techVendors.email,
- website: techVendors.website,
- status: techVendors.status,
- representativeName: techVendors.representativeName,
- representativeEmail: techVendors.representativeEmail,
- representativePhone: techVendors.representativePhone,
- representativeBirth: techVendors.representativeBirth,
- items: techVendors.items,
- createdAt: techVendors.createdAt,
- updatedAt: techVendors.updatedAt,
- })
- .from(techVendors)
- .where(
- vendorIds.length === 1
- ? eq(techVendors.id, vendorIds[0])
- : inArray(techVendors.id, vendorIds)
- );
-
- // 벤더별 상세 정보를 포함하여 반환
- const vendorsWithDetails = await Promise.all(
- vendorsData.map(async (vendor) => {
- // 연락처 조회
- const contacts = await exportTechVendorContacts(vendor.id);
-
- // 아이템 조회
- const items = await exportTechVendorItems(vendor.id);
-
- return {
- ...vendor,
- vendorContacts: contacts,
- vendorItems: items,
- };
- })
- );
-
- return vendorsWithDetails;
- } catch (err) {
- console.error("기술영업 벤더 상세 내보내기 오류:", err);
- return [];
- }
-}
-
-/**
- * 기술영업 벤더 상세 정보 조회 (연락처, 첨부파일 포함)
- */
-export async function getTechVendorDetailById(id: number) {
- try {
- const vendor = await db.select().from(techVendors).where(eq(techVendors.id, id)).limit(1);
-
- if (!vendor || vendor.length === 0) {
- console.error(`Vendor not found with id: ${id}`);
- return null;
- }
-
- const contacts = await db.select().from(techVendorContacts).where(eq(techVendorContacts.vendorId, id));
- const attachments = await db.select().from(techVendorAttachments).where(eq(techVendorAttachments.vendorId, id));
- const possibleItems = await db.select().from(techVendorPossibleItems).where(eq(techVendorPossibleItems.vendorId, id));
-
- return {
- ...vendor[0],
- contacts,
- attachments,
- possibleItems
- };
- } catch (error) {
- console.error("Error fetching tech vendor detail:", error);
- return null;
- }
-}
-
-/**
- * 기술영업 벤더 첨부파일 다운로드를 위한 서버 액션
- * @param vendorId 기술영업 벤더 ID
- * @param fileId 특정 파일 ID (단일 파일 다운로드시)
- * @returns 다운로드할 수 있는 임시 URL
- */
-export async function downloadTechVendorAttachments(vendorId:number, fileId?:number) {
- try {
- // API 경로 생성 (단일 파일 또는 모든 파일)
- const url = fileId
- ? `/api/tech-vendors/attachments/download?id=${fileId}&vendorId=${vendorId}`
- : `/api/tech-vendors/attachments/download-all?vendorId=${vendorId}`;
-
- // fetch 요청 (기본적으로 Blob으로 응답 받기)
- const response = await fetch(url, {
- method: 'GET',
- headers: {
- 'Content-Type': 'application/json',
- },
- });
-
- if (!response.ok) {
- throw new Error(`Server responded with ${response.status}: ${response.statusText}`);
- }
-
- // 파일명 가져오기 (Content-Disposition 헤더에서)
- const contentDisposition = response.headers.get('content-disposition');
- let fileName = fileId ? `file-${fileId}.zip` : `tech-vendor-${vendorId}-files.zip`;
-
- if (contentDisposition) {
- const matches = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/.exec(contentDisposition);
- if (matches && matches[1]) {
- fileName = matches[1].replace(/['"]/g, '');
- }
- }
-
- // Blob으로 응답 변환
- const blob = await response.blob();
-
- // Blob URL 생성
- const blobUrl = window.URL.createObjectURL(blob);
-
- return {
- url: blobUrl,
- fileName,
- blob
- };
- } catch (error) {
- console.error('Download API error:', error);
- throw error;
- }
-}
-
-/**
- * 임시 ZIP 파일 정리를 위한 서버 액션
- * @param fileName 정리할 파일명
- */
-export async function cleanupTechTempFiles(fileName: string) {
- 'use server';
-
- try {
-
- await deleteFile(`tmp/${fileName}`)
-
- return { success: true };
- } catch (error) {
- console.error('임시 파일 정리 오류:', error);
- return { success: false, error: '임시 파일 정리 중 오류가 발생했습니다.' };
- }
-}
-
-export const findVendorById = async (id: number): Promise<TechVendor | null> => {
- try {
- // 직접 DB에서 조회
- const vendor = await db
- .select()
- .from(techVendors)
- .where(eq(techVendors.id, id))
- .limit(1)
- .then(rows => rows[0] || null);
-
- if (!vendor) {
- console.error(`Vendor not found with id: ${id}`);
- return null;
- }
-
- return vendor;
- } catch (error) {
- console.error('Error fetching vendor:', error);
- return null;
- }
-};
-
-/* -----------------------------------------------------
- 8) 기술영업 벤더 RFQ 히스토리 조회
------------------------------------------------------ */
-
-/**
- * 기술영업 벤더의 RFQ 히스토리 조회 (간단한 버전)
- */
-export async function getTechVendorRfqHistory(input: GetTechVendorRfqHistorySchema, id:number) {
- try {
-
- // 먼저 해당 벤더의 견적서가 있는지 확인
- const { techSalesVendorQuotations } = await import("@/db/schema/techSales");
-
- const quotationCheck = await db
- .select({ count: sql<number>`count(*)`.as("count") })
- .from(techSalesVendorQuotations)
- .where(eq(techSalesVendorQuotations.vendorId, id));
-
- console.log(`벤더 ${id}의 견적서 개수:`, quotationCheck[0]?.count);
-
- if (quotationCheck[0]?.count === 0) {
- console.log("해당 벤더의 견적서가 없습니다.");
- return { data: [], pageCount: 0 };
- }
-
- const offset = (input.page - 1) * input.perPage;
- const { techSalesRfqs } = await import("@/db/schema/techSales");
- const { biddingProjects } = await import("@/db/schema/projects");
-
- // 간단한 조회
- let whereCondition = eq(techSalesVendorQuotations.vendorId, id);
-
- // 검색이 있다면 추가
- if (input.search) {
- const s = `%${input.search}%`;
- const searchCondition = and(
- whereCondition,
- or(
- ilike(techSalesRfqs.rfqCode, s),
- ilike(techSalesRfqs.description, s),
- ilike(biddingProjects.pspid, s),
- ilike(biddingProjects.projNm, s)
- )
- );
- whereCondition = searchCondition;
- }
-
- // 데이터 조회 - 테이블에 필요한 필드들 (프로젝트 타입 추가)
- const data = await db
- .select({
- id: techSalesRfqs.id,
- rfqCode: techSalesRfqs.rfqCode,
- description: techSalesRfqs.description,
- projectCode: biddingProjects.pspid,
- projectName: biddingProjects.projNm,
- projectType: biddingProjects.pjtType, // 프로젝트 타입 추가
- status: techSalesRfqs.status,
- totalAmount: techSalesVendorQuotations.totalPrice,
- currency: techSalesVendorQuotations.currency,
- dueDate: techSalesRfqs.dueDate,
- createdAt: techSalesRfqs.createdAt,
- quotationCode: techSalesVendorQuotations.quotationCode,
- submittedAt: techSalesVendorQuotations.submittedAt,
- })
- .from(techSalesVendorQuotations)
- .innerJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id))
- .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id))
- .where(whereCondition)
- .orderBy(desc(techSalesRfqs.createdAt))
- .limit(input.perPage)
- .offset(offset);
-
- console.log("조회된 데이터:", data.length, "개");
-
- // 전체 개수 조회
- const totalResult = await db
- .select({ count: sql<number>`count(*)`.as("count") })
- .from(techSalesVendorQuotations)
- .innerJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id))
- .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id))
- .where(whereCondition);
-
- const total = totalResult[0]?.count || 0;
- const pageCount = Math.ceil(total / input.perPage);
-
- console.log("기술영업 벤더 RFQ 히스토리 조회 완료", {
- id,
- dataLength: data.length,
- total,
- pageCount
- });
-
- return { data, pageCount };
- } catch (err) {
- console.error("기술영업 벤더 RFQ 히스토리 조회 오류:", {
- err,
- id,
- stack: err instanceof Error ? err.stack : undefined
- });
- return { data: [], pageCount: 0 };
- }
-}
-
-/**
- * 기술영업 벤더 엑셀 import 시 유저 생성 및 아이템 등록
- */
-export async function importTechVendorsFromExcel(
- vendors: Array<{
- vendorName: string;
- vendorCode?: string | null;
- email: string;
- taxId: string;
- country?: string | null;
- countryEng?: string | null;
- countryFab?: string | null;
- agentName?: string | null;
- agentPhone?: string | null;
- agentEmail?: string | null;
- address?: string | null;
- phone?: string | null;
- website?: string | null;
- techVendorType: string;
- representativeName?: string | null;
- representativeEmail?: string | null;
- representativePhone?: string | null;
- representativeBirth?: string | null;
- items: string;
- }>,
-) {
- unstable_noStore();
-
- try {
- console.log("Import 시작 - 벤더 수:", vendors.length);
- console.log("첫 번째 벤더 데이터:", vendors[0]);
-
- const result = await db.transaction(async (tx) => {
- const createdVendors = [];
-
- for (const vendor of vendors) {
- console.log("벤더 처리 시작:", vendor.vendorName);
-
- try {
- // 1. 벤더 생성
- console.log("벤더 생성 시도:", {
- vendorName: vendor.vendorName,
- email: vendor.email,
- techVendorType: vendor.techVendorType
- });
-
- const [newVendor] = await tx.insert(techVendors).values({
- vendorName: vendor.vendorName,
- vendorCode: vendor.vendorCode || null,
- taxId: vendor.taxId,
- country: vendor.country || null,
- countryEng: vendor.countryEng || null,
- countryFab: vendor.countryFab || null,
- agentName: vendor.agentName || null,
- agentPhone: vendor.agentPhone || null,
- agentEmail: vendor.agentEmail || null,
- address: vendor.address || null,
- phone: vendor.phone || null,
- email: vendor.email,
- website: vendor.website || null,
- techVendorType: vendor.techVendorType,
- status: "ACTIVE",
- representativeName: vendor.representativeName || null,
- representativeEmail: vendor.representativeEmail || null,
- representativePhone: vendor.representativePhone || null,
- representativeBirth: vendor.representativeBirth || null,
- }).returning();
-
- console.log("벤더 생성 성공:", newVendor.id);
-
- // 2. 유저 생성 (이메일이 있는 경우)
- if (vendor.email) {
- console.log("유저 생성 시도:", vendor.email);
-
- // 이미 존재하는 유저인지 확인
- const existingUser = await tx.query.users.findFirst({
- where: eq(users.email, vendor.email),
- columns: { id: true }
- });
-
- // 유저가 존재하지 않는 경우에만 생성
- if (!existingUser) {
- await tx.insert(users).values({
- name: vendor.vendorName,
- email: vendor.email,
- techCompanyId: newVendor.id, // techCompanyId 설정
- domain: "partners",
- });
- console.log("유저 생성 성공");
- } else {
- console.log("이미 존재하는 유저:", existingUser.id);
- }
- }
-
- createdVendors.push(newVendor);
- console.log("벤더 처리 완료:", vendor.vendorName);
- } catch (error) {
- console.error("벤더 처리 중 오류 발생:", vendor.vendorName, error);
- throw error;
- }
- }
-
- console.log("모든 벤더 처리 완료. 생성된 벤더 수:", createdVendors.length);
- return createdVendors;
- });
-
- // 캐시 무효화
- revalidateTag("tech-vendors");
- revalidateTag("users");
-
- console.log("Import 완료 - 결과:", result);
- return { success: true, data: result };
- } catch (error) {
- console.error("Import 실패:", error);
- return { success: false, error: getErrorMessage(error) };
- }
-}
-
-export async function findTechVendorById(id: number): Promise<TechVendor | null> {
- const result = await db
- .select()
- .from(techVendors)
- .where(eq(techVendors.id, id))
- .limit(1)
-
- return result[0] || null
-}
-
-/**
- * 회원가입 폼을 통한 기술영업 벤더 생성 (초대 토큰 기반)
- */
-export async function createTechVendorFromSignup(params: {
- vendorData: {
- vendorName: string
- vendorCode?: string
- items: string
- website?: string
- taxId: string
- address?: string
- email: string
- phone?: string
- country: string
- techVendorType: "조선" | "해양TOP" | "해양HULL"
- representativeName?: string
- representativeBirth?: string
- representativeEmail?: string
- representativePhone?: string
- }
- files?: File[]
- contacts: {
- contactName: string
- contactPosition?: string
- contactEmail: string
- contactPhone?: string
- isPrimary?: boolean
- }[]
- invitationToken?: string // 초대 토큰
-}) {
- unstable_noStore();
-
- try {
- console.log("기술영업 벤더 회원가입 시작:", params.vendorData.vendorName);
-
- // 초대 토큰 검증
- let existingVendorId: number | null = null;
- if (params.invitationToken) {
- const { verifyTechVendorInvitationToken } = await import("@/lib/tech-vendor-invitation-token");
- const tokenPayload = await verifyTechVendorInvitationToken(params.invitationToken);
-
- if (!tokenPayload) {
- throw new Error("유효하지 않은 초대 토큰입니다.");
- }
-
- existingVendorId = tokenPayload.vendorId;
- console.log("초대 토큰 검증 성공, 벤더 ID:", existingVendorId);
- }
-
- const result = await db.transaction(async (tx) => {
- let vendorResult;
-
- if (existingVendorId) {
- // 기존 벤더 정보 업데이트
- const [updatedVendor] = await tx.update(techVendors)
- .set({
- vendorName: params.vendorData.vendorName,
- vendorCode: params.vendorData.vendorCode || null,
- taxId: params.vendorData.taxId,
- country: params.vendorData.country,
- address: params.vendorData.address || null,
- phone: params.vendorData.phone || null,
- email: params.vendorData.email,
- website: params.vendorData.website || null,
- techVendorType: params.vendorData.techVendorType,
- status: "QUOTE_COMPARISON", // 가입 완료 시 QUOTE_COMPARISON으로 변경
- representativeName: params.vendorData.representativeName || null,
- representativeEmail: params.vendorData.representativeEmail || null,
- representativePhone: params.vendorData.representativePhone || null,
- representativeBirth: params.vendorData.representativeBirth || null,
- items: params.vendorData.items,
- updatedAt: new Date(),
- })
- .where(eq(techVendors.id, existingVendorId))
- .returning();
-
- vendorResult = updatedVendor;
- console.log("기존 벤더 정보 업데이트 완료:", vendorResult.id);
- } else {
- // 1. 이메일 중복 체크 (새 벤더인 경우)
- const existingVendor = await tx.query.techVendors.findFirst({
- where: eq(techVendors.email, params.vendorData.email),
- columns: { id: true, vendorName: true }
- });
-
- if (existingVendor) {
- throw new Error(`이미 등록된 이메일입니다: ${params.vendorData.email}`);
- }
-
- // 2. 새 벤더 생성
- const [newVendor] = await tx.insert(techVendors).values({
- vendorName: params.vendorData.vendorName,
- vendorCode: params.vendorData.vendorCode || null,
- taxId: params.vendorData.taxId,
- country: params.vendorData.country,
- address: params.vendorData.address || null,
- phone: params.vendorData.phone || null,
- email: params.vendorData.email,
- website: params.vendorData.website || null,
- techVendorType: params.vendorData.techVendorType,
- status: "ACTIVE",
- isQuoteComparison: false,
- representativeName: params.vendorData.representativeName || null,
- representativeEmail: params.vendorData.representativeEmail || null,
- representativePhone: params.vendorData.representativePhone || null,
- representativeBirth: params.vendorData.representativeBirth || null,
- items: params.vendorData.items,
- }).returning();
-
- vendorResult = newVendor;
- console.log("새 벤더 생성 완료:", vendorResult.id);
- }
-
- // 이 부분은 위에서 이미 처리되었으므로 주석 처리
-
- // 3. 연락처 생성
- if (params.contacts && params.contacts.length > 0) {
- for (const [index, contact] of params.contacts.entries()) {
- await tx.insert(techVendorContacts).values({
- vendorId: vendorResult.id,
- contactName: contact.contactName,
- contactPosition: contact.contactPosition || null,
- contactEmail: contact.contactEmail,
- contactPhone: contact.contactPhone || null,
- isPrimary: index === 0, // 첫 번째 연락처를 primary로 설정
- });
- }
- console.log("연락처 생성 완료:", params.contacts.length, "개");
- }
-
- // 4. 첨부파일 처리
- if (params.files && params.files.length > 0) {
- await storeTechVendorFiles(tx, vendorResult.id, params.files, "GENERAL");
- console.log("첨부파일 저장 완료:", params.files.length, "개");
- }
-
- // 5. 유저 생성 (techCompanyId 설정)
- console.log("유저 생성 시도:", params.vendorData.email);
-
- const existingUser = await tx.query.users.findFirst({
- where: eq(users.email, params.vendorData.email),
- columns: { id: true, techCompanyId: true }
- });
-
- let userId = null;
- if (!existingUser) {
- const [newUser] = await tx.insert(users).values({
- name: params.vendorData.vendorName,
- email: params.vendorData.email,
- techCompanyId: vendorResult.id, // 중요: techCompanyId 설정
- domain: "partners",
- }).returning();
- userId = newUser.id;
- console.log("유저 생성 성공:", userId);
- } else {
- // 기존 유저의 techCompanyId 업데이트
- if (!existingUser.techCompanyId) {
- await tx.update(users)
- .set({ techCompanyId: vendorResult.id })
- .where(eq(users.id, existingUser.id));
- console.log("기존 유저의 techCompanyId 업데이트:", existingUser.id);
- }
- userId = existingUser.id;
- }
-
- // 6. 후보에서 해당 이메일이 있으면 vendorId 업데이트 및 상태 변경
- if (params.vendorData.email) {
- await tx.update(techVendorCandidates)
- .set({
- vendorId: vendorResult.id,
- status: "INVITED"
- })
- .where(eq(techVendorCandidates.contactEmail, params.vendorData.email));
- }
-
- return { vendor: vendorResult, userId };
- });
-
- // 캐시 무효화
- revalidateTag("tech-vendors");
- revalidateTag("tech-vendor-candidates");
- revalidateTag("users");
-
- console.log("기술영업 벤더 회원가입 완료:", result);
- return { success: true, data: result };
- } catch (error) {
- console.error("기술영업 벤더 회원가입 실패:", error);
- return { success: false, error: getErrorMessage(error) };
- }
-}
-
-/**
- * 단일 기술영업 벤더 추가 (사용자 계정도 함께 생성)
- */
-export async function addTechVendor(input: {
- vendorName: string;
- vendorCode?: string | null;
- email: string;
- taxId: string;
- country?: string | null;
- countryEng?: string | null;
- countryFab?: string | null;
- agentName?: string | null;
- agentPhone?: string | null;
- agentEmail?: string | null;
- address?: string | null;
- phone?: string | null;
- website?: string | null;
- techVendorType: string;
- representativeName?: string | null;
- representativeEmail?: string | null;
- representativePhone?: string | null;
- representativeBirth?: string | null;
- isQuoteComparison?: boolean;
-}) {
- unstable_noStore();
-
- try {
- console.log("벤더 추가 시작:", input.vendorName);
-
- const result = await db.transaction(async (tx) => {
- // 1. 이메일 중복 체크
- const existingVendor = await tx.query.techVendors.findFirst({
- where: eq(techVendors.email, input.email),
- columns: { id: true, vendorName: true }
- });
-
- if (existingVendor) {
- throw new Error(`이미 등록된 이메일입니다: ${input.email} (업체명: ${existingVendor.vendorName})`);
- }
-
- // 2. 벤더 생성
- console.log("벤더 생성 시도:", {
- vendorName: input.vendorName,
- email: input.email,
- techVendorType: input.techVendorType
- });
-
- const [newVendor] = await tx.insert(techVendors).values({
- vendorName: input.vendorName,
- vendorCode: input.vendorCode || null,
- taxId: input.taxId || null,
- country: input.country || null,
- countryEng: input.countryEng || null,
- countryFab: input.countryFab || null,
- agentName: input.agentName || null,
- agentPhone: input.agentPhone || null,
- agentEmail: input.agentEmail || null,
- address: input.address || null,
- phone: input.phone || null,
- email: input.email,
- website: input.website || null,
- techVendorType: Array.isArray(input.techVendorType) ? input.techVendorType.join(',') : input.techVendorType,
- status: input.isQuoteComparison ? "PENDING_INVITE" : "ACTIVE",
- isQuoteComparison: input.isQuoteComparison || false,
- representativeName: input.representativeName || null,
- representativeEmail: input.representativeEmail || null,
- representativePhone: input.representativePhone || null,
- representativeBirth: input.representativeBirth || null,
- }).returning();
-
- console.log("벤더 생성 성공:", newVendor.id);
-
- // 3. 견적비교용 벤더인 경우 PENDING_REVIEW 상태로 생성됨
- // 초대는 별도의 초대 버튼을 통해 진행
- console.log("벤더 생성 완료:", newVendor.id, "상태:", newVendor.status);
-
- // 4. 유저 생성 (techCompanyId 설정)
- console.log("유저 생성 시도:", input.email);
-
- // 이미 존재하는 유저인지 확인
- const existingUser = await tx.query.users.findFirst({
- where: eq(users.email, input.email),
- columns: { id: true, techCompanyId: true }
- });
-
- let userId = null;
- // 유저가 존재하지 않는 경우에만 생성
- if (!existingUser) {
- const [newUser] = await tx.insert(users).values({
- name: input.vendorName,
- email: input.email,
- techCompanyId: newVendor.id, // techCompanyId 설정
- domain: "partners",
- }).returning();
- userId = newUser.id;
- console.log("유저 생성 성공:", userId);
- } else {
- // 이미 존재하는 유저의 techCompanyId가 null인 경우 업데이트
- if (!existingUser.techCompanyId) {
- await tx.update(users)
- .set({ techCompanyId: newVendor.id })
- .where(eq(users.id, existingUser.id));
- console.log("기존 유저의 techCompanyId 업데이트:", existingUser.id);
- }
- userId = existingUser.id;
- console.log("이미 존재하는 유저:", userId);
- }
-
- return { vendor: newVendor, userId };
- });
-
- // 캐시 무효화
- revalidateTag("tech-vendors");
- revalidateTag("users");
-
- console.log("벤더 추가 완료:", result);
- return { success: true, data: result };
- } catch (error) {
- console.error("벤더 추가 실패:", error);
- return { success: false, error: getErrorMessage(error) };
- }
-}
-
-/**
- * 벤더의 possible items 개수 조회
- */
-export async function getTechVendorPossibleItemsCount(vendorId: number): Promise<number> {
- try {
- const result = await db
- .select({ count: sql<number>`count(*)`.as("count") })
- .from(techVendorPossibleItems)
- .where(eq(techVendorPossibleItems.vendorId, vendorId));
-
- return result[0]?.count || 0;
- } catch (err) {
- console.error("Error getting tech vendor possible items count:", err);
- return 0;
- }
-}
-
-/**
- * 기술영업 벤더 초대 메일 발송
- */
-export async function inviteTechVendor(params: {
- vendorId: number;
- subject: string;
- message: string;
- recipientEmail: string;
-}) {
- unstable_noStore();
-
- try {
- console.log("기술영업 벤더 초대 메일 발송 시작:", params.vendorId);
-
- const result = await db.transaction(async (tx) => {
- // 벤더 정보 조회
- const vendor = await tx.query.techVendors.findFirst({
- where: eq(techVendors.id, params.vendorId),
- });
-
- if (!vendor) {
- throw new Error("벤더를 찾을 수 없습니다.");
- }
-
- // 벤더 상태를 INVITED로 변경 (PENDING_INVITE에서)
- if (vendor.status !== "PENDING_INVITE") {
- throw new Error("초대 가능한 상태가 아닙니다. (PENDING_INVITE 상태만 초대 가능)");
- }
-
- await tx.update(techVendors)
- .set({
- status: "INVITED",
- updatedAt: new Date(),
- })
- .where(eq(techVendors.id, params.vendorId));
-
- // 초대 토큰 생성
- const { createTechVendorInvitationToken, createTechVendorSignupUrl } = await import("@/lib/tech-vendor-invitation-token");
- const { sendEmail } = await import("@/lib/mail/sendEmail");
-
- const invitationToken = await createTechVendorInvitationToken({
- vendorId: vendor.id,
- vendorName: vendor.vendorName,
- email: params.recipientEmail,
- });
-
- const signupUrl = await createTechVendorSignupUrl(invitationToken);
-
- // 초대 메일 발송
- await sendEmail({
- to: params.recipientEmail,
- subject: params.subject,
- template: "tech-vendor-invitation",
- context: {
- companyName: vendor.vendorName,
- language: "ko",
- registrationLink: signupUrl,
- customMessage: params.message,
- }
- });
-
- console.log("초대 메일 발송 완료:", params.recipientEmail);
-
- return { vendor, invitationToken, signupUrl };
- });
-
- // 캐시 무효화
- revalidateTag("tech-vendors");
-
- console.log("기술영업 벤더 초대 완료:", result);
- return { success: true, data: result };
- } catch (error) {
- console.error("기술영업 벤더 초대 실패:", error);
- return { success: false, error: getErrorMessage(error) };
- }
-}
-
+"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택)
+
+import { revalidateTag, unstable_noStore } from "next/cache";
+import db from "@/db/db";
+import { techVendorAttachments, techVendorContacts, techVendorPossibleItems, techVendors, techVendorItemsView, type TechVendor, techVendorCandidates } from "@/db/schema/techVendors";
+import { items, itemShipbuilding, itemOffshoreTop, itemOffshoreHull } from "@/db/schema/items";
+import { users } from "@/db/schema/users";
+import ExcelJS from "exceljs";
+import { filterColumns } from "@/lib/filter-columns";
+import { unstable_cache } from "@/lib/unstable-cache";
+import { getErrorMessage } from "@/lib/handle-error";
+
+import {
+ insertTechVendor,
+ updateTechVendor,
+ groupByTechVendorStatus,
+ selectTechVendorContacts,
+ countTechVendorContacts,
+ insertTechVendorContact,
+ selectTechVendorItems,
+ countTechVendorItems,
+ insertTechVendorItem,
+ selectTechVendorsWithAttachments,
+ countTechVendorsWithAttachments,
+ updateTechVendors,
+} from "./repository";
+
+import type {
+ CreateTechVendorSchema,
+ UpdateTechVendorSchema,
+ GetTechVendorsSchema,
+ GetTechVendorContactsSchema,
+ CreateTechVendorContactSchema,
+ GetTechVendorItemsSchema,
+ CreateTechVendorItemSchema,
+ GetTechVendorRfqHistorySchema,
+ GetTechVendorPossibleItemsSchema,
+ CreateTechVendorPossibleItemSchema,
+ UpdateTechVendorPossibleItemSchema,
+ UpdateTechVendorContactSchema,
+} from "./validations";
+
+import { asc, desc, ilike, inArray, and, or, eq, isNull, not } from "drizzle-orm";
+import path from "path";
+import { sql } from "drizzle-orm";
+import { decryptWithServerAction } from "@/components/drm/drmUtils";
+import { deleteFile, saveDRMFile } from "../file-stroage";
+
+/* -----------------------------------------------------
+ 1) 조회 관련
+----------------------------------------------------- */
+
+/**
+ * 복잡한 조건으로 기술영업 Vendor 목록을 조회 (+ pagination) 하고,
+ * 총 개수에 따라 pageCount를 계산해서 리턴.
+ * Next.js의 unstable_cache를 사용해 일정 시간 캐시.
+ */
+export async function getTechVendors(input: GetTechVendorsSchema) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // 1) 고급 필터 (workTypes와 techVendorType 제외 - 별도 처리)
+ const filteredFilters = input.filters.filter(
+ filter => filter.id !== "workTypes" && filter.id !== "techVendorType"
+ );
+
+ const advancedWhere = filterColumns({
+ table: techVendors,
+ filters: filteredFilters,
+ joinOperator: input.joinOperator,
+ });
+
+ // 2) 글로벌 검색
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(techVendors.vendorName, s),
+ ilike(techVendors.vendorCode, s),
+ ilike(techVendors.email, s),
+ ilike(techVendors.status, s)
+ );
+ }
+
+ // 최종 where 결합
+ const finalWhere = and(advancedWhere, globalWhere);
+
+ // 벤더 타입 필터링 로직 추가
+ let vendorTypeWhere;
+ if (input.vendorType) {
+ // URL의 vendorType 파라미터를 실제 벤더 타입으로 매핑
+ const vendorTypeMap = {
+ "ship": "조선",
+ "top": "해양TOP",
+ "hull": "해양HULL"
+ };
+
+ const actualVendorType = input.vendorType in vendorTypeMap
+ ? vendorTypeMap[input.vendorType as keyof typeof vendorTypeMap]
+ : undefined;
+ if (actualVendorType) {
+ // techVendorType 필드는 콤마로 구분된 문자열이므로 LIKE 사용
+ vendorTypeWhere = ilike(techVendors.techVendorType, `%${actualVendorType}%`);
+ }
+ }
+
+ // 간단 검색 (advancedTable=false) 시 예시
+ const simpleWhere = and(
+ input.vendorName
+ ? ilike(techVendors.vendorName, `%${input.vendorName}%`)
+ : undefined,
+ input.status ? ilike(techVendors.status, input.status) : undefined,
+ input.country
+ ? ilike(techVendors.country, `%${input.country}%`)
+ : undefined
+ );
+
+ // TechVendorType 필터링 로직 추가 (고급 필터에서)
+ let techVendorTypeWhere;
+ const techVendorTypeFilters = input.filters.filter(filter => filter.id === "techVendorType");
+ if (techVendorTypeFilters.length > 0) {
+ const typeFilter = techVendorTypeFilters[0];
+ if (Array.isArray(typeFilter.value) && typeFilter.value.length > 0) {
+ // 각 타입에 대해 LIKE 조건으로 OR 연결
+ const typeConditions = typeFilter.value.map(type =>
+ ilike(techVendors.techVendorType, `%${type}%`)
+ );
+ techVendorTypeWhere = or(...typeConditions);
+ }
+ }
+
+ // WorkTypes 필터링 로직 추가
+ let workTypesWhere;
+ const workTypesFilters = input.filters.filter(filter => filter.id === "workTypes");
+ if (workTypesFilters.length > 0) {
+ const workTypeFilter = workTypesFilters[0];
+ if (Array.isArray(workTypeFilter.value) && workTypeFilter.value.length > 0) {
+ // workTypes에 해당하는 벤더 ID들을 서브쿼리로 찾음
+ const vendorIdsWithWorkTypes = db
+ .selectDistinct({ vendorId: techVendorPossibleItems.vendorId })
+ .from(techVendorPossibleItems)
+ .leftJoin(itemShipbuilding, eq(techVendorPossibleItems.itemCode, itemShipbuilding.itemCode))
+ .leftJoin(itemOffshoreTop, eq(techVendorPossibleItems.itemCode, itemOffshoreTop.itemCode))
+ .leftJoin(itemOffshoreHull, eq(techVendorPossibleItems.itemCode, itemOffshoreHull.itemCode))
+ .where(
+ or(
+ inArray(itemShipbuilding.workType, workTypeFilter.value),
+ inArray(itemOffshoreTop.workType, workTypeFilter.value),
+ inArray(itemOffshoreHull.workType, workTypeFilter.value)
+ )
+ );
+
+ workTypesWhere = inArray(techVendors.id, vendorIdsWithWorkTypes);
+ }
+ }
+
+ // 실제 사용될 where (vendorType, techVendorType, workTypes 필터링 추가)
+ const where = and(finalWhere, vendorTypeWhere, techVendorTypeWhere, workTypesWhere);
+
+ // 정렬
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(techVendors[item.id]) : asc(techVendors[item.id])
+ )
+ : [asc(techVendors.createdAt)];
+
+ // 트랜잭션 내에서 데이터 조회
+ const { data, total } = await db.transaction(async (tx) => {
+ // 1) vendor 목록 조회 (with attachments)
+ const vendorsData = await selectTechVendorsWithAttachments(tx, {
+ where,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+
+ // 2) 전체 개수
+ const total = await countTechVendorsWithAttachments(tx, where);
+ return { data: vendorsData, total };
+ });
+
+ // 페이지 수
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data, pageCount };
+ } catch (err) {
+ console.error("Error fetching tech vendors:", err);
+ // 에러 발생 시
+ return { data: [], pageCount: 0 };
+ }
+ },
+ [JSON.stringify(input)], // 캐싱 키
+ {
+ revalidate: 3600,
+ tags: ["tech-vendors"], // revalidateTag("tech-vendors") 호출 시 무효화
+ }
+ )();
+}
+
+/**
+ * 기술영업 벤더 상태별 카운트 조회
+ */
+export async function getTechVendorStatusCounts() {
+ return unstable_cache(
+ async () => {
+ try {
+ const initial: Record<TechVendor["status"], number> = {
+ "PENDING_INVITE": 0,
+ "INVITED": 0,
+ "QUOTE_COMPARISON": 0,
+ "ACTIVE": 0,
+ "INACTIVE": 0,
+ "BLACKLISTED": 0,
+ };
+
+ const result = await db.transaction(async (tx) => {
+ const rows = await groupByTechVendorStatus(tx);
+ type StatusCountRow = { status: TechVendor["status"]; count: number };
+ return (rows as StatusCountRow[]).reduce<Record<TechVendor["status"], number>>((acc, { status, count }) => {
+ acc[status] = count;
+ return acc;
+ }, initial);
+ });
+
+ return result;
+ } catch (err) {
+ return {} as Record<TechVendor["status"], number>;
+ }
+ },
+ ["tech-vendor-status-counts"], // 캐싱 키
+ {
+ revalidate: 3600,
+ }
+ )();
+}
+
+/**
+ * 벤더 상세 정보 조회
+ */
+export async function getTechVendorById(id: number) {
+ return unstable_cache(
+ async () => {
+ try {
+ const result = await getTechVendorDetailById(id);
+ return { data: result };
+ } catch (err) {
+ console.error("기술영업 벤더 상세 조회 오류:", err);
+ return { data: null };
+ }
+ },
+ [`tech-vendor-${id}`],
+ {
+ revalidate: 3600,
+ tags: ["tech-vendors", `tech-vendor-${id}`],
+ }
+ )();
+}
+
+/* -----------------------------------------------------
+ 2) 생성(Create)
+----------------------------------------------------- */
+
+/**
+ * 첨부파일 저장 헬퍼 함수
+ */
+async function storeTechVendorFiles(
+ tx: any,
+ vendorId: number,
+ files: File[],
+ attachmentType: string
+) {
+
+ for (const file of files) {
+
+ const saveResult = await saveDRMFile(file, decryptWithServerAction, `tech-vendors/${vendorId}`)
+
+ // Insert attachment record
+ await tx.insert(techVendorAttachments).values({
+ vendorId,
+ fileName: file.name,
+ filePath: saveResult.publicPath,
+ attachmentType,
+ });
+ }
+}
+
+/**
+ * 신규 기술영업 벤더 생성
+ */
+export async function createTechVendor(input: CreateTechVendorSchema) {
+ unstable_noStore();
+
+ try {
+ // 이메일 중복 검사
+ const existingVendorByEmail = await db
+ .select({ id: techVendors.id, vendorName: techVendors.vendorName })
+ .from(techVendors)
+ .where(eq(techVendors.email, input.email))
+ .limit(1);
+
+ // 이미 동일한 이메일을 가진 업체가 존재하면 에러 반환
+ if (existingVendorByEmail.length > 0) {
+ return {
+ success: false,
+ data: null,
+ error: `이미 등록된 이메일입니다. (업체명: ${existingVendorByEmail[0].vendorName})`
+ };
+ }
+
+ // taxId 중복 검사
+ const existingVendorByTaxId = await db
+ .select({ id: techVendors.id })
+ .from(techVendors)
+ .where(eq(techVendors.taxId, input.taxId))
+ .limit(1);
+
+ // 이미 동일한 taxId를 가진 업체가 존재하면 에러 반환
+ if (existingVendorByTaxId.length > 0) {
+ return {
+ success: false,
+ data: null,
+ error: `이미 등록된 사업자등록번호입니다. (Tax ID ${input.taxId} already exists in the system)`
+ };
+ }
+
+ const result = await db.transaction(async (tx) => {
+ // 1. 벤더 생성
+ const [newVendor] = await insertTechVendor(tx, {
+ vendorName: input.vendorName,
+ vendorCode: input.vendorCode || null,
+ taxId: input.taxId,
+ address: input.address || null,
+ country: input.country,
+ countryEng: null,
+ countryFab: null,
+ agentName: null,
+ agentPhone: null,
+ agentEmail: null,
+ phone: input.phone || null,
+ email: input.email,
+ website: input.website || null,
+ techVendorType: Array.isArray(input.techVendorType) ? input.techVendorType.join(',') : input.techVendorType,
+ representativeName: input.representativeName || null,
+ representativeBirth: input.representativeBirth || null,
+ representativeEmail: input.representativeEmail || null,
+ representativePhone: input.representativePhone || null,
+ items: input.items || null,
+ status: "ACTIVE",
+ isQuoteComparison: false,
+ });
+
+ // 2. 연락처 정보 등록
+ for (const contact of input.contacts) {
+ await insertTechVendorContact(tx, {
+ vendorId: newVendor.id,
+ contactName: contact.contactName,
+ contactPosition: contact.contactPosition || null,
+ contactEmail: contact.contactEmail,
+ contactPhone: contact.contactPhone || null,
+ isPrimary: contact.isPrimary ?? false,
+ contactCountry: contact.contactCountry || null,
+ });
+ }
+
+ // 3. 첨부파일 저장
+ if (input.files && input.files.length > 0) {
+ await storeTechVendorFiles(tx, newVendor.id, input.files, "GENERAL");
+ }
+
+ return newVendor;
+ });
+
+ revalidateTag("tech-vendors");
+
+ return {
+ success: true,
+ data: result,
+ error: null
+ };
+ } catch (err) {
+ console.error("기술영업 벤더 생성 오류:", err);
+
+ return {
+ success: false,
+ data: null,
+ error: getErrorMessage(err)
+ };
+ }
+}
+
+/* -----------------------------------------------------
+ 3) 업데이트 (단건/복수)
+----------------------------------------------------- */
+
+/** 단건 업데이트 */
+export async function modifyTechVendor(
+ input: UpdateTechVendorSchema & { id: string; }
+) {
+ unstable_noStore();
+ try {
+ const updated = await db.transaction(async (tx) => {
+ // 벤더 정보 업데이트
+ const [res] = await updateTechVendor(tx, input.id, {
+ vendorName: input.vendorName,
+ vendorCode: input.vendorCode,
+ address: input.address,
+ country: input.country,
+ countryEng: input.countryEng,
+ countryFab: input.countryFab,
+ phone: input.phone,
+ email: input.email,
+ website: input.website,
+ status: input.status,
+ // 에이전트 정보 추가
+ agentName: input.agentName,
+ agentEmail: input.agentEmail,
+ agentPhone: input.agentPhone,
+ // 대표자 정보 추가
+ representativeName: input.representativeName,
+ representativeEmail: input.representativeEmail,
+ representativePhone: input.representativePhone,
+ representativeBirth: input.representativeBirth,
+ // techVendorType 처리
+ techVendorType: Array.isArray(input.techVendorType) ? input.techVendorType.join(',') : input.techVendorType,
+ });
+
+ return res;
+ });
+
+ // 캐시 무효화
+ revalidateTag("tech-vendors");
+ revalidateTag(`tech-vendor-${input.id}`);
+
+ return { data: updated, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/** 복수 업데이트 */
+export async function modifyTechVendors(input: {
+ ids: string[];
+ status?: TechVendor["status"];
+}) {
+ unstable_noStore();
+ try {
+ const data = await db.transaction(async (tx) => {
+ // 여러 협력업체 일괄 업데이트
+ const [updated] = await updateTechVendors(tx, input.ids, {
+ // 예: 상태만 일괄 변경
+ status: input.status,
+ });
+ return updated;
+ });
+
+ // 캐시 무효화
+ revalidateTag("tech-vendors");
+ revalidateTag("tech-vendor-status-counts");
+
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/* -----------------------------------------------------
+ 4) 연락처 관리
+----------------------------------------------------- */
+
+export async function getTechVendorContacts(input: GetTechVendorContactsSchema, id: number) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // 필터링 설정
+ const advancedWhere = filterColumns({
+ table: techVendorContacts,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+ // 검색 조건
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(techVendorContacts.contactName, s),
+ ilike(techVendorContacts.contactPosition, s),
+ ilike(techVendorContacts.contactEmail, s),
+ ilike(techVendorContacts.contactPhone, s)
+ );
+ }
+
+ // 해당 벤더 조건
+ const vendorWhere = eq(techVendorContacts.vendorId, id);
+
+ // 최종 조건 결합
+ const finalWhere = and(advancedWhere, globalWhere, vendorWhere);
+
+ // 정렬 조건
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(techVendorContacts[item.id]) : asc(techVendorContacts[item.id])
+ )
+ : [asc(techVendorContacts.createdAt)];
+
+ // 트랜잭션 내부에서 Repository 호출
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectTechVendorContacts(tx, {
+ where: finalWhere,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+ const total = await countTechVendorContacts(tx, finalWhere);
+ return { data, total };
+ });
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data, pageCount };
+ } catch (err) {
+ // 에러 발생 시 디폴트
+ return { data: [], pageCount: 0 };
+ }
+ },
+ [JSON.stringify(input), String(id)], // 캐싱 키
+ {
+ revalidate: 3600,
+ tags: [`tech-vendor-contacts-${id}`],
+ }
+ )();
+}
+
+export async function createTechVendorContact(input: CreateTechVendorContactSchema) {
+ unstable_noStore();
+ try {
+ await db.transaction(async (tx) => {
+ // DB Insert
+ const [newContact] = await insertTechVendorContact(tx, {
+ vendorId: input.vendorId,
+ contactName: input.contactName,
+ contactPosition: input.contactPosition || "",
+ contactEmail: input.contactEmail,
+ contactPhone: input.contactPhone || "",
+ contactCountry: input.contactCountry || "",
+ isPrimary: input.isPrimary || false,
+ });
+
+ return newContact;
+ });
+
+ // 캐시 무효화
+ revalidateTag(`tech-vendor-contacts-${input.vendorId}`);
+ revalidateTag("users");
+
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+export async function updateTechVendorContact(input: UpdateTechVendorContactSchema & { id: number; vendorId: number }) {
+ unstable_noStore();
+ try {
+ const [updatedContact] = await db
+ .update(techVendorContacts)
+ .set({
+ contactName: input.contactName,
+ contactPosition: input.contactPosition || null,
+ contactEmail: input.contactEmail,
+ contactPhone: input.contactPhone || null,
+ contactCountry: input.contactCountry || null,
+ isPrimary: input.isPrimary || false,
+ updatedAt: new Date(),
+ })
+ .where(eq(techVendorContacts.id, input.id))
+ .returning();
+
+ // 캐시 무효화
+ revalidateTag(`tech-vendor-contacts-${input.vendorId}`);
+ revalidateTag("users");
+
+ return { data: updatedContact, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+export async function deleteTechVendorContact(contactId: number, vendorId: number) {
+ unstable_noStore();
+ try {
+ const [deletedContact] = await db
+ .delete(techVendorContacts)
+ .where(eq(techVendorContacts.id, contactId))
+ .returning();
+
+ // 캐시 무효화
+ revalidateTag(`tech-vendor-contacts-${contactId}`);
+ revalidateTag(`tech-vendor-contacts-${vendorId}`);
+
+ return { data: deletedContact, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/* -----------------------------------------------------
+ 5) 아이템 관리
+----------------------------------------------------- */
+
+export async function getTechVendorItems(input: GetTechVendorItemsSchema, id: number) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // 필터링 설정
+ const advancedWhere = filterColumns({
+ table: techVendorItemsView,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+ // 검색 조건
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(techVendorItemsView.itemCode, s)
+ );
+ }
+
+ // 해당 벤더 조건
+ const vendorWhere = eq(techVendorItemsView.vendorId, id);
+
+ // 최종 조건 결합
+ const finalWhere = and(advancedWhere, globalWhere, vendorWhere);
+
+ // 정렬 조건
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(techVendorItemsView[item.id]) : asc(techVendorItemsView[item.id])
+ )
+ : [asc(techVendorItemsView.createdAt)];
+
+ // 트랜잭션 내부에서 Repository 호출
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectTechVendorItems(tx, {
+ where: finalWhere,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+ const total = await countTechVendorItems(tx, finalWhere);
+ return { data, total };
+ });
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data, pageCount };
+ } catch (err) {
+ // 에러 발생 시 디폴트
+ return { data: [], pageCount: 0 };
+ }
+ },
+ [JSON.stringify(input), String(id)], // 캐싱 키
+ {
+ revalidate: 3600,
+ tags: [`tech-vendor-items-${id}`],
+ }
+ )();
+}
+
+export interface ItemDropdownOption {
+ itemCode: string;
+ itemList: string;
+ workType: string | null;
+ shipTypes: string | null;
+ subItemList: string | null;
+}
+
+/**
+ * Vendor Item 추가 시 사용할 아이템 목록 조회 (전체 목록 반환)
+ * 아이템 코드, 이름, 설명만 간소화해서 반환
+ */
+export async function getItemsForTechVendor(vendorId: number) {
+ return unstable_cache(
+ async () => {
+ try {
+ // 1. 벤더 정보 조회로 벤더 타입 확인
+ const vendor = await db.query.techVendors.findFirst({
+ where: eq(techVendors.id, vendorId),
+ columns: {
+ techVendorType: true
+ }
+ });
+
+ if (!vendor) {
+ return {
+ data: [],
+ error: "벤더를 찾을 수 없습니다.",
+ };
+ }
+
+ // 2. 해당 벤더가 이미 가지고 있는 itemCode 목록 조회
+ const existingItems = await db
+ .select({
+ itemCode: techVendorPossibleItems.itemCode,
+ })
+ .from(techVendorPossibleItems)
+ .where(eq(techVendorPossibleItems.vendorId, vendorId));
+
+ const existingItemCodes = existingItems.map(item => item.itemCode);
+
+ // 3. 벤더 타입에 따라 해당 타입의 아이템만 조회
+ // let availableItems: ItemDropdownOption[] = [];
+ let availableItems: (typeof itemShipbuilding.$inferSelect | typeof itemOffshoreTop.$inferSelect | typeof itemOffshoreHull.$inferSelect)[] = [];
+ switch (vendor.techVendorType) {
+ case "조선":
+ const shipbuildingItems = await db
+ .select({
+ id: itemShipbuilding.id,
+ createdAt: itemShipbuilding.createdAt,
+ updatedAt: itemShipbuilding.updatedAt,
+ itemCode: itemShipbuilding.itemCode,
+ itemList: itemShipbuilding.itemList,
+ workType: itemShipbuilding.workType,
+ shipTypes: itemShipbuilding.shipTypes,
+ })
+ .from(itemShipbuilding)
+ .where(
+ existingItemCodes.length > 0
+ ? not(inArray(itemShipbuilding.itemCode, existingItemCodes))
+ : undefined
+ )
+ .orderBy(asc(itemShipbuilding.itemCode));
+
+ availableItems = shipbuildingItems
+ .filter(item => item.itemCode != null)
+ .map(item => ({
+ id: item.id,
+ createdAt: item.createdAt,
+ updatedAt: item.updatedAt,
+ itemCode: item.itemCode!,
+ itemList: item.itemList || "조선 아이템",
+ workType: item.workType || "조선 관련 아이템",
+ shipTypes: item.shipTypes || "조선 관련 아이템"
+ }));
+ break;
+
+ case "해양TOP":
+ const offshoreTopItems = await db
+ .select({
+ id: itemOffshoreTop.id,
+ createdAt: itemOffshoreTop.createdAt,
+ updatedAt: itemOffshoreTop.updatedAt,
+ itemCode: itemOffshoreTop.itemCode,
+ itemList: itemOffshoreTop.itemList,
+ workType: itemOffshoreTop.workType,
+ subItemList: itemOffshoreTop.subItemList,
+ })
+ .from(itemOffshoreTop)
+ .where(
+ existingItemCodes.length > 0
+ ? not(inArray(itemOffshoreTop.itemCode, existingItemCodes))
+ : undefined
+ )
+ .orderBy(asc(itemOffshoreTop.itemCode));
+
+ availableItems = offshoreTopItems
+ .filter(item => item.itemCode != null)
+ .map(item => ({
+ id: item.id,
+ createdAt: item.createdAt,
+ updatedAt: item.updatedAt,
+ itemCode: item.itemCode!,
+ itemList: item.itemList || "해양TOP 아이템",
+ workType: item.workType || "해양TOP 관련 아이템",
+ subItemList: item.subItemList || "해양TOP 관련 아이템"
+ }));
+ break;
+
+ case "해양HULL":
+ const offshoreHullItems = await db
+ .select({
+ id: itemOffshoreHull.id,
+ createdAt: itemOffshoreHull.createdAt,
+ updatedAt: itemOffshoreHull.updatedAt,
+ itemCode: itemOffshoreHull.itemCode,
+ itemList: itemOffshoreHull.itemList,
+ workType: itemOffshoreHull.workType,
+ subItemList: itemOffshoreHull.subItemList,
+ })
+ .from(itemOffshoreHull)
+ .where(
+ existingItemCodes.length > 0
+ ? not(inArray(itemOffshoreHull.itemCode, existingItemCodes))
+ : undefined
+ )
+ .orderBy(asc(itemOffshoreHull.itemCode));
+
+ availableItems = offshoreHullItems
+ .filter(item => item.itemCode != null)
+ .map(item => ({
+ id: item.id,
+ createdAt: item.createdAt,
+ updatedAt: item.updatedAt,
+ itemCode: item.itemCode!,
+ itemList: item.itemList || "해양HULL 아이템",
+ workType: item.workType || "해양HULL 관련 아이템",
+ subItemList: item.subItemList || "해양HULL 관련 아이템"
+ }));
+ break;
+
+ default:
+ return {
+ data: [],
+ error: `지원하지 않는 벤더 타입입니다: ${vendor.techVendorType}`,
+ };
+ }
+
+ return {
+ data: availableItems,
+ error: null
+ };
+ } catch (err) {
+ console.error("Failed to fetch items for tech vendor dropdown:", err);
+ return {
+ data: [],
+ error: "아이템 목록을 불러오는데 실패했습니다.",
+ };
+ }
+ },
+ // 캐시 키를 vendorId 별로 달리 해야 한다.
+ ["items-for-tech-vendor", String(vendorId)],
+ {
+ revalidate: 3600, // 1시간 캐싱
+ tags: ["items"], // revalidateTag("items") 호출 시 무효화
+ }
+ )();
+}
+
+/**
+ * 벤더 타입과 아이템 코드에 따른 아이템 조회
+ */
+export async function getItemsByVendorType(vendorType: string, itemCode: string) {
+ try {
+ let items: (typeof itemShipbuilding.$inferSelect | typeof itemOffshoreTop.$inferSelect | typeof itemOffshoreHull.$inferSelect)[] = [];
+
+ switch (vendorType) {
+ case "조선":
+ const shipbuildingResults = await db
+ .select({
+ id: itemShipbuilding.id,
+ itemCode: itemShipbuilding.itemCode,
+ workType: itemShipbuilding.workType,
+ shipTypes: itemShipbuilding.shipTypes,
+ itemList: itemShipbuilding.itemList,
+ createdAt: itemShipbuilding.createdAt,
+ updatedAt: itemShipbuilding.updatedAt,
+ })
+ .from(itemShipbuilding)
+ .where(itemCode ? eq(itemShipbuilding.itemCode, itemCode) : undefined);
+ items = shipbuildingResults;
+ break;
+
+ case "해양TOP":
+ const offshoreTopResults = await db
+ .select({
+ id: itemOffshoreTop.id,
+ itemCode: itemOffshoreTop.itemCode,
+ workType: itemOffshoreTop.workType,
+ itemList: itemOffshoreTop.itemList,
+ subItemList: itemOffshoreTop.subItemList,
+ createdAt: itemOffshoreTop.createdAt,
+ updatedAt: itemOffshoreTop.updatedAt,
+ })
+ .from(itemOffshoreTop)
+ .where(itemCode ? eq(itemOffshoreTop.itemCode, itemCode) : undefined);
+ items = offshoreTopResults;
+ break;
+
+ case "해양HULL":
+ const offshoreHullResults = await db
+ .select({
+ id: itemOffshoreHull.id,
+ itemCode: itemOffshoreHull.itemCode,
+ workType: itemOffshoreHull.workType,
+ itemList: itemOffshoreHull.itemList,
+ subItemList: itemOffshoreHull.subItemList,
+ createdAt: itemOffshoreHull.createdAt,
+ updatedAt: itemOffshoreHull.updatedAt,
+ })
+ .from(itemOffshoreHull)
+ .where(itemCode ? eq(itemOffshoreHull.itemCode, itemCode) : undefined);
+ items = offshoreHullResults;
+ break;
+
+ default:
+ items = [];
+ }
+
+ const result = items.map(item => ({
+ ...item,
+ techVendorType: vendorType
+ }));
+
+ return { data: result, error: null };
+ } catch (err) {
+ console.error("Error fetching items by vendor type:", err);
+ return { data: [], error: "Failed to fetch items" };
+ }
+}
+
+/**
+ * 벤더의 possible_items를 조회하고 해당 아이템 코드로 각 타입별 테이블을 조회
+ * 벤더 타입이 콤마로 구분된 경우 (예: "조선,해양TOP,해양HULL") 모든 타입의 아이템을 조회
+ */
+export async function getVendorItemsByType(vendorId: number, vendorType: string) {
+ try {
+ // 벤더의 possible_items 조회
+ const possibleItems = await db.query.techVendorPossibleItems.findMany({
+ where: eq(techVendorPossibleItems.vendorId, vendorId),
+ columns: {
+ itemCode: true
+ }
+ })
+
+ const itemCodes = possibleItems.map(item => item.itemCode)
+
+ if (itemCodes.length === 0) {
+ return { data: [] }
+ }
+
+ // 벤더 타입을 콤마로 분리
+ const vendorTypes = vendorType.split(',').map(type => type.trim())
+ const allItems: Array<Record<string, any> & { techVendorType: "조선" | "해양TOP" | "해양HULL" }> = []
+
+ // 각 벤더 타입에 따라 해당하는 테이블에서 아이템 조회
+ for (const singleType of vendorTypes) {
+ switch (singleType) {
+ case "조선":
+ const shipbuildingItems = await db.query.itemShipbuilding.findMany({
+ where: inArray(itemShipbuilding.itemCode, itemCodes)
+ })
+ allItems.push(...shipbuildingItems.map(item => ({
+ ...item,
+ techVendorType: "조선" as const
+ })))
+ break
+
+ case "해양TOP":
+ const offshoreTopItems = await db.query.itemOffshoreTop.findMany({
+ where: inArray(itemOffshoreTop.itemCode, itemCodes)
+ })
+ allItems.push(...offshoreTopItems.map(item => ({
+ ...item,
+ techVendorType: "해양TOP" as const
+ })))
+ break
+
+ case "해양HULL":
+ const offshoreHullItems = await db.query.itemOffshoreHull.findMany({
+ where: inArray(itemOffshoreHull.itemCode, itemCodes)
+ })
+ allItems.push(...offshoreHullItems.map(item => ({
+ ...item,
+ techVendorType: "해양HULL" as const
+ })))
+ break
+
+ default:
+ console.warn(`Unknown vendor type: ${singleType}`)
+ break
+ }
+ }
+
+ // 중복 허용 - 모든 아이템을 그대로 반환
+ return {
+ data: allItems.sort((a, b) => a.itemCode.localeCompare(b.itemCode))
+ }
+ } catch (err) {
+ console.error("Error getting vendor items by type:", err)
+ return { data: [] }
+ }
+}
+
+export async function createTechVendorItem(input: CreateTechVendorItemSchema) {
+ unstable_noStore();
+ try {
+ // DB에 이미 존재하는지 확인
+ const existingItem = await db
+ .select({ id: techVendorPossibleItems.id })
+ .from(techVendorPossibleItems)
+ .where(
+ and(
+ eq(techVendorPossibleItems.vendorId, input.vendorId),
+ eq(techVendorPossibleItems.itemCode, input.itemCode)
+ )
+ )
+ .limit(1);
+
+ if (existingItem.length > 0) {
+ return { data: null, error: "이미 추가된 아이템입니다." };
+ }
+
+ await db.transaction(async (tx) => {
+ // DB Insert
+ const [newItem] = await tx
+ .insert(techVendorPossibleItems)
+ .values({
+ vendorId: input.vendorId,
+ itemCode: input.itemCode,
+ })
+ .returning();
+ return newItem;
+ });
+
+ // 캐시 무효화
+ revalidateTag(`tech-vendor-items-${input.vendorId}`);
+
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/* -----------------------------------------------------
+ 6) 기술영업 벤더 승인/거부
+----------------------------------------------------- */
+
+interface ApproveTechVendorsInput {
+ ids: string[];
+}
+
+/**
+ * 기술영업 벤더 승인 (상태를 ACTIVE로 변경)
+ */
+export async function approveTechVendors(input: ApproveTechVendorsInput) {
+ unstable_noStore();
+
+ try {
+ // 트랜잭션 내에서 협력업체 상태 업데이트
+ const result = await db.transaction(async (tx) => {
+ // 협력업체 상태 업데이트
+ const [updated] = await tx
+ .update(techVendors)
+ .set({
+ status: "ACTIVE",
+ updatedAt: new Date()
+ })
+ .where(inArray(techVendors.id, input.ids.map(id => parseInt(id))))
+ .returning();
+
+ return updated;
+ });
+
+ // 캐시 무효화
+ revalidateTag("tech-vendors");
+ revalidateTag("tech-vendor-status-counts");
+
+ return { data: result, error: null };
+ } catch (err) {
+ console.error("Error approving tech vendors:", err);
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * 기술영업 벤더 거부 (상태를 REJECTED로 변경)
+ */
+export async function rejectTechVendors(input: ApproveTechVendorsInput) {
+ unstable_noStore();
+
+ try {
+ // 트랜잭션 내에서 협력업체 상태 업데이트
+ const result = await db.transaction(async (tx) => {
+ // 협력업체 상태 업데이트
+ const [updated] = await tx
+ .update(techVendors)
+ .set({
+ status: "INACTIVE",
+ updatedAt: new Date()
+ })
+ .where(inArray(techVendors.id, input.ids.map(id => parseInt(id))))
+ .returning();
+
+ return updated;
+ });
+
+ // 캐시 무효화
+ revalidateTag("tech-vendors");
+ revalidateTag("tech-vendor-status-counts");
+
+ return { data: result, error: null };
+ } catch (err) {
+ console.error("Error rejecting tech vendors:", err);
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/* -----------------------------------------------------
+ 7) 엑셀 내보내기
+----------------------------------------------------- */
+
+/**
+ * 벤더 연락처 목록 엑셀 내보내기
+ */
+export async function exportTechVendorContacts(vendorId: number) {
+ try {
+ const contacts = await db
+ .select()
+ .from(techVendorContacts)
+ .where(eq(techVendorContacts.vendorId, vendorId))
+ .orderBy(techVendorContacts.isPrimary, techVendorContacts.contactName);
+
+ return contacts;
+ } catch (err) {
+ console.error("기술영업 벤더 연락처 내보내기 오류:", err);
+ return [];
+ }
+}
+
+/**
+ * 벤더 아이템 목록 엑셀 내보내기
+ */
+export async function exportTechVendorItems(vendorId: number) {
+ try {
+ const items = await db
+ .select({
+ id: techVendorItemsView.vendorItemId,
+ vendorId: techVendorItemsView.vendorId,
+ itemCode: techVendorItemsView.itemCode,
+ createdAt: techVendorItemsView.createdAt,
+ updatedAt: techVendorItemsView.updatedAt,
+ })
+ .from(techVendorItemsView)
+ .where(eq(techVendorItemsView.vendorId, vendorId))
+
+ return items;
+ } catch (err) {
+ console.error("기술영업 벤더 아이템 내보내기 오류:", err);
+ return [];
+ }
+}
+
+/**
+ * 벤더 정보 엑셀 내보내기
+ */
+export async function exportTechVendorDetails(vendorIds: number[]) {
+ try {
+ if (!vendorIds.length) return [];
+
+ // 벤더 기본 정보 조회
+ const vendorsData = await db
+ .select({
+ id: techVendors.id,
+ vendorName: techVendors.vendorName,
+ vendorCode: techVendors.vendorCode,
+ taxId: techVendors.taxId,
+ address: techVendors.address,
+ country: techVendors.country,
+ phone: techVendors.phone,
+ email: techVendors.email,
+ website: techVendors.website,
+ status: techVendors.status,
+ representativeName: techVendors.representativeName,
+ representativeEmail: techVendors.representativeEmail,
+ representativePhone: techVendors.representativePhone,
+ representativeBirth: techVendors.representativeBirth,
+ items: techVendors.items,
+ createdAt: techVendors.createdAt,
+ updatedAt: techVendors.updatedAt,
+ })
+ .from(techVendors)
+ .where(
+ vendorIds.length === 1
+ ? eq(techVendors.id, vendorIds[0])
+ : inArray(techVendors.id, vendorIds)
+ );
+
+ // 벤더별 상세 정보를 포함하여 반환
+ const vendorsWithDetails = await Promise.all(
+ vendorsData.map(async (vendor) => {
+ // 연락처 조회
+ const contacts = await exportTechVendorContacts(vendor.id);
+
+ // 아이템 조회
+ const items = await exportTechVendorItems(vendor.id);
+
+ return {
+ ...vendor,
+ vendorContacts: contacts,
+ vendorItems: items,
+ };
+ })
+ );
+
+ return vendorsWithDetails;
+ } catch (err) {
+ console.error("기술영업 벤더 상세 내보내기 오류:", err);
+ return [];
+ }
+}
+
+/**
+ * 기술영업 벤더 상세 정보 조회 (연락처, 첨부파일 포함)
+ */
+export async function getTechVendorDetailById(id: number) {
+ try {
+ const vendor = await db.select().from(techVendors).where(eq(techVendors.id, id)).limit(1);
+
+ if (!vendor || vendor.length === 0) {
+ console.error(`Vendor not found with id: ${id}`);
+ return null;
+ }
+
+ const contacts = await db.select().from(techVendorContacts).where(eq(techVendorContacts.vendorId, id));
+ const attachments = await db.select().from(techVendorAttachments).where(eq(techVendorAttachments.vendorId, id));
+ const possibleItems = await db.select().from(techVendorPossibleItems).where(eq(techVendorPossibleItems.vendorId, id));
+
+ return {
+ ...vendor[0],
+ contacts,
+ attachments,
+ possibleItems
+ };
+ } catch (error) {
+ console.error("Error fetching tech vendor detail:", error);
+ return null;
+ }
+}
+
+/**
+ * 기술영업 벤더 첨부파일 다운로드를 위한 서버 액션
+ * @param vendorId 기술영업 벤더 ID
+ * @param fileId 특정 파일 ID (단일 파일 다운로드시)
+ * @returns 다운로드할 수 있는 임시 URL
+ */
+export async function downloadTechVendorAttachments(vendorId:number, fileId?:number) {
+ try {
+ // API 경로 생성 (단일 파일 또는 모든 파일)
+ const url = fileId
+ ? `/api/tech-vendors/attachments/download?id=${fileId}&vendorId=${vendorId}`
+ : `/api/tech-vendors/attachments/download-all?vendorId=${vendorId}`;
+
+ // fetch 요청 (기본적으로 Blob으로 응답 받기)
+ const response = await fetch(url, {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(`Server responded with ${response.status}: ${response.statusText}`);
+ }
+
+ // 파일명 가져오기 (Content-Disposition 헤더에서)
+ const contentDisposition = response.headers.get('content-disposition');
+ let fileName = fileId ? `file-${fileId}.zip` : `tech-vendor-${vendorId}-files.zip`;
+
+ if (contentDisposition) {
+ const matches = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/.exec(contentDisposition);
+ if (matches && matches[1]) {
+ fileName = matches[1].replace(/['"]/g, '');
+ }
+ }
+
+ // Blob으로 응답 변환
+ const blob = await response.blob();
+
+ // Blob URL 생성
+ const blobUrl = window.URL.createObjectURL(blob);
+
+ return {
+ url: blobUrl,
+ fileName,
+ blob
+ };
+ } catch (error) {
+ console.error('Download API error:', error);
+ throw error;
+ }
+}
+
+/**
+ * 임시 ZIP 파일 정리를 위한 서버 액션
+ * @param fileName 정리할 파일명
+ */
+export async function cleanupTechTempFiles(fileName: string) {
+ 'use server';
+
+ try {
+
+ await deleteFile(`tmp/${fileName}`)
+
+ return { success: true };
+ } catch (error) {
+ console.error('임시 파일 정리 오류:', error);
+ return { success: false, error: '임시 파일 정리 중 오류가 발생했습니다.' };
+ }
+}
+
+export const findVendorById = async (id: number): Promise<TechVendor | null> => {
+ try {
+ // 직접 DB에서 조회
+ const vendor = await db
+ .select()
+ .from(techVendors)
+ .where(eq(techVendors.id, id))
+ .limit(1)
+ .then(rows => rows[0] || null);
+
+ if (!vendor) {
+ console.error(`Vendor not found with id: ${id}`);
+ return null;
+ }
+
+ return vendor;
+ } catch (error) {
+ console.error('Error fetching vendor:', error);
+ return null;
+ }
+};
+
+/* -----------------------------------------------------
+ 8) 기술영업 벤더 RFQ 히스토리 조회
+----------------------------------------------------- */
+
+/**
+ * 기술영업 벤더의 RFQ 히스토리 조회 (간단한 버전)
+ */
+export async function getTechVendorRfqHistory(input: GetTechVendorRfqHistorySchema, id:number) {
+ try {
+
+ // 먼저 해당 벤더의 견적서가 있는지 확인
+ const { techSalesVendorQuotations } = await import("@/db/schema/techSales");
+
+ const quotationCheck = await db
+ .select({ count: sql<number>`count(*)`.as("count") })
+ .from(techSalesVendorQuotations)
+ .where(eq(techSalesVendorQuotations.vendorId, id));
+
+ console.log(`벤더 ${id}의 견적서 개수:`, quotationCheck[0]?.count);
+
+ if (quotationCheck[0]?.count === 0) {
+ console.log("해당 벤더의 견적서가 없습니다.");
+ return { data: [], pageCount: 0 };
+ }
+
+ const offset = (input.page - 1) * input.perPage;
+ const { techSalesRfqs } = await import("@/db/schema/techSales");
+ const { biddingProjects } = await import("@/db/schema/projects");
+
+ // 간단한 조회
+ let whereCondition = eq(techSalesVendorQuotations.vendorId, id);
+
+ // 검색이 있다면 추가
+ if (input.search) {
+ const s = `%${input.search}%`;
+ const searchCondition = and(
+ whereCondition,
+ or(
+ ilike(techSalesRfqs.rfqCode, s),
+ ilike(techSalesRfqs.description, s),
+ ilike(biddingProjects.pspid, s),
+ ilike(biddingProjects.projNm, s)
+ )
+ );
+ whereCondition = searchCondition || whereCondition;
+ }
+
+ // 데이터 조회 - 테이블에 필요한 필드들 (프로젝트 타입 추가)
+ const data = await db
+ .select({
+ id: techSalesRfqs.id,
+ rfqCode: techSalesRfqs.rfqCode,
+ description: techSalesRfqs.description,
+ projectCode: biddingProjects.pspid,
+ projectName: biddingProjects.projNm,
+ projectType: biddingProjects.pjtType, // 프로젝트 타입 추가
+ status: techSalesRfqs.status,
+ totalAmount: techSalesVendorQuotations.totalPrice,
+ currency: techSalesVendorQuotations.currency,
+ dueDate: techSalesRfqs.dueDate,
+ createdAt: techSalesRfqs.createdAt,
+ quotationCode: techSalesVendorQuotations.quotationCode,
+ submittedAt: techSalesVendorQuotations.submittedAt,
+ })
+ .from(techSalesVendorQuotations)
+ .innerJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id))
+ .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id))
+ .where(whereCondition)
+ .orderBy(desc(techSalesRfqs.createdAt))
+ .limit(input.perPage)
+ .offset(offset);
+
+ console.log("조회된 데이터:", data.length, "개");
+
+ // 전체 개수 조회
+ const totalResult = await db
+ .select({ count: sql<number>`count(*)`.as("count") })
+ .from(techSalesVendorQuotations)
+ .innerJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id))
+ .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id))
+ .where(whereCondition);
+
+ const total = totalResult[0]?.count || 0;
+ const pageCount = Math.ceil(total / input.perPage);
+
+ console.log("기술영업 벤더 RFQ 히스토리 조회 완료", {
+ id,
+ dataLength: data.length,
+ total,
+ pageCount
+ });
+
+ return { data, pageCount };
+ } catch (err) {
+ console.error("기술영업 벤더 RFQ 히스토리 조회 오류:", {
+ err,
+ id,
+ stack: err instanceof Error ? err.stack : undefined
+ });
+ return { data: [], pageCount: 0 };
+ }
+}
+
+/**
+ * 기술영업 벤더 엑셀 import 시 유저 생성 및 담당자 등록
+ */
+export async function importTechVendorsFromExcel(
+ vendors: Array<{
+ vendorName: string;
+ vendorCode?: string | null;
+ email: string;
+ taxId: string;
+ country?: string | null;
+ countryEng?: string | null;
+ countryFab?: string | null;
+ agentName?: string | null;
+ agentPhone?: string | null;
+ agentEmail?: string | null;
+ address?: string | null;
+ phone?: string | null;
+ website?: string | null;
+ techVendorType: string;
+ representativeName?: string | null;
+ representativeEmail?: string | null;
+ representativePhone?: string | null;
+ representativeBirth?: string | null;
+ items: string;
+ contacts?: Array<{
+ contactName: string;
+ contactPosition?: string;
+ contactEmail: string;
+ contactPhone?: string;
+ contactCountry?: string | null;
+ isPrimary?: boolean;
+ }>;
+ }>,
+) {
+ unstable_noStore();
+
+ try {
+ console.log("Import 시작 - 벤더 수:", vendors.length);
+ console.log("첫 번째 벤더 데이터:", vendors[0]);
+
+ const result = await db.transaction(async (tx) => {
+ const createdVendors = [];
+ const skippedVendors = [];
+ const errors = [];
+
+ for (const vendor of vendors) {
+ console.log("벤더 처리 시작:", vendor.vendorName);
+
+ try {
+ // 0. 이메일 타입 검사
+ // - 문자열이 아니거나, '@' 미포함, 혹은 객체(예: 하이퍼링크 등)인 경우 모두 거절
+ const isEmailString = typeof vendor.email === "string";
+ const isEmailContainsAt = isEmailString && vendor.email.includes("@");
+ // 하이퍼링크 등 객체로 넘어온 경우 (예: { href: "...", ... } 등) 방지
+ const isEmailPlainString = isEmailString && Object.prototype.toString.call(vendor.email) === "[object String]";
+
+ if (!isEmailPlainString || !isEmailContainsAt) {
+ console.log("이메일 형식이 올바르지 않습니다:", vendor.email);
+ errors.push({
+ vendorName: vendor.vendorName,
+ email: vendor.email,
+ error: "이메일 형식이 올바르지 않습니다"
+ });
+ continue;
+ }
+ // 1. 이메일로 기존 벤더 중복 체크
+ const existingVendor = await tx.query.techVendors.findFirst({
+ where: eq(techVendors.email, vendor.email),
+ columns: { id: true, vendorName: true, email: true }
+ });
+
+ if (existingVendor) {
+ console.log("이미 존재하는 벤더 스킵:", vendor.vendorName, vendor.email);
+ skippedVendors.push({
+ vendorName: vendor.vendorName,
+ email: vendor.email,
+ reason: `이미 등록된 이메일입니다 (기존 업체: ${existingVendor.vendorName})`
+ });
+ continue;
+ }
+
+ // 2. 벤더 생성
+ console.log("벤더 생성 시도:", {
+ vendorName: vendor.vendorName,
+ email: vendor.email,
+ techVendorType: vendor.techVendorType
+ });
+
+ const [newVendor] = await tx.insert(techVendors).values({
+ vendorName: vendor.vendorName,
+ vendorCode: vendor.vendorCode || null,
+ taxId: vendor.taxId,
+ country: vendor.country || null,
+ countryEng: vendor.countryEng || null,
+ countryFab: vendor.countryFab || null,
+ agentName: vendor.agentName || null,
+ agentPhone: vendor.agentPhone || null,
+ agentEmail: vendor.agentEmail || null,
+ address: vendor.address || null,
+ phone: vendor.phone || null,
+ email: vendor.email,
+ website: vendor.website || null,
+ techVendorType: vendor.techVendorType,
+ status: "ACTIVE",
+ representativeName: vendor.representativeName || null,
+ representativeEmail: vendor.representativeEmail || null,
+ representativePhone: vendor.representativePhone || null,
+ representativeBirth: vendor.representativeBirth || null,
+ }).returning();
+
+ console.log("벤더 생성 성공:", newVendor.id);
+
+ // 2. 담당자 생성 (최소 1명 이상 등록)
+ if (vendor.contacts && vendor.contacts.length > 0) {
+ console.log("담당자 생성 시도:", vendor.contacts.length, "명");
+
+ for (const contact of vendor.contacts) {
+ await tx.insert(techVendorContacts).values({
+ vendorId: newVendor.id,
+ contactName: contact.contactName,
+ contactPosition: contact.contactPosition || null,
+ contactEmail: contact.contactEmail,
+ contactPhone: contact.contactPhone || null,
+ contactCountry: contact.contactCountry || null,
+ isPrimary: contact.isPrimary || false,
+ });
+ console.log("담당자 생성 성공:", contact.contactName, contact.contactEmail);
+ }
+
+ // // 벤더 이메일을 주 담당자의 이메일로 업데이트
+ // const primaryContact = vendor.contacts.find(c => c.isPrimary) || vendor.contacts[0];
+ // if (primaryContact && primaryContact.contactEmail !== vendor.email) {
+ // await tx.update(techVendors)
+ // .set({ email: primaryContact.contactEmail })
+ // .where(eq(techVendors.id, newVendor.id));
+ // console.log("벤더 이메일 업데이트:", primaryContact.contactEmail);
+ // }
+ }
+ // else {
+ // // 담당자 정보가 없는 경우 벤더 정보로 기본 담당자 생성
+ // console.log("기본 담당자 생성");
+ // await tx.insert(techVendorContacts).values({
+ // vendorId: newVendor.id,
+ // contactName: vendor.representativeName || vendor.vendorName || "기본 담당자",
+ // contactPosition: null,
+ // contactEmail: vendor.email,
+ // contactPhone: vendor.representativePhone || vendor.phone || null,
+ // contactCountry: vendor.country || null,
+ // isPrimary: true,
+ // });
+ // console.log("기본 담당자 생성 성공:", vendor.email);
+ // }
+
+ // 3. 유저 생성 (이메일이 있는 경우)
+ if (vendor.email) {
+ console.log("유저 생성 시도:", vendor.email);
+
+ // 이미 존재하는 유저인지 확인
+ const existingUser = await tx.query.users.findFirst({
+ where: eq(users.email, vendor.email),
+ columns: { id: true }
+ });
+
+ if (!existingUser) {
+ // 유저가 존재하지 않는 경우 생성
+ await tx.insert(users).values({
+ name: vendor.vendorName,
+ email: vendor.email,
+ techCompanyId: newVendor.id,
+ domain: "partners",
+ });
+ console.log("유저 생성 성공");
+ } else {
+ // 이미 존재하는 유저라면 techCompanyId 업데이트
+ await tx.update(users)
+ .set({ techCompanyId: newVendor.id })
+ .where(eq(users.id, existingUser.id));
+ console.log("이미 존재하는 유저, techCompanyId 업데이트:", existingUser.id);
+ }
+ }
+
+ createdVendors.push(newVendor);
+ console.log("벤더 처리 완료:", vendor.vendorName);
+ } catch (error) {
+ console.error("벤더 처리 중 오류 발생:", vendor.vendorName, error);
+ errors.push({
+ vendorName: vendor.vendorName,
+ email: vendor.email,
+ error: error instanceof Error ? error.message : "알 수 없는 오류"
+ });
+ // 개별 벤더 오류는 전체 트랜잭션을 롤백하지 않도록 continue
+ continue;
+ }
+ }
+
+ console.log("모든 벤더 처리 완료:", {
+ 생성됨: createdVendors.length,
+ 스킵됨: skippedVendors.length,
+ 오류: errors.length
+ });
+
+ return {
+ createdVendors,
+ skippedVendors,
+ errors,
+ totalProcessed: vendors.length,
+ successCount: createdVendors.length,
+ skipCount: skippedVendors.length,
+ errorCount: errors.length
+ };
+ });
+
+ // 캐시 무효화
+ revalidateTag("tech-vendors");
+ revalidateTag("tech-vendor-contacts");
+ revalidateTag("users");
+
+ console.log("Import 완료 - 결과:", result);
+
+ // 결과 메시지 생성
+ const messages = [];
+ if (result.successCount > 0) {
+ messages.push(`${result.successCount}개 벤더 생성 성공`);
+ }
+ if (result.skipCount > 0) {
+ messages.push(`${result.skipCount}개 벤더 중복으로 스킵`);
+ }
+ if (result.errorCount > 0) {
+ messages.push(`${result.errorCount}개 벤더 처리 중 오류`);
+ }
+
+ return {
+ success: true,
+ data: result,
+ message: messages.join(", "),
+ details: {
+ created: result.createdVendors,
+ skipped: result.skippedVendors,
+ errors: result.errors
+ }
+ };
+ } catch (error) {
+ console.error("Import 실패:", error);
+ return { success: false, error: getErrorMessage(error) };
+ }
+}
+
+export async function findTechVendorById(id: number): Promise<TechVendor | null> {
+ const result = await db
+ .select()
+ .from(techVendors)
+ .where(eq(techVendors.id, id))
+ .limit(1)
+
+ return result[0] || null
+}
+
+/**
+ * 회원가입 폼을 통한 기술영업 벤더 생성 (초대 토큰 기반)
+ */
+export async function createTechVendorFromSignup(params: {
+ vendorData: {
+ vendorName: string
+ vendorCode?: string
+ items: string
+ website?: string
+ taxId: string
+ address?: string
+ email: string
+ phone?: string
+ country: string
+ techVendorType: "조선" | "해양TOP" | "해양HULL" | ("조선" | "해양TOP" | "해양HULL")[]
+ representativeName?: string
+ representativeBirth?: string
+ representativeEmail?: string
+ representativePhone?: string
+ }
+ files?: File[]
+ contacts: {
+ contactName: string
+ contactPosition?: string
+ contactEmail: string
+ contactPhone?: string
+ isPrimary?: boolean
+ }[]
+ selectedItemCodes?: string[] // 선택된 아이템 코드들
+ invitationToken?: string // 초대 토큰
+}) {
+ unstable_noStore();
+
+ try {
+ console.log("기술영업 벤더 회원가입 시작:", params.vendorData.vendorName);
+
+ // 초대 토큰 검증
+ let existingVendorId: number | null = null;
+ if (params.invitationToken) {
+ const { verifyTechVendorInvitationToken } = await import("@/lib/tech-vendor-invitation-token");
+ const tokenPayload = await verifyTechVendorInvitationToken(params.invitationToken);
+
+ if (!tokenPayload) {
+ throw new Error("유효하지 않은 초대 토큰입니다.");
+ }
+
+ existingVendorId = tokenPayload.vendorId;
+ console.log("초대 토큰 검증 성공, 벤더 ID:", existingVendorId);
+ }
+
+ const result = await db.transaction(async (tx) => {
+ let vendorResult;
+
+ if (existingVendorId) {
+ // 기존 벤더 정보 업데이트
+ const [updatedVendor] = await tx.update(techVendors)
+ .set({
+ vendorName: params.vendorData.vendorName,
+ vendorCode: params.vendorData.vendorCode || null,
+ taxId: params.vendorData.taxId,
+ country: params.vendorData.country,
+ address: params.vendorData.address || null,
+ phone: params.vendorData.phone || null,
+ email: params.vendorData.email,
+ website: params.vendorData.website || null,
+ techVendorType: Array.isArray(params.vendorData.techVendorType)
+ ? params.vendorData.techVendorType[0]
+ : params.vendorData.techVendorType,
+ status: "QUOTE_COMPARISON", // 가입 완료 시 QUOTE_COMPARISON으로 변경
+ representativeName: params.vendorData.representativeName || null,
+ representativeEmail: params.vendorData.representativeEmail || null,
+ representativePhone: params.vendorData.representativePhone || null,
+ representativeBirth: params.vendorData.representativeBirth || null,
+ items: params.vendorData.items,
+ updatedAt: new Date(),
+ })
+ .where(eq(techVendors.id, existingVendorId))
+ .returning();
+
+ vendorResult = updatedVendor;
+ console.log("기존 벤더 정보 업데이트 완료:", vendorResult.id);
+ } else {
+ // 1. 이메일 중복 체크 (새 벤더인 경우)
+ const existingVendor = await tx.query.techVendors.findFirst({
+ where: eq(techVendors.email, params.vendorData.email),
+ columns: { id: true, vendorName: true }
+ });
+
+ if (existingVendor) {
+ throw new Error(`이미 등록된 이메일입니다: ${params.vendorData.email} (기존 업체: ${existingVendor.vendorName})`);
+ }
+
+ // 2. 새 벤더 생성
+ const [newVendor] = await tx.insert(techVendors).values({
+ vendorName: params.vendorData.vendorName,
+ vendorCode: params.vendorData.vendorCode || null,
+ taxId: params.vendorData.taxId,
+ country: params.vendorData.country,
+ address: params.vendorData.address || null,
+ phone: params.vendorData.phone || null,
+ email: params.vendorData.email,
+ website: params.vendorData.website || null,
+ techVendorType: Array.isArray(params.vendorData.techVendorType)
+ ? params.vendorData.techVendorType[0]
+ : params.vendorData.techVendorType,
+ status: "QUOTE_COMPARISON",
+ isQuoteComparison: false,
+ representativeName: params.vendorData.representativeName || null,
+ representativeEmail: params.vendorData.representativeEmail || null,
+ representativePhone: params.vendorData.representativePhone || null,
+ representativeBirth: params.vendorData.representativeBirth || null,
+ items: params.vendorData.items,
+ }).returning();
+
+ vendorResult = newVendor;
+ console.log("새 벤더 생성 완료:", vendorResult.id);
+ }
+
+ // 이 부분은 위에서 이미 처리되었으므로 주석 처리
+
+ // 3. 연락처 생성
+ if (params.contacts && params.contacts.length > 0) {
+ for (const [index, contact] of params.contacts.entries()) {
+ await tx.insert(techVendorContacts).values({
+ vendorId: vendorResult.id,
+ contactName: contact.contactName,
+ contactPosition: contact.contactPosition || null,
+ contactEmail: contact.contactEmail,
+ contactPhone: contact.contactPhone || null,
+ isPrimary: index === 0, // 첫 번째 연락처를 primary로 설정
+ });
+ }
+ console.log("연락처 생성 완료:", params.contacts.length, "개");
+ }
+
+ // 4. 선택된 아이템들을 tech_vendor_possible_items에 저장
+ if (params.selectedItemCodes && params.selectedItemCodes.length > 0) {
+ for (const itemCode of params.selectedItemCodes) {
+ await tx.insert(techVendorPossibleItems).values({
+ vendorId: vendorResult.id,
+ vendorCode: vendorResult.vendorCode,
+ vendorEmail: vendorResult.email,
+ itemCode: itemCode,
+ workType: null,
+ shipTypes: null,
+ itemList: null,
+ subItemList: null,
+ });
+ }
+ console.log("선택된 아이템 저장 완료:", params.selectedItemCodes.length, "개");
+ }
+
+ // 4. 첨부파일 처리
+ if (params.files && params.files.length > 0) {
+ await storeTechVendorFiles(tx, vendorResult.id, params.files, "GENERAL");
+ console.log("첨부파일 저장 완료:", params.files.length, "개");
+ }
+
+ // 5. 유저 생성 (techCompanyId 설정)
+ console.log("유저 생성 시도:", params.vendorData.email);
+
+ const existingUser = await tx.query.users.findFirst({
+ where: eq(users.email, params.vendorData.email),
+ columns: { id: true, techCompanyId: true }
+ });
+
+ let userId = null;
+ if (!existingUser) {
+ const [newUser] = await tx.insert(users).values({
+ name: params.vendorData.vendorName,
+ email: params.vendorData.email,
+ techCompanyId: vendorResult.id, // 중요: techCompanyId 설정
+ domain: "partners",
+ }).returning();
+ userId = newUser.id;
+ console.log("유저 생성 성공:", userId);
+ } else {
+ // 기존 유저의 techCompanyId 업데이트
+ if (!existingUser.techCompanyId) {
+ await tx.update(users)
+ .set({ techCompanyId: vendorResult.id })
+ .where(eq(users.id, existingUser.id));
+ console.log("기존 유저의 techCompanyId 업데이트:", existingUser.id);
+ }
+ userId = existingUser.id;
+ }
+
+ // 6. 후보에서 해당 이메일이 있으면 vendorId 업데이트 및 상태 변경
+ if (params.vendorData.email) {
+ await tx.update(techVendorCandidates)
+ .set({
+ vendorId: vendorResult.id,
+ status: "INVITED"
+ })
+ .where(eq(techVendorCandidates.contactEmail, params.vendorData.email));
+ }
+
+ return { vendor: vendorResult, userId };
+ });
+
+ // 캐시 무효화
+ revalidateTag("tech-vendors");
+ revalidateTag("tech-vendor-candidates");
+ revalidateTag("users");
+
+ console.log("기술영업 벤더 회원가입 완료:", result);
+ return { success: true, data: result };
+ } catch (error) {
+ console.error("기술영업 벤더 회원가입 실패:", error);
+ return { success: false, error: getErrorMessage(error) };
+ }
+}
+
+/**
+ * 단일 기술영업 벤더 추가 (사용자 계정도 함께 생성)
+ */
+export async function addTechVendor(input: {
+ vendorName: string;
+ vendorCode?: string | null;
+ email: string;
+ taxId: string;
+ country?: string | null;
+ countryEng?: string | null;
+ countryFab?: string | null;
+ agentName?: string | null;
+ agentPhone?: string | null;
+ agentEmail?: string | null;
+ address?: string | null;
+ phone?: string | null;
+ website?: string | null;
+ techVendorType: string;
+ representativeName?: string | null;
+ representativeEmail?: string | null;
+ representativePhone?: string | null;
+ representativeBirth?: string | null;
+ isQuoteComparison?: boolean;
+}) {
+ unstable_noStore();
+
+ try {
+ console.log("벤더 추가 시작:", input.vendorName);
+
+ const result = await db.transaction(async (tx) => {
+ // 1. 이메일 중복 체크
+ const existingVendor = await tx.query.techVendors.findFirst({
+ where: eq(techVendors.email, input.email),
+ columns: { id: true, vendorName: true }
+ });
+
+ if (existingVendor) {
+ throw new Error(`이미 등록된 이메일입니다: ${input.email} (업체명: ${existingVendor.vendorName})`);
+ }
+
+ // 2. 벤더 생성
+ console.log("벤더 생성 시도:", {
+ vendorName: input.vendorName,
+ email: input.email,
+ techVendorType: input.techVendorType
+ });
+
+ const [newVendor] = await tx.insert(techVendors).values({
+ vendorName: input.vendorName,
+ vendorCode: input.vendorCode || null,
+ taxId: input.taxId || null,
+ country: input.country || null,
+ countryEng: input.countryEng || null,
+ countryFab: input.countryFab || null,
+ agentName: input.agentName || null,
+ agentPhone: input.agentPhone || null,
+ agentEmail: input.agentEmail || null,
+ address: input.address || null,
+ phone: input.phone || null,
+ email: input.email,
+ website: input.website || null,
+ techVendorType: Array.isArray(input.techVendorType) ? input.techVendorType.join(',') : input.techVendorType,
+ status: input.isQuoteComparison ? "PENDING_INVITE" : "ACTIVE",
+ isQuoteComparison: input.isQuoteComparison || false,
+ representativeName: input.representativeName || null,
+ representativeEmail: input.representativeEmail || null,
+ representativePhone: input.representativePhone || null,
+ representativeBirth: input.representativeBirth || null,
+ }).returning();
+
+ console.log("벤더 생성 성공:", newVendor.id);
+
+ // 3. 견적비교용 벤더인 경우 PENDING_REVIEW 상태로 생성됨
+ // 초대는 별도의 초대 버튼을 통해 진행
+ console.log("벤더 생성 완료:", newVendor.id, "상태:", newVendor.status);
+
+ // 4. 견적비교용 벤더(isQuoteComparison)가 아닌 경우에만 유저 생성
+ let userId = null;
+ if (!input.isQuoteComparison) {
+ console.log("유저 생성 시도:", input.email);
+
+ // 이미 존재하는 유저인지 확인
+ const existingUser = await tx.query.users.findFirst({
+ where: eq(users.email, input.email),
+ columns: { id: true, techCompanyId: true }
+ });
+
+ // 유저가 존재하지 않는 경우에만 생성
+ if (!existingUser) {
+ const [newUser] = await tx.insert(users).values({
+ name: input.vendorName,
+ email: input.email,
+ techCompanyId: newVendor.id, // techCompanyId 설정
+ domain: "partners",
+ }).returning();
+ userId = newUser.id;
+ console.log("유저 생성 성공:", userId);
+ } else {
+ // 이미 존재하는 유저의 techCompanyId가 null인 경우 업데이트
+ if (!existingUser.techCompanyId) {
+ await tx.update(users)
+ .set({ techCompanyId: newVendor.id })
+ .where(eq(users.id, existingUser.id));
+ console.log("기존 유저의 techCompanyId 업데이트:", existingUser.id);
+ }
+ userId = existingUser.id;
+ console.log("이미 존재하는 유저:", userId);
+ }
+ } else {
+ console.log("견적비교용 벤더이므로 유저를 생성하지 않습니다.");
+ }
+
+ return { vendor: newVendor, userId };
+ });
+
+ // 캐시 무효화
+ revalidateTag("tech-vendors");
+ revalidateTag("users");
+
+ console.log("벤더 추가 완료:", result);
+ return { success: true, data: result };
+ } catch (error) {
+ console.error("벤더 추가 실패:", error);
+ return { success: false, error: getErrorMessage(error) };
+ }
+}
+
+/**
+ * 벤더의 possible items 개수 조회
+ */
+export async function getTechVendorPossibleItemsCount(vendorId: number): Promise<number> {
+ try {
+ const result = await db
+ .select({ count: sql<number>`count(*)`.as("count") })
+ .from(techVendorPossibleItems)
+ .where(eq(techVendorPossibleItems.vendorId, vendorId));
+
+ return result[0]?.count || 0;
+ } catch (err) {
+ console.error("Error getting tech vendor possible items count:", err);
+ return 0;
+ }
+}
+
+/**
+ * 기술영업 벤더 초대 메일 발송
+ */
+export async function inviteTechVendor(params: {
+ vendorId: number;
+ subject: string;
+ message: string;
+ recipientEmail: string;
+}) {
+ unstable_noStore();
+
+ try {
+ console.log("기술영업 벤더 초대 메일 발송 시작:", params.vendorId);
+
+ const result = await db.transaction(async (tx) => {
+ // 벤더 정보 조회
+ const vendor = await tx.query.techVendors.findFirst({
+ where: eq(techVendors.id, params.vendorId),
+ });
+
+ if (!vendor) {
+ throw new Error("벤더를 찾을 수 없습니다.");
+ }
+
+ // 벤더 상태를 INVITED로 변경 (PENDING_INVITE에서)
+ if (vendor.status !== "PENDING_INVITE") {
+ throw new Error("초대 가능한 상태가 아닙니다. (PENDING_INVITE 상태만 초대 가능)");
+ }
+
+ await tx.update(techVendors)
+ .set({
+ status: "INVITED",
+ updatedAt: new Date(),
+ })
+ .where(eq(techVendors.id, params.vendorId));
+
+ // 초대 토큰 생성
+ const { createTechVendorInvitationToken, createTechVendorSignupUrl } = await import("@/lib/tech-vendor-invitation-token");
+ const { sendEmail } = await import("@/lib/mail/sendEmail");
+
+ const invitationToken = await createTechVendorInvitationToken({
+ vendorType: vendor.techVendorType as "조선" | "해양TOP" | "해양HULL" | ("조선" | "해양TOP" | "해양HULL")[],
+ vendorId: vendor.id,
+ vendorName: vendor.vendorName,
+ email: params.recipientEmail,
+ });
+
+ const signupUrl = await createTechVendorSignupUrl(invitationToken);
+
+ // 초대 메일 발송
+ await sendEmail({
+ to: params.recipientEmail,
+ subject: params.subject,
+ template: "tech-vendor-invitation",
+ context: {
+ companyName: vendor.vendorName,
+ language: "ko",
+ registrationLink: signupUrl,
+ customMessage: params.message,
+ }
+ });
+
+ console.log("초대 메일 발송 완료:", params.recipientEmail);
+
+ return { vendor, invitationToken, signupUrl };
+ });
+
+ // 캐시 무효화
+ revalidateTag("tech-vendors");
+
+ console.log("기술영업 벤더 초대 완료:", result);
+ return { success: true, data: result };
+ } catch (error) {
+ console.error("기술영업 벤더 초대 실패:", error);
+ return { success: false, error: getErrorMessage(error) };
+ }
+}
+
+/* -----------------------------------------------------
+ Possible Items 관련 함수들
+----------------------------------------------------- */
+
+/**
+ * 특정 벤더의 possible items 조회 (페이지네이션 포함)
+ */
+export async function getTechVendorPossibleItems(input: GetTechVendorPossibleItemsSchema, vendorId: number) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage
+
+ // 고급 필터 처리
+ const advancedWhere = filterColumns({
+ table: techVendorPossibleItems,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ })
+
+ // 글로벌 검색
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(techVendorPossibleItems.itemCode, s),
+ ilike(techVendorPossibleItems.workType, s),
+ ilike(techVendorPossibleItems.itemList, s),
+ ilike(techVendorPossibleItems.shipTypes, s),
+ ilike(techVendorPossibleItems.subItemList, s)
+ );
+ }
+
+ // 벤더 ID 조건
+ const vendorWhere = eq(techVendorPossibleItems.vendorId, vendorId)
+
+ // 개별 필터들
+ const individualFilters = []
+ if (input.itemCode) {
+ individualFilters.push(ilike(techVendorPossibleItems.itemCode, `%${input.itemCode}%`))
+ }
+ if (input.workType) {
+ individualFilters.push(ilike(techVendorPossibleItems.workType, `%${input.workType}%`))
+ }
+ if (input.itemList) {
+ individualFilters.push(ilike(techVendorPossibleItems.itemList, `%${input.itemList}%`))
+ }
+ if (input.shipTypes) {
+ individualFilters.push(ilike(techVendorPossibleItems.shipTypes, `%${input.shipTypes}%`))
+ }
+ if (input.subItemList) {
+ individualFilters.push(ilike(techVendorPossibleItems.subItemList, `%${input.subItemList}%`))
+ }
+
+ // 최종 where 조건
+ const finalWhere = and(
+ vendorWhere,
+ advancedWhere,
+ globalWhere,
+ ...(individualFilters.length > 0 ? individualFilters : [])
+ )
+
+ // 정렬
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) => {
+ // techVendorType은 실제 테이블 컬럼이 아니므로 제외
+ if (item.id === 'techVendorType') return desc(techVendorPossibleItems.createdAt)
+ const column = (techVendorPossibleItems as any)[item.id]
+ return item.desc ? desc(column) : asc(column)
+ })
+ : [desc(techVendorPossibleItems.createdAt)]
+
+ // 데이터 조회
+ const data = await db
+ .select()
+ .from(techVendorPossibleItems)
+ .where(finalWhere)
+ .orderBy(...orderBy)
+ .limit(input.perPage)
+ .offset(offset)
+
+ // 전체 개수 조회
+ const totalResult = await db
+ .select({ count: sql<number>`count(*)`.as("count") })
+ .from(techVendorPossibleItems)
+ .where(finalWhere)
+
+ const total = totalResult[0]?.count || 0
+ const pageCount = Math.ceil(total / input.perPage)
+
+ return { data, pageCount }
+ } catch (err) {
+ console.error("Error fetching tech vendor possible items:", err)
+ return { data: [], pageCount: 0 }
+ }
+ },
+ [JSON.stringify(input), String(vendorId)],
+ {
+ revalidate: 3600,
+ tags: [`tech-vendor-possible-items-${vendorId}`],
+ }
+ )()
+}
+
+export async function createTechVendorPossibleItemNew(input: CreateTechVendorPossibleItemSchema) {
+ unstable_noStore()
+
+ try {
+ // 중복 체크
+ const existing = await db
+ .select({ id: techVendorPossibleItems.id })
+ .from(techVendorPossibleItems)
+ .where(
+ and(
+ eq(techVendorPossibleItems.vendorId, input.vendorId),
+ eq(techVendorPossibleItems.itemCode, input.itemCode)
+ )
+ )
+ .limit(1)
+
+ if (existing.length > 0) {
+ return { data: null, error: "이미 등록된 아이템입니다." }
+ }
+
+ const [newItem] = await db
+ .insert(techVendorPossibleItems)
+ .values({
+ vendorId: input.vendorId,
+ itemCode: input.itemCode,
+ workType: input.workType,
+ shipTypes: input.shipTypes,
+ itemList: input.itemList,
+ subItemList: input.subItemList,
+ })
+ .returning()
+
+ revalidateTag(`tech-vendor-possible-items-${input.vendorId}`)
+ return { data: newItem, error: null }
+ } catch (err) {
+ console.error("Error creating tech vendor possible item:", err)
+ return { data: null, error: getErrorMessage(err) }
+ }
+}
+
+export async function updateTechVendorPossibleItemNew(input: UpdateTechVendorPossibleItemSchema) {
+ unstable_noStore()
+
+ try {
+ const [updatedItem] = await db
+ .update(techVendorPossibleItems)
+ .set({
+ itemCode: input.itemCode,
+ workType: input.workType,
+ shipTypes: input.shipTypes,
+ itemList: input.itemList,
+ subItemList: input.subItemList,
+ updatedAt: new Date(),
+ })
+ .where(eq(techVendorPossibleItems.id, input.id))
+ .returning()
+
+ revalidateTag(`tech-vendor-possible-items-${input.vendorId}`)
+ return { data: updatedItem, error: null }
+ } catch (err) {
+ console.error("Error updating tech vendor possible item:", err)
+ return { data: null, error: getErrorMessage(err) }
+ }
+}
+
+export async function deleteTechVendorPossibleItemsNew(ids: number[], vendorId: number) {
+ unstable_noStore()
+
+ try {
+ await db
+ .delete(techVendorPossibleItems)
+ .where(inArray(techVendorPossibleItems.id, ids))
+
+ revalidateTag(`tech-vendor-possible-items-${vendorId}`)
+ return { data: null, error: null }
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) }
+ }
+}
+
+export async function addTechVendorPossibleItem(input: {
+ vendorId: number;
+ itemCode?: string;
+ workType?: string;
+ shipTypes?: string;
+ itemList?: string;
+ subItemList?: string;
+}) {
+ unstable_noStore();
+ try {
+ if (!input.itemCode) {
+ return { success: false, error: "아이템 코드는 필수입니다." };
+ }
+
+ const [newItem] = await db
+ .insert(techVendorPossibleItems)
+ .values({
+ vendorId: input.vendorId,
+ itemCode: input.itemCode,
+ workType: input.workType || null,
+ shipTypes: input.shipTypes || null,
+ itemList: input.itemList || null,
+ subItemList: input.subItemList || null,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .returning();
+
+ revalidateTag(`tech-vendor-possible-items-${input.vendorId}`);
+
+ return { success: true, data: newItem };
+ } catch (err) {
+ return { success: false, error: getErrorMessage(err) };
+ }
+}
+
+export async function deleteTechVendorPossibleItem(itemId: number, vendorId: number) {
+ unstable_noStore();
+ try {
+ const [deletedItem] = await db
+ .delete(techVendorPossibleItems)
+ .where(eq(techVendorPossibleItems.id, itemId))
+ .returning();
+
+ revalidateTag(`tech-vendor-possible-items-${vendorId}`);
+
+ return { success: true, data: deletedItem };
+ } catch (err) {
+ return { success: false, error: getErrorMessage(err) };
+ }
+}
+
+
+
+//기술영업 담당자 연락처 관련 함수들
+
+export interface ImportContactData {
+ vendorEmail: string // 벤더 대표이메일 (유니크)
+ contactName: string
+ contactPosition?: string
+ contactEmail: string
+ contactPhone?: string
+ contactCountry?: string
+ isPrimary?: boolean
+}
+
+export interface ImportResult {
+ success: boolean
+ totalRows: number
+ successCount: number
+ failedRows: Array<{
+ row: number
+ error: string
+ vendorEmail: string
+ contactName: string
+ contactEmail: string
+ }>
+}
+
+/**
+ * 벤더 대표이메일로 벤더 찾기
+ */
+async function getTechVendorByEmail(email: string) {
+ const vendor = await db
+ .select({
+ id: techVendors.id,
+ vendorName: techVendors.vendorName,
+ email: techVendors.email,
+ })
+ .from(techVendors)
+ .where(eq(techVendors.email, email))
+ .limit(1)
+
+ return vendor[0] || null
+}
+
+/**
+ * 연락처 이메일 중복 체크
+ */
+async function checkContactEmailExists(vendorId: number, contactEmail: string) {
+ const existing = await db
+ .select()
+ .from(techVendorContacts)
+ .where(
+ and(
+ eq(techVendorContacts.vendorId, vendorId),
+ eq(techVendorContacts.contactEmail, contactEmail)
+ )
+ )
+ .limit(1)
+
+ return existing.length > 0
+}
+
+/**
+ * 벤더 연락처 일괄 import
+ */
+export async function importTechVendorContacts(
+ data: ImportContactData[]
+): Promise<ImportResult> {
+ const result: ImportResult = {
+ success: true,
+ totalRows: data.length,
+ successCount: 0,
+ failedRows: [],
+ }
+
+ for (let i = 0; i < data.length; i++) {
+ const row = data[i]
+ const rowNumber = i + 1
+
+ try {
+ // 1. 벤더 이메일로 벤더 찾기
+ if (!row.vendorEmail || !row.vendorEmail.trim()) {
+ result.failedRows.push({
+ row: rowNumber,
+ error: "벤더 대표이메일은 필수입니다.",
+ vendorEmail: row.vendorEmail,
+ contactName: row.contactName,
+ contactEmail: row.contactEmail,
+ })
+ continue
+ }
+
+ const vendor = await getTechVendorByEmail(row.vendorEmail.trim())
+ if (!vendor) {
+ result.failedRows.push({
+ row: rowNumber,
+ error: `벤더 대표이메일 '${row.vendorEmail}'을(를) 찾을 수 없습니다.`,
+ vendorEmail: row.vendorEmail,
+ contactName: row.contactName,
+ contactEmail: row.contactEmail,
+ })
+ continue
+ }
+
+ // 2. 연락처 이메일 중복 체크
+ const isDuplicate = await checkContactEmailExists(vendor.id, row.contactEmail)
+ if (isDuplicate) {
+ result.failedRows.push({
+ row: rowNumber,
+ error: `이미 존재하는 연락처 이메일입니다: ${row.contactEmail}`,
+ vendorEmail: row.vendorEmail,
+ contactName: row.contactName,
+ contactEmail: row.contactEmail,
+ })
+ continue
+ }
+
+ // 3. 연락처 생성
+ await db.insert(techVendorContacts).values({
+ vendorId: vendor.id,
+ contactName: row.contactName,
+ contactPosition: row.contactPosition || null,
+ contactEmail: row.contactEmail,
+ contactPhone: row.contactPhone || null,
+ contactCountry: row.contactCountry || null,
+ isPrimary: row.isPrimary || false,
+ })
+
+ result.successCount++
+ } catch (error) {
+ result.failedRows.push({
+ row: rowNumber,
+ error: error instanceof Error ? error.message : "알 수 없는 오류",
+ vendorEmail: row.vendorEmail,
+ contactName: row.contactName,
+ contactEmail: row.contactEmail,
+ })
+ }
+ }
+
+ // 캐시 무효화
+ revalidateTag("tech-vendor-contacts")
+
+ return result
+}
+
+/**
+ * 벤더 연락처 import 템플릿 생성
+ */
+export async function generateContactImportTemplate(): Promise<Blob> {
+ const workbook = new ExcelJS.Workbook()
+ const worksheet = workbook.addWorksheet("벤더연락처_템플릿")
+
+ // 헤더 설정
+ worksheet.columns = [
+ { header: "벤더대표이메일*", key: "vendorEmail", width: 25 },
+ { header: "담당자명*", key: "contactName", width: 20 },
+ { header: "직책", key: "contactPosition", width: 15 },
+ { header: "담당자이메일*", key: "contactEmail", width: 25 },
+ { header: "담당자연락처", key: "contactPhone", width: 15 },
+ { header: "담당자국가", key: "contactCountry", width: 15 },
+ { header: "주담당자여부", key: "isPrimary", width: 12 },
+ ]
+
+ // 헤더 스타일 설정
+ const headerRow = worksheet.getRow(1)
+ headerRow.font = { bold: true }
+ headerRow.fill = {
+ type: "pattern",
+ pattern: "solid",
+ fgColor: { argb: "FFE0E0E0" },
+ }
+
+ // 예시 데이터 추가
+ worksheet.addRow({
+ vendorEmail: "example@company.com",
+ contactName: "홍길동",
+ contactPosition: "대표",
+ contactEmail: "hong@company.com",
+ contactPhone: "010-1234-5678",
+ contactCountry: "대한민국",
+ isPrimary: "Y",
+ })
+
+ worksheet.addRow({
+ vendorEmail: "example@company.com",
+ contactName: "김철수",
+ contactPosition: "과장",
+ contactEmail: "kim@company.com",
+ contactPhone: "010-9876-5432",
+ contactCountry: "대한민국",
+ isPrimary: "N",
+ })
+
+ const buffer = await workbook.xlsx.writeBuffer()
+ return new Blob([buffer], {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ })
+}
+
+/**
+ * Excel 파일에서 연락처 데이터 파싱
+ */
+export async function parseContactImportFile(file: File): Promise<ImportContactData[]> {
+ const arrayBuffer = await file.arrayBuffer()
+ const workbook = new ExcelJS.Workbook()
+ await workbook.xlsx.load(arrayBuffer)
+
+ const worksheet = workbook.worksheets[0]
+ if (!worksheet) {
+ throw new Error("Excel 파일에 워크시트가 없습니다.")
+ }
+
+ const data: ImportContactData[] = []
+
+ worksheet.eachRow((row, index) => {
+ console.log(`행 ${index} 처리 중:`, row.values)
+ // 헤더 행 건너뛰기 (1행)
+ if (index === 1) return
+
+ const values = row.values as (string | null)[]
+ if (!values || values.length < 4) return
+
+ const vendorEmail = values[1]?.toString().trim()
+ const contactName = values[2]?.toString().trim()
+ const contactPosition = values[3]?.toString().trim()
+ const contactEmail = values[4]?.toString().trim()
+ const contactPhone = values[5]?.toString().trim()
+ const contactCountry = values[6]?.toString().trim()
+ const isPrimary = values[7]?.toString().trim()
+
+ // 필수 필드 검증
+ if (!vendorEmail || !contactName || !contactEmail) {
+ return
+ }
+
+ data.push({
+ vendorEmail,
+ contactName,
+ contactPosition: contactPosition || undefined,
+ contactEmail,
+ contactPhone: contactPhone || undefined,
+ contactCountry: contactCountry || undefined,
+ isPrimary: isPrimary === "Y" || isPrimary === "y",
+ })
+
+ // rowNumber++
+ })
+
+ return data
+} \ No newline at end of file
diff --git a/lib/tech-vendors/table/add-vendor-dialog.tsx b/lib/tech-vendors/table/add-vendor-dialog.tsx
index 22c03bcc..e89f5d6b 100644
--- a/lib/tech-vendors/table/add-vendor-dialog.tsx
+++ b/lib/tech-vendors/table/add-vendor-dialog.tsx
@@ -255,7 +255,7 @@ export function AddVendorDialog({ onSuccess }: AddVendorDialogProps) {
className="w-4 h-4 mt-1"
/>
</FormControl>
- <div className="space-y-1 leading-none">
+ <div className="space-y-1 leading-none ml-2">
<FormLabel className="cursor-pointer">
견적비교용 벤더
</FormLabel>
@@ -361,6 +361,52 @@ export function AddVendorDialog({ onSuccess }: AddVendorDialogProps) {
</div>
</div>
+ {/* 에이전트 정보 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-medium">에이전트 정보</h3>
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="agentName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>에이전트명</FormLabel>
+ <FormControl>
+ <Input placeholder="에이전트명을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="agentPhone"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>에이전트 전화번호</FormLabel>
+ <FormControl>
+ <Input placeholder="에이전트 전화번호를 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ <FormField
+ control={form.control}
+ name="agentEmail"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>에이전트 이메일</FormLabel>
+ <FormControl>
+ <Input type="email" placeholder="에이전트 이메일을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
{/* 대표자 정보 */}
<div className="space-y-4">
<h3 className="text-lg font-medium">대표자 정보</h3>
diff --git a/lib/tech-vendors/table/attachmentButton.tsx b/lib/tech-vendors/table/attachmentButton.tsx
index 12dc6f77..2754c9f0 100644
--- a/lib/tech-vendors/table/attachmentButton.tsx
+++ b/lib/tech-vendors/table/attachmentButton.tsx
@@ -1,76 +1,76 @@
-'use client';
-
-import React from 'react';
-import { Button } from '@/components/ui/button';
-import { PaperclipIcon } from 'lucide-react';
-import { Badge } from '@/components/ui/badge';
-import { toast } from 'sonner';
-import { type VendorAttach } from '@/db/schema/vendors';
-import { downloadTechVendorAttachments } from '../service';
-
-interface AttachmentsButtonProps {
- vendorId: number;
- hasAttachments: boolean;
- attachmentsList?: VendorAttach[];
-}
-
-export function AttachmentsButton({ vendorId, hasAttachments, attachmentsList = [] }: AttachmentsButtonProps) {
- if (!hasAttachments) return null;
-
- const handleDownload = async () => {
- try {
- toast.loading('첨부파일을 준비하는 중...');
-
- // 서버 액션 호출
- const result = await downloadTechVendorAttachments(vendorId);
-
- // 로딩 토스트 닫기
- toast.dismiss();
-
- if (!result || !result.url) {
- toast.error('다운로드 준비 중 오류가 발생했습니다.');
- return;
- }
-
- // 파일 다운로드 트리거
- toast.success('첨부파일 다운로드가 시작되었습니다.');
-
- // 다운로드 링크 열기
- const a = document.createElement('a');
- a.href = result.url;
- a.download = result.fileName || '첨부파일.zip';
- a.style.display = 'none';
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
-
- } catch (error) {
- toast.dismiss();
- toast.error('첨부파일 다운로드에 실패했습니다.');
- console.error('첨부파일 다운로드 오류:', error);
- }
- };
-
- return (
- <>
- {attachmentsList && attachmentsList.length > 0 &&
- <Button
- variant="ghost"
- size="icon"
- onClick={handleDownload}
- title={`${attachmentsList.length}개 파일 다운로드`}
- >
- <PaperclipIcon className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
- {/* {attachmentsList.length > 1 && (
- <Badge
- variant="secondary"
- className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.425rem] leading-none flex items-center justify-center"
- >
- {attachmentsList.length}
- </Badge>
- )} */}
- </Button>
- }
- </>
- );
-}
+'use client';
+
+import React from 'react';
+import { Button } from '@/components/ui/button';
+import { PaperclipIcon } from 'lucide-react';
+import { Badge } from '@/components/ui/badge';
+import { toast } from 'sonner';
+import { type VendorAttach } from '@/db/schema/vendors';
+import { downloadTechVendorAttachments } from '../service';
+
+interface AttachmentsButtonProps {
+ vendorId: number;
+ hasAttachments: boolean;
+ attachmentsList?: VendorAttach[];
+}
+
+export function AttachmentsButton({ vendorId, hasAttachments, attachmentsList = [] }: AttachmentsButtonProps) {
+ if (!hasAttachments) return null;
+
+ const handleDownload = async () => {
+ try {
+ toast.loading('첨부파일을 준비하는 중...');
+
+ // 서버 액션 호출
+ const result = await downloadTechVendorAttachments(vendorId);
+
+ // 로딩 토스트 닫기
+ toast.dismiss();
+
+ if (!result || !result.url) {
+ toast.error('다운로드 준비 중 오류가 발생했습니다.');
+ return;
+ }
+
+ // 파일 다운로드 트리거
+ toast.success('첨부파일 다운로드가 시작되었습니다.');
+
+ // 다운로드 링크 열기
+ const a = document.createElement('a');
+ a.href = result.url;
+ a.download = result.fileName || '첨부파일.zip';
+ a.style.display = 'none';
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+
+ } catch (error) {
+ toast.dismiss();
+ toast.error('첨부파일 다운로드에 실패했습니다.');
+ console.error('첨부파일 다운로드 오류:', error);
+ }
+ };
+
+ return (
+ <>
+ {attachmentsList && attachmentsList.length > 0 &&
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={handleDownload}
+ title={`${attachmentsList.length}개 파일 다운로드`}
+ >
+ <PaperclipIcon className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ {/* {attachmentsList.length > 1 && (
+ <Badge
+ variant="secondary"
+ className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.425rem] leading-none flex items-center justify-center"
+ >
+ {attachmentsList.length}
+ </Badge>
+ )} */}
+ </Button>
+ }
+ </>
+ );
+}
diff --git a/lib/tech-vendors/table/excel-template-download.tsx b/lib/tech-vendors/table/excel-template-download.tsx
index b6011e2c..3de9ab33 100644
--- a/lib/tech-vendors/table/excel-template-download.tsx
+++ b/lib/tech-vendors/table/excel-template-download.tsx
@@ -1,150 +1,232 @@
-import * as ExcelJS from 'exceljs';
-import { saveAs } from "file-saver";
-
-/**
- * 기술영업 벤더 데이터 가져오기를 위한 Excel 템플릿 파일 생성 및 다운로드
- */
-export async function exportTechVendorTemplate() {
- // 워크북 생성
- const workbook = new ExcelJS.Workbook();
- workbook.creator = 'Tech Vendor Management System';
- workbook.created = new Date();
-
- // 워크시트 생성
- const worksheet = workbook.addWorksheet('기술영업 벤더');
-
- // 컬럼 헤더 정의 및 스타일 적용
- worksheet.columns = [
- { header: '업체명', key: 'vendorName', width: 20 },
- { header: '업체코드', key: 'vendorCode', width: 15 },
- { header: '사업자등록번호', key: 'taxId', width: 15 },
- { header: '국가', key: 'country', width: 15 },
- { header: '영문국가명', key: 'countryEng', width: 15 },
- { header: '제조국', key: 'countryFab', width: 15 },
- { header: '대리점명', key: 'agentName', width: 20 },
- { header: '대리점연락처', key: 'agentPhone', width: 15 },
- { header: '대리점이메일', key: 'agentEmail', width: 25 },
- { header: '주소', key: 'address', width: 30 },
- { header: '전화번호', key: 'phone', width: 15 },
- { header: '이메일', key: 'email', width: 25 },
- { header: '웹사이트', key: 'website', width: 25 },
- { header: '벤더타입', key: 'techVendorType', width: 15 },
- { header: '대표자명', key: 'representativeName', width: 20 },
- { header: '대표자이메일', key: 'representativeEmail', width: 25 },
- { header: '대표자연락처', key: 'representativePhone', width: 15 },
- { header: '대표자생년월일', key: 'representativeBirth', width: 15 },
- { header: '아이템', key: 'items', width: 30 },
- ];
-
- // 헤더 스타일 적용
- const headerRow = worksheet.getRow(1);
- headerRow.font = { bold: true };
- headerRow.fill = {
- type: 'pattern',
- pattern: 'solid',
- fgColor: { argb: 'FFE0E0E0' }
- };
- headerRow.alignment = { vertical: 'middle', horizontal: 'center' };
-
- // 테두리 스타일 적용
- headerRow.eachCell((cell) => {
- cell.border = {
- top: { style: 'thin' },
- left: { style: 'thin' },
- bottom: { style: 'thin' },
- right: { style: 'thin' }
- };
- });
-
- // 샘플 데이터 추가
- const sampleData = [
- {
- vendorName: '샘플 업체 1',
- vendorCode: 'TV001',
- taxId: '123-45-67890',
- country: '대한민국',
- countryEng: 'Korea',
- countryFab: '대한민국',
- agentName: '대리점1',
- agentPhone: '02-1234-5678',
- agentEmail: 'agent1@example.com',
- address: '서울시 강남구',
- phone: '02-1234-5678',
- email: 'sample1@example.com',
- website: 'https://example1.com',
- techVendorType: '조선,해양TOP',
- representativeName: '홍길동',
- representativeEmail: 'ceo1@example.com',
- representativePhone: '010-1234-5678',
- representativeBirth: '1980-01-01',
- items: 'ITEM001,ITEM002'
- },
- {
- vendorName: '샘플 업체 2',
- vendorCode: 'TV002',
- taxId: '234-56-78901',
- country: '대한민국',
- countryEng: 'Korea',
- countryFab: '대한민국',
- agentName: '대리점2',
- agentPhone: '051-234-5678',
- agentEmail: 'agent2@example.com',
- address: '부산시 해운대구',
- phone: '051-234-5678',
- email: 'sample2@example.com',
- website: 'https://example2.com',
- techVendorType: '해양HULL',
- representativeName: '김철수',
- representativeEmail: 'ceo2@example.com',
- representativePhone: '010-2345-6789',
- representativeBirth: '1985-02-02',
- items: 'ITEM003,ITEM004'
- }
- ];
-
- // 데이터 행 추가
- sampleData.forEach(item => {
- worksheet.addRow(item);
- });
-
- // 데이터 행 스타일 적용
- worksheet.eachRow((row, rowNumber) => {
- if (rowNumber > 1) { // 헤더를 제외한 데이터 행
- row.eachCell((cell) => {
- cell.border = {
- top: { style: 'thin' },
- left: { style: 'thin' },
- bottom: { style: 'thin' },
- right: { style: 'thin' }
- };
- });
- }
- });
-
- // 워크시트 보호 (선택적)
- worksheet.protect('', {
- selectLockedCells: true,
- selectUnlockedCells: true,
- formatColumns: true,
- formatRows: true,
- insertColumns: false,
- insertRows: true,
- insertHyperlinks: false,
- deleteColumns: false,
- deleteRows: true,
- sort: true,
- autoFilter: true,
- pivotTables: false
- });
-
- try {
- // 워크북을 Blob으로 변환
- const buffer = await workbook.xlsx.writeBuffer();
- const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
- saveAs(blob, 'tech-vendor-template.xlsx');
- return true;
- } catch (error) {
- console.error('Excel 템플릿 생성 오류:', error);
- throw error;
- }
+import * as ExcelJS from 'exceljs';
+import { saveAs } from "file-saver";
+
+/**
+ * 기술영업 벤더 데이터 가져오기를 위한 Excel 템플릿 파일 생성 및 다운로드
+ */
+export async function exportTechVendorTemplate() {
+ // 워크북 생성
+ const workbook = new ExcelJS.Workbook();
+ workbook.creator = 'Tech Vendor Management System';
+ workbook.created = new Date();
+
+ // 워크시트 생성
+ const worksheet = workbook.addWorksheet('기술영업 벤더');
+
+ // 컬럼 헤더 정의 및 스타일 적용
+ worksheet.columns = [
+ { header: '업체명', key: 'vendorName', width: 20 },
+ { header: '업체코드', key: 'vendorCode', width: 15 },
+ { header: '사업자등록번호', key: 'taxId', width: 15 },
+ { header: '국가', key: 'country', width: 15 },
+ { header: '영문국가명', key: 'countryEng', width: 15 },
+ { header: '제조국', key: 'countryFab', width: 15 },
+ { header: '에이전트명', key: 'agentName', width: 20 },
+ { header: '에이전트연락처', key: 'agentPhone', width: 15 },
+ { header: '에이전트이메일', key: 'agentEmail', width: 25 },
+ { header: '주소', key: 'address', width: 30 },
+ { header: '전화번호', key: 'phone', width: 15 },
+ { header: '이메일', key: 'email', width: 25 },
+ { header: '웹사이트', key: 'website', width: 25 },
+ { header: '벤더타입', key: 'techVendorType', width: 15 },
+ { header: '대표자명', key: 'representativeName', width: 20 },
+ { header: '대표자이메일', key: 'representativeEmail', width: 25 },
+ { header: '대표자연락처', key: 'representativePhone', width: 15 },
+ { header: '대표자생년월일', key: 'representativeBirth', width: 15 },
+ { header: '담당자명', key: 'contactName', width: 20 },
+ { header: '담당자직책', key: 'contactPosition', width: 15 },
+ { header: '담당자이메일', key: 'contactEmail', width: 25 },
+ { header: '담당자연락처', key: 'contactPhone', width: 15 },
+ { header: '담당자국가', key: 'contactCountry', width: 15 },
+ { header: '아이템', key: 'items', width: 30 },
+ ];
+
+ // 헤더 스타일 적용
+ const headerRow = worksheet.getRow(1);
+ headerRow.font = { bold: true };
+ headerRow.fill = {
+ type: 'pattern',
+ pattern: 'solid',
+ fgColor: { argb: 'FFE0E0E0' }
+ };
+
+ // 샘플 데이터 추가
+ worksheet.addRow([
+ 'ABC 조선소', // 업체명
+ 'ABC001', // 업체코드
+ '123-45-67890', // 사업자등록번호
+ '대한민국', // 국가
+ 'South Korea', // 영문국가명
+ '대한민국', // 제조국
+ '김대리', // 에이전트명
+ '02-123-4567', // 에이전트연락처
+ 'agent@abc.co.kr', // 에이전트이메일
+ '서울시 강남구 테헤란로 123', // 주소
+ '02-123-4567', // 전화번호
+ 'contact@abc.co.kr', // 이메일
+ 'https://www.abc.co.kr', // 웹사이트
+ '조선', // 벤더타입
+ '홍길동', // 대표자명
+ 'ceo@abc.co.kr', // 대표자이메일
+ '02-123-4567', // 대표자연락처
+ '1970-01-01', // 대표자생년월일
+ '박담당', // 담당자명
+ '과장', // 담당자직책
+ 'contact@abc.co.kr', // 담당자이메일
+ '010-1234-5678', // 담당자연락처
+ '대한민국', // 담당자국가
+ '선박부품, 엔진부품' // 아이템
+ ]);
+
+ // 설명을 위한 시트 추가
+ const instructionSheet = workbook.addWorksheet('입력 가이드');
+ instructionSheet.columns = [
+ { header: '컬럼명', key: 'column', width: 20 },
+ { header: '필수여부', key: 'required', width: 10 },
+ { header: '설명', key: 'description', width: 50 },
+ ];
+
+ // 가이드 헤더 스타일
+ const guideHeaderRow = instructionSheet.getRow(1);
+ guideHeaderRow.font = { bold: true };
+ guideHeaderRow.fill = {
+ type: 'pattern',
+ pattern: 'solid',
+ fgColor: { argb: 'FFE0E0E0' }
+ };
+
+ // 입력 가이드 데이터
+ const guideData = [
+ ['업체명', '필수', '벤더 업체명을 입력하세요'],
+ ['업체코드', '선택', '벤더 고유 코드 (없으면 자동 생성)'],
+ ['사업자등록번호', '필수', '벤더의 사업자등록번호'],
+ ['국가', '선택', '벤더 소재 국가'],
+ ['영문국가명', '선택', '벤더 소재 국가의 영문명'],
+ ['제조국', '선택', '제품 제조 국가'],
+ ['에이전트명', '선택', '담당 에이전트 이름'],
+ ['에이전트연락처', '선택', '담당 에이전트 연락처'],
+ ['에이전트이메일', '선택', '담당 에이전트 이메일'],
+ ['주소', '선택', '벤더 주소'],
+ ['전화번호', '선택', '벤더 대표 전화번호'],
+ ['이메일', '필수', '벤더 대표 이메일 (대표 담당자가 없으면 이 이메일이 기본 담당자가 됩니다)'],
+ ['웹사이트', '선택', '벤더 웹사이트 URL'],
+ ['벤더타입', '필수', '벤더 유형 (조선, 해양TOP, 해양HULL 중 선택)'],
+ ['대표자명', '선택', '벤더 대표자 이름'],
+ ['대표자이메일', '선택', '벤더 대표자 이메일'],
+ ['대표자연락처', '선택', '벤더 대표자 연락처'],
+ ['대표자생년월일', '선택', '벤더 대표자 생년월일 (YYYY-MM-DD 형식)'],
+ ['담당자명', '선택', '주 담당자 이름 (없으면 대표자 또는 업체명으로 기본 담당자 생성)'],
+ ['담당자직책', '선택', '주 담당자 직책'],
+ ['담당자이메일', '선택', '주 담당자 이메일 (있으면 벤더 이메일보다 우선)'],
+ ['담당자연락처', '선택', '주 담당자 연락처'],
+ ['담당자국가', '선택', '주 담당자 소재 국가'],
+ ['아이템', '선택', '벤더가 제공하는 아이템 (쉼표로 구분)'],
+ ];
+
+ guideData.forEach(row => {
+ instructionSheet.addRow(row);
+ });
+ headerRow.alignment = { vertical: 'middle', horizontal: 'center' };
+
+ // 테두리 스타일 적용
+ headerRow.eachCell((cell) => {
+ cell.border = {
+ top: { style: 'thin' },
+ left: { style: 'thin' },
+ bottom: { style: 'thin' },
+ right: { style: 'thin' }
+ };
+ });
+
+ // 샘플 데이터 추가
+ const sampleData = [
+ {
+ vendorName: '샘플 업체 1',
+ vendorCode: 'TV001',
+ taxId: '123-45-67890',
+ country: '대한민국',
+ countryEng: 'Korea',
+ countryFab: '대한민국',
+ agentName: '에이전트1',
+ agentPhone: '02-1234-5678',
+ agentEmail: 'agent1@example.com',
+ address: '서울시 강남구',
+ phone: '02-1234-5678',
+ email: 'sample1@example.com',
+ website: 'https://example1.com',
+ techVendorType: '조선,해양TOP',
+ representativeName: '홍길동',
+ representativeEmail: 'ceo1@example.com',
+ representativePhone: '010-1234-5678',
+ representativeBirth: '1980-01-01',
+ items: 'ITEM001,ITEM002'
+ },
+ {
+ vendorName: '샘플 업체 2',
+ vendorCode: 'TV002',
+ taxId: '234-56-78901',
+ country: '대한민국',
+ countryEng: 'Korea',
+ countryFab: '대한민국',
+ agentName: '에이전트2',
+ agentPhone: '051-234-5678',
+ agentEmail: 'agent2@example.com',
+ address: '부산시 해운대구',
+ phone: '051-234-5678',
+ email: 'sample2@example.com',
+ website: 'https://example2.com',
+ techVendorType: '해양HULL',
+ representativeName: '김철수',
+ representativeEmail: 'ceo2@example.com',
+ representativePhone: '010-2345-6789',
+ representativeBirth: '1985-02-02',
+ items: 'ITEM003,ITEM004'
+ }
+ ];
+
+ // 데이터 행 추가
+ sampleData.forEach(item => {
+ worksheet.addRow(item);
+ });
+
+ // 데이터 행 스타일 적용
+ worksheet.eachRow((row, rowNumber) => {
+ if (rowNumber > 1) { // 헤더를 제외한 데이터 행
+ row.eachCell((cell) => {
+ cell.border = {
+ top: { style: 'thin' },
+ left: { style: 'thin' },
+ bottom: { style: 'thin' },
+ right: { style: 'thin' }
+ };
+ });
+ }
+ });
+
+ // 워크시트 보호 (선택적)
+ worksheet.protect('', {
+ selectLockedCells: true,
+ selectUnlockedCells: true,
+ formatColumns: true,
+ formatRows: true,
+ insertColumns: false,
+ insertRows: true,
+ insertHyperlinks: false,
+ deleteColumns: false,
+ deleteRows: true,
+ sort: true,
+ autoFilter: true,
+ pivotTables: false
+ });
+
+ try {
+ // 워크북을 Blob으로 변환
+ const buffer = await workbook.xlsx.writeBuffer();
+ const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
+ saveAs(blob, 'tech-vendor-template.xlsx');
+ return true;
+ } catch (error) {
+ console.error('Excel 템플릿 생성 오류:', error);
+ throw error;
+ }
} \ No newline at end of file
diff --git a/lib/tech-vendors/table/feature-flags-provider.tsx b/lib/tech-vendors/table/feature-flags-provider.tsx
index 81131894..615377d6 100644
--- a/lib/tech-vendors/table/feature-flags-provider.tsx
+++ b/lib/tech-vendors/table/feature-flags-provider.tsx
@@ -1,108 +1,108 @@
-"use client"
-
-import * as React from "react"
-import { useQueryState } from "nuqs"
-
-import { dataTableConfig, type DataTableConfig } from "@/config/data-table"
-import { cn } from "@/lib/utils"
-import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/ui/tooltip"
-
-type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
-
-interface FeatureFlagsContextProps {
- featureFlags: FeatureFlagValue[]
- setFeatureFlags: (value: FeatureFlagValue[]) => void
-}
-
-const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
- featureFlags: [],
- setFeatureFlags: () => {},
-})
-
-export function useFeatureFlags() {
- const context = React.useContext(FeatureFlagsContext)
- if (!context) {
- throw new Error(
- "useFeatureFlags must be used within a FeatureFlagsProvider"
- )
- }
- return context
-}
-
-interface FeatureFlagsProviderProps {
- children: React.ReactNode
-}
-
-export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
- const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
- "flags",
- {
- defaultValue: [],
- parse: (value) => value.split(",") as FeatureFlagValue[],
- serialize: (value) => value.join(","),
- eq: (a, b) =>
- a.length === b.length && a.every((value, index) => value === b[index]),
- clearOnDefault: true,
- shallow: false,
- }
- )
-
- return (
- <FeatureFlagsContext.Provider
- value={{
- featureFlags,
- setFeatureFlags: (value) => void setFeatureFlags(value),
- }}
- >
- <div className="w-full overflow-x-auto">
- <ToggleGroup
- type="multiple"
- variant="outline"
- size="sm"
- value={featureFlags}
- onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
- className="w-fit gap-0"
- >
- {dataTableConfig.featureFlags.map((flag, index) => (
- <Tooltip key={flag.value}>
- <ToggleGroupItem
- value={flag.value}
- className={cn(
- "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90",
- {
- "rounded-l-sm border-r-0": index === 0,
- "rounded-r-sm":
- index === dataTableConfig.featureFlags.length - 1,
- }
- )}
- asChild
- >
- <TooltipTrigger>
- <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
- {flag.label}
- </TooltipTrigger>
- </ToggleGroupItem>
- <TooltipContent
- align="start"
- side="bottom"
- sideOffset={6}
- className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
- >
- <div>{flag.tooltipTitle}</div>
- <div className="text-xs text-muted-foreground">
- {flag.tooltipDescription}
- </div>
- </TooltipContent>
- </Tooltip>
- ))}
- </ToggleGroup>
- </div>
- {children}
- </FeatureFlagsContext.Provider>
- )
-}
+"use client"
+
+import * as React from "react"
+import { useQueryState } from "nuqs"
+
+import { dataTableConfig, type DataTableConfig } from "@/config/data-table"
+import { cn } from "@/lib/utils"
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
+
+interface FeatureFlagsContextProps {
+ featureFlags: FeatureFlagValue[]
+ setFeatureFlags: (value: FeatureFlagValue[]) => void
+}
+
+const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
+ featureFlags: [],
+ setFeatureFlags: () => {},
+})
+
+export function useFeatureFlags() {
+ const context = React.useContext(FeatureFlagsContext)
+ if (!context) {
+ throw new Error(
+ "useFeatureFlags must be used within a FeatureFlagsProvider"
+ )
+ }
+ return context
+}
+
+interface FeatureFlagsProviderProps {
+ children: React.ReactNode
+}
+
+export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
+ const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
+ "flags",
+ {
+ defaultValue: [],
+ parse: (value) => value.split(",") as FeatureFlagValue[],
+ serialize: (value) => value.join(","),
+ eq: (a, b) =>
+ a.length === b.length && a.every((value, index) => value === b[index]),
+ clearOnDefault: true,
+ shallow: false,
+ }
+ )
+
+ return (
+ <FeatureFlagsContext.Provider
+ value={{
+ featureFlags,
+ setFeatureFlags: (value) => void setFeatureFlags(value),
+ }}
+ >
+ <div className="w-full overflow-x-auto">
+ <ToggleGroup
+ type="multiple"
+ variant="outline"
+ size="sm"
+ value={featureFlags}
+ onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
+ className="w-fit gap-0"
+ >
+ {dataTableConfig.featureFlags.map((flag, index) => (
+ <Tooltip key={flag.value}>
+ <ToggleGroupItem
+ value={flag.value}
+ className={cn(
+ "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90",
+ {
+ "rounded-l-sm border-r-0": index === 0,
+ "rounded-r-sm":
+ index === dataTableConfig.featureFlags.length - 1,
+ }
+ )}
+ asChild
+ >
+ <TooltipTrigger>
+ <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
+ {flag.label}
+ </TooltipTrigger>
+ </ToggleGroupItem>
+ <TooltipContent
+ align="start"
+ side="bottom"
+ sideOffset={6}
+ className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
+ >
+ <div>{flag.tooltipTitle}</div>
+ <div className="text-xs text-muted-foreground">
+ {flag.tooltipDescription}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ ))}
+ </ToggleGroup>
+ </div>
+ {children}
+ </FeatureFlagsContext.Provider>
+ )
+}
diff --git a/lib/tech-vendors/table/import-button.tsx b/lib/tech-vendors/table/import-button.tsx
index ba01e150..1d3bf242 100644
--- a/lib/tech-vendors/table/import-button.tsx
+++ b/lib/tech-vendors/table/import-button.tsx
@@ -1,313 +1,381 @@
-"use client"
-
-import * as React from "react"
-import { Upload } from "lucide-react"
-import { toast } from "sonner"
-import * as ExcelJS from 'exceljs'
-
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
-import { Progress } from "@/components/ui/progress"
-import { importTechVendorsFromExcel } from "../service"
-import { decryptWithServerAction } from "@/components/drm/drmUtils"
-
-interface ImportTechVendorButtonProps {
- onSuccess?: () => void;
-}
-
-export function ImportTechVendorButton({ onSuccess }: ImportTechVendorButtonProps) {
- const [open, setOpen] = React.useState(false);
- const [file, setFile] = React.useState<File | null>(null);
- const [isUploading, setIsUploading] = React.useState(false);
- const [progress, setProgress] = React.useState(0);
- const [error, setError] = React.useState<string | null>(null);
-
- const fileInputRef = React.useRef<HTMLInputElement>(null);
-
- // 파일 선택 처리
- const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
- const selectedFile = e.target.files?.[0];
- if (!selectedFile) return;
-
- if (!selectedFile.name.endsWith('.xlsx') && !selectedFile.name.endsWith('.xls')) {
- setError("Excel 파일(.xlsx 또는 .xls)만 가능합니다.");
- return;
- }
-
- setFile(selectedFile);
- setError(null);
- };
-
- // 데이터 가져오기 처리
- const handleImport = async () => {
- if (!file) {
- setError("가져올 파일을 선택해주세요.");
- return;
- }
-
- try {
- setIsUploading(true);
- setProgress(0);
- setError(null);
-
- // DRM 복호화 처리
- let arrayBuffer: ArrayBuffer;
- try {
- setProgress(10);
- toast.info("파일 복호화 중...");
- arrayBuffer = await decryptWithServerAction(file);
- setProgress(30);
- } catch (decryptError) {
- console.error("파일 복호화 실패, 원본 파일 사용:", decryptError);
- toast.warning("파일 복호화에 실패하여 원본 파일을 사용합니다.");
- arrayBuffer = await file.arrayBuffer();
- }
-
- // ExcelJS 워크북 로드
- const workbook = new ExcelJS.Workbook();
- await workbook.xlsx.load(arrayBuffer);
-
- // 첫 번째 워크시트 가져오기
- const worksheet = workbook.worksheets[0];
- if (!worksheet) {
- throw new Error("Excel 파일에 워크시트가 없습니다.");
- }
-
- // 헤더 행 찾기
- let headerRowIndex = 1;
- let headerRow: ExcelJS.Row | undefined;
- let headerValues: (string | null)[] = [];
-
- worksheet.eachRow((row, rowNumber) => {
- const values = row.values as (string | null)[];
- if (!headerRow && values.some(v => v === "업체명" || v === "vendorName")) {
- headerRowIndex = rowNumber;
- headerRow = row;
- headerValues = [...values];
- }
- });
-
- if (!headerRow) {
- throw new Error("Excel 파일에서 헤더 행을 찾을 수 없습니다.");
- }
-
- // 헤더를 기반으로 인덱스 매핑 생성
- const headerMapping: Record<string, number> = {};
- headerValues.forEach((value, index) => {
- if (typeof value === 'string') {
- headerMapping[value] = index;
- }
- });
-
- // 필수 헤더 확인
- const requiredHeaders = ["업체명", "이메일", "사업자등록번호", "벤더타입"];
- const alternativeHeaders = {
- "업체명": ["vendorName"],
- "업체코드": ["vendorCode"],
- "이메일": ["email"],
- "사업자등록번호": ["taxId"],
- "국가": ["country"],
- "영문국가명": ["countryEng"],
- "제조국": ["countryFab"],
- "대리점명": ["agentName"],
- "대리점연락처": ["agentPhone"],
- "대리점이메일": ["agentEmail"],
- "주소": ["address"],
- "전화번호": ["phone"],
- "웹사이트": ["website"],
- "벤더타입": ["techVendorType"],
- "대표자명": ["representativeName"],
- "대표자이메일": ["representativeEmail"],
- "대표자연락처": ["representativePhone"],
- "대표자생년월일": ["representativeBirth"],
- "아이템": ["items"]
- };
-
- // 헤더 매핑 확인 (대체 이름 포함)
- const missingHeaders = requiredHeaders.filter(header => {
- const alternatives = alternativeHeaders[header as keyof typeof alternativeHeaders] || [];
- return !(header in headerMapping) &&
- !alternatives.some(alt => alt in headerMapping);
- });
-
- if (missingHeaders.length > 0) {
- throw new Error(`다음 필수 헤더가 누락되었습니다: ${missingHeaders.join(", ")}`);
- }
-
- // 데이터 행 추출
- const dataRows: Record<string, any>[] = [];
-
- worksheet.eachRow((row, rowNumber) => {
- if (rowNumber > headerRowIndex) {
- const rowData: Record<string, any> = {};
- const values = row.values as (string | null | undefined)[];
-
- // 헤더 매핑에 따라 데이터 추출
- Object.entries(headerMapping).forEach(([header, index]) => {
- rowData[header] = values[index] || "";
- });
-
- // 빈 행이 아닌 경우만 추가
- if (Object.values(rowData).some(value => value && value.toString().trim() !== "")) {
- dataRows.push(rowData);
- }
- }
- });
-
- if (dataRows.length === 0) {
- throw new Error("Excel 파일에 가져올 데이터가 없습니다.");
- }
-
- // 진행 상황 업데이트를 위한 콜백
- const updateProgress = (current: number, total: number) => {
- const percentage = Math.round((current / total) * 100);
- setProgress(percentage);
- };
-
- // 벤더 데이터 처리
- const vendors = dataRows.map(row => ({
- vendorName: row["업체명"] || row["vendorName"] || "",
- vendorCode: row["업체코드"] || row["vendorCode"] || null,
- email: row["이메일"] || row["email"] || "",
- taxId: row["사업자등록번호"] || row["taxId"] || "",
- country: row["국가"] || row["country"] || null,
- countryEng: row["영문국가명"] || row["countryEng"] || null,
- countryFab: row["제조국"] || row["countryFab"] || null,
- agentName: row["대리점명"] || row["agentName"] || null,
- agentPhone: row["대리점연락처"] || row["agentPhone"] || null,
- agentEmail: row["대리점이메일"] || row["agentEmail"] || null,
- address: row["주소"] || row["address"] || null,
- phone: row["전화번호"] || row["phone"] || null,
- website: row["웹사이트"] || row["website"] || null,
- techVendorType: row["벤더타입"] || row["techVendorType"] || "",
- representativeName: row["대표자명"] || row["representativeName"] || null,
- representativeEmail: row["대표자이메일"] || row["representativeEmail"] || null,
- representativePhone: row["대표자연락처"] || row["representativePhone"] || null,
- representativeBirth: row["대표자생년월일"] || row["representativeBirth"] || null,
- items: row["아이템"] || row["items"] || ""
- }));
-
- // 벤더 데이터 가져오기 실행
- const result = await importTechVendorsFromExcel(vendors);
-
- if (result.success) {
- toast.success(`${vendors.length}개의 기술영업 벤더가 성공적으로 가져와졌습니다.`);
- } else {
- toast.error(result.error || "벤더 가져오기에 실패했습니다.");
- }
-
- // 상태 초기화 및 다이얼로그 닫기
- setFile(null);
- setOpen(false);
-
- // 성공 콜백 호출
- if (onSuccess) {
- onSuccess();
- }
- } catch (error) {
- console.error("Excel 파일 처리 중 오류 발생:", error);
- setError(error instanceof Error ? error.message : "파일 처리 중 오류가 발생했습니다.");
- } finally {
- setIsUploading(false);
- }
- };
-
- // 다이얼로그 열기/닫기 핸들러
- const handleOpenChange = (newOpen: boolean) => {
- if (!newOpen) {
- // 닫을 때 상태 초기화
- setFile(null);
- setError(null);
- setProgress(0);
- if (fileInputRef.current) {
- fileInputRef.current.value = "";
- }
- }
- setOpen(newOpen);
- };
-
- return (
- <>
- <Button
- variant="outline"
- size="sm"
- className="gap-2"
- onClick={() => setOpen(true)}
- disabled={isUploading}
- >
- <Upload className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">Import</span>
- </Button>
-
- <Dialog open={open} onOpenChange={handleOpenChange}>
- <DialogContent className="sm:max-w-[500px]">
- <DialogHeader>
- <DialogTitle>기술영업 벤더 가져오기</DialogTitle>
- <DialogDescription>
- 기술영업 벤더를 Excel 파일에서 가져옵니다.
- <br />
- 올바른 형식의 Excel 파일(.xlsx)을 업로드하세요.
- </DialogDescription>
- </DialogHeader>
-
- <div className="space-y-4 py-4">
- <div className="flex items-center gap-4">
- <input
- type="file"
- ref={fileInputRef}
- className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-foreground file:font-medium"
- accept=".xlsx,.xls"
- onChange={handleFileChange}
- disabled={isUploading}
- />
- </div>
-
- {file && (
- <div className="text-sm text-muted-foreground">
- 선택된 파일: <span className="font-medium">{file.name}</span> ({(file.size / 1024).toFixed(1)} KB)
- </div>
- )}
-
- {isUploading && (
- <div className="space-y-2">
- <Progress value={progress} />
- <p className="text-sm text-muted-foreground text-center">
- {progress}% 완료
- </p>
- </div>
- )}
-
- {error && (
- <div className="text-sm font-medium text-destructive">
- {error}
- </div>
- )}
- </div>
-
- <DialogFooter>
- <Button
- variant="outline"
- onClick={() => setOpen(false)}
- disabled={isUploading}
- >
- 취소
- </Button>
- <Button
- onClick={handleImport}
- disabled={!file || isUploading}
- >
- {isUploading ? "처리 중..." : "가져오기"}
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- </>
- );
+"use client"
+
+import * as React from "react"
+import { Upload } from "lucide-react"
+import { toast } from "sonner"
+import * as ExcelJS from 'exceljs'
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Progress } from "@/components/ui/progress"
+import { importTechVendorsFromExcel } from "../service"
+import { decryptWithServerAction } from "@/components/drm/drmUtils"
+
+interface ImportTechVendorButtonProps {
+ onSuccess?: () => void;
+}
+
+export function ImportTechVendorButton({ onSuccess }: ImportTechVendorButtonProps) {
+ const [open, setOpen] = React.useState(false);
+ const [file, setFile] = React.useState<File | null>(null);
+ const [isUploading, setIsUploading] = React.useState(false);
+ const [progress, setProgress] = React.useState(0);
+ const [error, setError] = React.useState<string | null>(null);
+
+ const fileInputRef = React.useRef<HTMLInputElement>(null);
+
+ // 파일 선택 처리
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ const selectedFile = e.target.files?.[0];
+ if (!selectedFile) return;
+
+ if (!selectedFile.name.endsWith('.xlsx') && !selectedFile.name.endsWith('.xls')) {
+ setError("Excel 파일(.xlsx 또는 .xls)만 가능합니다.");
+ return;
+ }
+
+ setFile(selectedFile);
+ setError(null);
+ };
+
+ // 데이터 가져오기 처리
+ const handleImport = async () => {
+ if (!file) {
+ setError("가져올 파일을 선택해주세요.");
+ return;
+ }
+
+ try {
+ setIsUploading(true);
+ setProgress(0);
+ setError(null);
+
+ // DRM 복호화 처리
+ let arrayBuffer: ArrayBuffer;
+ try {
+ setProgress(10);
+ toast.info("파일 복호화 중...");
+ arrayBuffer = await decryptWithServerAction(file);
+ setProgress(30);
+ } catch (decryptError) {
+ console.error("파일 복호화 실패, 원본 파일 사용:", decryptError);
+ toast.warning("파일 복호화에 실패하여 원본 파일을 사용합니다.");
+ arrayBuffer = await file.arrayBuffer();
+ }
+
+ // ExcelJS 워크북 로드
+ const workbook = new ExcelJS.Workbook();
+ await workbook.xlsx.load(arrayBuffer);
+
+ // 첫 번째 워크시트 가져오기
+ const worksheet = workbook.worksheets[0];
+ if (!worksheet) {
+ throw new Error("Excel 파일에 워크시트가 없습니다.");
+ }
+
+ // 헤더 행 찾기
+ let headerRowIndex = 1;
+ let headerRow: ExcelJS.Row | undefined;
+ let headerValues: (string | null)[] = [];
+
+ worksheet.eachRow((row, rowNumber) => {
+ const values = row.values as (string | null)[];
+ if (!headerRow && values.some(v => v === "업체명" || v === "vendorName")) {
+ headerRowIndex = rowNumber;
+ headerRow = row;
+ headerValues = [...values];
+ }
+ });
+
+ if (!headerRow) {
+ throw new Error("Excel 파일에서 헤더 행을 찾을 수 없습니다.");
+ }
+
+ // 헤더를 기반으로 인덱스 매핑 생성
+ const headerMapping: Record<string, number> = {};
+ headerValues.forEach((value, index) => {
+ if (typeof value === 'string') {
+ headerMapping[value] = index;
+ }
+ });
+
+ // 필수 헤더 확인
+ const requiredHeaders = ["업체명", "이메일", "사업자등록번호", "벤더타입"];
+ const alternativeHeaders = {
+ "업체명": ["vendorName"],
+ "업체코드": ["vendorCode"],
+ "이메일": ["email"],
+ "사업자등록번호": ["taxId"],
+ "국가": ["country"],
+ "영문국가명": ["countryEng"],
+ "제조국": ["countryFab"],
+ "에이전트명": ["agentName"],
+ "에이전트연락처": ["agentPhone"],
+ "에이전트이메일": ["agentEmail"],
+ "주소": ["address"],
+ "전화번호": ["phone"],
+ "웹사이트": ["website"],
+ "벤더타입": ["techVendorType"],
+ "대표자명": ["representativeName"],
+ "대표자이메일": ["representativeEmail"],
+ "대표자연락처": ["representativePhone"],
+ "대표자생년월일": ["representativeBirth"],
+ "담당자명": ["contactName"],
+ "담당자직책": ["contactPosition"],
+ "담당자이메일": ["contactEmail"],
+ "담당자연락처": ["contactPhone"],
+ "담당자국가": ["contactCountry"],
+ "아이템": ["items"]
+ };
+
+ // 헤더 매핑 확인 (대체 이름 포함)
+ const missingHeaders = requiredHeaders.filter(header => {
+ const alternatives = alternativeHeaders[header as keyof typeof alternativeHeaders] || [];
+ return !(header in headerMapping) &&
+ !alternatives.some(alt => alt in headerMapping);
+ });
+
+ if (missingHeaders.length > 0) {
+ throw new Error(`다음 필수 헤더가 누락되었습니다: ${missingHeaders.join(", ")}`);
+ }
+
+ // 데이터 행 추출
+ const dataRows: Record<string, string | null | undefined>[] = [];
+
+ worksheet.eachRow((row, rowNumber) => {
+ if (rowNumber > headerRowIndex) {
+ const rowData: Record<string, string | null | undefined> = {};
+ const values = row.values as (string | null | undefined)[];
+
+ // 헤더 매핑에 따라 데이터 추출
+ Object.entries(headerMapping).forEach(([header, index]) => {
+ rowData[header] = values[index] || "";
+ });
+
+ // 빈 행이 아닌 경우만 추가
+ if (Object.values(rowData).some(value => value && value.toString().trim() !== "")) {
+ dataRows.push(rowData);
+ }
+ }
+ });
+
+ if (dataRows.length === 0) {
+ throw new Error("Excel 파일에 가져올 데이터가 없습니다.");
+ }
+
+ setProgress(70);
+
+ // 벤더 데이터 처리
+ const vendors = dataRows.map(row => {
+ const vendorEmail = row["이메일"] || row["email"] || "";
+ const contactName = row["담당자명"] || row["contactName"] || "";
+ const contactEmail = row["담당자이메일"] || row["contactEmail"] || "";
+
+ // 담당자 정보 처리: 담당자가 없으면 벤더 이메일을 기본 담당자로 사용
+ const contacts = [];
+
+ if (contactName && contactEmail) {
+ // 명시적인 담당자가 있는 경우
+ contacts.push({
+ contactName: contactName,
+ contactPosition: row["담당자직책"] || row["contactPosition"] || "",
+ contactEmail: contactEmail,
+ contactPhone: row["담당자연락처"] || row["contactPhone"] || "",
+ country: row["담당자국가"] || row["contactCountry"] || null,
+ isPrimary: true
+ });
+ } else if (vendorEmail) {
+ // 담당자 정보가 없으면 벤더 정보를 기본 담당자로 사용
+ const representativeName = row["대표자명"] || row["representativeName"];
+ contacts.push({
+ contactName: representativeName || row["업체명"] || row["vendorName"] || "기본 담당자",
+ contactPosition: "기본 담당자",
+ contactEmail: vendorEmail,
+ contactPhone: row["대표자연락처"] || row["representativePhone"] || row["전화번호"] || row["phone"] || "",
+ country: row["국가"] || row["country"] || null,
+ isPrimary: true
+ });
+ }
+
+ return {
+ vendorName: row["업체명"] || row["vendorName"] || "",
+ vendorCode: row["업체코드"] || row["vendorCode"] || null,
+ email: vendorEmail,
+ taxId: row["사업자등록번호"] || row["taxId"] || "",
+ country: row["국가"] || row["country"] || null,
+ countryEng: row["영문국가명"] || row["countryEng"] || null,
+ countryFab: row["제조국"] || row["countryFab"] || null,
+ agentName: row["에이전트명"] || row["agentName"] || null,
+ agentPhone: row["에이전트연락처"] || row["agentPhone"] || null,
+ agentEmail: row["에이전트이메일"] || row["agentEmail"] || null,
+ address: row["주소"] || row["address"] || null,
+ phone: row["전화번호"] || row["phone"] || null,
+ website: row["웹사이트"] || row["website"] || null,
+ techVendorType: row["벤더타입"] || row["techVendorType"] || "",
+ representativeName: row["대표자명"] || row["representativeName"] || null,
+ representativeEmail: row["대표자이메일"] || row["representativeEmail"] || null,
+ representativePhone: row["대표자연락처"] || row["representativePhone"] || null,
+ representativeBirth: row["대표자생년월일"] || row["representativeBirth"] || null,
+ items: row["아이템"] || row["items"] || "",
+ contacts: contacts
+ };
+ });
+
+ setProgress(90);
+ toast.info(`${vendors.length}개 벤더 데이터를 서버로 전송 중...`);
+
+ // 벤더 데이터 가져오기 실행
+ const result = await importTechVendorsFromExcel(vendors);
+
+ setProgress(100);
+
+ if (result.success) {
+ // 상세한 결과 메시지 표시
+ if (result.message) {
+ toast.success(`가져오기 완료: ${result.message}`);
+ } else {
+ toast.success(`${vendors.length}개의 기술영업 벤더가 성공적으로 가져와졌습니다.`);
+ }
+
+ // 스킵된 벤더가 있으면 경고 메시지 추가
+ if (result.details?.skipped && result.details.skipped.length > 0) {
+ setTimeout(() => {
+ const skippedList = result.details.skipped
+ .map(item => `${item.vendorName} (${item.email}): ${item.reason}`)
+ .slice(0, 3) // 최대 3개만 표시
+ .join('\n');
+ const moreText = result.details.skipped.length > 3 ? `\n... 외 ${result.details.skipped.length - 3}개` : '';
+ toast.warning(`중복으로 스킵된 벤더:\n${skippedList}${moreText}`);
+ }, 1000);
+ }
+
+ // 오류가 있으면 오류 메시지 추가
+ if (result.details?.errors && result.details.errors.length > 0) {
+ setTimeout(() => {
+ const errorList = result.details.errors
+ .map(item => `${item.vendorName} (${item.email}): ${item.error}`)
+ .slice(0, 3) // 최대 3개만 표시
+ .join('\n');
+ const moreText = result.details.errors.length > 3 ? `\n... 외 ${result.details.errors.length - 3}개` : '';
+ toast.error(`처리 중 오류 발생:\n${errorList}${moreText}`);
+ }, 2000);
+ }
+ } else {
+ toast.error(result.error || "벤더 가져오기에 실패했습니다.");
+ }
+
+ // 상태 초기화 및 다이얼로그 닫기
+ setFile(null);
+ setOpen(false);
+
+ // 성공 콜백 호출
+ if (onSuccess) {
+ onSuccess();
+ }
+ } catch (error) {
+ console.error("Excel 파일 처리 중 오류 발생:", error);
+ setError(error instanceof Error ? error.message : "파일 처리 중 오류가 발생했습니다.");
+ } finally {
+ setIsUploading(false);
+ }
+ };
+
+ // 다이얼로그 열기/닫기 핸들러
+ const handleOpenChange = (newOpen: boolean) => {
+ if (!newOpen) {
+ // 닫을 때 상태 초기화
+ setFile(null);
+ setError(null);
+ setProgress(0);
+ if (fileInputRef.current) {
+ fileInputRef.current.value = "";
+ }
+ }
+ setOpen(newOpen);
+ };
+
+ return (
+ <>
+ <Button
+ variant="outline"
+ size="sm"
+ className="gap-2"
+ onClick={() => setOpen(true)}
+ disabled={isUploading}
+ >
+ <Upload className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Import</span>
+ </Button>
+
+ <Dialog open={open} onOpenChange={handleOpenChange}>
+ <DialogContent className="sm:max-w-[500px]">
+ <DialogHeader>
+ <DialogTitle>기술영업 벤더 가져오기</DialogTitle>
+ <DialogDescription>
+ 기술영업 벤더를 Excel 파일에서 가져옵니다.
+ <br />
+ 올바른 형식의 Excel 파일(.xlsx)을 업로드하세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4 py-4">
+ <div className="flex items-center gap-4">
+ <input
+ type="file"
+ ref={fileInputRef}
+ className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-foreground file:font-medium"
+ accept=".xlsx,.xls"
+ onChange={handleFileChange}
+ disabled={isUploading}
+ />
+ </div>
+
+ {file && (
+ <div className="text-sm text-muted-foreground">
+ 선택된 파일: <span className="font-medium">{file.name}</span> ({(file.size / 1024).toFixed(1)} KB)
+ </div>
+ )}
+
+ {isUploading && (
+ <div className="space-y-2">
+ <Progress value={progress} />
+ <p className="text-sm text-muted-foreground text-center">
+ {progress}% 완료
+ </p>
+ </div>
+ )}
+
+ {error && (
+ <div className="text-sm font-medium text-destructive">
+ {error}
+ </div>
+ )}
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => setOpen(false)}
+ disabled={isUploading}
+ >
+ 취소
+ </Button>
+ <Button
+ onClick={handleImport}
+ disabled={!file || isUploading}
+ >
+ {isUploading ? "처리 중..." : "가져오기"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </>
+ );
} \ No newline at end of file
diff --git a/lib/tech-vendors/table/tech-vendor-possible-items-view-dialog.tsx b/lib/tech-vendors/table/tech-vendor-possible-items-view-dialog.tsx
deleted file mode 100644
index b2b9c990..00000000
--- a/lib/tech-vendors/table/tech-vendor-possible-items-view-dialog.tsx
+++ /dev/null
@@ -1,201 +0,0 @@
-"use client"
-
-import * as React from "react"
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogDescription,
- DialogFooter,
-} from "@/components/ui/dialog"
-import { Button } from "@/components/ui/button"
-import { Badge } from "@/components/ui/badge"
-import { Package, FileText, X } from "lucide-react"
-import { getVendorItemsByType } from "../service"
-
-interface VendorPossibleItem {
- id: number;
- itemCode: string;
- itemList: string;
- workType: string | null;
- shipTypes?: string | null; // 조선용
- subItemList?: string | null; // 해양용
- techVendorType: "조선" | "해양TOP" | "해양HULL";
-}
-
-interface TechVendorPossibleItemsViewDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- vendor: {
- id: number;
- vendorName?: string | null;
- vendorCode?: string | null;
- techVendorType?: string | null;
- } | null;
-}
-
-export function TechVendorPossibleItemsViewDialog({
- open,
- onOpenChange,
- vendor,
-}: TechVendorPossibleItemsViewDialogProps) {
- const [items, setItems] = React.useState<VendorPossibleItem[]>([]);
- const [loading, setLoading] = React.useState(false);
-
- console.log("TechVendorPossibleItemsViewDialog render:", { open, vendor });
-
- React.useEffect(() => {
- console.log("TechVendorPossibleItemsViewDialog useEffect:", { open, vendorId: vendor?.id });
- if (open && vendor?.id && vendor?.techVendorType) {
- loadItems();
- }
- }, [open, vendor?.id, vendor?.techVendorType]);
-
- const loadItems = async () => {
- if (!vendor?.id || !vendor?.techVendorType) return;
-
- console.log("Loading items for vendor:", vendor.id, vendor.techVendorType);
- setLoading(true);
- try {
- const result = await getVendorItemsByType(vendor.id, vendor.techVendorType);
- console.log("Items loaded:", result);
- if (result.data) {
- setItems(result.data);
- }
- } catch (error) {
- console.error("Failed to load items:", error);
- } finally {
- setLoading(false);
- }
- };
-
- const getTypeLabel = (type: string) => {
- switch (type) {
- case "조선":
- return "조선";
- case "해양TOP":
- return "해양TOP";
- case "해양HULL":
- return "해양HULL";
- default:
- return type;
- }
- };
-
- const getTypeColor = (type: string) => {
- switch (type) {
- case "조선":
- return "bg-blue-100 text-blue-800";
- case "해양TOP":
- return "bg-green-100 text-green-800";
- case "해양HULL":
- return "bg-purple-100 text-purple-800";
- default:
- return "bg-gray-100 text-gray-800";
- }
- };
-
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-none w-[1200px]">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- 벤더 Possible Items 조회
- <Badge variant="outline" className="ml-2">
- {vendor?.vendorName || `Vendor #${vendor?.id}`}
- </Badge>
- {vendor?.techVendorType && (
- <Badge variant="secondary" className={getTypeColor(vendor.techVendorType)}>
- {getTypeLabel(vendor.techVendorType)}
- </Badge>
- )}
- </DialogTitle>
- <DialogDescription>
- 해당 벤더가 공급 가능한 아이템 목록을 확인할 수 있습니다.
- </DialogDescription>
- </DialogHeader>
-
- <div className="overflow-x-auto w-full">
- <div className="space-y-4">
- {loading ? (
- <div className="flex items-center justify-center py-8">
- <div className="text-center space-y-2">
- <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
- <p className="text-sm text-muted-foreground">아이템을 불러오는 중...</p>
- </div>
- </div>
- ) : items.length === 0 ? (
- <div className="flex flex-col items-center justify-center py-12 text-center">
- <FileText className="h-12 w-12 text-muted-foreground mb-3" />
- <h3 className="text-lg font-medium mb-1">등록된 아이템이 없습니다</h3>
- <p className="text-sm text-muted-foreground">
- 이 벤더에 등록된 아이템이 없습니다.
- </p>
- </div>
- ) : (
- <>
- {/* 헤더 행 (라벨) */}
- <div className="flex items-center gap-2 border-b pb-2 font-medium text-sm">
- <div className="w-[50px] text-center">No.</div>
- <div className="w-[120px] pl-2">타입</div>
- <div className="w-[200px] ">자재 그룹</div>
- <div className="w-[150px] ">공종</div>
- <div className="w-[300px] ">자재명</div>
- <div className="w-[150px] ">선종/자재명(상세)</div>
- </div>
-
- {/* 아이템 행들 */}
- <div className="max-h-[50vh] overflow-y-auto pr-1 space-y-2">
- {items.map((item, index) => (
- <div
- key={item.id}
- className="flex items-center gap-2 group hover:bg-gray-50 p-2 rounded-md transition-colors border"
- >
- <div className="w-[50px] text-center text-sm font-medium text-muted-foreground">
- {index + 1}
- </div>
- <div className="w-[120px] pl-2">
- <Badge variant="secondary" className={`text-xs ${getTypeColor(item.techVendorType)}`}>
- {getTypeLabel(item.techVendorType)}
- </Badge>
- </div>
- <div className="w-[200px] pl-2 font-mono text-sm">
- {item.itemCode}
- </div>
- <div className="w-[150px] pl-2 text-sm">
- {item.workType || '-'}
- </div>
- <div className="w-[300px] pl-2 font-medium">
- {item.itemList}
- </div>
- <div className="w-[150px] pl-2 text-sm">
- {item.techVendorType === '조선' ? item.shipTypes : item.subItemList}
- </div>
- </div>
- ))}
- </div>
-
- <div className="flex justify-between items-center pt-2 border-t">
- <div className="flex items-center gap-2">
- <Package className="h-4 w-4 text-muted-foreground" />
- <span className="text-sm text-muted-foreground">
- 총 {items.length}개 아이템
- </span>
- </div>
- </div>
- </>
- )}
- </div>
- </div>
-
- <DialogFooter className="mt-6">
- <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
- <X className="mr-2 h-4 w-4" />
- 닫기
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
-} \ No newline at end of file
diff --git a/lib/tech-vendors/table/tech-vendors-filter-sheet.tsx b/lib/tech-vendors/table/tech-vendors-filter-sheet.tsx
new file mode 100644
index 00000000..c6beb7a9
--- /dev/null
+++ b/lib/tech-vendors/table/tech-vendors-filter-sheet.tsx
@@ -0,0 +1,617 @@
+"use client"
+
+import { useEffect, useTransition, useState, useRef } from "react"
+import { z } from "zod"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Search, X } from "lucide-react"
+import { customAlphabet } from "nanoid"
+import { parseAsStringEnum, useQueryState } from "nuqs"
+
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import { Badge } from "@/components/ui/badge"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { cn } from "@/lib/utils"
+import { getFiltersStateParser } from "@/lib/parsers"
+import { Checkbox } from "@/components/ui/checkbox"
+
+// nanoid 생성기
+const generateId = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 6)
+
+// 필터 스키마 정의 (기술영업 벤더에 맞게 수정)
+const filterSchema = z.object({
+ vendorCode: z.string().optional(),
+ vendorName: z.string().optional(),
+ country: z.string().optional(),
+ status: z.string().optional(),
+ techVendorType: z.array(z.string()).optional(),
+ workTypes: z.array(z.string()).optional(),
+})
+
+// 상태 옵션 정의
+const statusOptions = [
+ { value: "ACTIVE", label: "활성 상태" },
+ { value: "INACTIVE", label: "비활성 상태" },
+ { value: "BLACKLISTED", label: "거래 금지" },
+ { value: "PENDING_INVITE", label: "초대 대기" },
+ { value: "INVITED", label: "초대 완료" },
+ { value: "QUOTE_COMPARISON", label: "견적 비교" },
+]
+
+// 벤더 타입 옵션
+const vendorTypeOptions = [
+ { value: "조선", label: "조선" },
+ { value: "해양TOP", label: "해양TOP" },
+ { value: "해양HULL", label: "해양HULL" },
+]
+
+// 공종 옵션
+const workTypeOptions = [
+ // 조선 workTypes
+ { value: "기장", label: "기장" },
+ { value: "전장", label: "전장" },
+ { value: "선실", label: "선실" },
+ { value: "배관", label: "배관" },
+ { value: "철의", label: "철의" },
+ { value: "선체", label: "선체" },
+ // 해양TOP workTypes
+ { value: "TM", label: "TM" },
+ { value: "TS", label: "TS" },
+ { value: "TE", label: "TE" },
+ { value: "TP", label: "TP" },
+ // 해양HULL workTypes
+ { value: "HA", label: "HA" },
+ { value: "HE", label: "HE" },
+ { value: "HH", label: "HH" },
+ { value: "HM", label: "HM" },
+ { value: "NC", label: "NC" },
+ { value: "HO", label: "HO" },
+ { value: "HP", label: "HP" },
+]
+
+type FilterFormValues = z.infer<typeof filterSchema>
+
+interface TechVendorsFilterSheetProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSearch?: () => void;
+ isLoading?: boolean;
+}
+
+export function TechVendorsFilterSheet({
+ isOpen,
+ onSearch,
+ isLoading = false
+}: TechVendorsFilterSheetProps) {
+
+
+ const [isPending, startTransition] = useTransition()
+
+ // 초기화 상태 추가 - 폼 초기화 중에는 상태 변경을 방지
+ const [isInitializing, setIsInitializing] = useState(false)
+ // 마지막으로 적용된 필터를 추적하기 위한 ref
+ const lastAppliedFilters = useRef<string>("")
+
+ // nuqs로 URL 상태 관리 - 파라미터명을 'filters'로 변경하여 searchParamsCache와 일치
+ const [filters] = useQueryState(
+ "filters",
+ getFiltersStateParser().withDefault([])
+ )
+
+ // joinOperator 설정
+ const [joinOperator, setJoinOperator] = useQueryState(
+ "joinOperator",
+ parseAsStringEnum(["and", "or"]).withDefault("and")
+ )
+
+ // 폼 상태 초기화
+ const form = useForm<FilterFormValues>({
+ resolver: zodResolver(filterSchema),
+ defaultValues: {
+ vendorCode: "",
+ vendorName: "",
+ country: "",
+ status: "",
+ techVendorType: [],
+ workTypes: [],
+ },
+ })
+
+ // URL 필터에서 초기 폼 상태 설정
+ useEffect(() => {
+ const currentFiltersString = JSON.stringify(filters);
+
+ if (isOpen && filters && filters.length > 0 && currentFiltersString !== lastAppliedFilters.current) {
+ setIsInitializing(true);
+
+ const formValues = { ...form.getValues() };
+ let formUpdated = false;
+
+ filters.forEach(filter => {
+ if (filter.id === "techVendorType" && Array.isArray(filter.value)) {
+ formValues.techVendorType = filter.value;
+ formUpdated = true;
+ } else if (filter.id === "workTypes" && Array.isArray(filter.value)) {
+ formValues.workTypes = filter.value;
+ formUpdated = true;
+ } else if (filter.id in formValues) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (formValues as any)[filter.id] = filter.value;
+ formUpdated = true;
+ }
+ });
+
+ if (formUpdated) {
+ form.reset(formValues);
+ lastAppliedFilters.current = currentFiltersString;
+ }
+
+ setIsInitializing(false);
+ }
+ }, [filters, isOpen, form])
+
+ // 현재 적용된 필터 카운트
+ const getActiveFilterCount = () => {
+ return filters?.length || 0
+ }
+
+ // 폼 제출 핸들러
+ async function onSubmit(data: FilterFormValues) {
+ if (isInitializing) return;
+
+ startTransition(async () => {
+ try {
+ const newFilters = []
+
+ if (data.vendorCode?.trim()) {
+ newFilters.push({
+ id: "vendorCode",
+ value: data.vendorCode.trim(),
+ type: "text",
+ operator: "iLike",
+ rowId: generateId()
+ })
+ }
+
+ if (data.vendorName?.trim()) {
+ newFilters.push({
+ id: "vendorName",
+ value: data.vendorName.trim(),
+ type: "text",
+ operator: "iLike",
+ rowId: generateId()
+ })
+ }
+
+ if (data.country?.trim()) {
+ newFilters.push({
+ id: "country",
+ value: data.country.trim(),
+ type: "text",
+ operator: "iLike",
+ rowId: generateId()
+ })
+ }
+
+ if (data.status?.trim()) {
+ newFilters.push({
+ id: "status",
+ value: data.status.trim(),
+ type: "select",
+ operator: "eq",
+ rowId: generateId()
+ })
+ }
+
+ if (data.techVendorType && data.techVendorType.length > 0) {
+ newFilters.push({
+ id: "techVendorType",
+ value: data.techVendorType,
+ type: "multi-select",
+ operator: "eq",
+ rowId: generateId()
+ })
+ }
+
+ if (data.workTypes && data.workTypes.length > 0) {
+ newFilters.push({
+ id: "workTypes",
+ value: data.workTypes,
+ type: "multi-select",
+ operator: "eq",
+ rowId: generateId()
+ })
+ }
+
+ // URL 수동 업데이트
+ const currentUrl = new URL(window.location.href);
+ const params = new URLSearchParams(currentUrl.search);
+
+ // 기존 필터 관련 파라미터 제거
+ params.delete('filters');
+ params.delete('joinOperator');
+ params.delete('page');
+
+ // 새로운 필터 추가
+ if (newFilters.length > 0) {
+ params.set('filters', JSON.stringify(newFilters));
+ params.set('joinOperator', joinOperator);
+ }
+
+ // 페이지를 1로 설정
+ params.set('page', '1');
+
+ const newUrl = `${currentUrl.pathname}?${params.toString()}`;
+
+ // 페이지 완전 새로고침
+ window.location.href = newUrl;
+
+ // 마지막 적용된 필터 업데이트
+ lastAppliedFilters.current = JSON.stringify(newFilters);
+
+ if (onSearch) {
+ onSearch();
+ }
+ } catch (error) {
+ console.error("벤더 필터 적용 오류:", error);
+ }
+ })
+ }
+
+ // 필터 초기화 핸들러
+ async function handleReset() {
+ try {
+ setIsInitializing(true);
+
+ form.reset({
+ vendorCode: "",
+ vendorName: "",
+ country: "",
+ status: "",
+ techVendorType: [],
+ workTypes: [],
+ });
+
+ const currentUrl = new URL(window.location.href);
+ const params = new URLSearchParams(currentUrl.search);
+
+ params.delete('filters');
+ params.delete('joinOperator');
+ params.set('page', '1');
+
+ const newUrl = `${currentUrl.pathname}?${params.toString()}`;
+ window.location.href = newUrl;
+
+ lastAppliedFilters.current = "";
+ setIsInitializing(false);
+ } catch (error) {
+ console.error("벤더 필터 초기화 오류:", error);
+ setIsInitializing(false);
+ }
+ }
+
+ if (!isOpen) {
+ return null;
+ }
+
+ return (
+ <div className="flex flex-col h-full max-h-full bg-[#F5F7FB] px-6 sm:px-8" style={{backgroundColor:"#F5F7FB", paddingLeft:"2rem", paddingRight:"2rem"}}>
+ {/* Filter Panel Header */}
+ <div className="flex items-center justify-between px-6 min-h-[60px] shrink-0">
+ <h3 className="text-lg font-semibold whitespace-nowrap">벤더 검색 필터</h3>
+ <div className="flex items-center gap-2">
+ {getActiveFilterCount() > 0 && (
+ <Badge variant="secondary" className="px-2 py-1">
+ {getActiveFilterCount()}개 필터 적용됨
+ </Badge>
+ )}
+ </div>
+ </div>
+
+ {/* Join Operator Selection */}
+ <div className="px-6 shrink-0">
+ <label className="text-sm font-medium">조건 결합 방식</label>
+ <Select
+ value={joinOperator}
+ onValueChange={(value: "and" | "or") => setJoinOperator(value)}
+ disabled={isInitializing}
+ >
+ <SelectTrigger className="h-8 w-[180px] mt-2 bg-white">
+ <SelectValue placeholder="조건 결합 방식" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="and">모든 조건 충족 (AND)</SelectItem>
+ <SelectItem value="or">하나라도 충족 (OR)</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col h-full min-h-0">
+ {/* Scrollable content area */}
+ <div className="flex-1 min-h-0 overflow-y-auto px-6 pb-4">
+ <div className="space-y-4 pt-2">
+ {/* 벤더코드 */}
+ <FormField
+ control={form.control}
+ name="vendorCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>벤더코드</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder="벤더코드 입력"
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("vendorCode", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 벤더명 */}
+ <FormField
+ control={form.control}
+ name="vendorName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>벤더명</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder="벤더명 입력"
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("vendorName", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 국가 */}
+ <FormField
+ control={form.control}
+ name="country"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>국가</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder="국가 입력"
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("country", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 상태 */}
+ <FormField
+ control={form.control}
+ name="status"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>상태</FormLabel>
+ <Select
+ value={field.value}
+ onValueChange={field.onChange}
+ disabled={isInitializing}
+ >
+ <FormControl>
+ <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}>
+ <div className="flex justify-between w-full">
+ <SelectValue placeholder="상태 선택" />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="h-4 w-4 -mr-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("status", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {statusOptions.map(option => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 벤더 타입 */}
+ <FormField
+ control={form.control}
+ name="techVendorType"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>벤더 타입</FormLabel>
+ <div className="space-y-2">
+ {vendorTypeOptions.map((option) => (
+ <div key={option.value} className="flex items-center space-x-2">
+ <Checkbox
+ id={`vendorType-${option.value}`}
+ checked={field.value?.includes(option.value) || false}
+ onCheckedChange={(checked) => {
+ const updatedValue = checked
+ ? [...(field.value || []), option.value]
+ : (field.value || []).filter((value) => value !== option.value);
+ field.onChange(updatedValue);
+ }}
+ disabled={isInitializing}
+ />
+ <label
+ htmlFor={`vendorType-${option.value}`}
+ className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
+ >
+ {option.label}
+ </label>
+ </div>
+ ))}
+ </div>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 공종 */}
+ <FormField
+ control={form.control}
+ name="workTypes"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>공종</FormLabel>
+ <div className="grid grid-cols-2 gap-2">
+ {workTypeOptions.map((option) => (
+ <div key={option.value} className="flex items-center space-x-2">
+ <Checkbox
+ id={`workType-${option.value}`}
+ checked={field.value?.includes(option.value) || false}
+ onCheckedChange={(checked) => {
+ const updatedValue = checked
+ ? [...(field.value || []), option.value]
+ : (field.value || []).filter((value) => value !== option.value);
+ field.onChange(updatedValue);
+ }}
+ disabled={isInitializing}
+ />
+ <label
+ htmlFor={`workType-${option.value}`}
+ className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
+ >
+ {option.label}
+ </label>
+ </div>
+ ))}
+ </div>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </div>
+
+ {/* Action buttons */}
+ <div className="shrink-0 border-t bg-white px-6 py-4">
+ <div className="flex gap-2">
+ <Button
+ type="submit"
+ className="flex-1"
+ disabled={isPending || isInitializing || isLoading}
+ >
+ {isPending ? (
+ <>
+ <Search className="mr-2 h-4 w-4 animate-spin" />
+ 검색 중...
+ </>
+ ) : (
+ <>
+ <Search className="mr-2 h-4 w-4" />
+ 검색
+ </>
+ )}
+ </Button>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={handleReset}
+ disabled={isPending || isInitializing || isLoading}
+ >
+ 초기화
+ </Button>
+ </div>
+ </div>
+ </form>
+ </Form>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/tech-vendors/table/tech-vendors-table-columns.tsx b/lib/tech-vendors/table/tech-vendors-table-columns.tsx
index 052794ce..5184e3f3 100644
--- a/lib/tech-vendors/table/tech-vendors-table-columns.tsx
+++ b/lib/tech-vendors/table/tech-vendors-table-columns.tsx
@@ -1,414 +1,376 @@
-"use client"
-
-import * as React from "react"
-import { type DataTableRowAction } from "@/types/table"
-import { type ColumnDef } from "@tanstack/react-table"
-import { Ellipsis, Package } from "lucide-react"
-import { toast } from "sonner"
-
-import { getErrorMessage } from "@/lib/handle-error"
-import { formatDate } from "@/lib/utils"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import { Checkbox } from "@/components/ui/checkbox"
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuRadioGroup,
- DropdownMenuRadioItem,
- DropdownMenuSub,
- DropdownMenuSubContent,
- DropdownMenuSubTrigger,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu"
-import { useRouter } from "next/navigation"
-
-import { TechVendor, techVendors } from "@/db/schema/techVendors"
-import { modifyTechVendor } from "../service"
-import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
-import { techVendorColumnsConfig } from "@/config/techVendorColumnsConfig"
-import { Separator } from "@/components/ui/separator"
-import { getVendorStatusIcon } from "../utils"
-
-// 타입 정의 추가
-type StatusType = (typeof techVendors.status.enumValues)[number];
-type BadgeVariantType = "default" | "secondary" | "destructive" | "outline";
-type StatusConfig = {
- variant: BadgeVariantType;
- className: string;
-};
-type StatusDisplayMap = {
- [key in StatusType]: string;
-};
-
-type NextRouter = ReturnType<typeof useRouter>;
-
-interface GetColumnsProps {
- setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<TechVendor> | null>>;
- router: NextRouter;
- openItemsDialog: (vendor: TechVendor) => void;
-}
-
-
-
-
-/**
- * tanstack table 컬럼 정의 (중첩 헤더 버전)
- */
-export function getColumns({ setRowAction, router, openItemsDialog }: GetColumnsProps): ColumnDef<TechVendor>[] {
- // ----------------------------------------------------------------
- // 1) select 컬럼 (체크박스)
- // ----------------------------------------------------------------
- const selectColumn: ColumnDef<TechVendor> = {
- id: "select",
- header: ({ table }) => (
- <Checkbox
- checked={
- table.getIsAllPageRowsSelected() ||
- (table.getIsSomePageRowsSelected() && "indeterminate")
- }
- onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
- aria-label="Select all"
- className="translate-y-0.5"
- />
- ),
- cell: ({ row }) => (
- <Checkbox
- checked={row.getIsSelected()}
- onCheckedChange={(value) => row.toggleSelected(!!value)}
- aria-label="Select row"
- className="translate-y-0.5"
- />
- ),
- size: 40,
- enableSorting: false,
- enableHiding: false,
- }
-
- // ----------------------------------------------------------------
- // 2) actions 컬럼 (Dropdown 메뉴)
- // ----------------------------------------------------------------
- const actionsColumn: ColumnDef<TechVendor> = {
- id: "actions",
- enableHiding: false,
- cell: function Cell({ row }) {
- const [isUpdatePending, startUpdateTransition] = React.useTransition()
-
- return (
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- aria-label="Open menu"
- variant="ghost"
- className="flex size-8 p-0 data-[state=open]:bg-muted"
- >
- <Ellipsis className="size-4" aria-hidden="true" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end" className="w-56">
- <DropdownMenuItem
- onSelect={() => setRowAction({ row, type: "update" })}
- >
- 레코드 편집
- </DropdownMenuItem>
-
- <DropdownMenuItem
- onSelect={() => {
- // 1) 만약 rowAction을 열고 싶다면
- // setRowAction({ row, type: "update" })
-
- // 2) 자세히 보기 페이지로 클라이언트 라우팅
- router.push(`/evcp/tech-vendors/${row.original.id}/info`);
- }}
- >
- 상세보기
- </DropdownMenuItem>
- <DropdownMenuItem
- onSelect={() => {
- // 새창으로 열기 위해 window.open() 사용
- window.open(`/evcp/tech-vendors/${row.original.id}/info`, '_blank');
- }}
- >
- 상세보기(새창)
- </DropdownMenuItem>
-
- <Separator />
- <DropdownMenuSub>
- <DropdownMenuSubTrigger>Status</DropdownMenuSubTrigger>
- <DropdownMenuSubContent>
- <DropdownMenuRadioGroup
- value={row.original.status}
- onValueChange={(value) => {
- startUpdateTransition(() => {
- toast.promise(
- modifyTechVendor({
- id: String(row.original.id),
- status: value as TechVendor["status"],
- vendorName: row.original.vendorName, // Required field from UpdateVendorSchema
- }),
- {
- loading: "Updating...",
- success: "Label updated",
- error: (err) => getErrorMessage(err),
- }
- )
- })
- }}
- >
- {techVendors.status.enumValues.map((status) => (
- <DropdownMenuRadioItem
- key={status}
- value={status}
- className="capitalize"
- disabled={isUpdatePending}
- >
- {status}
- </DropdownMenuRadioItem>
- ))}
- </DropdownMenuRadioGroup>
- </DropdownMenuSubContent>
- </DropdownMenuSub>
-
-
- </DropdownMenuContent>
- </DropdownMenu>
- )
- },
- size: 40,
- }
-
- // ----------------------------------------------------------------
- // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
- // ----------------------------------------------------------------
- // 3-1) groupMap: { [groupName]: ColumnDef<TechVendor>[] }
- const groupMap: Record<string, ColumnDef<TechVendor>[]> = {}
-
- techVendorColumnsConfig.forEach((cfg) => {
- // 만약 group가 없으면 "_noGroup" 처리
- const groupName = cfg.group || "_noGroup"
-
- if (!groupMap[groupName]) {
- groupMap[groupName] = []
- }
-
- // child column 정의
- const childCol: ColumnDef<TechVendor> = {
- accessorKey: cfg.id,
- enableResizing: true,
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title={cfg.label} />
- ),
- meta: {
- excelHeader: cfg.excelHeader,
- group: cfg.group,
- type: cfg.type,
- },
- cell: ({ row, cell }) => {
- // Status 컬럼 렌더링 개선 - 아이콘과 더 선명한 배경색 사용
- if (cfg.id === "status") {
- const statusVal = row.original.status;
- if (!statusVal) return null;
-
- // Status badge variant mapping - 더 뚜렷한 색상으로 변경
- const getStatusConfig = (status: StatusType): StatusConfig & { iconColor: string } => {
- switch (status) {
- case "ACTIVE":
- return {
- variant: "default",
- className: "bg-emerald-100 text-emerald-800 border-emerald-300 font-semibold",
- iconColor: "text-emerald-600"
- };
- case "INACTIVE":
- return {
- variant: "default",
- className: "bg-gray-100 text-gray-800 border-gray-300",
- iconColor: "text-gray-600"
- };
-
- case "PENDING_INVITE":
- return {
- variant: "default",
- className: "bg-blue-100 text-blue-800 border-blue-300",
- iconColor: "text-blue-600"
- };
- case "INVITED":
- return {
- variant: "default",
- className: "bg-green-100 text-green-800 border-green-300",
- iconColor: "text-green-600"
- };
- case "QUOTE_COMPARISON":
- return {
- variant: "default",
- className: "bg-purple-100 text-purple-800 border-purple-300",
- iconColor: "text-purple-600"
- };
- case "BLACKLISTED":
- return {
- variant: "destructive",
- className: "bg-slate-800 text-white border-slate-900",
- iconColor: "text-white"
- };
- default:
- return {
- variant: "default",
- className: "bg-gray-100 text-gray-800 border-gray-300",
- iconColor: "text-gray-600"
- };
- }
- };
-
- // 상태 표시 텍스트
- const getStatusDisplay = (status: StatusType): string => {
- const statusMap: StatusDisplayMap = {
- "ACTIVE": "활성 상태",
- "INACTIVE": "비활성 상태",
- "BLACKLISTED": "거래 금지",
- "PENDING_INVITE": "초대 대기",
- "INVITED": "초대 완료",
- "QUOTE_COMPARISON": "견적 비교"
- };
-
- return statusMap[status] || status;
- };
-
- const statusConfig = getStatusConfig(statusVal);
- const displayText = getStatusDisplay(statusVal);
- const StatusIcon = getVendorStatusIcon(statusVal);
-
- return (
- <div className="flex items-center gap-2">
- <Badge
- variant={statusConfig.variant}
- className={statusConfig.className}
- >
- <StatusIcon className={`mr-1 h-3.5 w-3.5 ${statusConfig.iconColor}`} />
- {displayText}
- </Badge>
- </div>
- );
- }
- // TechVendorType 컬럼을 badge로 표시
- if (cfg.id === "techVendorType") {
- const techVendorType = row.original.techVendorType as string | null | undefined;
-
- // 벤더 타입 파싱 개선 - null/undefined 안전 처리
- let types: string[] = [];
- if (!techVendorType) {
- types = [];
- } else if (techVendorType.startsWith('[') && techVendorType.endsWith(']')) {
- // JSON 배열 형태
- try {
- const parsed = JSON.parse(techVendorType);
- types = Array.isArray(parsed) ? parsed.filter(Boolean) : [techVendorType];
- } catch {
- types = [techVendorType];
- }
- } else if (techVendorType.includes(',')) {
- // 콤마로 구분된 문자열
- types = techVendorType.split(',').map(t => t.trim()).filter(Boolean);
- } else {
- // 단일 문자열
- types = [techVendorType.trim()].filter(Boolean);
- }
-
- // 벤더 타입 정렬 - 조선 > 해양top > 해양hull 순
- const typeOrder = ["조선", "해양top", "해양hull"];
- types.sort((a, b) => {
- const indexA = typeOrder.indexOf(a);
- const indexB = typeOrder.indexOf(b);
-
- // 정의된 순서에 있는 경우 우선순위 적용
- if (indexA !== -1 && indexB !== -1) {
- return indexA - indexB;
- }
- return a.localeCompare(b);
- });
-
- return (
- <div className="flex flex-wrap gap-1">
- {types.length > 0 ? types.map((type, index) => (
- <Badge key={`${type}-${index}`} variant="secondary" className="text-xs">
- {type}
- </Badge>
- )) : (
- <span className="text-muted-foreground">-</span>
- )}
- </div>
- );
- }
-
- // 날짜 컬럼 포맷팅
- if (cfg.type === "date" && cell.getValue()) {
- return formatDate(cell.getValue() as Date);
- }
-
- return cell.getValue();
- },
- };
-
- groupMap[groupName].push(childCol);
- });
-
- // 3-2) groupMap -> columns (그룹별 -> 중첩 헤더 ColumnDef[] 배열 변환)
- const columns: ColumnDef<TechVendor>[] = [
- selectColumn, // 1) 체크박스
- ];
-
- // 3-3) 그룹이 있는 컬럼들은 중첩 헤더로, 없는 것들은 그냥 컬럼으로
- Object.entries(groupMap).forEach(([groupName, childColumns]) => {
- if (groupName === "_noGroup") {
- // 그룹이 없는 컬럼들은 그냥 추가
- columns.push(...childColumns);
- } else {
- // 그룹이 있는 컬럼들은 헤더 아래 자식으로 중첩
- columns.push({
- id: groupName,
- header: groupName, // 그룹명을 헤더로
- columns: childColumns, // 그룹에 속한 컬럼들을 자식으로
- });
- }
- });
-
- // Possible Items 컬럼 추가 (액션 컬럼 직전에)
- const possibleItemsColumn: ColumnDef<TechVendor> = {
- id: "possibleItems",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="자재 그룹" />
- ),
- cell: ({ row }) => {
- const vendor = row.original;
-
- const handleClick = () => {
- openItemsDialog(vendor);
- };
-
- return (
- <Button
- variant="ghost"
- size="sm"
- className="relative h-8 w-8 p-0 group"
- onClick={handleClick}
- aria-label="View possible items"
- >
- <Package className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
- <span className="sr-only">
- Possible Items 보기
- </span>
- </Button>
- );
- },
- enableSorting: false,
- enableResizing: false,
- size: 80,
- meta: {
- excelHeader: "Possible Items"
- },
- };
-
- columns.push(possibleItemsColumn);
- columns.push(actionsColumn); // 마지막에 액션 컬럼 추가
-
- return columns;
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Ellipsis, Package } from "lucide-react"
+import { toast } from "sonner"
+
+import { getErrorMessage } from "@/lib/handle-error"
+import { formatDate } from "@/lib/utils"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { useRouter } from "next/navigation"
+
+import { TechVendor, techVendors } from "@/db/schema/techVendors"
+import { modifyTechVendor } from "../service"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { techVendorColumnsConfig } from "@/config/techVendorColumnsConfig"
+import { Separator } from "@/components/ui/separator"
+import { getVendorStatusIcon } from "../utils"
+
+// 타입 정의 추가
+type StatusType = (typeof techVendors.status.enumValues)[number];
+type BadgeVariantType = "default" | "secondary" | "destructive" | "outline";
+type StatusConfig = {
+ variant: BadgeVariantType;
+ className: string;
+};
+type StatusDisplayMap = {
+ [key in StatusType]: string;
+};
+
+type NextRouter = ReturnType<typeof useRouter>;
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<TechVendor> | null>>;
+ router: NextRouter;
+}
+
+
+
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef<TechVendor>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<TechVendor> = {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+ // ----------------------------------------------------------------
+ // 2) actions 컬럼 (Dropdown 메뉴)
+ // ----------------------------------------------------------------
+ const actionsColumn: ColumnDef<TechVendor> = {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-56">
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "update" })}
+ >
+ 레코드 편집
+ </DropdownMenuItem>
+
+ <DropdownMenuItem
+ onSelect={() => {
+ // 1) 만약 rowAction을 열고 싶다면
+ // setRowAction({ row, type: "update" })
+
+ // 2) 자세히 보기 페이지로 클라이언트 라우팅
+ router.push(`/evcp/tech-vendors/${row.original.id}/info`);
+ }}
+ >
+ 상세보기
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onSelect={() => {
+ // 새창으로 열기 위해 window.open() 사용
+ window.open(`/evcp/tech-vendors/${row.original.id}/info`, '_blank');
+ }}
+ >
+ 상세보기(새창)
+ </DropdownMenuItem>
+
+ <Separator />
+ <DropdownMenuSub>
+ <DropdownMenuSubTrigger>Status</DropdownMenuSubTrigger>
+ <DropdownMenuSubContent>
+ <DropdownMenuRadioGroup
+ value={row.original.status}
+ onValueChange={(value) => {
+ startUpdateTransition(() => {
+ toast.promise(
+ modifyTechVendor({
+ id: String(row.original.id),
+ status: value as TechVendor["status"],
+ vendorName: row.original.vendorName, // Required field from UpdateVendorSchema
+ }),
+ {
+ loading: "Updating...",
+ success: "Label updated",
+ error: (err) => getErrorMessage(err),
+ }
+ )
+ })
+ }}
+ >
+ {techVendors.status.enumValues.map((status) => (
+ <DropdownMenuRadioItem
+ key={status}
+ value={status}
+ className="capitalize"
+ disabled={isUpdatePending}
+ >
+ {status}
+ </DropdownMenuRadioItem>
+ ))}
+ </DropdownMenuRadioGroup>
+ </DropdownMenuSubContent>
+ </DropdownMenuSub>
+
+
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 40,
+ }
+
+ // ----------------------------------------------------------------
+ // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
+ // ----------------------------------------------------------------
+ // 3-1) groupMap: { [groupName]: ColumnDef<TechVendor>[] }
+ const groupMap: Record<string, ColumnDef<TechVendor>[]> = {}
+
+ techVendorColumnsConfig.forEach((cfg) => {
+ // 만약 group가 없으면 "_noGroup" 처리
+ const groupName = cfg.group || "_noGroup"
+
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // child column 정의
+ const childCol: ColumnDef<TechVendor> = {
+ accessorKey: cfg.id,
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title={cfg.label} />
+ ),
+ meta: {
+ excelHeader: cfg.excelHeader,
+ group: cfg.group,
+ type: cfg.type,
+ },
+ cell: ({ row, cell }) => {
+ // Status 컬럼 렌더링 개선 - 아이콘과 더 선명한 배경색 사용
+ if (cfg.id === "status") {
+ const statusVal = row.original.status;
+ if (!statusVal) return null;
+
+ // Status badge variant mapping - 더 뚜렷한 색상으로 변경
+ const getStatusConfig = (status: StatusType): StatusConfig & { iconColor: string } => {
+ switch (status) {
+ case "ACTIVE":
+ return {
+ variant: "default",
+ className: "bg-emerald-100 text-emerald-800 border-emerald-300 font-semibold",
+ iconColor: "text-emerald-600"
+ };
+ case "INACTIVE":
+ return {
+ variant: "default",
+ className: "bg-gray-100 text-gray-800 border-gray-300",
+ iconColor: "text-gray-600"
+ };
+
+ case "PENDING_INVITE":
+ return {
+ variant: "default",
+ className: "bg-blue-100 text-blue-800 border-blue-300",
+ iconColor: "text-blue-600"
+ };
+ case "INVITED":
+ return {
+ variant: "default",
+ className: "bg-green-100 text-green-800 border-green-300",
+ iconColor: "text-green-600"
+ };
+ case "QUOTE_COMPARISON":
+ return {
+ variant: "default",
+ className: "bg-purple-100 text-purple-800 border-purple-300",
+ iconColor: "text-purple-600"
+ };
+ case "BLACKLISTED":
+ return {
+ variant: "destructive",
+ className: "bg-slate-800 text-white border-slate-900",
+ iconColor: "text-white"
+ };
+ default:
+ return {
+ variant: "default",
+ className: "bg-gray-100 text-gray-800 border-gray-300",
+ iconColor: "text-gray-600"
+ };
+ }
+ };
+
+ // 상태 표시 텍스트
+ const getStatusDisplay = (status: StatusType): string => {
+ const statusMap: StatusDisplayMap = {
+ "ACTIVE": "활성 상태",
+ "INACTIVE": "비활성 상태",
+ "BLACKLISTED": "거래 금지",
+ "PENDING_INVITE": "초대 대기",
+ "INVITED": "초대 완료",
+ "QUOTE_COMPARISON": "견적 비교"
+ };
+
+ return statusMap[status] || status;
+ };
+
+ const statusConfig = getStatusConfig(statusVal);
+ const displayText = getStatusDisplay(statusVal);
+ const StatusIcon = getVendorStatusIcon(statusVal);
+
+ return (
+ <div className="flex items-center gap-2">
+ <Badge
+ variant={statusConfig.variant}
+ className={statusConfig.className}
+ >
+ <StatusIcon className={`mr-1 h-3.5 w-3.5 ${statusConfig.iconColor}`} />
+ {displayText}
+ </Badge>
+ </div>
+ );
+ }
+ // TechVendorType 컬럼을 badge로 표시
+ if (cfg.id === "techVendorType") {
+ const techVendorType = row.original.techVendorType as string | null | undefined;
+
+ // 벤더 타입 파싱 개선 - null/undefined 안전 처리
+ let types: string[] = [];
+ if (!techVendorType) {
+ types = [];
+ } else if (techVendorType.startsWith('[') && techVendorType.endsWith(']')) {
+ // JSON 배열 형태
+ try {
+ const parsed = JSON.parse(techVendorType);
+ types = Array.isArray(parsed) ? parsed.filter(Boolean) : [techVendorType];
+ } catch {
+ types = [techVendorType];
+ }
+ } else if (techVendorType.includes(',')) {
+ // 콤마로 구분된 문자열
+ types = techVendorType.split(',').map(t => t.trim()).filter(Boolean);
+ } else {
+ // 단일 문자열
+ types = [techVendorType.trim()].filter(Boolean);
+ }
+
+ // 벤더 타입 정렬 - 조선 > 해양top > 해양hull 순
+ const typeOrder = ["조선", "해양top", "해양hull"];
+ types.sort((a, b) => {
+ const indexA = typeOrder.indexOf(a);
+ const indexB = typeOrder.indexOf(b);
+
+ // 정의된 순서에 있는 경우 우선순위 적용
+ if (indexA !== -1 && indexB !== -1) {
+ return indexA - indexB;
+ }
+ return a.localeCompare(b);
+ });
+
+ return (
+ <div className="flex flex-wrap gap-1">
+ {types.length > 0 ? types.map((type, index) => (
+ <Badge key={`${type}-${index}`} variant="secondary" className="text-xs">
+ {type}
+ </Badge>
+ )) : (
+ <span className="text-muted-foreground">-</span>
+ )}
+ </div>
+ );
+ }
+
+ // 날짜 컬럼 포맷팅
+ if (cfg.type === "date" && cell.getValue()) {
+ return formatDate(cell.getValue() as Date);
+ }
+
+ return cell.getValue();
+ },
+ };
+
+ groupMap[groupName].push(childCol);
+ });
+
+ // 3-2) groupMap -> columns (그룹별 -> 중첩 헤더 ColumnDef[] 배열 변환)
+ const columns: ColumnDef<TechVendor>[] = [
+ selectColumn, // 1) 체크박스
+ ];
+
+ // 3-3) 그룹이 있는 컬럼들은 중첩 헤더로, 없는 것들은 그냥 컬럼으로
+ Object.entries(groupMap).forEach(([groupName, childColumns]) => {
+ if (groupName === "_noGroup") {
+ // 그룹이 없는 컬럼들은 그냥 추가
+ columns.push(...childColumns);
+ } else {
+ // 그룹이 있는 컬럼들은 헤더 아래 자식으로 중첩
+ columns.push({
+ id: groupName,
+ header: groupName, // 그룹명을 헤더로
+ columns: childColumns, // 그룹에 속한 컬럼들을 자식으로
+ });
+ }
+ });
+
+ columns.push(actionsColumn); // 마지막에 액션 컬럼 추가
+
+ return columns;
} \ No newline at end of file
diff --git a/lib/tech-vendors/table/tech-vendors-table-floating-bar.tsx b/lib/tech-vendors/table/tech-vendors-table-floating-bar.tsx
deleted file mode 100644
index 2cc83105..00000000
--- a/lib/tech-vendors/table/tech-vendors-table-floating-bar.tsx
+++ /dev/null
@@ -1,240 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { SelectTrigger } from "@radix-ui/react-select"
-import { type Table } from "@tanstack/react-table"
-import {
- ArrowUp,
- CheckCircle2,
- Download,
- Loader,
- Trash2,
- X,
-} from "lucide-react"
-import { toast } from "sonner"
-
-import { exportTableToExcel } from "@/lib/export"
-import { Button } from "@/components/ui/button"
-import { Portal } from "@/components/ui/portal"
-import {
- Select,
- SelectContent,
- SelectGroup,
- SelectItem,
-} from "@/components/ui/select"
-import { Separator } from "@/components/ui/separator"
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/ui/tooltip"
-import { Kbd } from "@/components/kbd"
-
-import { ActionConfirmDialog } from "@/components/ui/action-dialog"
-import { Vendor, vendors } from "@/db/schema/vendors"
-import { modifyTechVendors } from "../service"
-import { TechVendor } from "@/db/schema"
-
-interface VendorsTableFloatingBarProps {
- table: Table<Vendor>
-}
-
-
-export function VendorsTableFloatingBar({ table }: VendorsTableFloatingBarProps) {
- const rows = table.getFilteredSelectedRowModel().rows
-
- const [isPending, startTransition] = React.useTransition()
- const [action, setAction] = React.useState<
- "update-status" | "export" | "delete"
- >()
- // Clear selection on Escape key press
- React.useEffect(() => {
- function handleKeyDown(event: KeyboardEvent) {
- if (event.key === "Escape") {
- table.toggleAllRowsSelected(false)
- }
- }
-
- window.addEventListener("keydown", handleKeyDown)
- return () => window.removeEventListener("keydown", handleKeyDown)
- }, [table])
-
-
-
- // 공용 confirm dialog state
- const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false)
- const [confirmProps, setConfirmProps] = React.useState<{
- title: string
- description?: string
- onConfirm: () => Promise<void> | void
- }>({
- title: "",
- description: "",
- onConfirm: () => { },
- })
-
-
- // 2)
- function handleSelectStatus(newStatus: Vendor["status"]) {
- setAction("update-status")
-
- setConfirmProps({
- title: `Update ${rows.length} vendor${rows.length > 1 ? "s" : ""} with status: ${newStatus}?`,
- description: "This action will override their current status.",
- onConfirm: async () => {
- startTransition(async () => {
- const { error } = await modifyTechVendors({
- ids: rows.map((row) => String(row.original.id)),
- status: newStatus as TechVendor["status"],
- })
- if (error) {
- toast.error(error)
- return
- }
- toast.success("Vendors updated")
- setConfirmDialogOpen(false)
- })
- },
- })
- setConfirmDialogOpen(true)
- }
-
-
- return (
- <Portal >
- <div className="fixed inset-x-0 bottom-10 z-50 mx-auto w-fit px-2.5" style={{ bottom: '1.5rem' }}>
- <div className="w-full overflow-x-auto">
- <div className="mx-auto flex w-fit items-center gap-2 rounded-md border bg-background p-2 text-foreground shadow">
- <div className="flex h-7 items-center rounded-md border border-dashed pl-2.5 pr-1">
- <span className="whitespace-nowrap text-xs">
- {rows.length} selected
- </span>
- <Separator orientation="vertical" className="ml-2 mr-1" />
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="ghost"
- size="icon"
- className="size-5 hover:border"
- onClick={() => table.toggleAllRowsSelected(false)}
- >
- <X className="size-3.5 shrink-0" aria-hidden="true" />
- </Button>
- </TooltipTrigger>
- <TooltipContent className="flex items-center border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-zinc-900">
- <p className="mr-2">Clear selection</p>
- <Kbd abbrTitle="Escape" variant="outline">
- Esc
- </Kbd>
- </TooltipContent>
- </Tooltip>
- </div>
- <Separator orientation="vertical" className="hidden h-5 sm:block" />
- <div className="flex items-center gap-1.5">
- <Select
- onValueChange={(value: Vendor["status"]) => {
- handleSelectStatus(value)
- }}
- >
- <Tooltip>
- <SelectTrigger asChild>
- <TooltipTrigger asChild>
- <Button
- variant="secondary"
- size="icon"
- className="size-7 border data-[state=open]:bg-accent data-[state=open]:text-accent-foreground"
- disabled={isPending}
- >
- {isPending && action === "update-status" ? (
- <Loader
- className="size-3.5 animate-spin"
- aria-hidden="true"
- />
- ) : (
- <CheckCircle2
- className="size-3.5"
- aria-hidden="true"
- />
- )}
- </Button>
- </TooltipTrigger>
- </SelectTrigger>
- <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900">
- <p>Update status</p>
- </TooltipContent>
- </Tooltip>
- <SelectContent align="center">
- <SelectGroup>
- {vendors.status.enumValues.map((status) => (
- <SelectItem
- key={status}
- value={status}
- className="capitalize"
- >
- {status}
- </SelectItem>
- ))}
- </SelectGroup>
- </SelectContent>
- </Select>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="secondary"
- size="icon"
- className="size-7 border"
- onClick={() => {
- setAction("export")
-
- startTransition(() => {
- exportTableToExcel(table, {
- excludeColumns: ["select", "actions"],
- onlySelected: true,
- })
- })
- }}
- disabled={isPending}
- >
- {isPending && action === "export" ? (
- <Loader
- className="size-3.5 animate-spin"
- aria-hidden="true"
- />
- ) : (
- <Download className="size-3.5" aria-hidden="true" />
- )}
- </Button>
- </TooltipTrigger>
- <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900">
- <p>Export vendors</p>
- </TooltipContent>
- </Tooltip>
-
- </div>
- </div>
- </div>
- </div>
-
-
- {/* 공용 Confirm Dialog */}
- <ActionConfirmDialog
- open={confirmDialogOpen}
- onOpenChange={setConfirmDialogOpen}
- title={confirmProps.title}
- description={confirmProps.description}
- onConfirm={confirmProps.onConfirm}
- isLoading={isPending && (action === "delete" || action === "update-status")}
- confirmLabel={
- action === "delete"
- ? "Delete"
- : action === "update-status"
- ? "Update"
- : "Confirm"
- }
- confirmVariant={
- action === "delete" ? "destructive" : "default"
- }
- />
- </Portal>
- )
-}
diff --git a/lib/tech-vendors/table/tech-vendors-table-toolbar-actions.tsx b/lib/tech-vendors/table/tech-vendors-table-toolbar-actions.tsx
index ac7ee184..c5380140 100644
--- a/lib/tech-vendors/table/tech-vendors-table-toolbar-actions.tsx
+++ b/lib/tech-vendors/table/tech-vendors-table-toolbar-actions.tsx
@@ -1,197 +1,201 @@
-"use client"
-
-import * as React from "react"
-import { type Table } from "@tanstack/react-table"
-import { Download, FileSpreadsheet, FileText } from "lucide-react"
-import { toast } from "sonner"
-
-import { exportTableToExcel } from "@/lib/export"
-import { Button } from "@/components/ui/button"
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu"
-
-import { exportVendorsWithRelatedData } from "./vendor-all-export"
-import { TechVendor } from "@/db/schema/techVendors"
-import { ImportTechVendorButton } from "./import-button"
-import { exportTechVendorTemplate } from "./excel-template-download"
-import { AddVendorDialog } from "./add-vendor-dialog"
-import { InviteTechVendorDialog } from "./invite-tech-vendor-dialog"
-
-interface TechVendorsTableToolbarActionsProps {
- table: Table<TechVendor>
- onRefresh?: () => void
-}
-
-export function TechVendorsTableToolbarActions({ table, onRefresh }: TechVendorsTableToolbarActionsProps) {
- const [isExporting, setIsExporting] = React.useState(false);
-
- // 선택된 모든 벤더 가져오기
- const selectedVendors = React.useMemo(() => {
- return table
- .getFilteredSelectedRowModel()
- .rows
- .map(row => row.original);
- }, [table.getFilteredSelectedRowModel().rows]);
-
- // 초대 가능한 벤더들 (PENDING_INVITE 상태 + 이메일 있음)
- const invitableVendors = React.useMemo(() => {
- return selectedVendors.filter(vendor =>
- vendor.status === "PENDING_INVITE" && vendor.email
- );
- }, [selectedVendors]);
-
- // 테이블의 모든 벤더 가져오기 (필터링된 결과)
- const allFilteredVendors = React.useMemo(() => {
- return table
- .getFilteredRowModel()
- .rows
- .map(row => row.original);
- }, [table.getFilteredRowModel().rows]);
-
- // 선택된 벤더 통합 내보내기 함수 실행
- const handleSelectedExport = async () => {
- if (selectedVendors.length === 0) {
- toast.warning("내보낼 협력업체를 선택해주세요.");
- return;
- }
-
- try {
- setIsExporting(true);
- toast.info(`선택된 ${selectedVendors.length}개 업체의 정보를 내보내는 중입니다...`);
- await exportVendorsWithRelatedData(selectedVendors, "selected-vendors-detailed");
- toast.success(`${selectedVendors.length}개 업체 정보 내보내기가 완료되었습니다.`);
- } catch (error) {
- console.error("상세 정보 내보내기 오류:", error);
- toast.error("상세 정보 내보내기 중 오류가 발생했습니다.");
- } finally {
- setIsExporting(false);
- }
- };
-
- // 모든 벤더 통합 내보내기 함수 실행
- const handleAllFilteredExport = async () => {
- if (allFilteredVendors.length === 0) {
- toast.warning("내보낼 협력업체가 없습니다.");
- return;
- }
-
- try {
- setIsExporting(true);
- toast.info(`총 ${allFilteredVendors.length}개 업체의 정보를 내보내는 중입니다...`);
- await exportVendorsWithRelatedData(allFilteredVendors, "all-vendors-detailed");
- toast.success(`${allFilteredVendors.length}개 업체 정보 내보내기가 완료되었습니다.`);
- } catch (error) {
- console.error("상세 정보 내보내기 오류:", error);
- toast.error("상세 정보 내보내기 중 오류가 발생했습니다.");
- } finally {
- setIsExporting(false);
- }
- };
-
- // 벤더 추가 성공 시 테이블 새로고침을 위한 핸들러
- const handleVendorAddSuccess = () => {
- // 테이블 데이터 리프레시
- if (onRefresh) {
- onRefresh();
- } else {
- window.location.reload(); // 간단한 새로고침 방법
- }
- };
-
- return (
- <div className="flex items-center gap-2">
- {/* 초대 버튼 - 선택된 PENDING_REVIEW 벤더들이 있을 때만 표시 */}
- {invitableVendors.length > 0 && (
- <InviteTechVendorDialog
- vendors={invitableVendors}
- onSuccess={handleVendorAddSuccess}
- />
- )}
-
- {/* 벤더 추가 다이얼로그 추가 */}
- <AddVendorDialog onSuccess={handleVendorAddSuccess} />
-
- {/* Import 버튼 추가 */}
- <ImportTechVendorButton
- onSuccess={() => {
- // 성공 시 테이블 새로고침
- toast.success("업체 정보 가져오기가 완료되었습니다.");
- }}
- />
-
- {/* Export 드롭다운 메뉴로 변경 */}
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- variant="outline"
- size="sm"
- className="gap-2"
- disabled={isExporting}
- >
- <Download className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">
- {isExporting ? "내보내는 중..." : "Export"}
- </span>
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end">
- {/* 템플릿 다운로드 추가 */}
- <DropdownMenuItem
- onClick={() => exportTechVendorTemplate()}
- disabled={isExporting}
- >
- <FileText className="mr-2 size-4" />
- <span>Excel 템플릿 다운로드</span>
- </DropdownMenuItem>
-
- <DropdownMenuSeparator />
-
- {/* 기본 내보내기 - 현재 테이블에 보이는 데이터만 */}
- <DropdownMenuItem
- onClick={() =>
- exportTableToExcel(table, {
- filename: "vendors",
- excludeColumns: ["select", "actions"],
- })
- }
- disabled={isExporting}
- >
- <FileText className="mr-2 size-4" />
- <span>현재 테이블 데이터 내보내기</span>
- </DropdownMenuItem>
-
- <DropdownMenuSeparator />
-
- {/* 선택된 벤더만 상세 내보내기 */}
- <DropdownMenuItem
- onClick={handleSelectedExport}
- disabled={selectedVendors.length === 0 || isExporting}
- >
- <FileSpreadsheet className="mr-2 size-4" />
- <span>선택한 업체 상세 정보 내보내기</span>
- {selectedVendors.length > 0 && (
- <span className="ml-1 text-xs text-muted-foreground">({selectedVendors.length}개)</span>
- )}
- </DropdownMenuItem>
-
- {/* 모든 필터링된 벤더 상세 내보내기 */}
- <DropdownMenuItem
- onClick={handleAllFilteredExport}
- disabled={allFilteredVendors.length === 0 || isExporting}
- >
- <Download className="mr-2 size-4" />
- <span>모든 업체 상세 정보 내보내기</span>
- {allFilteredVendors.length > 0 && (
- <span className="ml-1 text-xs text-muted-foreground">({allFilteredVendors.length}개)</span>
- )}
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
- </div>
- )
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Download, FileSpreadsheet, FileText } from "lucide-react"
+import { toast } from "sonner"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
+
+// import { exportVendorsWithRelatedData } from "./vendor-all-export"
+import { TechVendor } from "@/db/schema/techVendors"
+import { ImportTechVendorButton } from "./import-button"
+import { exportTechVendorTemplate } from "./excel-template-download"
+import { AddVendorDialog } from "./add-vendor-dialog"
+import { InviteTechVendorDialog } from "./invite-tech-vendor-dialog"
+
+interface TechVendorsTableToolbarActionsProps {
+ table: Table<TechVendor>
+ onRefresh?: () => void
+}
+
+export function TechVendorsTableToolbarActions({
+ table,
+ onRefresh
+}: TechVendorsTableToolbarActionsProps) {
+ const [isExporting, setIsExporting] = React.useState(false);
+
+ // 선택된 모든 벤더 가져오기
+ const selectedVendors = React.useMemo(() => {
+ return table
+ .getFilteredSelectedRowModel()
+ .rows
+ .map(row => row.original);
+ }, [table.getFilteredSelectedRowModel().rows]);
+
+ // 초대 가능한 벤더들 (PENDING_INVITE 상태 + 이메일 있음)
+ const invitableVendors = React.useMemo(() => {
+ return selectedVendors.filter(vendor =>
+ vendor.status === "PENDING_INVITE" && vendor.email
+ );
+ }, [selectedVendors]);
+
+ // // 테이블의 모든 벤더 가져오기 (필터링된 결과)
+ // const allFilteredVendors = React.useMemo(() => {
+ // return table
+ // .getFilteredRowModel()
+ // .rows
+ // .map(row => row.original);
+ // }, [table.getFilteredRowModel().rows]);
+
+ // // 선택된 벤더 통합 내보내기 함수 실행
+ // const handleSelectedExport = async () => {
+ // if (selectedVendors.length === 0) {
+ // toast.warning("내보낼 협력업체를 선택해주세요.");
+ // return;
+ // }
+
+ // try {
+ // setIsExporting(true);
+ // toast.info(`선택된 ${selectedVendors.length}개 업체의 정보를 내보내는 중입니다...`);
+ // await exportVendorsWithRelatedData(selectedVendors, "selected-vendors-detailed");
+ // toast.success(`${selectedVendors.length}개 업체 정보 내보내기가 완료되었습니다.`);
+ // } catch (error) {
+ // console.error("상세 정보 내보내기 오류:", error);
+ // toast.error("상세 정보 내보내기 중 오류가 발생했습니다.");
+ // } finally {
+ // setIsExporting(false);
+ // }
+ // };
+
+ // // 모든 벤더 통합 내보내기 함수 실행
+ // const handleAllFilteredExport = async () => {
+ // if (allFilteredVendors.length === 0) {
+ // toast.warning("내보낼 협력업체가 없습니다.");
+ // return;
+ // }
+
+ // try {
+ // setIsExporting(true);
+ // toast.info(`총 ${allFilteredVendors.length}개 업체의 정보를 내보내는 중입니다...`);
+ // await exportVendorsWithRelatedData(allFilteredVendors, "all-vendors-detailed");
+ // toast.success(`${allFilteredVendors.length}개 업체 정보 내보내기가 완료되었습니다.`);
+ // } catch (error) {
+ // console.error("상세 정보 내보내기 오류:", error);
+ // toast.error("상세 정보 내보내기 중 오류가 발생했습니다.");
+ // } finally {
+ // setIsExporting(false);
+ // }
+ // };
+
+ // 벤더 추가 성공 시 테이블 새로고침을 위한 핸들러
+ const handleVendorAddSuccess = () => {
+ // 테이블 데이터 리프레시
+ if (onRefresh) {
+ onRefresh();
+ } else {
+ window.location.reload(); // 간단한 새로고침 방법
+ }
+ };
+
+ return (
+ <div className="flex items-center gap-2">
+ {/* 초대 버튼 - 선택된 PENDING_REVIEW 벤더들이 있을 때만 표시 */}
+ {invitableVendors.length > 0 && (
+ <InviteTechVendorDialog
+ vendors={invitableVendors}
+ onSuccess={handleVendorAddSuccess}
+ />
+ )}
+
+ {/* 벤더 추가 다이얼로그 추가 */}
+ <AddVendorDialog onSuccess={handleVendorAddSuccess} />
+
+ {/* Import 버튼 추가 */}
+ <ImportTechVendorButton
+ onSuccess={() => {
+ // 성공 시 테이블 새로고침
+ toast.success("업체 정보 가져오기가 완료되었습니다.");
+ }}
+ />
+
+ {/* Export 드롭다운 메뉴로 변경 */}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ className="gap-2"
+ disabled={isExporting}
+ >
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">
+ {isExporting ? "내보내는 중..." : "Export"}
+ </span>
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ {/* 템플릿 다운로드 추가 */}
+ <DropdownMenuItem
+ onClick={() => exportTechVendorTemplate()}
+ disabled={isExporting}
+ >
+ <FileText className="mr-2 size-4" />
+ <span>Excel 템플릿 다운로드</span>
+ </DropdownMenuItem>
+
+ <DropdownMenuSeparator />
+
+ {/* 기본 내보내기 - 현재 테이블에 보이는 데이터만 */}
+ <DropdownMenuItem
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "vendors",
+ excludeColumns: ["select", "actions"],
+ })
+ }
+ disabled={isExporting}
+ >
+ <FileText className="mr-2 size-4" />
+ <span>현재 테이블 데이터 내보내기</span>
+ </DropdownMenuItem>
+
+ <DropdownMenuSeparator />
+
+ {/* 선택된 벤더만 상세 내보내기 */}
+ {/* <DropdownMenuItem
+ onClick={handleSelectedExport}
+ disabled={selectedVendors.length === 0 || isExporting}
+ >
+ <FileSpreadsheet className="mr-2 size-4" />
+ <span>선택한 업체 상세 정보 내보내기</span>
+ {selectedVendors.length > 0 && (
+ <span className="ml-1 text-xs text-muted-foreground">({selectedVendors.length}개)</span>
+ )}
+ </DropdownMenuItem> */}
+
+ {/* 모든 필터링된 벤더 상세 내보내기 */}
+ {/* <DropdownMenuItem
+ onClick={handleAllFilteredExport}
+ disabled={allFilteredVendors.length === 0 || isExporting}
+ >
+ <Download className="mr-2 size-4" />
+ <span>모든 업체 상세 정보 내보내기</span>
+ {allFilteredVendors.length > 0 && (
+ <span className="ml-1 text-xs text-muted-foreground">({allFilteredVendors.length}개)</span>
+ )}
+ </DropdownMenuItem> */}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+ )
} \ No newline at end of file
diff --git a/lib/tech-vendors/table/tech-vendors-table.tsx b/lib/tech-vendors/table/tech-vendors-table.tsx
index a8e18501..7f9625cf 100644
--- a/lib/tech-vendors/table/tech-vendors-table.tsx
+++ b/lib/tech-vendors/table/tech-vendors-table.tsx
@@ -1,195 +1,277 @@
-"use client"
-
-import * as React from "react"
-import { useRouter } from "next/navigation"
-import type {
- DataTableAdvancedFilterField,
- DataTableFilterField,
- DataTableRowAction,
-} from "@/types/table"
-
-import { useDataTable } from "@/hooks/use-data-table"
-import { DataTable } from "@/components/data-table/data-table"
-import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
-import { getColumns } from "./tech-vendors-table-columns"
-import { getTechVendors, getTechVendorStatusCounts } from "../service"
-import { TechVendor, techVendors, TechVendorWithAttachments } from "@/db/schema/techVendors"
-import { TechVendorsTableToolbarActions } from "./tech-vendors-table-toolbar-actions"
-import { UpdateVendorSheet } from "./update-vendor-sheet"
-import { getVendorStatusIcon } from "../utils"
-import { TechVendorPossibleItemsViewDialog } from "./tech-vendor-possible-items-view-dialog"
-// import { ViewTechVendorLogsDialog } from "./view-tech-vendors-logs-dialog"
-
-interface TechVendorsTableProps {
- promises: Promise<
- [
- Awaited<ReturnType<typeof getTechVendors>>,
- Awaited<ReturnType<typeof getTechVendorStatusCounts>>
- ]
- >
-}
-
-export function TechVendorsTable({ promises }: TechVendorsTableProps) {
- // Suspense로 받아온 데이터
- const [{ data, pageCount }, statusCounts] = React.use(promises)
- const [isCompact, setIsCompact] = React.useState<boolean>(false)
-
- const [rowAction, setRowAction] = React.useState<DataTableRowAction<TechVendor> | null>(null)
- const [itemsDialogOpen, setItemsDialogOpen] = React.useState(false)
- const [selectedVendorForItems, setSelectedVendorForItems] = React.useState<TechVendor | null>(null)
-
- // **router** 획득
- const router = useRouter()
-
- // openItemsDialog 함수 정의
- const openItemsDialog = React.useCallback((vendor: TechVendor) => {
- setSelectedVendorForItems(vendor)
- setItemsDialogOpen(true)
- }, [])
-
- // getColumns() 호출 시, router와 openItemsDialog를 주입
- const columns = React.useMemo(
- () => getColumns({ setRowAction, router, openItemsDialog }),
- [setRowAction, router, openItemsDialog]
- )
-
- // 상태 한글 변환 유틸리티 함수
- const getStatusDisplay = (status: string): string => {
- const statusMap: Record<string, string> = {
- "ACTIVE": "활성 상태",
- "INACTIVE": "비활성 상태",
- "BLACKLISTED": "거래 금지",
- "PENDING_INVITE": "초대 대기",
- "INVITED": "초대 완료",
- "QUOTE_COMPARISON": "견적 비교",
- };
-
- return statusMap[status] || status;
- };
-
- const filterFields: DataTableFilterField<TechVendorWithAttachments>[] = [
- {
- id: "status",
- label: "상태",
- options: techVendors.status.enumValues.map((status) => ({
- label: getStatusDisplay(status),
- value: status,
- count: statusCounts[status],
- })),
- },
-
- { id: "vendorCode", label: "업체 코드" },
- ]
-
- const advancedFilterFields: DataTableAdvancedFilterField<TechVendorWithAttachments>[] = [
- { id: "vendorName", label: "업체명", type: "text" },
- { id: "vendorCode", label: "업체코드", type: "text" },
- { id: "email", label: "이메일", type: "text" },
- { id: "country", label: "국가", type: "text" },
- {
- id: "status",
- label: "업체승인상태",
- type: "multi-select",
- options: techVendors.status.enumValues.map((status) => ({
- label: getStatusDisplay(status),
- value: status,
- count: statusCounts[status],
- icon: getVendorStatusIcon(status),
- })),
- },
- {
- id: "techVendorType",
- label: "벤더 타입",
- type: "multi-select",
- options: [
- { label: "조선", value: "조선" },
- { label: "해양TOP", value: "해양TOP" },
- { label: "해양HULL", value: "해양HULL" },
- ],
- },
- {
- id: "workTypes",
- label: "Work Type",
- type: "multi-select",
- options: [
- // 조선 workTypes
- { label: "기장", value: "기장" },
- { label: "전장", value: "전장" },
- { label: "선실", value: "선실" },
- { label: "배관", value: "배관" },
- { label: "철의", value: "철의" },
- // 해양TOP workTypes
- { label: "TM", value: "TM" },
- { label: "TS", value: "TS" },
- { label: "TE", value: "TE" },
- { label: "TP", value: "TP" },
- // 해양HULL workTypes
- { label: "HA", value: "HA" },
- { label: "HE", value: "HE" },
- { label: "HH", value: "HH" },
- { label: "HM", value: "HM" },
- { label: "NC", value: "NC" },
- ],
- },
- { id: "createdAt", label: "등록일", type: "date" },
- { id: "updatedAt", label: "수정일", type: "date" },
- ]
-
- const { table } = useDataTable({
- data,
- columns,
- pageCount,
- filterFields,
- enablePinning: true,
- enableAdvancedFilter: true,
- initialState: {
- sorting: [{ id: "createdAt", desc: true }],
- columnPinning: { right: ["actions", "possibleItems"] },
- },
- getRowId: (originalRow) => String(originalRow.id),
- shallow: false,
- clearOnDefault: true,
- })
-
- const handleCompactChange = React.useCallback((compact: boolean) => {
- setIsCompact(compact)
- }, [])
-
- // 테이블 새로고침 핸들러
- const handleRefresh = React.useCallback(() => {
- router.refresh()
- }, [router])
-
-
- return (
- <>
- <DataTable
- table={table}
- compact={isCompact}
- // floatingBar={<TechVendorsTableFloatingBar table={table} />}
- >
- <DataTableAdvancedToolbar
- table={table}
- filterFields={advancedFilterFields}
- shallow={false}
- enableCompactToggle={true}
- compactStorageKey="techVendorsTableCompact"
- onCompactChange={handleCompactChange}
- >
- <TechVendorsTableToolbarActions table={table} onRefresh={handleRefresh} />
- </DataTableAdvancedToolbar>
- </DataTable>
- <UpdateVendorSheet
- open={rowAction?.type === "update"}
- onOpenChange={() => setRowAction(null)}
- vendor={rowAction?.row.original ?? null}
- />
- <TechVendorPossibleItemsViewDialog
- open={itemsDialogOpen}
- onOpenChange={setItemsDialogOpen}
- vendor={selectedVendorForItems}
- />
-
- </>
- )
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+import { cn } from "@/lib/utils"
+import { PanelLeftClose, PanelLeftOpen } from "lucide-react"
+
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import { Button } from "@/components/ui/button"
+import { getColumns } from "./tech-vendors-table-columns"
+import { getTechVendors, getTechVendorStatusCounts } from "../service"
+import { TechVendor, techVendors, TechVendorWithAttachments } from "@/db/schema/techVendors"
+import { TechVendorsTableToolbarActions } from "./tech-vendors-table-toolbar-actions"
+import { UpdateVendorSheet } from "./update-vendor-sheet"
+import { getVendorStatusIcon } from "../utils"
+import { TechVendorsFilterSheet } from "./tech-vendors-filter-sheet"
+// import { ViewTechVendorLogsDialog } from "./view-tech-vendors-logs-dialog"
+
+// 필터 패널 관련 상수
+const FILTER_PANEL_WIDTH = 400;
+const LAYOUT_HEADER_HEIGHT = 60;
+const LOCAL_HEADER_HEIGHT = 60;
+const FIXED_FILTER_HEIGHT = "calc(100vh - 120px)";
+
+interface TechVendorsTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getTechVendors>>,
+ Awaited<ReturnType<typeof getTechVendorStatusCounts>>
+ ]
+ >
+ className?: string;
+ calculatedHeight?: string;
+}
+
+export function TechVendorsTable({
+ promises,
+ className,
+ calculatedHeight
+}: TechVendorsTableProps) {
+ // Suspense로 받아온 데이터
+ const [{ data, pageCount }, statusCounts] = React.use(promises)
+ const [isCompact, setIsCompact] = React.useState<boolean>(false)
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<TechVendor> | null>(null)
+
+ // 필터 패널 상태
+ const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false)
+
+ // **router** 획득
+ const router = useRouter()
+
+ // getColumns() 호출 시, router를 주입
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction, router }),
+ [setRowAction, router]
+ )
+
+ // 상태 한글 변환 유틸리티 함수
+ const getStatusDisplay = (status: string): string => {
+ const statusMap: Record<string, string> = {
+ "ACTIVE": "활성 상태",
+ "INACTIVE": "비활성 상태",
+ "BLACKLISTED": "거래 금지",
+ "PENDING_INVITE": "초대 대기",
+ "INVITED": "초대 완료",
+ "QUOTE_COMPARISON": "견적 비교",
+ };
+
+ return statusMap[status] || status;
+ };
+
+ const filterFields: DataTableFilterField<TechVendorWithAttachments>[] = [
+ {
+ id: "status",
+ label: "상태",
+ options: techVendors.status.enumValues.map((status) => ({
+ label: getStatusDisplay(status),
+ value: status,
+ count: statusCounts[status],
+ })),
+ },
+
+ { id: "vendorCode", label: "업체 코드" },
+ ]
+
+ const advancedFilterFields: DataTableAdvancedFilterField<TechVendorWithAttachments>[] = [
+ { id: "vendorName", label: "업체명", type: "text" },
+ { id: "vendorCode", label: "업체코드", type: "text" },
+ { id: "email", label: "이메일", type: "text" },
+ { id: "country", label: "국가", type: "text" },
+ {
+ id: "status",
+ label: "업체승인상태",
+ type: "multi-select",
+ options: techVendors.status.enumValues.map((status) => ({
+ label: getStatusDisplay(status),
+ value: status,
+ count: statusCounts[status],
+ icon: getVendorStatusIcon(status),
+ })),
+ },
+ {
+ id: "techVendorType",
+ label: "벤더 타입",
+ type: "multi-select",
+ options: [
+ { label: "조선", value: "조선" },
+ { label: "해양TOP", value: "해양TOP" },
+ { label: "해양HULL", value: "해양HULL" },
+ ],
+ },
+ {
+ id: "workTypes",
+ label: "Work Type",
+ type: "multi-select",
+ options: [
+ // 조선 workTypes
+ { label: "기장", value: "기장" },
+ { label: "전장", value: "전장" },
+ { label: "선실", value: "선실" },
+ { label: "배관", value: "배관" },
+ { label: "철의", value: "철의" },
+ { label: "선체", value: "선체" },
+ // 해양TOP workTypes
+ { label: "TM", value: "TM" },
+ { label: "TS", value: "TS" },
+ { label: "TE", value: "TE" },
+ { label: "TP", value: "TP" },
+ // 해양HULL workTypes
+ { label: "HA", value: "HA" },
+ { label: "HE", value: "HE" },
+ { label: "HH", value: "HH" },
+ { label: "HM", value: "HM" },
+ { label: "NC", value: "NC" },
+ { label: "HP", value: "HP" },
+ { label: "HO", value: "HO" },
+ ],
+ },
+ { id: "createdAt", label: "등록일", type: "date" },
+ { id: "updatedAt", label: "수정일", type: "date" },
+ ]
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "createdAt", desc: true }],
+ columnPinning: { right: ["actions", "possibleItems"] },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ const handleCompactChange = React.useCallback((compact: boolean) => {
+ setIsCompact(compact)
+ }, [])
+
+ // 테이블 새로고침 핸들러
+ const handleRefresh = React.useCallback(() => {
+ router.refresh()
+ }, [router])
+
+ // 필터 패널 검색 핸들러
+ const handleSearch = React.useCallback(() => {
+ router.refresh()
+ }, [router])
+
+ return (
+ <div
+ className={cn("flex flex-col relative", className)}
+ style={{ height: calculatedHeight }}
+ >
+ {/* Filter Panel */}
+ <div
+ className={cn(
+ "fixed left-0 bg-background border-r z-30 flex flex-col transition-all duration-300 ease-in-out overflow-hidden",
+ isFilterPanelOpen ? "border-r shadow-lg" : "border-r-0"
+ )}
+ style={{
+ width: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px',
+ top: `${LAYOUT_HEADER_HEIGHT*2}px`,
+ height: FIXED_FILTER_HEIGHT
+ }}
+ >
+ {/* Filter Content */}
+ <div className="h-full">
+ <TechVendorsFilterSheet
+ isOpen={isFilterPanelOpen}
+ onClose={() => setIsFilterPanelOpen(false)}
+ onSearch={handleSearch}
+ isLoading={false}
+ />
+ </div>
+ </div>
+
+ {/* Main Content */}
+ <div
+ className="flex flex-col transition-all duration-300 ease-in-out"
+ style={{
+ width: isFilterPanelOpen ? `calc(100% - ${FILTER_PANEL_WIDTH}px)` : '100%',
+ marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px',
+ height: '100%'
+ }}
+ >
+ {/* Header Bar - 고정 높이 */}
+ <div
+ className="flex items-center justify-between p-4 bg-background border-b"
+ style={{
+ height: `${LOCAL_HEADER_HEIGHT}px`,
+ flexShrink: 0
+ }}
+ >
+ <div className="flex items-center gap-3">
+ <Button
+ variant="outline"
+ size="sm"
+ type='button'
+ onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)}
+ className="flex items-center shadow-sm"
+ >
+ {isFilterPanelOpen ? <PanelLeftClose className="size-4"/> : <PanelLeftOpen className="size-4"/>}
+ </Button>
+ </div>
+
+ {/* Right side info
+ <div className="text-sm text-muted-foreground">
+ {data && (
+ <span>총 {data.length || 0}건</span>
+ )}
+ </div> */}
+ </div>
+
+ {/* DataTable */}
+ <div className="flex-1 overflow-hidden">
+ <DataTable
+ table={table}
+ compact={isCompact}
+ // floatingBar={<TechVendorsTableFloatingBar table={table} />}
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ enableCompactToggle={true}
+ compactStorageKey="techVendorsTableCompact"
+ onCompactChange={handleCompactChange}
+ >
+ <TechVendorsTableToolbarActions
+ table={table}
+ onRefresh={handleRefresh}
+ />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ </div>
+ </div>
+
+ <UpdateVendorSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ vendor={rowAction?.row.original ?? null}
+ />
+ </div>
+ )
} \ No newline at end of file
diff --git a/lib/tech-vendors/table/update-vendor-sheet.tsx b/lib/tech-vendors/table/update-vendor-sheet.tsx
index 1d05b0c4..8498df51 100644
--- a/lib/tech-vendors/table/update-vendor-sheet.tsx
+++ b/lib/tech-vendors/table/update-vendor-sheet.tsx
@@ -1,413 +1,624 @@
-"use client"
-
-import * as React from "react"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { useForm } from "react-hook-form"
-import {
- Loader,
- Activity,
- AlertCircle,
- AlertTriangle,
- Circle as CircleIcon,
- Building,
-} from "lucide-react"
-import { toast } from "sonner"
-
-import { Button } from "@/components/ui/button"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
- FormDescription
-} from "@/components/ui/form"
-import { Input } from "@/components/ui/input"
-import {
- Sheet,
- SheetClose,
- SheetContent,
- SheetDescription,
- SheetFooter,
- SheetHeader,
- SheetTitle,
-} from "@/components/ui/sheet"
-import {
- Select,
- SelectContent,
- SelectGroup,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
-import { useSession } from "next-auth/react" // Import useSession
-
-import { TechVendor, techVendors } from "@/db/schema/techVendors"
-import { updateTechVendorSchema, type UpdateTechVendorSchema } from "../validations"
-import { modifyTechVendor } from "../service"
-
-interface UpdateVendorSheetProps
- extends React.ComponentPropsWithRef<typeof Sheet> {
- vendor: TechVendor | null
-}
-type StatusType = (typeof techVendors.status.enumValues)[number];
-
-type StatusConfig = {
- Icon: React.ElementType;
- className: string;
- label: string;
-};
-
-// 상태 표시 유틸리티 함수
-const getStatusConfig = (status: StatusType): StatusConfig => {
- switch(status) {
- case "ACTIVE":
- return {
- Icon: Activity,
- className: "text-emerald-600",
- label: "활성 상태"
- };
- case "INACTIVE":
- return {
- Icon: AlertCircle,
- className: "text-gray-600",
- label: "비활성 상태"
- };
- case "BLACKLISTED":
- return {
- Icon: AlertTriangle,
- className: "text-slate-800",
- label: "거래 금지"
- };
- case "PENDING_REVIEW":
- return {
- Icon: AlertTriangle,
- className: "text-slate-800",
- label: "비교 견적"
- };
- default:
- return {
- Icon: CircleIcon,
- className: "text-gray-600",
- label: status
- };
- }
-};
-
-
-// 폼 컴포넌트
-export function UpdateVendorSheet({ vendor, ...props }: UpdateVendorSheetProps) {
- const [isPending, startTransition] = React.useTransition()
- const { data: session } = useSession()
- // 폼 정의 - UpdateVendorSchema 타입을 직접 사용
- const form = useForm<UpdateTechVendorSchema>({
- resolver: zodResolver(updateTechVendorSchema),
- defaultValues: {
- // 업체 기본 정보
- vendorName: vendor?.vendorName ?? "",
- vendorCode: vendor?.vendorCode ?? "",
- address: vendor?.address ?? "",
- country: vendor?.country ?? "",
- phone: vendor?.phone ?? "",
- email: vendor?.email ?? "",
- website: vendor?.website ?? "",
- techVendorType: vendor?.techVendorType ? vendor.techVendorType.split(',').map(s => s.trim()).filter(Boolean) as ("조선" | "해양TOP" | "해양HULL")[] : [],
- status: vendor?.status ?? "ACTIVE",
- },
- })
-
- React.useEffect(() => {
- if (vendor) {
- form.reset({
- vendorName: vendor?.vendorName ?? "",
- vendorCode: vendor?.vendorCode ?? "",
- address: vendor?.address ?? "",
- country: vendor?.country ?? "",
- phone: vendor?.phone ?? "",
- email: vendor?.email ?? "",
- website: vendor?.website ?? "",
- techVendorType: vendor?.techVendorType ? vendor.techVendorType.split(',').map(s => s.trim()).filter(Boolean) as ("조선" | "해양TOP" | "해양HULL")[] : [],
- status: vendor?.status ?? "ACTIVE",
-
- });
- }
- }, [vendor, form]);
-
-
- // 제출 핸들러
- async function onSubmit(data: UpdateTechVendorSchema) {
- if (!vendor) return
-
- if (!session?.user?.id) {
- toast.error("사용자 인증 정보를 찾을 수 없습니다.")
- return
- }
- startTransition(async () => {
- try {
- // Add status change comment if status has changed
- const oldStatus = vendor.status ?? "ACTIVE" // Default to ACTIVE if undefined
- const newStatus = data.status ?? "ACTIVE" // Default to ACTIVE if undefined
-
- const statusComment =
- oldStatus !== newStatus
- ? `상태 변경: ${getStatusConfig(oldStatus).label} → ${getStatusConfig(newStatus).label}`
- : "" // Empty string instead of undefined
-
- // 업체 정보 업데이트 - userId와 상태 변경 코멘트 추가
- const { error } = await modifyTechVendor({
- id: String(vendor.id),
- userId: Number(session.user.id), // Add user ID from session
- comment: statusComment, // Add comment for status changes
- ...data, // 모든 데이터 전달 - 서비스 함수에서 필요한 필드만 처리
- techVendorType: Array.isArray(data.techVendorType) ? data.techVendorType.join(',') : undefined,
- })
-
- if (error) throw new Error(error)
-
- toast.success("업체 정보가 업데이트되었습니다!")
- form.reset()
- props.onOpenChange?.(false)
- } catch (err: unknown) {
- toast.error(String(err))
- }
- })
-}
-
- return (
- <Sheet {...props}>
- <SheetContent className="flex flex-col gap-6 sm:max-w-lg overflow-y-auto">
- <SheetHeader className="text-left">
- <SheetTitle>업체 정보 수정</SheetTitle>
- <SheetDescription>
- 업체 세부 정보를 수정하고 변경 사항을 저장하세요
- </SheetDescription>
- </SheetHeader>
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
- {/* 업체 기본 정보 섹션 */}
- <div className="space-y-4">
- <div className="flex items-center">
- <Building className="mr-2 h-5 w-5 text-muted-foreground" />
- <h3 className="text-sm font-medium">업체 기본 정보</h3>
- </div>
- <FormDescription>
- 업체가 제공한 기본 정보입니다. 필요시 수정하세요.
- </FormDescription>
- <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
- {/* vendorName */}
- <FormField
- control={form.control}
- name="vendorName"
- render={({ field }) => (
- <FormItem>
- <FormLabel>업체명</FormLabel>
- <FormControl>
- <Input placeholder="업체명 입력" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* vendorCode */}
- <FormField
- control={form.control}
- name="vendorCode"
- render={({ field }) => (
- <FormItem>
- <FormLabel>업체 코드</FormLabel>
- <FormControl>
- <Input placeholder="예: ABC123" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* address */}
- <FormField
- control={form.control}
- name="address"
- render={({ field }) => (
- <FormItem className="md:col-span-2">
- <FormLabel>주소</FormLabel>
- <FormControl>
- <Input placeholder="주소 입력" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* country */}
- <FormField
- control={form.control}
- name="country"
- render={({ field }) => (
- <FormItem>
- <FormLabel>국가</FormLabel>
- <FormControl>
- <Input placeholder="예: 대한민국" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* phone */}
- <FormField
- control={form.control}
- name="phone"
- render={({ field }) => (
- <FormItem>
- <FormLabel>전화번호</FormLabel>
- <FormControl>
- <Input placeholder="예: 010-1234-5678" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* email */}
- <FormField
- control={form.control}
- name="email"
- render={({ field }) => (
- <FormItem>
- <FormLabel>이메일</FormLabel>
- <FormControl>
- <Input placeholder="예: info@company.com" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* website */}
- <FormField
- control={form.control}
- name="website"
- render={({ field }) => (
- <FormItem>
- <FormLabel>웹사이트</FormLabel>
- <FormControl>
- <Input placeholder="예: https://www.company.com" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* techVendorType */}
- <FormField
- control={form.control}
- name="techVendorType"
- render={({ field }) => (
- <FormItem className="md:col-span-2">
- <FormLabel>벤더 타입 *</FormLabel>
- <div className="space-y-2">
- {["조선", "해양TOP", "해양HULL"].map((type) => (
- <div key={type} className="flex items-center space-x-2">
- <input
- type="checkbox"
- id={`update-${type}`}
- checked={field.value?.includes(type as "조선" | "해양TOP" | "해양HULL")}
- onChange={(e) => {
- const currentValue = Array.isArray(field.value) ? field.value : [];
- if (e.target.checked) {
- field.onChange([...currentValue, type]);
- } else {
- field.onChange(currentValue.filter((v: string) => v !== type));
- }
- }}
- className="w-4 h-4"
- />
- <label htmlFor={`update-${type}`} className="text-sm font-medium cursor-pointer">
- {type}
- </label>
- </div>
- ))}
- </div>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* status with icons */}
- <FormField
- control={form.control}
- name="status"
- render={({ field }) => {
- // 현재 선택된 상태의 구성 정보 가져오기
- const selectedConfig = getStatusConfig(field.value ?? "ACTIVE");
- const SelectedIcon = selectedConfig?.Icon || CircleIcon;
-
- return (
- <FormItem>
- <FormLabel>업체승인상태</FormLabel>
- <FormControl>
- <Select
- value={field.value || ""}
- onValueChange={field.onChange}
- >
- <SelectTrigger className="w-full">
- <SelectValue>
- {field.value && (
- <div className="flex items-center">
- <SelectedIcon className={`mr-2 h-4 w-4 ${selectedConfig.className}`} />
- <span>{selectedConfig.label}</span>
- </div>
- )}
- </SelectValue>
- </SelectTrigger>
- <SelectContent>
- <SelectGroup>
- {techVendors.status.enumValues.map((status) => {
- const config = getStatusConfig(status);
- const StatusIcon = config.Icon;
- return (
- <SelectItem key={status} value={status}>
- <div className="flex items-center">
- <StatusIcon className={`mr-2 h-4 w-4 ${config.className}`} />
- <span>{config.label}</span>
- </div>
- </SelectItem>
- );
- })}
- </SelectGroup>
- </SelectContent>
- </Select>
- </FormControl>
- <FormMessage />
- </FormItem>
- );
- }}
- />
-
-
-
-
- </div>
- </div>
-
- <SheetFooter className="gap-2 pt-2 sm:space-x-0">
- <SheetClose asChild>
- <Button type="button" variant="outline">
- 취소
- </Button>
- </SheetClose>
- <Button disabled={isPending}>
- {isPending && (
- <Loader className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
- )}
- 저장
- </Button>
- </SheetFooter>
- </form>
- </Form>
- </SheetContent>
- </Sheet>
- )
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useForm } from "react-hook-form"
+import {
+ Loader,
+ Activity,
+ AlertCircle,
+ AlertTriangle,
+ Circle as CircleIcon
+} from "lucide-react"
+import { toast } from "sonner"
+
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { useSession } from "next-auth/react"
+
+import { TechVendor, techVendors } from "@/db/schema/techVendors"
+import { updateTechVendorSchema, type UpdateTechVendorSchema } from "../validations"
+import { modifyTechVendor } from "../service"
+
+interface UpdateVendorSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ vendor: TechVendor | null
+}
+type StatusType = (typeof techVendors.status.enumValues)[number];
+
+type StatusConfig = {
+ Icon: React.ElementType;
+ className: string;
+ label: string;
+};
+
+// 상태 표시 유틸리티 함수
+const getStatusConfig = (status: StatusType): StatusConfig => {
+ switch(status) {
+ case "ACTIVE":
+ return {
+ Icon: Activity,
+ className: "text-emerald-600",
+ label: "활성 상태"
+ };
+ case "INACTIVE":
+ return {
+ Icon: AlertCircle,
+ className: "text-gray-600",
+ label: "비활성 상태"
+ };
+ case "BLACKLISTED":
+ return {
+ Icon: AlertTriangle,
+ className: "text-slate-800",
+ label: "거래 금지"
+ };
+ case "QUOTE_COMPARISON":
+ return {
+ Icon: AlertTriangle,
+ className: "text-slate-800",
+ label: "비교 견적"
+ };
+ case "PENDING_INVITE":
+ return {
+ Icon: AlertTriangle,
+ className: "text-slate-800",
+ label: "초대 대기"
+ };
+ case "INVITED":
+ return {
+ Icon: AlertTriangle,
+ className: "text-slate-800",
+ label: "초대 완료"
+ };
+ default:
+ return {
+ Icon: CircleIcon,
+ className: "text-gray-600",
+ label: status
+ };
+ }
+};
+
+// 폼 컴포넌트
+export function UpdateVendorSheet({ vendor, ...props }: UpdateVendorSheetProps) {
+ const [isPending, startTransition] = React.useTransition()
+ const { data: session } = useSession()
+
+ // 폼 정의 - UpdateVendorSchema 타입을 직접 사용
+ const form = useForm<UpdateTechVendorSchema>({
+ resolver: zodResolver(updateTechVendorSchema),
+ defaultValues: {
+ // 업체 기본 정보
+ vendorName: vendor?.vendorName ?? "",
+ vendorCode: vendor?.vendorCode ?? "",
+ address: vendor?.address ?? "",
+ country: vendor?.country ?? "",
+ countryEng: vendor?.countryEng ?? "",
+ countryFab: vendor?.countryFab ?? "",
+ phone: vendor?.phone ?? "",
+ email: vendor?.email ?? "",
+ website: vendor?.website ?? "",
+ techVendorType: vendor?.techVendorType ? vendor.techVendorType.split(',').map(s => s.trim()).filter(Boolean) as ("조선" | "해양TOP" | "해양HULL")[] : [],
+ status: vendor?.status ?? "ACTIVE",
+ // 에이전트 정보
+ agentName: vendor?.agentName ?? "",
+ agentEmail: vendor?.agentEmail ?? "",
+ agentPhone: vendor?.agentPhone ?? "",
+ // 대표자 정보
+ representativeName: vendor?.representativeName ?? "",
+ representativeEmail: vendor?.representativeEmail ?? "",
+ representativePhone: vendor?.representativePhone ?? "",
+ representativeBirth: vendor?.representativeBirth ?? "",
+ },
+ })
+
+ React.useEffect(() => {
+ if (vendor) {
+ form.reset({
+ vendorName: vendor?.vendorName ?? "",
+ vendorCode: vendor?.vendorCode ?? "",
+ address: vendor?.address ?? "",
+ country: vendor?.country ?? "",
+ countryEng: vendor?.countryEng ?? "",
+ countryFab: vendor?.countryFab ?? "",
+ phone: vendor?.phone ?? "",
+ email: vendor?.email ?? "",
+ website: vendor?.website ?? "",
+ techVendorType: vendor?.techVendorType ? vendor.techVendorType.split(',').map(s => s.trim()).filter(Boolean) as ("조선" | "해양TOP" | "해양HULL")[] : [],
+ status: vendor?.status ?? "ACTIVE",
+ // 에이전트 정보
+ agentName: vendor?.agentName ?? "",
+ agentEmail: vendor?.agentEmail ?? "",
+ agentPhone: vendor?.agentPhone ?? "",
+ // 대표자 정보
+ representativeName: vendor?.representativeName ?? "",
+ representativeEmail: vendor?.representativeEmail ?? "",
+ representativePhone: vendor?.representativePhone ?? "",
+ representativeBirth: vendor?.representativeBirth ?? "",
+ });
+ }
+ }, [vendor, form]);
+
+ // 제출 핸들러
+ async function onSubmit(data: UpdateTechVendorSchema) {
+ if (!vendor) return
+
+ if (!session?.user?.id) {
+ toast.error("사용자 인증 정보를 찾을 수 없습니다.")
+ return
+ }
+
+ startTransition(async () => {
+ try {
+ // Add status change comment if status has changed
+ const oldStatus = vendor.status ?? "ACTIVE" // Default to ACTIVE if undefined
+ const newStatus = data.status ?? "ACTIVE" // Default to ACTIVE if undefined
+
+ const statusComment =
+ oldStatus !== newStatus
+ ? `상태 변경: ${getStatusConfig(oldStatus).label} → ${getStatusConfig(newStatus).label}`
+ : "" // Empty string instead of undefined
+
+ // 업체 정보 업데이트 - userId와 상태 변경 코멘트 추가
+ const { error } = await modifyTechVendor({
+ id: String(vendor.id),
+ userId: Number(session.user.id), // Add user ID from session
+ comment: statusComment, // Add comment for status changes
+ ...data, // 모든 데이터 전달 - 서비스 함수에서 필요한 필드만 처리
+ techVendorType: Array.isArray(data.techVendorType) ? data.techVendorType.join(',') : undefined,
+ })
+
+ if (error) throw new Error(error)
+
+ toast.success("업체 정보가 업데이트되었습니다!")
+ form.reset()
+ props.onOpenChange?.(false)
+ } catch (err: unknown) {
+ toast.error(String(err))
+ }
+ })
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-xl overflow-y-auto">
+ <SheetHeader className="text-left">
+ <SheetTitle>업체 정보 수정</SheetTitle>
+ <SheetDescription>
+ 업체 세부 정보를 수정하고 변경 사항을 저장하세요
+ </SheetDescription>
+ </SheetHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
+
+ {/* 업체 기본 정보 섹션 */}
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-base">
+ 업체 기본 정보
+ </CardTitle>
+ <CardDescription>
+ 업체의 기본 정보를 관리합니다
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ {/* 업체명 */}
+ <FormField
+ control={form.control}
+ name="vendorName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>업체명</FormLabel>
+ <FormControl>
+ <Input placeholder="업체명 입력" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 업체 코드 */}
+ <FormField
+ control={form.control}
+ name="vendorCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>업체 코드</FormLabel>
+ <FormControl>
+ <Input placeholder="예: ABC123" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 이메일 */}
+ <FormField
+ control={form.control}
+ name="email"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>이메일</FormLabel>
+ <FormControl>
+ <Input placeholder="예: info@company.com" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 전화번호 */}
+ <FormField
+ control={form.control}
+ name="phone"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>전화번호</FormLabel>
+ <FormControl>
+ <Input placeholder="예: 010-1234-5678" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 웹사이트 */}
+ <FormField
+ control={form.control}
+ name="website"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>웹사이트</FormLabel>
+ <FormControl>
+ <Input placeholder="예: https://www.company.com" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 주소 */}
+ <FormField
+ control={form.control}
+ name="address"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>주소</FormLabel>
+ <FormControl>
+ <Input placeholder="주소 입력" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
+ {/* 국가 */}
+ <FormField
+ control={form.control}
+ name="country"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>국가</FormLabel>
+ <FormControl>
+ <Input placeholder="예: 대한민국" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 국가(영문) */}
+ <FormField
+ control={form.control}
+ name="countryEng"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>국가(영문)</FormLabel>
+ <FormControl>
+ <Input placeholder="예: South Korea" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 제조국가 */}
+ <FormField
+ control={form.control}
+ name="countryFab"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>제조국가</FormLabel>
+ <FormControl>
+ <Input placeholder="제조국가 입력" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 벤더 타입 */}
+ <FormField
+ control={form.control}
+ name="techVendorType"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>벤더 타입 *</FormLabel>
+ <div className="flex gap-6">
+ {["조선", "해양TOP", "해양HULL"].map((type) => (
+ <div key={type} className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id={`update-${type}`}
+ checked={field.value?.includes(type as "조선" | "해양TOP" | "해양HULL")}
+ onChange={(e) => {
+ const currentValue = Array.isArray(field.value) ? field.value : [];
+ if (e.target.checked) {
+ field.onChange([...currentValue, type]);
+ } else {
+ field.onChange(currentValue.filter((v: string) => v !== type));
+ }
+ }}
+ className="w-4 h-4"
+ />
+ <label htmlFor={`update-${type}`} className="text-sm font-medium cursor-pointer">
+ {type}
+ </label>
+ </div>
+ ))}
+ </div>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </CardContent>
+ </Card>
+
+ {/* 승인 상태 섹션 */}
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-base">
+ 승인 상태
+ </CardTitle>
+ <CardDescription>
+ 업체의 승인 상태를 관리합니다
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <FormField
+ control={form.control}
+ name="status"
+ render={({ field }) => {
+ const selectedConfig = getStatusConfig(field.value ?? "ACTIVE");
+ const SelectedIcon = selectedConfig?.Icon || CircleIcon;
+
+ return (
+ <FormItem>
+ <FormLabel>업체 승인 상태</FormLabel>
+ <FormControl>
+ <Select
+ value={field.value || ""}
+ onValueChange={field.onChange}
+ >
+ <SelectTrigger className="w-full">
+ <SelectValue>
+ {field.value && (
+ <div className="flex items-center">
+ <SelectedIcon className={`mr-2 h-4 w-4 ${selectedConfig.className}`} />
+ <span>{selectedConfig.label}</span>
+ </div>
+ )}
+ </SelectValue>
+ </SelectTrigger>
+ <SelectContent>
+ <SelectGroup>
+ {techVendors.status.enumValues.map((status) => {
+ const config = getStatusConfig(status);
+ const StatusIcon = config.Icon;
+ return (
+ <SelectItem key={status} value={status}>
+ <div className="flex items-center">
+ <StatusIcon className={`mr-2 h-4 w-4 ${config.className}`} />
+ <span>{config.label}</span>
+ </div>
+ </SelectItem>
+ );
+ })}
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ );
+ }}
+ />
+ </CardContent>
+ </Card>
+
+ {/* 에이전트 정보 섹션 */}
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-base">
+ 에이전트 정보
+ </CardTitle>
+ <CardDescription>
+ 해당 업체의 에이전트 정보를 관리합니다
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ {/* 에이전트명 */}
+ <FormField
+ control={form.control}
+ name="agentName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>에이전트명</FormLabel>
+ <FormControl>
+ <Input placeholder="에이전트명 입력" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 에이전트 전화번호 */}
+ <FormField
+ control={form.control}
+ name="agentPhone"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>에이전트 전화번호</FormLabel>
+ <FormControl>
+ <Input placeholder="에이전트 전화번호 입력" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 에이전트 이메일 */}
+ <FormField
+ control={form.control}
+ name="agentEmail"
+ render={({ field }) => (
+ <FormItem className="md:col-span-2">
+ <FormLabel>에이전트 이메일</FormLabel>
+ <FormControl>
+ <Input type="email" placeholder="에이전트 이메일 입력" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 대표자 정보 섹션 */}
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-base">
+ 대표자 정보
+ </CardTitle>
+ <CardDescription>
+ 업체 대표자의 정보를 관리합니다
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ {/* 대표자명 */}
+ <FormField
+ control={form.control}
+ name="representativeName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>대표자명</FormLabel>
+ <FormControl>
+ <Input placeholder="대표자명 입력" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 대표자 생년월일 */}
+ <FormField
+ control={form.control}
+ name="representativeBirth"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>대표자 생년월일</FormLabel>
+ <FormControl>
+ <Input placeholder="YYYY-MM-DD" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 대표자 전화번호 */}
+ <FormField
+ control={form.control}
+ name="representativePhone"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>대표자 전화번호</FormLabel>
+ <FormControl>
+ <Input placeholder="대표자 전화번호 입력" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 대표자 이메일 */}
+ <FormField
+ control={form.control}
+ name="representativeEmail"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>대표자 이메일</FormLabel>
+ <FormControl>
+ <Input type="email" placeholder="대표자 이메일 입력" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </CardContent>
+ </Card>
+
+ <SheetFooter className="gap-2 pt-2 sm:space-x-0">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ 취소
+ </Button>
+ </SheetClose>
+ <Button disabled={isPending}>
+ {isPending && (
+ <Loader className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
+ )}
+ 저장
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
} \ No newline at end of file
diff --git a/lib/tech-vendors/table/vendor-all-export.ts b/lib/tech-vendors/table/vendor-all-export.ts
index f2650102..f1492324 100644
--- a/lib/tech-vendors/table/vendor-all-export.ts
+++ b/lib/tech-vendors/table/vendor-all-export.ts
@@ -1,257 +1,257 @@
-// /lib/vendor-export.ts
-import ExcelJS from "exceljs"
-import { TechVendor, TechVendorContact, TechVendorItem } from "@/db/schema/techVendors"
-import { exportTechVendorDetails } from "../service";
-
-/**
- * 선택된 벤더의 모든 관련 정보를 통합 시트 형식으로 엑셀로 내보내는 함수
- * - 기본정보 시트
- * - 연락처 시트
- * - 아이템 시트
- * 각 시트에는 식별을 위한 벤더 코드, 벤더명, 세금ID가 포함됨
- */
-export async function exportVendorsWithRelatedData(
- vendors: TechVendor[],
- filename = "tech-vendors-detailed"
-): Promise<void> {
- if (!vendors.length) return;
-
- // 선택된 벤더 ID 목록
- const vendorIds = vendors.map(vendor => vendor.id);
-
- try {
- // 서버로부터 모든 관련 데이터 가져오기
- const vendorsWithDetails = await exportTechVendorDetails(vendorIds);
-
- if (!vendorsWithDetails.length) {
- throw new Error("내보내기 데이터를 가져오는 중 오류가 발생했습니다.");
- }
-
- // 워크북 생성
- const workbook = new ExcelJS.Workbook();
-
- // 데이터 타입 확인 (서비스에서 반환하는 실제 데이터 형태)
- const vendorData = vendorsWithDetails as unknown as any[];
-
- // ===== 1. 기본 정보 시트 =====
- createBasicInfoSheet(workbook, vendorData);
-
- // ===== 2. 연락처 시트 =====
- createContactsSheet(workbook, vendorData);
-
- // ===== 3. 아이템 시트 =====
- createItemsSheet(workbook, vendorData);
-
-
- // 파일 다운로드
- const buffer = await workbook.xlsx.writeBuffer();
- const blob = new Blob([buffer], {
- type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
- });
- const url = URL.createObjectURL(blob);
- const link = document.createElement("a");
- link.href = url;
- link.download = `${filename}-${new Date().toISOString().split("T")[0]}.xlsx`;
- link.click();
- URL.revokeObjectURL(url);
-
- return;
- } catch (error) {
- console.error("Export error:", error);
- throw error;
- }
-}
-
-// 기본 정보 시트 생성 함수
-function createBasicInfoSheet(
- workbook: ExcelJS.Workbook,
- vendors: TechVendor[]
-): void {
- const basicInfoSheet = workbook.addWorksheet("기본정보");
-
- // 기본 정보 시트 헤더 설정
- basicInfoSheet.columns = [
- { header: "업체코드", key: "vendorCode", width: 15 },
- { header: "업체명", key: "vendorName", width: 20 },
- { header: "세금ID", key: "taxId", width: 15 },
- { header: "국가", key: "country", width: 10 },
- { header: "상태", key: "status", width: 15 },
- { header: "이메일", key: "email", width: 20 },
- { header: "전화번호", key: "phone", width: 15 },
- { header: "웹사이트", key: "website", width: 20 },
- { header: "주소", key: "address", width: 30 },
- { header: "대표자명", key: "representativeName", width: 15 },
- { header: "생성일", key: "createdAt", width: 15 },
- { header: "벤더타입", key: "techVendorType", width: 15 },
- { header: "대리점명", key: "agentName", width: 15 },
- { header: "대리점연락처", key: "agentPhone", width: 15 },
- { header: "대리점이메일", key: "agentEmail", width: 25 },
- { header: "대리점주소", key: "agentAddress", width: 30 },
- { header: "대리점국가", key: "agentCountry", width: 15 },
- { header: "대리점영문국가명", key: "agentCountryEng", width: 20 },
- ];
-
- // 헤더 스타일 설정
- applyHeaderStyle(basicInfoSheet);
-
- // 벤더 데이터 추가
- vendors.forEach((vendor: TechVendor) => {
- basicInfoSheet.addRow({
- vendorCode: vendor.vendorCode || "",
- vendorName: vendor.vendorName,
- taxId: vendor.taxId,
- country: vendor.country,
- status: getStatusText(vendor.status), // 상태 코드를 읽기 쉬운 텍스트로 변환
- email: vendor.email,
- phone: vendor.phone,
- website: vendor.website,
- address: vendor.address,
- representativeName: vendor.representativeName,
- createdAt: vendor.createdAt ? formatDate(vendor.createdAt) : "",
- techVendorType: vendor.techVendorType?.split(',').join(', ') || vendor.techVendorType,
- });
- });
-}
-
-// 연락처 시트 생성 함수
-function createContactsSheet(
- workbook: ExcelJS.Workbook,
- vendors: TechVendor[]
-): void {
- const contactsSheet = workbook.addWorksheet("연락처");
-
- contactsSheet.columns = [
- // 벤더 식별 정보
- { header: "업체코드", key: "vendorCode", width: 15 },
- { header: "업체명", key: "vendorName", width: 20 },
- { header: "세금ID", key: "taxId", width: 15 },
- // 연락처 정보
- { header: "이름", key: "contactName", width: 15 },
- { header: "직책", key: "contactPosition", width: 15 },
- { header: "이메일", key: "contactEmail", width: 25 },
- { header: "전화번호", key: "contactPhone", width: 15 },
- { header: "주요 연락처", key: "isPrimary", width: 10 },
- ];
-
- // 헤더 스타일 설정
- applyHeaderStyle(contactsSheet);
-
- // 벤더별 연락처 데이터 추가
- vendors.forEach((vendor: TechVendor) => {
- if (vendor.contacts && vendor.contacts.length > 0) {
- vendor.contacts.forEach((contact: TechVendorContact) => {
- contactsSheet.addRow({
- // 벤더 식별 정보
- vendorCode: vendor.vendorCode || "",
- vendorName: vendor.vendorName,
- taxId: vendor.taxId,
- // 연락처 정보
- contactName: contact.contactName,
- contactPosition: contact.contactPosition || "",
- contactEmail: contact.contactEmail,
- contactPhone: contact.contactPhone || "",
- isPrimary: contact.isPrimary ? "예" : "아니오",
- });
- });
- } else {
- // 연락처가 없는 경우에도 벤더 정보만 추가
- contactsSheet.addRow({
- vendorCode: vendor.vendorCode || "",
- vendorName: vendor.vendorName,
- taxId: vendor.taxId,
- contactName: "",
- contactPosition: "",
- contactEmail: "",
- contactPhone: "",
- isPrimary: "",
- });
- }
- });
-}
-
-// 아이템 시트 생성 함수
-function createItemsSheet(
- workbook: ExcelJS.Workbook,
- vendors: TechVendor[]
-): void {
- const itemsSheet = workbook.addWorksheet("아이템");
-
- itemsSheet.columns = [
- // 벤더 식별 정보
- { header: "업체코드", key: "vendorCode", width: 15 },
- { header: "업체명", key: "vendorName", width: 20 },
- { header: "세금ID", key: "taxId", width: 15 },
- // 아이템 정보
- { header: "아이템 코드", key: "itemCode", width: 15 },
- { header: "아이템명", key: "itemName", width: 25 },
- { header: "설명", key: "description", width: 30 },
- { header: "등록일", key: "createdAt", width: 15 },
- ];
-
- // 헤더 스타일 설정
- applyHeaderStyle(itemsSheet);
-
- // 벤더별 아이템 데이터 추가
- vendors.forEach((vendor: TechVendor) => {
- if (vendor.items && vendor.items.length > 0) {
- vendor.items.forEach((item: TechVendorItem) => {
- itemsSheet.addRow({
- // 벤더 식별 정보
- vendorCode: vendor.vendorCode || "",
- vendorName: vendor.vendorName,
- taxId: vendor.taxId,
- // 아이템 정보
- itemCode: item.itemCode,
- itemName: item.itemName,
- createdAt: item.createdAt ? formatDate(item.createdAt) : "",
- });
- });
- } else {
- // 아이템이 없는 경우에도 벤더 정보만 추가
- itemsSheet.addRow({
- vendorCode: vendor.vendorCode || "",
- vendorName: vendor.vendorName,
- taxId: vendor.taxId,
- itemCode: "",
- itemName: "",
- createdAt: "",
- });
- }
- });
-}
-
-
-// 헤더 스타일 적용 함수
-function applyHeaderStyle(sheet: ExcelJS.Worksheet): void {
- const headerRow = sheet.getRow(1);
- headerRow.font = { bold: true };
- headerRow.alignment = { horizontal: "center" };
- headerRow.eachCell((cell: ExcelJS.Cell) => {
- cell.fill = {
- type: "pattern",
- pattern: "solid",
- fgColor: { argb: "FFCCCCCC" },
- };
- });
-}
-
-// 날짜 포맷 함수
-function formatDate(date: Date | string): string {
- if (!date) return "";
- if (typeof date === 'string') {
- date = new Date(date);
- }
- return date.toISOString().split('T')[0];
-}
-
-
-// 상태 코드를 읽기 쉬운 텍스트로 변환하는 함수
-function getStatusText(status: string): string {
- const statusMap: Record<string, string> = {
- "ACTIVE": "활성",
- "INACTIVE": "비활성",
- "BLACKLISTED": "거래 금지"
- };
-
- return statusMap[status] || status;
+// /lib/vendor-export.ts
+import ExcelJS from "exceljs"
+import { TechVendor, TechVendorContact, TechVendorItem } from "@/db/schema/techVendors"
+import { exportTechVendorDetails } from "../service";
+
+/**
+ * 선택된 벤더의 모든 관련 정보를 통합 시트 형식으로 엑셀로 내보내는 함수
+ * - 기본정보 시트
+ * - 연락처 시트
+ * - 아이템 시트
+ * 각 시트에는 식별을 위한 벤더 코드, 벤더명, 세금ID가 포함됨
+ */
+export async function exportVendorsWithRelatedData(
+ vendors: TechVendor[],
+ filename = "tech-vendors-detailed"
+): Promise<void> {
+ if (!vendors.length) return;
+
+ // 선택된 벤더 ID 목록
+ const vendorIds = vendors.map(vendor => vendor.id);
+
+ try {
+ // 서버로부터 모든 관련 데이터 가져오기
+ const vendorsWithDetails = await exportTechVendorDetails(vendorIds);
+
+ if (!vendorsWithDetails.length) {
+ throw new Error("내보내기 데이터를 가져오는 중 오류가 발생했습니다.");
+ }
+
+ // 워크북 생성
+ const workbook = new ExcelJS.Workbook();
+
+ // 데이터 타입 확인 (서비스에서 반환하는 실제 데이터 형태)
+ const vendorData = vendorsWithDetails as unknown as any[];
+
+ // ===== 1. 기본 정보 시트 =====
+ createBasicInfoSheet(workbook, vendorData);
+
+ // ===== 2. 연락처 시트 =====
+ createContactsSheet(workbook, vendorData);
+
+ // ===== 3. 아이템 시트 =====
+ createItemsSheet(workbook, vendorData);
+
+
+ // 파일 다운로드
+ const buffer = await workbook.xlsx.writeBuffer();
+ const blob = new Blob([buffer], {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = `${filename}-${new Date().toISOString().split("T")[0]}.xlsx`;
+ link.click();
+ URL.revokeObjectURL(url);
+
+ return;
+ } catch (error) {
+ console.error("Export error:", error);
+ throw error;
+ }
+}
+
+// 기본 정보 시트 생성 함수
+function createBasicInfoSheet(
+ workbook: ExcelJS.Workbook,
+ vendors: TechVendor[]
+): void {
+ const basicInfoSheet = workbook.addWorksheet("기본정보");
+
+ // 기본 정보 시트 헤더 설정
+ basicInfoSheet.columns = [
+ { header: "업체코드", key: "vendorCode", width: 15 },
+ { header: "업체명", key: "vendorName", width: 20 },
+ { header: "세금ID", key: "taxId", width: 15 },
+ { header: "국가", key: "country", width: 10 },
+ { header: "상태", key: "status", width: 15 },
+ { header: "이메일", key: "email", width: 20 },
+ { header: "전화번호", key: "phone", width: 15 },
+ { header: "웹사이트", key: "website", width: 20 },
+ { header: "주소", key: "address", width: 30 },
+ { header: "대표자명", key: "representativeName", width: 15 },
+ { header: "생성일", key: "createdAt", width: 15 },
+ { header: "벤더타입", key: "techVendorType", width: 15 },
+ { header: "에이전트명", key: "agentName", width: 15 },
+ { header: "에이전트연락처", key: "agentPhone", width: 15 },
+ { header: "에이전트이메일", key: "agentEmail", width: 25 },
+ { header: "에이전트주소", key: "agentAddress", width: 30 },
+ { header: "에이전트국가", key: "agentCountry", width: 15 },
+ { header: "에이전트영문국가명", key: "agentCountryEng", width: 20 },
+ ];
+
+ // 헤더 스타일 설정
+ applyHeaderStyle(basicInfoSheet);
+
+ // 벤더 데이터 추가
+ vendors.forEach((vendor: TechVendor) => {
+ basicInfoSheet.addRow({
+ vendorCode: vendor.vendorCode || "",
+ vendorName: vendor.vendorName,
+ taxId: vendor.taxId,
+ country: vendor.country,
+ status: getStatusText(vendor.status), // 상태 코드를 읽기 쉬운 텍스트로 변환
+ email: vendor.email,
+ phone: vendor.phone,
+ website: vendor.website,
+ address: vendor.address,
+ representativeName: vendor.representativeName,
+ createdAt: vendor.createdAt ? formatDate(vendor.createdAt) : "",
+ techVendorType: vendor.techVendorType?.split(',').join(', ') || vendor.techVendorType,
+ });
+ });
+}
+
+// 연락처 시트 생성 함수
+function createContactsSheet(
+ workbook: ExcelJS.Workbook,
+ vendors: TechVendor[]
+): void {
+ const contactsSheet = workbook.addWorksheet("연락처");
+
+ contactsSheet.columns = [
+ // 벤더 식별 정보
+ { header: "업체코드", key: "vendorCode", width: 15 },
+ { header: "업체명", key: "vendorName", width: 20 },
+ { header: "세금ID", key: "taxId", width: 15 },
+ // 연락처 정보
+ { header: "이름", key: "contactName", width: 15 },
+ { header: "직책", key: "contactPosition", width: 15 },
+ { header: "이메일", key: "contactEmail", width: 25 },
+ { header: "전화번호", key: "contactPhone", width: 15 },
+ { header: "주요 연락처", key: "isPrimary", width: 10 },
+ ];
+
+ // 헤더 스타일 설정
+ applyHeaderStyle(contactsSheet);
+
+ // 벤더별 연락처 데이터 추가
+ vendors.forEach((vendor: TechVendor) => {
+ if (vendor.contacts && vendor.contacts.length > 0) {
+ vendor.contacts.forEach((contact: TechVendorContact) => {
+ contactsSheet.addRow({
+ // 벤더 식별 정보
+ vendorCode: vendor.vendorCode || "",
+ vendorName: vendor.vendorName,
+ taxId: vendor.taxId,
+ // 연락처 정보
+ contactName: contact.contactName,
+ contactPosition: contact.contactPosition || "",
+ contactEmail: contact.contactEmail,
+ contactPhone: contact.contactPhone || "",
+ isPrimary: contact.isPrimary ? "예" : "아니오",
+ });
+ });
+ } else {
+ // 연락처가 없는 경우에도 벤더 정보만 추가
+ contactsSheet.addRow({
+ vendorCode: vendor.vendorCode || "",
+ vendorName: vendor.vendorName,
+ taxId: vendor.taxId,
+ contactName: "",
+ contactPosition: "",
+ contactEmail: "",
+ contactPhone: "",
+ isPrimary: "",
+ });
+ }
+ });
+}
+
+// 아이템 시트 생성 함수
+function createItemsSheet(
+ workbook: ExcelJS.Workbook,
+ vendors: TechVendor[]
+): void {
+ const itemsSheet = workbook.addWorksheet("아이템");
+
+ itemsSheet.columns = [
+ // 벤더 식별 정보
+ { header: "업체코드", key: "vendorCode", width: 15 },
+ { header: "업체명", key: "vendorName", width: 20 },
+ { header: "세금ID", key: "taxId", width: 15 },
+ // 아이템 정보
+ { header: "아이템 코드", key: "itemCode", width: 15 },
+ { header: "아이템명", key: "itemName", width: 25 },
+ { header: "설명", key: "description", width: 30 },
+ { header: "등록일", key: "createdAt", width: 15 },
+ ];
+
+ // 헤더 스타일 설정
+ applyHeaderStyle(itemsSheet);
+
+ // 벤더별 아이템 데이터 추가
+ vendors.forEach((vendor: TechVendor) => {
+ if (vendor.items && vendor.items.length > 0) {
+ vendor.items.forEach((item: TechVendorItem) => {
+ itemsSheet.addRow({
+ // 벤더 식별 정보
+ vendorCode: vendor.vendorCode || "",
+ vendorName: vendor.vendorName,
+ taxId: vendor.taxId,
+ // 아이템 정보
+ itemCode: item.itemCode,
+ itemName: item.itemName,
+ createdAt: item.createdAt ? formatDate(item.createdAt) : "",
+ });
+ });
+ } else {
+ // 아이템이 없는 경우에도 벤더 정보만 추가
+ itemsSheet.addRow({
+ vendorCode: vendor.vendorCode || "",
+ vendorName: vendor.vendorName,
+ taxId: vendor.taxId,
+ itemCode: "",
+ itemName: "",
+ createdAt: "",
+ });
+ }
+ });
+}
+
+
+// 헤더 스타일 적용 함수
+function applyHeaderStyle(sheet: ExcelJS.Worksheet): void {
+ const headerRow = sheet.getRow(1);
+ headerRow.font = { bold: true };
+ headerRow.alignment = { horizontal: "center" };
+ headerRow.eachCell((cell: ExcelJS.Cell) => {
+ cell.fill = {
+ type: "pattern",
+ pattern: "solid",
+ fgColor: { argb: "FFCCCCCC" },
+ };
+ });
+}
+
+// 날짜 포맷 함수
+function formatDate(date: Date | string): string {
+ if (!date) return "";
+ if (typeof date === 'string') {
+ date = new Date(date);
+ }
+ return date.toISOString().split('T')[0];
+}
+
+
+// 상태 코드를 읽기 쉬운 텍스트로 변환하는 함수
+function getStatusText(status: string): string {
+ const statusMap: Record<string, string> = {
+ "ACTIVE": "활성",
+ "INACTIVE": "비활성",
+ "BLACKLISTED": "거래 금지"
+ };
+
+ return statusMap[status] || status;
} \ No newline at end of file
diff --git a/lib/tech-vendors/utils.ts b/lib/tech-vendors/utils.ts
index e409975a..ac91cd8d 100644
--- a/lib/tech-vendors/utils.ts
+++ b/lib/tech-vendors/utils.ts
@@ -1,28 +1,28 @@
-import { LucideIcon, CheckCircle2, CircleAlert, Clock, ShieldAlert, Mail, BarChart2 } from "lucide-react";
-import type { TechVendor } from "@/db/schema/techVendors";
-
-type StatusType = TechVendor["status"];
-
-/**
- * 기술벤더 상태에 대한 아이콘을 반환합니다.
- */
-export function getVendorStatusIcon(status: StatusType): LucideIcon {
- switch (status) {
- case "PENDING_INVITE":
- return Clock;
- case "INVITED":
- return Mail;
- case "QUOTE_COMPARISON":
- return BarChart2;
- case "ACTIVE":
- return CheckCircle2;
- case "INACTIVE":
- return CircleAlert;
- case "BLACKLISTED":
- return ShieldAlert;
- default:
- return CircleAlert;
- }
-}
-
-
+import { LucideIcon, CheckCircle2, CircleAlert, Clock, ShieldAlert, Mail, BarChart2 } from "lucide-react";
+import type { TechVendor } from "@/db/schema/techVendors";
+
+type StatusType = TechVendor["status"];
+
+/**
+ * 기술벤더 상태에 대한 아이콘을 반환합니다.
+ */
+export function getVendorStatusIcon(status: StatusType): LucideIcon {
+ switch (status) {
+ case "PENDING_INVITE":
+ return Clock;
+ case "INVITED":
+ return Mail;
+ case "QUOTE_COMPARISON":
+ return BarChart2;
+ case "ACTIVE":
+ return CheckCircle2;
+ case "INACTIVE":
+ return CircleAlert;
+ case "BLACKLISTED":
+ return ShieldAlert;
+ default:
+ return CircleAlert;
+ }
+}
+
+
diff --git a/lib/tech-vendors/validations.ts b/lib/tech-vendors/validations.ts
index 0c850c1f..618ad22e 100644
--- a/lib/tech-vendors/validations.ts
+++ b/lib/tech-vendors/validations.ts
@@ -1,321 +1,398 @@
-import {
- createSearchParamsCache,
- parseAsArrayOf,
- parseAsInteger,
- parseAsString,
- parseAsStringEnum,
-} from "nuqs/server"
-import * as z from "zod"
-
-import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
-import { techVendors, TechVendor, TechVendorContact, TechVendorItemsView, VENDOR_TYPES } from "@/db/schema/techVendors";
-
-export const searchParamsCache = createSearchParamsCache({
- // 공통 플래그
- flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
- []
- ),
-
- // 페이징
- page: parseAsInteger.withDefault(1),
- perPage: parseAsInteger.withDefault(10),
-
- // 정렬 (techVendors 테이블에 맞춰 TechVendor 타입 지정)
- sort: getSortingStateParser<TechVendor>().withDefault([
- { id: "createdAt", desc: true }, // createdAt 기준 내림차순
- ]),
-
- // 고급 필터
- filters: getFiltersStateParser().withDefault([]),
- joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- // 검색 키워드
- search: parseAsString.withDefault(""),
-
- // -----------------------------------------------------------------
- // 기술영업 협력업체에 특화된 검색 필드
- // -----------------------------------------------------------------
- // 상태 (ACTIVE, INACTIVE, BLACKLISTED 등) 중에서 선택
- status: parseAsStringEnum(["ACTIVE", "INACTIVE", "BLACKLISTED", "PENDING_REVIEW"]),
-
- // 협력업체명 검색
- vendorName: parseAsString.withDefault(""),
-
- // 국가 검색
- country: parseAsString.withDefault(""),
-
- // 예) 코드 검색
- vendorCode: parseAsString.withDefault(""),
-
- // 벤더 타입 필터링 (다중 선택 가능)
- vendorType: parseAsStringEnum(["ship", "top", "hull"]),
-
- // workTypes 필터링 (다중 선택 가능)
- workTypes: parseAsArrayOf(parseAsStringEnum([
- // 조선 workTypes
- "기장", "전장", "선실", "배관", "철의",
- // 해양TOP workTypes
- "TM", "TS", "TE", "TP",
- // 해양HULL workTypes
- "HA", "HE", "HH", "HM", "NC"
- ])).withDefault([]),
-
- // 필요하다면 이메일 검색 / 웹사이트 검색 등 추가 가능
- email: parseAsString.withDefault(""),
- website: parseAsString.withDefault(""),
-});
-
-export const searchParamsContactCache = createSearchParamsCache({
- // 공통 플래그
- flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
- []
- ),
-
- // 페이징
- page: parseAsInteger.withDefault(1),
- perPage: parseAsInteger.withDefault(10),
-
- // 정렬
- sort: getSortingStateParser<TechVendorContact>().withDefault([
- { id: "createdAt", desc: true }, // createdAt 기준 내림차순
- ]),
-
- // 고급 필터
- filters: getFiltersStateParser().withDefault([]),
- joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- // 검색 키워드
- search: parseAsString.withDefault(""),
-
- // 특정 필드 검색
- contactName: parseAsString.withDefault(""),
- contactPosition: parseAsString.withDefault(""),
- contactEmail: parseAsString.withDefault(""),
- contactPhone: parseAsString.withDefault(""),
-});
-
-export const searchParamsItemCache = createSearchParamsCache({
- // 공통 플래그
- flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
- []
- ),
-
- // 페이징
- page: parseAsInteger.withDefault(1),
- perPage: parseAsInteger.withDefault(10),
-
- // 정렬
- sort: getSortingStateParser<TechVendorItemsView>().withDefault([
- { id: "createdAt", desc: true }, // createdAt 기준 내림차순
- ]),
-
- // 고급 필터
- filters: getFiltersStateParser().withDefault([]),
- joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- // 검색 키워드
- search: parseAsString.withDefault(""),
-
- // 특정 필드 검색
- itemName: parseAsString.withDefault(""),
- itemCode: parseAsString.withDefault(""),
-});
-
-// 기술영업 벤더 기본 정보 업데이트 스키마
-export const updateTechVendorSchema = z.object({
- vendorName: z.string().min(1, "업체명은 필수 입력사항입니다"),
- vendorCode: z.string().optional(),
- address: z.string().optional(),
- country: z.string().optional(),
- phone: z.string().optional(),
- email: z.string().email("유효한 이메일 주소를 입력해주세요").optional(),
- website: z.string().url("유효한 URL을 입력해주세요").optional(),
- techVendorType: z.union([
- z.array(z.enum(VENDOR_TYPES)).min(1, "최소 하나의 벤더 타입을 선택해주세요"),
- z.string().min(1, "벤더 타입을 선택해주세요")
- ]).optional(),
- status: z.enum(techVendors.status.enumValues).optional(),
- userId: z.number().optional(),
- comment: z.string().optional(),
-});
-
-// 연락처 스키마
-const contactSchema = z.object({
- id: z.number().optional(),
- contactName: z
- .string()
- .min(1, "Contact name is required")
- .max(255, "Max length 255"),
- contactPosition: z.string().max(100).optional(),
- contactEmail: z.string().email("Invalid email").max(255),
- contactPhone: z.string().max(50).optional(),
- isPrimary: z.boolean().default(false).optional()
-});
-
-// 기술영업 벤더 생성 스키마
-export const createTechVendorSchema = z
- .object({
- vendorName: z
- .string()
- .min(1, "Vendor name is required")
- .max(255, "Max length 255"),
-
- email: z.string().email("Invalid email").max(255),
- // 나머지 optional
- vendorCode: z.string().max(100, "Max length 100").optional(),
- address: z.string().optional(),
- country: z.string()
- .min(1, "국가 선택은 필수입니다.")
- .max(100, "Max length 100"),
- phone: z.string().max(50, "Max length 50").optional(),
- website: z.string().max(255).optional(),
-
- files: z.any().optional(),
- status: z.enum(techVendors.status.enumValues).default("ACTIVE"),
- techVendorType: z.union([
- z.array(z.enum(VENDOR_TYPES)).min(1, "최소 하나의 벤더 타입을 선택해주세요"),
- z.string().min(1, "벤더 타입을 선택해주세요")
- ]).default(["조선"]),
-
- representativeName: z.union([z.string().max(255), z.literal("")]).optional(),
- representativeBirth: z.union([z.string().max(20), z.literal("")]).optional(),
- representativeEmail: z.union([z.string().email("Invalid email").max(255), z.literal("")]).optional(),
- representativePhone: z.union([z.string().max(50), z.literal("")]).optional(),
- taxId: z.string().min(1, { message: "사업자등록번호를 입력해주세요" }),
-
- items: z.string().min(1, { message: "공급품목을 입력해주세요" }),
-
- contacts: z
- .array(contactSchema)
- .nonempty("At least one contact is required.")
- })
- .superRefine((data, ctx) => {
- if (data.country === "KR") {
- // 1) 대표자 정보가 누락되면 각각 에러 발생
- if (!data.representativeName) {
- ctx.addIssue({
- code: "custom",
- path: ["representativeName"],
- message: "대표자 이름은 한국(KR) 업체일 경우 필수입니다.",
- })
- }
- if (!data.representativeBirth) {
- ctx.addIssue({
- code: "custom",
- path: ["representativeBirth"],
- message: "대표자 생년월일은 한국(KR) 업체일 경우 필수입니다.",
- })
- }
- if (!data.representativeEmail) {
- ctx.addIssue({
- code: "custom",
- path: ["representativeEmail"],
- message: "대표자 이메일은 한국(KR) 업체일 경우 필수입니다.",
- })
- }
- if (!data.representativePhone) {
- ctx.addIssue({
- code: "custom",
- path: ["representativePhone"],
- message: "대표자 전화번호는 한국(KR) 업체일 경우 필수입니다.",
- })
- }
-
- }
- });
-
-// 연락처 생성 스키마
-export const createTechVendorContactSchema = z.object({
- vendorId: z.number(),
- contactName: z.string()
- .min(1, "Contact name is required")
- .max(255, "Max length 255"),
- contactPosition: z.string().max(100, "Max length 100"),
- contactEmail: z.string().email(),
- contactPhone: z.string().max(50, "Max length 50").optional(),
- country: z.string().max(100, "Max length 100").optional(),
- isPrimary: z.boolean(),
-});
-
-// 연락처 업데이트 스키마
-export const updateTechVendorContactSchema = z.object({
- contactName: z.string()
- .min(1, "Contact name is required")
- .max(255, "Max length 255"),
- contactPosition: z.string().max(100, "Max length 100").optional(),
- contactEmail: z.string().email().optional(),
- contactPhone: z.string().max(50, "Max length 50").optional(),
- country: z.string().max(100, "Max length 100").optional(),
- isPrimary: z.boolean().optional(),
-});
-
-// 아이템 생성 스키마
-export const createTechVendorItemSchema = z.object({
- vendorId: z.number(),
- itemCode: z.string().max(100, "Max length 100"),
- itemList: z.string().min(1, "Item list is required").max(255, "Max length 255"),
-});
-
-// 아이템 업데이트 스키마
-export const updateTechVendorItemSchema = z.object({
- itemList: z.string().optional(),
- itemCode: z.string().max(100, "Max length 100"),
-});
-
-export const searchParamsRfqHistoryCache = createSearchParamsCache({
- // 공통 플래그
- flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
- []
- ),
-
- // 페이징
- page: parseAsInteger.withDefault(1),
- perPage: parseAsInteger.withDefault(10),
-
- // 정렬 (RFQ 히스토리에 맞춰)
- sort: getSortingStateParser<{
- id: number;
- rfqCode: string | null;
- description: string | null;
- projectCode: string | null;
- projectName: string | null;
- projectType: string | null; // 프로젝트 타입 추가
- status: string;
- totalAmount: string | null;
- currency: string | null;
- dueDate: Date | null;
- createdAt: Date;
- quotationCode: string | null;
- submittedAt: Date | null;
- }>().withDefault([
- { id: "createdAt", desc: true },
- ]),
-
- // 고급 필터
- filters: getFiltersStateParser().withDefault([]),
- joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- // 검색 키워드
- search: parseAsString.withDefault(""),
-
- // RFQ 히스토리 특화 필드
- rfqCode: parseAsString.withDefault(""),
- description: parseAsString.withDefault(""),
- projectCode: parseAsString.withDefault(""),
- projectName: parseAsString.withDefault(""),
- projectType: parseAsStringEnum(["SHIP", "TOP", "HULL"]), // 프로젝트 타입 필터 추가
- status: parseAsStringEnum(["DRAFT", "PUBLISHED", "EVALUATION", "AWARDED"]),
-});
-
-// 타입 내보내기
-export type GetTechVendorsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>
-export type GetTechVendorContactsSchema = Awaited<ReturnType<typeof searchParamsContactCache.parse>>
-export type GetTechVendorItemsSchema = Awaited<ReturnType<typeof searchParamsItemCache.parse>>
-export type GetTechVendorRfqHistorySchema = Awaited<ReturnType<typeof searchParamsRfqHistoryCache.parse>>
-
-export type UpdateTechVendorSchema = z.infer<typeof updateTechVendorSchema>
-export type CreateTechVendorSchema = z.infer<typeof createTechVendorSchema>
-export type CreateTechVendorContactSchema = z.infer<typeof createTechVendorContactSchema>
-export type UpdateTechVendorContactSchema = z.infer<typeof updateTechVendorContactSchema>
-export type CreateTechVendorItemSchema = z.infer<typeof createTechVendorItemSchema>
-export type UpdateTechVendorItemSchema = z.infer<typeof updateTechVendorItemSchema> \ No newline at end of file
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server"
+import * as z from "zod"
+
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+import { techVendors, TechVendor, TechVendorContact, TechVendorItemsView, VENDOR_TYPES } from "@/db/schema/techVendors";
+
+// TechVendorPossibleItem 타입 정의
+export interface TechVendorPossibleItem {
+ id: number;
+ vendorId: number;
+ vendorCode: string | null;
+ vendorEmail: string | null;
+ itemCode: string;
+ workType: string | null;
+ shipTypes: string | null;
+ itemList: string | null;
+ subItemList: string | null;
+ createdAt: Date;
+ updatedAt: Date;
+ // 조인된 정보
+ techVendorType?: "조선" | "해양TOP" | "해양HULL";
+}
+
+export const searchParamsCache = createSearchParamsCache({
+ // 공통 플래그
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+
+ // 페이징
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+
+ // 정렬 (techVendors 테이블에 맞춰 TechVendor 타입 지정)
+ sort: getSortingStateParser<TechVendor>().withDefault([
+ { id: "createdAt", desc: true }, // createdAt 기준 내림차순
+ ]),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ // 검색 키워드
+ search: parseAsString.withDefault(""),
+
+ // -----------------------------------------------------------------
+ // 기술영업 협력업체에 특화된 검색 필드
+ // -----------------------------------------------------------------
+ // 상태 (ACTIVE, INACTIVE, BLACKLISTED 등) 중에서 선택
+ status: parseAsStringEnum(["ACTIVE", "INACTIVE", "BLACKLISTED", "PENDING_REVIEW"]),
+
+ // 협력업체명 검색
+ vendorName: parseAsString.withDefault(""),
+
+ // 국가 검색
+ country: parseAsString.withDefault(""),
+
+ // 예) 코드 검색
+ vendorCode: parseAsString.withDefault(""),
+
+ // 벤더 타입 필터링 (다중 선택 가능)
+ vendorType: parseAsStringEnum(["ship", "top", "hull"]),
+
+ // workTypes 필터링 (다중 선택 가능)
+ workTypes: parseAsArrayOf(parseAsStringEnum([
+ // 조선 workTypes
+ "기장", "전장", "선실", "배관", "철의", "선체",
+ // 해양TOP workTypes
+ "TM", "TS", "TE", "TP",
+ // 해양HULL workTypes
+ "HA", "HE", "HH", "HM", "NC", "HO", "HP"
+ ])).withDefault([]),
+
+ // 필요하다면 이메일 검색 / 웹사이트 검색 등 추가 가능
+ email: parseAsString.withDefault(""),
+ website: parseAsString.withDefault(""),
+});
+
+export const searchParamsContactCache = createSearchParamsCache({
+ // 공통 플래그
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+
+ // 페이징
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+
+ // 정렬
+ sort: getSortingStateParser<TechVendorContact>().withDefault([
+ { id: "createdAt", desc: true }, // createdAt 기준 내림차순
+ ]),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ // 검색 키워드
+ search: parseAsString.withDefault(""),
+
+ // 특정 필드 검색
+ contactName: parseAsString.withDefault(""),
+ contactPosition: parseAsString.withDefault(""),
+ contactEmail: parseAsString.withDefault(""),
+ contactPhone: parseAsString.withDefault(""),
+});
+
+export const searchParamsItemCache = createSearchParamsCache({
+ // 공통 플래그
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+
+ // 페이징
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+
+ // 정렬
+ sort: getSortingStateParser<TechVendorItemsView>().withDefault([
+ { id: "createdAt", desc: true }, // createdAt 기준 내림차순
+ ]),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ // 검색 키워드
+ search: parseAsString.withDefault(""),
+
+ // 특정 필드 검색
+ itemName: parseAsString.withDefault(""),
+ itemCode: parseAsString.withDefault(""),
+});
+
+export const searchParamsPossibleItemsCache = createSearchParamsCache({
+ // 공통 플래그
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+
+ // 페이징
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+
+ // 정렬
+ sort: getSortingStateParser<TechVendorPossibleItem>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ // 검색 키워드
+ search: parseAsString.withDefault(""),
+
+ // 개별 필터 필드들
+ itemCode: parseAsString.withDefault(""),
+ workType: parseAsString.withDefault(""),
+ itemList: parseAsString.withDefault(""),
+ shipTypes: parseAsString.withDefault(""),
+ subItemList: parseAsString.withDefault(""),
+});
+
+// 기술영업 벤더 기본 정보 업데이트 스키마
+export const updateTechVendorSchema = z.object({
+ vendorName: z.string().min(1, "업체명은 필수 입력사항입니다"),
+ vendorCode: z.string().optional(),
+ address: z.string().optional(),
+ country: z.string().optional(),
+ countryEng: z.string().optional(),
+ countryFab: z.string().optional(),
+ phone: z.string().optional(),
+ email: z.string().email("유효한 이메일 주소를 입력해주세요").optional(),
+ website: z.string().optional(),
+ techVendorType: z.union([
+ z.array(z.enum(VENDOR_TYPES)).min(1, "최소 하나의 벤더 타입을 선택해주세요"),
+ z.string().min(1, "벤더 타입을 선택해주세요")
+ ]).optional(),
+ status: z.enum(techVendors.status.enumValues).optional(),
+ // 에이전트 정보
+ agentName: z.string().optional(),
+ agentEmail: z.string().email("유효한 이메일 주소를 입력해주세요").optional().or(z.literal("")),
+ agentPhone: z.string().optional(),
+ // 대표자 정보
+ representativeName: z.string().optional(),
+ representativeEmail: z.string().email("유효한 이메일 주소를 입력해주세요").optional().or(z.literal("")),
+ representativePhone: z.string().optional(),
+ representativeBirth: z.string().optional(),
+ userId: z.number().optional(),
+ comment: z.string().optional(),
+});
+
+// 연락처 스키마
+const contactSchema = z.object({
+ id: z.number().optional(),
+ contactName: z
+ .string()
+ .min(1, "Contact name is required")
+ .max(255, "Max length 255"),
+ contactPosition: z.string().max(100).optional(),
+ contactEmail: z.string().email("Invalid email").max(255),
+ contactCountry: z.string().max(100).optional(),
+ contactPhone: z.string().max(50).optional(),
+ isPrimary: z.boolean().default(false).optional()
+});
+
+// 기술영업 벤더 생성 스키마
+export const createTechVendorSchema = z
+ .object({
+ vendorName: z
+ .string()
+ .min(1, "Vendor name is required")
+ .max(255, "Max length 255"),
+
+ email: z.string().email("Invalid email").max(255),
+ // 나머지 optional
+ vendorCode: z.string().max(100, "Max length 100").optional(),
+ address: z.string().optional(),
+ country: z.string()
+ .min(1, "국가 선택은 필수입니다.")
+ .max(100, "Max length 100"),
+ phone: z.string().max(50, "Max length 50").optional(),
+ website: z.string().max(255).optional(),
+
+ files: z.any().optional(),
+ status: z.enum(techVendors.status.enumValues).default("ACTIVE"),
+ techVendorType: z.union([
+ z.array(z.enum(VENDOR_TYPES)).min(1, "최소 하나의 벤더 타입을 선택해주세요"),
+ z.string().min(1, "벤더 타입을 선택해주세요")
+ ]).default(["조선"]),
+
+ representativeName: z.union([z.string().max(255), z.literal("")]).optional(),
+ representativeBirth: z.union([z.string().max(20), z.literal("")]).optional(),
+ representativeEmail: z.union([z.string().email("Invalid email").max(255), z.literal("")]).optional(),
+ representativePhone: z.union([z.string().max(50), z.literal("")]).optional(),
+ taxId: z.string().min(1, { message: "사업자등록번호를 입력해주세요" }),
+
+ items: z.string().min(1, { message: "공급품목을 입력해주세요" }),
+
+ contacts: z
+ .array(contactSchema)
+ .nonempty("At least one contact is required.")
+ })
+ .superRefine((data, ctx) => {
+ if (data.country === "KR") {
+ // 1) 대표자 정보가 누락되면 각각 에러 발생
+ if (!data.representativeName) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["representativeName"],
+ message: "대표자 이름은 한국(KR) 업체일 경우 필수입니다.",
+ })
+ }
+ if (!data.representativeBirth) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["representativeBirth"],
+ message: "대표자 생년월일은 한국(KR) 업체일 경우 필수입니다.",
+ })
+ }
+ if (!data.representativeEmail) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["representativeEmail"],
+ message: "대표자 이메일은 한국(KR) 업체일 경우 필수입니다.",
+ })
+ }
+ if (!data.representativePhone) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["representativePhone"],
+ message: "대표자 전화번호는 한국(KR) 업체일 경우 필수입니다.",
+ })
+ }
+
+ }
+ });
+
+// 연락처 생성 스키마
+export const createTechVendorContactSchema = z.object({
+ vendorId: z.number(),
+ contactName: z.string()
+ .min(1, "Contact name is required")
+ .max(255, "Max length 255"),
+ contactPosition: z.string().max(100, "Max length 100"),
+ contactEmail: z.string().email(),
+ contactPhone: z.string().max(50, "Max length 50").optional(),
+ contactCountry: z.string().max(100, "Max length 100").optional(),
+ isPrimary: z.boolean(),
+});
+
+// 연락처 업데이트 스키마
+export const updateTechVendorContactSchema = z.object({
+ contactName: z.string()
+ .min(1, "Contact name is required")
+ .max(255, "Max length 255"),
+ contactPosition: z.string().max(100, "Max length 100").optional(),
+ contactEmail: z.string().email().optional(),
+ contactPhone: z.string().max(50, "Max length 50").optional(),
+ contactCountry: z.string().max(100, "Max length 100").optional(),
+ isPrimary: z.boolean().optional(),
+});
+
+// 아이템 생성 스키마
+export const createTechVendorItemSchema = z.object({
+ vendorId: z.number(),
+ itemCode: z.string().max(100, "Max length 100"),
+ itemList: z.string().min(1, "Item list is required").max(255, "Max length 255"),
+});
+
+// 아이템 업데이트 스키마
+export const updateTechVendorItemSchema = z.object({
+ itemList: z.string().optional(),
+ itemCode: z.string().max(100, "Max length 100"),
+});
+
+// Possible Items 생성 스키마
+export const createTechVendorPossibleItemSchema = z.object({
+ vendorId: z.number(),
+ itemCode: z.string().min(1, "아이템 코드는 필수입니다"),
+ workType: z.string().optional(),
+ shipTypes: z.string().optional(),
+ itemList: z.string().optional(),
+ subItemList: z.string().optional(),
+});
+
+// Possible Items 업데이트 스키마
+export const updateTechVendorPossibleItemSchema = createTechVendorPossibleItemSchema.extend({
+ id: z.number(),
+});
+
+export const searchParamsRfqHistoryCache = createSearchParamsCache({
+ // 공통 플래그
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+
+ // 페이징
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+
+ // 정렬 (RFQ 히스토리에 맞춰)
+ sort: getSortingStateParser<{
+ id: number;
+ rfqCode: string | null;
+ description: string | null;
+ projectCode: string | null;
+ projectName: string | null;
+ projectType: string | null; // 프로젝트 타입 추가
+ status: string;
+ totalAmount: string | null;
+ currency: string | null;
+ dueDate: Date | null;
+ createdAt: Date;
+ quotationCode: string | null;
+ submittedAt: Date | null;
+ }>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ // 검색 키워드
+ search: parseAsString.withDefault(""),
+
+ // RFQ 히스토리 특화 필드
+ rfqCode: parseAsString.withDefault(""),
+ description: parseAsString.withDefault(""),
+ projectCode: parseAsString.withDefault(""),
+ projectName: parseAsString.withDefault(""),
+ projectType: parseAsStringEnum(["SHIP", "TOP", "HULL"]), // 프로젝트 타입 필터 추가
+ status: parseAsStringEnum(["DRAFT", "PUBLISHED", "EVALUATION", "AWARDED"]),
+});
+
+// 타입 내보내기
+export type GetTechVendorsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>
+export type GetTechVendorContactsSchema = Awaited<ReturnType<typeof searchParamsContactCache.parse>>
+export type GetTechVendorItemsSchema = Awaited<ReturnType<typeof searchParamsItemCache.parse>>
+export type GetTechVendorPossibleItemsSchema = Awaited<ReturnType<typeof searchParamsPossibleItemsCache.parse>>
+export type GetTechVendorRfqHistorySchema = Awaited<ReturnType<typeof searchParamsRfqHistoryCache.parse>>
+
+export type UpdateTechVendorSchema = z.infer<typeof updateTechVendorSchema>
+export type CreateTechVendorSchema = z.infer<typeof createTechVendorSchema>
+export type CreateTechVendorContactSchema = z.infer<typeof createTechVendorContactSchema>
+export type UpdateTechVendorContactSchema = z.infer<typeof updateTechVendorContactSchema>
+export type CreateTechVendorItemSchema = z.infer<typeof createTechVendorItemSchema>
+export type UpdateTechVendorItemSchema = z.infer<typeof updateTechVendorItemSchema>
+export type CreateTechVendorPossibleItemSchema = z.infer<typeof createTechVendorPossibleItemSchema>
+export type UpdateTechVendorPossibleItemSchema = z.infer<typeof updateTechVendorPossibleItemSchema> \ No newline at end of file
diff --git a/lib/techsales-rfq/actions.ts b/lib/techsales-rfq/actions.ts
index 5d5d5118..80b831e0 100644
--- a/lib/techsales-rfq/actions.ts
+++ b/lib/techsales-rfq/actions.ts
@@ -1,31 +1,31 @@
-"use server"
-
-import { revalidatePath } from "next/cache"
-import {
- acceptTechSalesVendorQuotation
-} from "./service"
-
-/**
- * 기술영업 벤더 견적 승인 (벤더 선택) Server Action
- */
-export async function acceptTechSalesVendorQuotationAction(quotationId: number) {
- try {
- const result = await acceptTechSalesVendorQuotation(quotationId)
-
- if (result.success) {
- // 관련 페이지들 재검증
- revalidatePath("/evcp/budgetary-tech-sales-ship")
- revalidatePath("/partners/techsales")
-
- return { success: true, message: "벤더가 성공적으로 선택되었습니다" }
- } else {
- return { success: false, error: result.error }
- }
- } catch (error) {
- console.error("벤더 선택 액션 오류:", error)
- return {
- success: false,
- error: error instanceof Error ? error.message : "벤더 선택에 실패했습니다"
- }
- }
-}
+"use server"
+
+import { revalidatePath } from "next/cache"
+import {
+ acceptTechSalesVendorQuotation
+} from "./service"
+
+/**
+ * 기술영업 벤더 견적 승인 (벤더 선택) Server Action
+ */
+export async function acceptTechSalesVendorQuotationAction(quotationId: number) {
+ try {
+ const result = await acceptTechSalesVendorQuotation(quotationId)
+
+ if (result.success) {
+ // 관련 페이지들 재검증
+ revalidatePath("/evcp/budgetary-tech-sales-ship")
+ revalidatePath("/partners/techsales")
+
+ return { success: true, message: "벤더가 성공적으로 선택되었습니다" }
+ } else {
+ return { success: false, error: result.error }
+ }
+ } catch (error) {
+ console.error("벤더 선택 액션 오류:", error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "벤더 선택에 실패했습니다"
+ }
+ }
+}
diff --git a/lib/techsales-rfq/repository.ts b/lib/techsales-rfq/repository.ts
index 1aaf4b3d..07c9ddf8 100644
--- a/lib/techsales-rfq/repository.ts
+++ b/lib/techsales-rfq/repository.ts
@@ -1,593 +1,611 @@
-/* eslint-disable @typescript-eslint/no-explicit-any */
-
-import {
- techSalesRfqs,
- techSalesVendorQuotations,
- users,
- biddingProjects
-} from "@/db/schema";
-import { techVendors } from "@/db/schema/techVendors";
-import {
- asc,
- desc, count, SQL, sql, eq
-} from "drizzle-orm";
-import { PgTransaction } from "drizzle-orm/pg-core";
-
-
-
-export type NewTechSalesRfq = typeof techSalesRfqs.$inferInsert;
-/**
- * 기술영업 RFQ 생성
- * ID 및 생성일 리턴
- */
-export async function insertTechSalesRfq(
- tx: PgTransaction<any, any, any>,
- data: NewTechSalesRfq
-) {
- return tx
- .insert(techSalesRfqs)
- .values(data)
- .returning({ id: techSalesRfqs.id, createdAt: techSalesRfqs.createdAt });
-}
-
-/**
- * 단건/복수 조회 시 공통으로 사용 가능한 SELECT 함수 예시
- * - 트랜잭션(tx)을 받아서 사용하도록 구현
- */
-export async function selectTechSalesRfqs(
- tx: PgTransaction<any, any, any>,
- params: {
- where?: any;
- orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[];
- offset?: number;
- limit?: number;
- }
-) {
- const { where, orderBy, offset = 0, limit = 10 } = params;
-
- return tx
- .select()
- .from(techSalesRfqs)
- .where(where ?? undefined)
- .orderBy(...(orderBy ?? []))
- .offset(offset)
- .limit(limit);
-}
-/** 총 개수 count */
-export async function countTechSalesRfqs(
- tx: PgTransaction<any, any, any>,
- where?: any
-) {
- const res = await tx.select({ count: count() }).from(techSalesRfqs).where(where);
- return res[0]?.count ?? 0;
-}
-
-
-/**
- * 기술영업 RFQ 조회 with 조인 (Repository)
- */
-export async function selectTechSalesRfqsWithJoin(
- tx: PgTransaction<any, any, any>,
- options: {
- where?: SQL;
- orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc> | SQL<unknown>)[];
- offset?: number;
- limit?: number;
- rfqType?: "SHIP" | "TOP" | "HULL";
- }
-) {
- const { where, orderBy, offset = 0, limit = 10, rfqType } = options;
-
- // 별칭 방식을 변경하여 select, join, where, orderBy 등을 순차적으로 수행
- let query = tx.select({
- // RFQ 기본 정보
- id: techSalesRfqs.id,
- rfqCode: techSalesRfqs.rfqCode,
- rfqType: techSalesRfqs.rfqType,
- biddingProjectId: techSalesRfqs.biddingProjectId,
- materialCode: techSalesRfqs.materialCode,
-
- // 날짜 및 상태 정보
- dueDate: techSalesRfqs.dueDate,
- rfqSendDate: techSalesRfqs.rfqSendDate,
- status: techSalesRfqs.status,
-
- // 담당자 및 비고
- picCode: techSalesRfqs.picCode,
- remark: techSalesRfqs.remark,
- cancelReason: techSalesRfqs.cancelReason,
- description: techSalesRfqs.description,
-
- // 생성/수정 정보
- createdAt: techSalesRfqs.createdAt,
- updatedAt: techSalesRfqs.updatedAt,
-
- // 사용자 정보
- createdBy: techSalesRfqs.createdBy,
- createdByName: sql<string>`created_user.name`,
- updatedBy: techSalesRfqs.updatedBy,
- updatedByName: sql<string>`updated_user.name`,
- sentBy: techSalesRfqs.sentBy,
- sentByName: sql<string | null>`sent_user.name`,
-
- // 프로젝트 정보 (조인)
- pspid: biddingProjects.pspid,
- projNm: biddingProjects.projNm,
- sector: biddingProjects.sector,
- projMsrm: biddingProjects.projMsrm,
- ptypeNm: biddingProjects.ptypeNm,
-
- // 첨부파일 개수 (타입별로 분리)
- attachmentCount: sql<number>`(
- SELECT COUNT(*)
- FROM tech_sales_attachments
- WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id}
- AND tech_sales_attachments.attachment_type = 'RFQ_COMMON'
- )`,
- hasTbeAttachments: sql<boolean>`(
- SELECT CASE WHEN COUNT(*) > 0 THEN TRUE ELSE FALSE END
- FROM tech_sales_attachments
- WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id}
- AND tech_sales_attachments.attachment_type = 'TBE_RESULT'
- )`,
- hasCbeAttachments: sql<boolean>`(
- SELECT CASE WHEN COUNT(*) > 0 THEN TRUE ELSE FALSE END
- FROM tech_sales_attachments
- WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id}
- AND tech_sales_attachments.attachment_type = 'CBE_RESULT'
- )`,
-
- // 벤더 견적 개수
- quotationCount: sql<number>`(
- SELECT COUNT(*)
- FROM tech_sales_vendor_quotations
- WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id}
- )`,
-
- // 아이템 개수
- itemCount: sql<number>`(
- SELECT COUNT(*)
- FROM tech_sales_rfq_items
- WHERE tech_sales_rfq_items.rfq_id = ${techSalesRfqs.id}
- )`,
- })
- .from(techSalesRfqs)
-
- // 프로젝트 정보 조인 추가
- .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id))
-
- // 사용자 정보 조인
- .leftJoin(sql`${users} AS created_user`, sql`${techSalesRfqs.createdBy} = created_user.id`)
- .leftJoin(sql`${users} AS updated_user`, sql`${techSalesRfqs.updatedBy} = updated_user.id`)
- .leftJoin(sql`${users} AS sent_user`, sql`${techSalesRfqs.sentBy} = sent_user.id`)
-
-;
-
- // rfqType 필터링
- const conditions = [];
- if (rfqType) {
- conditions.push(sql`${techSalesRfqs.rfqType} = ${rfqType}`);
- }
- if (where) {
- conditions.push(where);
- }
-
- if (conditions.length > 0) {
- query = query.where(sql`${sql.join(conditions, sql` AND `)}`);
- }
-
- // orderBy 적용
- const queryWithOrderBy = orderBy?.length
- ? query.orderBy(...orderBy)
- : query.orderBy(desc(techSalesRfqs.createdAt));
-
- // offset과 limit 적용 후 실행
- return queryWithOrderBy.offset(offset).limit(limit);
-}
-
-/**
- * RFQ 개수 직접 조회 (뷰 대신 테이블 조인 사용)
- */
-export async function countTechSalesRfqsWithJoin(
- tx: PgTransaction<any, any, any>,
- where?: any,
- rfqType?: "SHIP" | "TOP" | "HULL"
-) {
- const conditions = [];
- if (rfqType) {
- conditions.push(sql`${techSalesRfqs.rfqType} = ${rfqType}`);
- }
- if (where) {
- conditions.push(where);
- }
-
- const finalWhere = conditions.length > 0 ? sql`${sql.join(conditions, sql` AND `)}` : undefined;
-
- const res = await tx
- .select({ count: count() })
- .from(techSalesRfqs)
- .where(finalWhere);
- return res[0]?.count ?? 0;
-}
-
-/**
- * 벤더 견적서 직접 조인 조회 (뷰 대신 테이블 조인 사용)
- */
-export async function selectTechSalesVendorQuotationsWithJoin(
- tx: PgTransaction<any, any, any>,
- params: {
- where?: any;
- orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[];
- offset?: number;
- limit?: number;
- rfqType?: "SHIP" | "TOP" | "HULL";
- }
-) {
- const { where, orderBy, offset = 0, limit = 10, rfqType } = params;
-
- // 별칭 방식을 변경하여 select, join, where, orderBy 등을 순차적으로 수행
- let query = tx.select({
- // 견적 기본 정보
- id: techSalesVendorQuotations.id,
- rfqId: techSalesVendorQuotations.rfqId,
- rfqCode: techSalesRfqs.rfqCode,
- rfqType: techSalesRfqs.rfqType,
- vendorId: techSalesVendorQuotations.vendorId,
- vendorName: techVendors.vendorName,
- vendorCode: techVendors.vendorCode,
-
- // 견적 상세 정보
- totalPrice: techSalesVendorQuotations.totalPrice,
- currency: techSalesVendorQuotations.currency,
- validUntil: techSalesVendorQuotations.validUntil,
- status: techSalesVendorQuotations.status,
- remark: techSalesVendorQuotations.remark,
- rejectionReason: techSalesVendorQuotations.rejectionReason,
-
- // 날짜 정보
- submittedAt: techSalesVendorQuotations.submittedAt,
- acceptedAt: techSalesVendorQuotations.acceptedAt,
- createdAt: techSalesVendorQuotations.createdAt,
- updatedAt: techSalesVendorQuotations.updatedAt,
-
- // 생성/수정 사용자
- createdBy: techSalesVendorQuotations.createdBy,
- createdByName: sql<string | null>`created_user.name`,
- updatedBy: techSalesVendorQuotations.updatedBy,
- updatedByName: sql<string | null>`updated_user.name`,
-
- // 프로젝트 정보
- materialCode: techSalesRfqs.materialCode,
-
- // 프로젝트 핵심 정보 - null 체크 추가
- pspid: techSalesRfqs.biddingProjectId,
- projNm: biddingProjects.projNm,
- sector: biddingProjects.sector,
-
- // 첨부파일 개수
- attachmentCount: sql<number>`(
- SELECT COUNT(*)
- FROM tech_sales_attachments
- WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id}
- )`,
-
- // 견적서 첨부파일 개수
- quotationAttachmentCount: sql<number>`(
- SELECT COUNT(*)
- FROM tech_sales_vendor_quotation_attachments
- WHERE tech_sales_vendor_quotation_attachments.quotation_id = ${techSalesVendorQuotations.id}
- )`,
-
- // RFQ 아이템 개수
- itemCount: sql<number>`(
- SELECT COUNT(*)
- FROM tech_sales_rfq_items
- WHERE tech_sales_rfq_items.rfq_id = ${techSalesRfqs.id}
- )`,
-
- })
- .from(techSalesVendorQuotations)
- .leftJoin(techSalesRfqs, sql`${techSalesVendorQuotations.rfqId} = ${techSalesRfqs.id}`)
- .leftJoin(techVendors, sql`${techSalesVendorQuotations.vendorId} = ${techVendors.id}`)
- // 프로젝트 정보 조인 추가
- .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id))
- .leftJoin(sql`${users} AS created_user`, sql`${techSalesVendorQuotations.createdBy} = created_user.id`)
- .leftJoin(sql`${users} AS updated_user`, sql`${techSalesVendorQuotations.updatedBy} = updated_user.id`);
-
- // rfqType 필터링
- const conditions = [];
- if (rfqType) {
- conditions.push(sql`${techSalesRfqs.rfqType} = ${rfqType}`);
- }
- if (where) {
- conditions.push(where);
- }
-
- if (conditions.length > 0) {
- query = query.where(sql`${sql.join(conditions, sql` AND `)}`);
- }
-
- // orderBy 적용
- const queryWithOrderBy = orderBy?.length
- ? query.orderBy(...orderBy)
- : query.orderBy(desc(techSalesVendorQuotations.createdAt));
-
- // offset과 limit 적용 후 실행
- return queryWithOrderBy.offset(offset).limit(limit);
-}
-
-/**
- * 벤더 견적서 개수 직접 조회 (뷰 대신 테이블 조인 사용)
- */
-export async function countTechSalesVendorQuotationsWithJoin(
- tx: PgTransaction<any, any, any>,
- where?: any,
- rfqType?: "SHIP" | "TOP" | "HULL"
-) {
- const conditions = [];
- if (rfqType) {
- conditions.push(sql`${techSalesRfqs.rfqType} = ${rfqType}`);
- }
- if (where) {
- conditions.push(where);
- }
-
- const finalWhere = conditions.length > 0 ? sql`${sql.join(conditions, sql` AND `)}` : undefined;
-
- const res = await tx
- .select({ count: count() })
- .from(techSalesVendorQuotations)
- .leftJoin(techSalesRfqs, sql`${techSalesVendorQuotations.rfqId} = ${techSalesRfqs.id}`)
- .leftJoin(techVendors, sql`${techSalesVendorQuotations.vendorId} = ${techVendors.id}`)
- .where(finalWhere);
- return res[0]?.count ?? 0;
-}
-
-/**
- * RFQ 대시보드 데이터 직접 조인 조회 (뷰 대신 테이블 조인 사용)
- */
-export async function selectTechSalesDashboardWithJoin(
- tx: PgTransaction<any, any, any>,
- params: {
- where?: any;
- orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc> | SQL<unknown>)[];
- offset?: number;
- limit?: number;
- rfqType?: "SHIP" | "TOP" | "HULL";
- }
-) {
- const { where, orderBy, offset = 0, limit = 10, rfqType } = params;
-
- // 별칭 방식을 변경하여 select, join, where, orderBy 등을 순차적으로 수행
- let query = tx.select({
- // RFQ 기본 정보
- id: techSalesRfqs.id,
- rfqCode: techSalesRfqs.rfqCode,
- rfqType: techSalesRfqs.rfqType,
- status: techSalesRfqs.status,
- dueDate: techSalesRfqs.dueDate,
- rfqSendDate: techSalesRfqs.rfqSendDate,
- materialCode: techSalesRfqs.materialCode,
-
- // 프로젝트 정보 - null 체크 추가
- pspid: techSalesRfqs.biddingProjectId,
- projNm: biddingProjects.projNm,
- sector: biddingProjects.sector,
- projMsrm: biddingProjects.projMsrm,
- ptypeNm: biddingProjects.ptypeNm,
-
- // 벤더 견적 통계
- vendorCount: sql<number>`(
- SELECT COUNT(DISTINCT vendor_id)
- FROM tech_sales_vendor_quotations
- WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id}
- )`,
-
- quotationCount: sql<number>`(
- SELECT COUNT(*)
- FROM tech_sales_vendor_quotations
- WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id}
- )`,
-
- submittedQuotationCount: sql<number>`(
- SELECT COUNT(*)
- FROM tech_sales_vendor_quotations
- WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id}
- AND tech_sales_vendor_quotations.status = 'Submitted'
- )`,
-
- minPrice: sql<string | null>`(
- SELECT MIN(total_price)
- FROM tech_sales_vendor_quotations
- WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id}
- AND tech_sales_vendor_quotations.status = 'Submitted'
- )`,
-
- maxPrice: sql<string | null>`(
- SELECT MAX(total_price)
- FROM tech_sales_vendor_quotations
- WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id}
- AND tech_sales_vendor_quotations.status = 'Submitted'
- )`,
-
- avgPrice: sql<string | null>`(
- SELECT AVG(total_price)
- FROM tech_sales_vendor_quotations
- WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id}
- AND tech_sales_vendor_quotations.status = 'Submitted'
- )`,
-
- // 첨부파일 통계
- attachmentCount: sql<number>`(
- SELECT COUNT(*)
- FROM tech_sales_attachments
- WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id}
- )`,
-
- // 코멘트 통계
- commentCount: sql<number>`(
- SELECT COUNT(*)
- FROM tech_sales_rfq_comments
- WHERE tech_sales_rfq_comments.rfq_id = ${techSalesRfqs.id}
- )`,
-
- unreadCommentCount: sql<number>`(
- SELECT COUNT(*)
- FROM tech_sales_rfq_comments
- WHERE tech_sales_rfq_comments.rfq_id = ${techSalesRfqs.id}
- AND tech_sales_rfq_comments.is_read = false
- )`,
-
- // 생성/수정 정보
- createdAt: techSalesRfqs.createdAt,
- updatedAt: techSalesRfqs.updatedAt,
- createdByName: sql<string>`created_user.name`,
-
- // 아이템 정보 - rfqType에 따라 다른 테이블에서 조회
- itemName: sql<string>`
- CASE
- WHEN ${techSalesRfqs.rfqType} = 'SHIP' THEN ship_items.item_list
- WHEN ${techSalesRfqs.rfqType} = 'TOP' THEN top_items.item_list
- WHEN ${techSalesRfqs.rfqType} = 'HULL' THEN hull_items.item_list
- ELSE NULL
- END
- `,
- })
- .from(techSalesRfqs)
- .leftJoin(sql`${users} AS created_user`, sql`${techSalesRfqs.createdBy} = created_user.id`)
-
- // 아이템 정보 조인
- .leftJoin(
- sql`(
- SELECT DISTINCT ON (rfq_id)
- tri.rfq_id,
- ship.item_list
- FROM tech_sales_rfq_items tri
- LEFT JOIN item_shipbuilding ship ON tri.item_shipbuilding_id = ship.id
- WHERE tri.item_type = 'SHIP'
- ORDER BY rfq_id, tri.id
- ) AS ship_items`,
- sql`ship_items.rfq_id = ${techSalesRfqs.id} AND ${techSalesRfqs.rfqType} = 'SHIP'`
- )
- .leftJoin(
- sql`(
- SELECT DISTINCT ON (rfq_id)
- tri.rfq_id,
- top.item_list
- FROM tech_sales_rfq_items tri
- LEFT JOIN item_offshore_top top ON tri.item_offshore_top_id = top.id
- WHERE tri.item_type = 'TOP'
- ORDER BY rfq_id, tri.id
- ) AS top_items`,
- sql`top_items.rfq_id = ${techSalesRfqs.id} AND ${techSalesRfqs.rfqType} = 'TOP'`
- )
- .leftJoin(
- sql`(
- SELECT DISTINCT ON (rfq_id)
- tri.rfq_id,
- hull.item_list
- FROM tech_sales_rfq_items tri
- LEFT JOIN item_offshore_hull hull ON tri.item_offshore_hull_id = hull.id
- WHERE tri.item_type = 'HULL'
- ORDER BY rfq_id, tri.id
- ) AS hull_items`,
- sql`hull_items.rfq_id = ${techSalesRfqs.id} AND ${techSalesRfqs.rfqType} = 'HULL'`
- );
-
- // rfqType 필터링
- const conditions = [];
- if (rfqType) {
- conditions.push(sql`${techSalesRfqs.rfqType} = ${rfqType}`);
- }
- if (where) {
- conditions.push(where);
- }
-
- if (conditions.length > 0) {
- query = query.where(sql`${sql.join(conditions, sql` AND `)}`);
- }
-
- // orderBy 적용
- const queryWithOrderBy = orderBy?.length
- ? query.orderBy(...orderBy)
- : query.orderBy(desc(techSalesRfqs.updatedAt));
-
- // offset과 limit 적용 후 실행
- return queryWithOrderBy.offset(offset).limit(limit);
-}
-
-/**
- * 단일 벤더 견적서 직접 조인 조회 (단일 견적서 상세용)
- */
-export async function selectSingleTechSalesVendorQuotationWithJoin(
- tx: PgTransaction<any, any, any>,
- quotationId: number
-) {
- const result = await tx.select({
- // 견적 기본 정보
- id: techSalesVendorQuotations.id,
- rfqId: techSalesVendorQuotations.rfqId,
- vendorId: techSalesVendorQuotations.vendorId,
-
- // 견적 상세 정보
- quotationCode: techSalesVendorQuotations.quotationCode,
- quotationVersion: techSalesVendorQuotations.quotationVersion,
- totalPrice: techSalesVendorQuotations.totalPrice,
- currency: techSalesVendorQuotations.currency,
- validUntil: techSalesVendorQuotations.validUntil,
- status: techSalesVendorQuotations.status,
- remark: techSalesVendorQuotations.remark,
- rejectionReason: techSalesVendorQuotations.rejectionReason,
-
- // 날짜 정보
- submittedAt: techSalesVendorQuotations.submittedAt,
- acceptedAt: techSalesVendorQuotations.acceptedAt,
- createdAt: techSalesVendorQuotations.createdAt,
- updatedAt: techSalesVendorQuotations.updatedAt,
-
- // 생성/수정 사용자
- createdBy: techSalesVendorQuotations.createdBy,
- updatedBy: techSalesVendorQuotations.updatedBy,
-
- // RFQ 정보
- rfqCode: techSalesRfqs.rfqCode,
- rfqType: techSalesRfqs.rfqType,
- rfqStatus: techSalesRfqs.status,
- dueDate: techSalesRfqs.dueDate,
- rfqSendDate: techSalesRfqs.rfqSendDate,
- materialCode: techSalesRfqs.materialCode,
- description: techSalesRfqs.description,
- rfqRemark: techSalesRfqs.remark,
- picCode: techSalesRfqs.picCode,
-
- // RFQ 생성자 정보
- rfqCreatedBy: techSalesRfqs.createdBy,
- rfqCreatedByName: sql<string | null>`rfq_created_user.name`,
- rfqCreatedByEmail: sql<string | null>`rfq_created_user.email`,
-
- // 벤더 정보
- vendorName: techVendors.vendorName,
- vendorCode: techVendors.vendorCode,
- vendorCountry: techVendors.country,
- vendorEmail: techVendors.email,
- vendorPhone: techVendors.phone,
-
- // 프로젝트 정보
- biddingProjectId: techSalesRfqs.biddingProjectId,
- pspid: biddingProjects.pspid,
- projNm: biddingProjects.projNm,
- sector: biddingProjects.sector,
- projMsrm: biddingProjects.projMsrm,
- ptypeNm: biddingProjects.ptypeNm,
-
- })
- .from(techSalesVendorQuotations)
- .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id))
- .leftJoin(techVendors, eq(techSalesVendorQuotations.vendorId, techVendors.id))
- .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id))
- .leftJoin(sql`${users} AS rfq_created_user`, sql`${techSalesRfqs.createdBy} = rfq_created_user.id`)
- .where(eq(techSalesVendorQuotations.id, quotationId));
-
- return result[0] || null;
-}
-
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+import {
+ techSalesRfqs,
+ techSalesVendorQuotations,
+ users,
+ biddingProjects
+} from "@/db/schema";
+import { techVendors } from "@/db/schema/techVendors";
+import {
+ asc,
+ desc, count, SQL, sql, eq
+} from "drizzle-orm";
+import { PgTransaction } from "drizzle-orm/pg-core";
+
+
+
+export type NewTechSalesRfq = typeof techSalesRfqs.$inferInsert;
+/**
+ * 기술영업 RFQ 생성
+ * ID 및 생성일 리턴
+ */
+export async function insertTechSalesRfq(
+ tx: PgTransaction<any, any, any>,
+ data: NewTechSalesRfq
+) {
+ return tx
+ .insert(techSalesRfqs)
+ .values(data)
+ .returning({ id: techSalesRfqs.id, createdAt: techSalesRfqs.createdAt });
+}
+
+/**
+ * 단건/복수 조회 시 공통으로 사용 가능한 SELECT 함수 예시
+ * - 트랜잭션(tx)을 받아서 사용하도록 구현
+ */
+export async function selectTechSalesRfqs(
+ tx: PgTransaction<any, any, any>,
+ params: {
+ where?: any;
+ orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[];
+ offset?: number;
+ limit?: number;
+ }
+) {
+ const { where, orderBy, offset = 0, limit = 10 } = params;
+
+ return tx
+ .select()
+ .from(techSalesRfqs)
+ .where(where ?? undefined)
+ .orderBy(...(orderBy ?? []))
+ .offset(offset)
+ .limit(limit);
+}
+/** 총 개수 count */
+export async function countTechSalesRfqs(
+ tx: PgTransaction<any, any, any>,
+ where?: any
+) {
+ const res = await tx.select({ count: count() }).from(techSalesRfqs).where(where);
+ return res[0]?.count ?? 0;
+}
+
+
+/**
+ * 기술영업 RFQ 조회 with 조인 (Repository)
+ */
+export async function selectTechSalesRfqsWithJoin(
+ tx: PgTransaction<any, any, any>,
+ options: {
+ where?: SQL;
+ orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc> | SQL<unknown>)[];
+ offset?: number;
+ limit?: number;
+ rfqType?: "SHIP" | "TOP" | "HULL";
+ }
+) {
+ const { where, orderBy, offset = 0, limit = 10, rfqType } = options;
+
+ // 별칭 방식을 변경하여 select, join, where, orderBy 등을 순차적으로 수행
+ let query = tx.select({
+ // RFQ 기본 정보
+ id: techSalesRfqs.id,
+ rfqCode: techSalesRfqs.rfqCode,
+ rfqType: techSalesRfqs.rfqType,
+ biddingProjectId: techSalesRfqs.biddingProjectId,
+ materialCode: techSalesRfqs.materialCode,
+
+ // 날짜 및 상태 정보
+ dueDate: techSalesRfqs.dueDate,
+ rfqSendDate: techSalesRfqs.rfqSendDate,
+ status: techSalesRfqs.status,
+
+ // 담당자 및 비고
+ picCode: techSalesRfqs.picCode,
+ remark: techSalesRfqs.remark,
+ cancelReason: techSalesRfqs.cancelReason,
+ description: techSalesRfqs.description,
+
+ // 생성/수정 정보
+ createdAt: techSalesRfqs.createdAt,
+ updatedAt: techSalesRfqs.updatedAt,
+
+ // 사용자 정보
+ createdBy: techSalesRfqs.createdBy,
+ createdByName: sql<string>`created_user.name`,
+ updatedBy: techSalesRfqs.updatedBy,
+ updatedByName: sql<string>`updated_user.name`,
+ sentBy: techSalesRfqs.sentBy,
+ sentByName: sql<string | null>`sent_user.name`,
+
+ // 프로젝트 정보 (조인)
+ pspid: biddingProjects.pspid,
+ projNm: biddingProjects.projNm,
+ sector: biddingProjects.sector,
+ projMsrm: biddingProjects.projMsrm,
+ ptypeNm: biddingProjects.ptypeNm,
+
+ // 첨부파일 개수 (타입별로 분리)
+ attachmentCount: sql<number>`(
+ SELECT COUNT(*)
+ FROM tech_sales_attachments
+ WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id}
+ AND tech_sales_attachments.attachment_type = 'RFQ_COMMON'
+ )`,
+ hasTbeAttachments: sql<boolean>`(
+ SELECT CASE WHEN COUNT(*) > 0 THEN TRUE ELSE FALSE END
+ FROM tech_sales_attachments
+ WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id}
+ AND tech_sales_attachments.attachment_type = 'TBE_RESULT'
+ )`,
+ hasCbeAttachments: sql<boolean>`(
+ SELECT CASE WHEN COUNT(*) > 0 THEN TRUE ELSE FALSE END
+ FROM tech_sales_attachments
+ WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id}
+ AND tech_sales_attachments.attachment_type = 'CBE_RESULT'
+ )`,
+
+ // 벤더 견적 개수
+ quotationCount: sql<number>`(
+ SELECT COUNT(*)
+ FROM tech_sales_vendor_quotations
+ WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id}
+ )`,
+
+ // 아이템 개수
+ itemCount: sql<number>`(
+ SELECT COUNT(*)
+ FROM tech_sales_rfq_items
+ WHERE tech_sales_rfq_items.rfq_id = ${techSalesRfqs.id}
+ )`,
+
+ // WorkTypes aggregation - RFQ에 연결된 모든 아이템들의 workType을 콤마로 구분하여 반환
+ workTypes: sql<string>`(
+ SELECT STRING_AGG(DISTINCT
+ CASE
+ WHEN tri.item_type = 'SHIP' THEN ship.work_type
+ WHEN tri.item_type = 'TOP' THEN top.work_type
+ WHEN tri.item_type = 'HULL' THEN hull.work_type
+ ELSE NULL
+ END, ', '
+ )
+ FROM tech_sales_rfq_items tri
+ LEFT JOIN item_shipbuilding ship ON tri.item_shipbuilding_id = ship.id AND tri.item_type = 'SHIP'
+ LEFT JOIN item_offshore_top top ON tri.item_offshore_top_id = top.id AND tri.item_type = 'TOP'
+ LEFT JOIN item_offshore_hull hull ON tri.item_offshore_hull_id = hull.id AND tri.item_type = 'HULL'
+ WHERE tri.rfq_id = ${techSalesRfqs.id}
+ AND (ship.work_type IS NOT NULL OR top.work_type IS NOT NULL OR hull.work_type IS NOT NULL)
+ )`,
+ })
+ .from(techSalesRfqs)
+
+ // 프로젝트 정보 조인 추가
+ .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id))
+
+ // 사용자 정보 조인
+ .leftJoin(sql`${users} AS created_user`, sql`${techSalesRfqs.createdBy} = created_user.id`)
+ .leftJoin(sql`${users} AS updated_user`, sql`${techSalesRfqs.updatedBy} = updated_user.id`)
+ .leftJoin(sql`${users} AS sent_user`, sql`${techSalesRfqs.sentBy} = sent_user.id`)
+
+;
+
+ // rfqType 필터링
+ const conditions = [];
+ if (rfqType) {
+ conditions.push(sql`${techSalesRfqs.rfqType} = ${rfqType}`);
+ }
+ if (where) {
+ conditions.push(where);
+ }
+
+ if (conditions.length > 0) {
+ query = query.where(sql`${sql.join(conditions, sql` AND `)}`);
+ }
+
+ // orderBy 적용
+ const queryWithOrderBy = orderBy?.length
+ ? query.orderBy(...orderBy)
+ : query.orderBy(desc(techSalesRfqs.createdAt));
+
+ // offset과 limit 적용 후 실행
+ return queryWithOrderBy.offset(offset).limit(limit);
+}
+
+/**
+ * RFQ 개수 직접 조회 (뷰 대신 테이블 조인 사용)
+ */
+export async function countTechSalesRfqsWithJoin(
+ tx: PgTransaction<any, any, any>,
+ where?: any,
+ rfqType?: "SHIP" | "TOP" | "HULL"
+) {
+ const conditions = [];
+ if (rfqType) {
+ conditions.push(sql`${techSalesRfqs.rfqType} = ${rfqType}`);
+ }
+ if (where) {
+ conditions.push(where);
+ }
+
+ const finalWhere = conditions.length > 0 ? sql`${sql.join(conditions, sql` AND `)}` : undefined;
+
+ const res = await tx
+ .select({ count: count() })
+ .from(techSalesRfqs)
+ .where(finalWhere);
+ return res[0]?.count ?? 0;
+}
+
+/**
+ * 벤더 견적서 직접 조인 조회 (뷰 대신 테이블 조인 사용)
+ */
+export async function selectTechSalesVendorQuotationsWithJoin(
+ tx: PgTransaction<any, any, any>,
+ params: {
+ where?: any;
+ orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[];
+ offset?: number;
+ limit?: number;
+ rfqType?: "SHIP" | "TOP" | "HULL";
+ }
+) {
+ const { where, orderBy, offset = 0, limit = 10, rfqType } = params;
+
+ // 별칭 방식을 변경하여 select, join, where, orderBy 등을 순차적으로 수행
+ let query = tx.select({
+ // 견적 기본 정보
+ id: techSalesVendorQuotations.id,
+ rfqId: techSalesVendorQuotations.rfqId,
+ rfqCode: techSalesRfqs.rfqCode,
+ rfqType: techSalesRfqs.rfqType,
+ vendorId: techSalesVendorQuotations.vendorId,
+ vendorName: techVendors.vendorName,
+ vendorCode: techVendors.vendorCode,
+
+ // 견적 상세 정보
+ totalPrice: techSalesVendorQuotations.totalPrice,
+ currency: techSalesVendorQuotations.currency,
+ validUntil: techSalesVendorQuotations.validUntil,
+ status: techSalesVendorQuotations.status,
+ remark: techSalesVendorQuotations.remark,
+ rejectionReason: techSalesVendorQuotations.rejectionReason,
+
+ // 날짜 정보
+ submittedAt: techSalesVendorQuotations.submittedAt,
+ acceptedAt: techSalesVendorQuotations.acceptedAt,
+ createdAt: techSalesVendorQuotations.createdAt,
+ updatedAt: techSalesVendorQuotations.updatedAt,
+
+ // 생성/수정 사용자
+ createdBy: techSalesVendorQuotations.createdBy,
+ createdByName: sql<string | null>`created_user.name`,
+ updatedBy: techSalesVendorQuotations.updatedBy,
+ updatedByName: sql<string | null>`updated_user.name`,
+
+ // 프로젝트 정보
+ materialCode: techSalesRfqs.materialCode,
+
+ // 프로젝트 핵심 정보 - null 체크 추가
+ pspid: techSalesRfqs.biddingProjectId,
+ projNm: biddingProjects.projNm,
+ sector: biddingProjects.sector,
+
+ // 첨부파일 개수
+ attachmentCount: sql<number>`(
+ SELECT COUNT(*)
+ FROM tech_sales_attachments
+ WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id}
+ )`,
+
+ // 견적서 첨부파일 개수
+ quotationAttachmentCount: sql<number>`(
+ SELECT COUNT(*)
+ FROM tech_sales_vendor_quotation_attachments
+ WHERE tech_sales_vendor_quotation_attachments.quotation_id = ${techSalesVendorQuotations.id}
+ )`,
+
+ // RFQ 아이템 개수
+ itemCount: sql<number>`(
+ SELECT COUNT(*)
+ FROM tech_sales_rfq_items
+ WHERE tech_sales_rfq_items.rfq_id = ${techSalesRfqs.id}
+ )`,
+
+ })
+ .from(techSalesVendorQuotations)
+ .leftJoin(techSalesRfqs, sql`${techSalesVendorQuotations.rfqId} = ${techSalesRfqs.id}`)
+ .leftJoin(techVendors, sql`${techSalesVendorQuotations.vendorId} = ${techVendors.id}`)
+ // 프로젝트 정보 조인 추가
+ .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id))
+ .leftJoin(sql`${users} AS created_user`, sql`${techSalesVendorQuotations.createdBy} = created_user.id`)
+ .leftJoin(sql`${users} AS updated_user`, sql`${techSalesVendorQuotations.updatedBy} = updated_user.id`);
+
+ // rfqType 필터링
+ const conditions = [];
+ if (rfqType) {
+ conditions.push(sql`${techSalesRfqs.rfqType} = ${rfqType}`);
+ }
+ if (where) {
+ conditions.push(where);
+ }
+
+ if (conditions.length > 0) {
+ query = query.where(sql`${sql.join(conditions, sql` AND `)}`);
+ }
+
+ // orderBy 적용
+ const queryWithOrderBy = orderBy?.length
+ ? query.orderBy(...orderBy)
+ : query.orderBy(desc(techSalesVendorQuotations.createdAt));
+
+ // offset과 limit 적용 후 실행
+ return queryWithOrderBy.offset(offset).limit(limit);
+}
+
+/**
+ * 벤더 견적서 개수 직접 조회 (뷰 대신 테이블 조인 사용)
+ */
+export async function countTechSalesVendorQuotationsWithJoin(
+ tx: PgTransaction<any, any, any>,
+ where?: any,
+ rfqType?: "SHIP" | "TOP" | "HULL"
+) {
+ const conditions = [];
+ if (rfqType) {
+ conditions.push(sql`${techSalesRfqs.rfqType} = ${rfqType}`);
+ }
+ if (where) {
+ conditions.push(where);
+ }
+
+ const finalWhere = conditions.length > 0 ? sql`${sql.join(conditions, sql` AND `)}` : undefined;
+
+ const res = await tx
+ .select({ count: count() })
+ .from(techSalesVendorQuotations)
+ .leftJoin(techSalesRfqs, sql`${techSalesVendorQuotations.rfqId} = ${techSalesRfqs.id}`)
+ .leftJoin(techVendors, sql`${techSalesVendorQuotations.vendorId} = ${techVendors.id}`)
+ .where(finalWhere);
+ return res[0]?.count ?? 0;
+}
+
+/**
+ * RFQ 대시보드 데이터 직접 조인 조회 (뷰 대신 테이블 조인 사용)
+ */
+export async function selectTechSalesDashboardWithJoin(
+ tx: PgTransaction<any, any, any>,
+ params: {
+ where?: any;
+ orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc> | SQL<unknown>)[];
+ offset?: number;
+ limit?: number;
+ rfqType?: "SHIP" | "TOP" | "HULL";
+ }
+) {
+ const { where, orderBy, offset = 0, limit = 10, rfqType } = params;
+
+ // 별칭 방식을 변경하여 select, join, where, orderBy 등을 순차적으로 수행
+ let query = tx.select({
+ // RFQ 기본 정보
+ id: techSalesRfqs.id,
+ rfqCode: techSalesRfqs.rfqCode,
+ rfqType: techSalesRfqs.rfqType,
+ status: techSalesRfqs.status,
+ dueDate: techSalesRfqs.dueDate,
+ rfqSendDate: techSalesRfqs.rfqSendDate,
+ materialCode: techSalesRfqs.materialCode,
+
+ // 프로젝트 정보 - null 체크 추가
+ pspid: techSalesRfqs.biddingProjectId,
+ projNm: biddingProjects.projNm,
+ sector: biddingProjects.sector,
+ projMsrm: biddingProjects.projMsrm,
+ ptypeNm: biddingProjects.ptypeNm,
+
+ // 벤더 견적 통계
+ vendorCount: sql<number>`(
+ SELECT COUNT(DISTINCT vendor_id)
+ FROM tech_sales_vendor_quotations
+ WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id}
+ )`,
+
+ quotationCount: sql<number>`(
+ SELECT COUNT(*)
+ FROM tech_sales_vendor_quotations
+ WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id}
+ )`,
+
+ submittedQuotationCount: sql<number>`(
+ SELECT COUNT(*)
+ FROM tech_sales_vendor_quotations
+ WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id}
+ AND tech_sales_vendor_quotations.status = 'Submitted'
+ )`,
+
+ minPrice: sql<string | null>`(
+ SELECT MIN(total_price)
+ FROM tech_sales_vendor_quotations
+ WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id}
+ AND tech_sales_vendor_quotations.status = 'Submitted'
+ )`,
+
+ maxPrice: sql<string | null>`(
+ SELECT MAX(total_price)
+ FROM tech_sales_vendor_quotations
+ WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id}
+ AND tech_sales_vendor_quotations.status = 'Submitted'
+ )`,
+
+ avgPrice: sql<string | null>`(
+ SELECT AVG(total_price)
+ FROM tech_sales_vendor_quotations
+ WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id}
+ AND tech_sales_vendor_quotations.status = 'Submitted'
+ )`,
+
+ // 첨부파일 통계
+ attachmentCount: sql<number>`(
+ SELECT COUNT(*)
+ FROM tech_sales_attachments
+ WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id}
+ )`,
+
+ // 코멘트 통계
+ commentCount: sql<number>`(
+ SELECT COUNT(*)
+ FROM tech_sales_rfq_comments
+ WHERE tech_sales_rfq_comments.rfq_id = ${techSalesRfqs.id}
+ )`,
+
+ unreadCommentCount: sql<number>`(
+ SELECT COUNT(*)
+ FROM tech_sales_rfq_comments
+ WHERE tech_sales_rfq_comments.rfq_id = ${techSalesRfqs.id}
+ AND tech_sales_rfq_comments.is_read = false
+ )`,
+
+ // 생성/수정 정보
+ createdAt: techSalesRfqs.createdAt,
+ updatedAt: techSalesRfqs.updatedAt,
+ createdByName: sql<string>`created_user.name`,
+
+ // 아이템 정보 - rfqType에 따라 다른 테이블에서 조회
+ itemName: sql<string>`
+ CASE
+ WHEN ${techSalesRfqs.rfqType} = 'SHIP' THEN ship_items.item_list
+ WHEN ${techSalesRfqs.rfqType} = 'TOP' THEN top_items.item_list
+ WHEN ${techSalesRfqs.rfqType} = 'HULL' THEN hull_items.item_list
+ ELSE NULL
+ END
+ `,
+ })
+ .from(techSalesRfqs)
+ .leftJoin(sql`${users} AS created_user`, sql`${techSalesRfqs.createdBy} = created_user.id`)
+
+ // 아이템 정보 조인
+ .leftJoin(
+ sql`(
+ SELECT DISTINCT ON (rfq_id)
+ tri.rfq_id,
+ ship.item_list
+ FROM tech_sales_rfq_items tri
+ LEFT JOIN item_shipbuilding ship ON tri.item_shipbuilding_id = ship.id
+ WHERE tri.item_type = 'SHIP'
+ ORDER BY rfq_id, tri.id
+ ) AS ship_items`,
+ sql`ship_items.rfq_id = ${techSalesRfqs.id} AND ${techSalesRfqs.rfqType} = 'SHIP'`
+ )
+ .leftJoin(
+ sql`(
+ SELECT DISTINCT ON (rfq_id)
+ tri.rfq_id,
+ top.item_list
+ FROM tech_sales_rfq_items tri
+ LEFT JOIN item_offshore_top top ON tri.item_offshore_top_id = top.id
+ WHERE tri.item_type = 'TOP'
+ ORDER BY rfq_id, tri.id
+ ) AS top_items`,
+ sql`top_items.rfq_id = ${techSalesRfqs.id} AND ${techSalesRfqs.rfqType} = 'TOP'`
+ )
+ .leftJoin(
+ sql`(
+ SELECT DISTINCT ON (rfq_id)
+ tri.rfq_id,
+ hull.item_list
+ FROM tech_sales_rfq_items tri
+ LEFT JOIN item_offshore_hull hull ON tri.item_offshore_hull_id = hull.id
+ WHERE tri.item_type = 'HULL'
+ ORDER BY rfq_id, tri.id
+ ) AS hull_items`,
+ sql`hull_items.rfq_id = ${techSalesRfqs.id} AND ${techSalesRfqs.rfqType} = 'HULL'`
+ );
+
+ // rfqType 필터링
+ const conditions = [];
+ if (rfqType) {
+ conditions.push(sql`${techSalesRfqs.rfqType} = ${rfqType}`);
+ }
+ if (where) {
+ conditions.push(where);
+ }
+
+ if (conditions.length > 0) {
+ query = query.where(sql`${sql.join(conditions, sql` AND `)}`);
+ }
+
+ // orderBy 적용
+ const queryWithOrderBy = orderBy?.length
+ ? query.orderBy(...orderBy)
+ : query.orderBy(desc(techSalesRfqs.updatedAt));
+
+ // offset과 limit 적용 후 실행
+ return queryWithOrderBy.offset(offset).limit(limit);
+}
+
+/**
+ * 단일 벤더 견적서 직접 조인 조회 (단일 견적서 상세용)
+ */
+export async function selectSingleTechSalesVendorQuotationWithJoin(
+ tx: PgTransaction<any, any, any>,
+ quotationId: number
+) {
+ const result = await tx.select({
+ // 견적 기본 정보
+ id: techSalesVendorQuotations.id,
+ rfqId: techSalesVendorQuotations.rfqId,
+ vendorId: techSalesVendorQuotations.vendorId,
+
+ // 견적 상세 정보
+ quotationCode: techSalesVendorQuotations.quotationCode,
+ quotationVersion: techSalesVendorQuotations.quotationVersion,
+ totalPrice: techSalesVendorQuotations.totalPrice,
+ currency: techSalesVendorQuotations.currency,
+ validUntil: techSalesVendorQuotations.validUntil,
+ status: techSalesVendorQuotations.status,
+ remark: techSalesVendorQuotations.remark,
+ rejectionReason: techSalesVendorQuotations.rejectionReason,
+
+ // 날짜 정보
+ submittedAt: techSalesVendorQuotations.submittedAt,
+ acceptedAt: techSalesVendorQuotations.acceptedAt,
+ createdAt: techSalesVendorQuotations.createdAt,
+ updatedAt: techSalesVendorQuotations.updatedAt,
+
+ // 생성/수정 사용자
+ createdBy: techSalesVendorQuotations.createdBy,
+ updatedBy: techSalesVendorQuotations.updatedBy,
+
+ // RFQ 정보
+ rfqCode: techSalesRfqs.rfqCode,
+ rfqType: techSalesRfqs.rfqType,
+ rfqStatus: techSalesRfqs.status,
+ dueDate: techSalesRfqs.dueDate,
+ rfqSendDate: techSalesRfqs.rfqSendDate,
+ materialCode: techSalesRfqs.materialCode,
+ description: techSalesRfqs.description,
+ rfqRemark: techSalesRfqs.remark,
+ picCode: techSalesRfqs.picCode,
+
+ // RFQ 생성자 정보
+ rfqCreatedBy: techSalesRfqs.createdBy,
+ rfqCreatedByName: sql<string | null>`rfq_created_user.name`,
+ rfqCreatedByEmail: sql<string | null>`rfq_created_user.email`,
+
+ // 벤더 정보
+ vendorName: techVendors.vendorName,
+ vendorCode: techVendors.vendorCode,
+ vendorCountry: techVendors.country,
+ vendorEmail: techVendors.email,
+ vendorPhone: techVendors.phone,
+
+ // 프로젝트 정보
+ biddingProjectId: techSalesRfqs.biddingProjectId,
+ pspid: biddingProjects.pspid,
+ projNm: biddingProjects.projNm,
+ sector: biddingProjects.sector,
+ projMsrm: biddingProjects.projMsrm,
+ ptypeNm: biddingProjects.ptypeNm,
+
+ })
+ .from(techSalesVendorQuotations)
+ .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id))
+ .leftJoin(techVendors, eq(techSalesVendorQuotations.vendorId, techVendors.id))
+ .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id))
+ .leftJoin(sql`${users} AS rfq_created_user`, sql`${techSalesRfqs.createdBy} = rfq_created_user.id`)
+ .where(eq(techSalesVendorQuotations.id, quotationId));
+
+ return result[0] || null;
+}
+
diff --git a/lib/techsales-rfq/service.ts b/lib/techsales-rfq/service.ts
index c991aa42..fd50b7a6 100644
--- a/lib/techsales-rfq/service.ts
+++ b/lib/techsales-rfq/service.ts
@@ -1,3416 +1,3698 @@
-'use server'
-
-import { unstable_noStore, revalidateTag, revalidatePath } from "next/cache";
-import db from "@/db/db";
-import {
- techSalesRfqs,
- techSalesVendorQuotations,
- techSalesVendorQuotationRevisions,
- techSalesAttachments,
- techSalesVendorQuotationAttachments,
- users,
- techSalesRfqComments,
- techSalesRfqItems,
- biddingProjects
-} from "@/db/schema";
-import { and, desc, eq, ilike, or, sql, inArray, count, asc } from "drizzle-orm";
-import { unstable_cache } from "@/lib/unstable-cache";
-import { filterColumns } from "@/lib/filter-columns";
-import { getErrorMessage } from "@/lib/handle-error";
-import type { Filter } from "@/types/table";
-import {
- selectTechSalesRfqsWithJoin,
- countTechSalesRfqsWithJoin,
- selectTechSalesVendorQuotationsWithJoin,
- countTechSalesVendorQuotationsWithJoin,
- selectTechSalesDashboardWithJoin,
- selectSingleTechSalesVendorQuotationWithJoin
-} from "./repository";
-import { GetTechSalesRfqsSchema } from "./validations";
-import { getServerSession } from "next-auth/next";
-import { authOptions } from "@/app/api/auth/[...nextauth]/route";
-import { sendEmail } from "../mail/sendEmail";
-import { formatDate } from "../utils";
-import { techVendors, techVendorPossibleItems } from "@/db/schema/techVendors";
-import { decryptWithServerAction } from "@/components/drm/drmUtils";
-import { deleteFile, saveDRMFile } from "../file-stroage";
-
-// 정렬 타입 정의
-// 의도적으로 any 사용 - drizzle ORM의 orderBy 타입이 복잡함
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-type OrderByType = any;
-
-export type Project = {
- id: number;
- projectCode: string;
- projectName: string;
- pjtType: "SHIP" | "TOP" | "HULL";
-}
-
-/**
- * 연도별 순차 RFQ 코드 생성 함수 (다중 생성 지원)
- * 형식: RFQ-YYYY-001, RFQ-YYYY-002, ...
- */
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-async function generateRfqCodes(tx: any, count: number, year?: number): Promise<string[]> {
- const currentYear = year || new Date().getFullYear();
- const yearPrefix = `RFQ-${currentYear}-`;
-
- // 해당 연도의 가장 최근 RFQ 코드 조회
- const latestRfq = await tx
- .select({ rfqCode: techSalesRfqs.rfqCode })
- .from(techSalesRfqs)
- .where(ilike(techSalesRfqs.rfqCode, `${yearPrefix}%`))
- .orderBy(desc(techSalesRfqs.rfqCode))
- .limit(1);
-
- let nextNumber = 1;
-
- if (latestRfq.length > 0) {
- // 기존 코드에서 번호 추출 (RFQ-2024-001 -> 001)
- const lastCode = latestRfq[0].rfqCode;
- const numberPart = lastCode.split('-').pop();
- if (numberPart) {
- const lastNumber = parseInt(numberPart, 10);
- if (!isNaN(lastNumber)) {
- nextNumber = lastNumber + 1;
- }
- }
- }
-
- // 요청된 개수만큼 순차적으로 코드 생성
- const codes: string[] = [];
- for (let i = 0; i < count; i++) {
- const paddedNumber = (nextNumber + i).toString().padStart(3, '0');
- codes.push(`${yearPrefix}${paddedNumber}`);
- }
-
- return codes;
-}
-
-
-/**
- * 직접 조인을 사용하여 RFQ 데이터 조회하는 함수
- * 페이지네이션, 필터링, 정렬 등 지원
- */
-export async function getTechSalesRfqsWithJoin(input: GetTechSalesRfqsSchema & { rfqType?: "SHIP" | "TOP" | "HULL" }) {
- return unstable_cache(
- async () => {
- try {
- const offset = (input.page - 1) * input.perPage;
-
- // 기본 필터 처리 - RFQFilterBox에서 오는 필터
- const basicFilters = input.basicFilters || [];
- const basicJoinOperator = input.basicJoinOperator || "and";
-
- // 고급 필터 처리 - 테이블의 DataTableFilterList에서 오는 필터
- const advancedFilters = input.filters || [];
- const advancedJoinOperator = input.joinOperator || "and";
-
- // 기본 필터 조건 생성
- let basicWhere;
- if (basicFilters.length > 0) {
- basicWhere = filterColumns({
- table: techSalesRfqs,
- filters: basicFilters,
- joinOperator: basicJoinOperator,
- });
- }
-
- // 고급 필터 조건 생성
- let advancedWhere;
- if (advancedFilters.length > 0) {
- advancedWhere = filterColumns({
- table: techSalesRfqs,
- filters: advancedFilters,
- joinOperator: advancedJoinOperator,
- });
- }
-
- // 전역 검색 조건
- let globalWhere;
- if (input.search) {
- const s = `%${input.search}%`;
- globalWhere = or(
- ilike(techSalesRfqs.rfqCode, s),
- ilike(techSalesRfqs.materialCode, s),
- ilike(techSalesRfqs.description, s),
- ilike(techSalesRfqs.remark, s)
- );
- }
-
- // 모든 조건 결합
- const whereConditions = [];
- if (basicWhere) whereConditions.push(basicWhere);
- if (advancedWhere) whereConditions.push(advancedWhere);
- if (globalWhere) whereConditions.push(globalWhere);
-
- // 조건이 있을 때만 and() 사용
- const finalWhere = whereConditions.length > 0
- ? and(...whereConditions)
- : undefined;
-
- // 정렬 기준 설정
- let orderBy: OrderByType[] = [desc(techSalesRfqs.createdAt)]; // 기본 정렬
-
- if (input.sort?.length) {
- // 안전하게 접근하여 정렬 기준 설정
- orderBy = input.sort.map(item => {
- // TypeScript 에러 방지를 위한 타입 단언
- const sortField = item.id as string;
-
- switch (sortField) {
- case 'id':
- return item.desc ? desc(techSalesRfqs.id) : techSalesRfqs.id;
- case 'rfqCode':
- return item.desc ? desc(techSalesRfqs.rfqCode) : techSalesRfqs.rfqCode;
- case 'materialCode':
- return item.desc ? desc(techSalesRfqs.materialCode) : techSalesRfqs.materialCode;
- case 'description':
- return item.desc ? desc(techSalesRfqs.description) : techSalesRfqs.description;
- case 'status':
- return item.desc ? desc(techSalesRfqs.status) : techSalesRfqs.status;
- case 'dueDate':
- return item.desc ? desc(techSalesRfqs.dueDate) : techSalesRfqs.dueDate;
- case 'rfqSendDate':
- return item.desc ? desc(techSalesRfqs.rfqSendDate) : techSalesRfqs.rfqSendDate;
- case 'remark':
- return item.desc ? desc(techSalesRfqs.remark) : techSalesRfqs.remark;
- case 'createdAt':
- return item.desc ? desc(techSalesRfqs.createdAt) : techSalesRfqs.createdAt;
- case 'updatedAt':
- return item.desc ? desc(techSalesRfqs.updatedAt) : techSalesRfqs.updatedAt;
- default:
- return item.desc ? desc(techSalesRfqs.createdAt) : techSalesRfqs.createdAt;
- }
- });
- }
-
- // Repository 함수 호출 - rfqType 매개변수 추가
- return await db.transaction(async (tx) => {
- const [data, total] = await Promise.all([
- selectTechSalesRfqsWithJoin(tx, {
- where: finalWhere,
- orderBy,
- offset,
- limit: input.perPage,
- rfqType: input.rfqType,
- }),
- countTechSalesRfqsWithJoin(tx, finalWhere, input.rfqType),
- ]);
-
- const pageCount = Math.ceil(Number(total) / input.perPage);
- return { data, pageCount, total: Number(total) };
- });
- } catch (err) {
- console.error("Error fetching RFQs with join:", err);
- return { data: [], pageCount: 0, total: 0 };
- }
- },
- [JSON.stringify(input)],
- {
- revalidate: 60,
- tags: ["techSalesRfqs"],
- }
- )();
-}
-
-/**
- * 직접 조인을 사용하여 벤더 견적서 조회하는 함수
- */
-export async function getTechSalesVendorQuotationsWithJoin(input: {
- rfqId?: number;
- vendorId?: number;
- search?: string;
- filters?: Filter<typeof techSalesVendorQuotations>[];
- sort?: { id: string; desc: boolean }[];
- page: number;
- perPage: number;
- rfqType?: "SHIP" | "TOP" | "HULL"; // rfqType 매개변수 추가
-}) {
- return unstable_cache(
- async () => {
- try {
- const offset = (input.page - 1) * input.perPage;
-
- // 기본 필터 조건들
- const whereConditions = [];
-
- // RFQ ID 필터
- if (input.rfqId) {
- whereConditions.push(eq(techSalesVendorQuotations.rfqId, input.rfqId));
- }
-
- // 벤더 ID 필터
- if (input.vendorId) {
- whereConditions.push(eq(techSalesVendorQuotations.vendorId, input.vendorId));
- }
-
- // 검색 조건
- if (input.search) {
- const s = `%${input.search}%`;
- const searchCondition = or(
- ilike(techSalesVendorQuotations.currency, s),
- ilike(techSalesVendorQuotations.status, s)
- );
- if (searchCondition) {
- whereConditions.push(searchCondition);
- }
- }
-
- // 고급 필터 처리
- if (input.filters && input.filters.length > 0) {
- const filterWhere = filterColumns({
- table: techSalesVendorQuotations,
- filters: input.filters as Filter<typeof techSalesVendorQuotations>[],
- joinOperator: "and",
- });
- if (filterWhere) {
- whereConditions.push(filterWhere);
- }
- }
-
- // 최종 WHERE 조건
- const finalWhere = whereConditions.length > 0
- ? and(...whereConditions)
- : undefined;
-
- // 정렬 기준 설정
- let orderBy: OrderByType[] = [desc(techSalesVendorQuotations.createdAt)];
-
- if (input.sort?.length) {
- orderBy = input.sort.map(item => {
- switch (item.id) {
- case 'id':
- return item.desc ? desc(techSalesVendorQuotations.id) : techSalesVendorQuotations.id;
- case 'status':
- return item.desc ? desc(techSalesVendorQuotations.status) : techSalesVendorQuotations.status;
- case 'currency':
- return item.desc ? desc(techSalesVendorQuotations.currency) : techSalesVendorQuotations.currency;
- case 'totalPrice':
- return item.desc ? desc(techSalesVendorQuotations.totalPrice) : techSalesVendorQuotations.totalPrice;
- case 'createdAt':
- return item.desc ? desc(techSalesVendorQuotations.createdAt) : techSalesVendorQuotations.createdAt;
- case 'updatedAt':
- return item.desc ? desc(techSalesVendorQuotations.updatedAt) : techSalesVendorQuotations.updatedAt;
- default:
- return item.desc ? desc(techSalesVendorQuotations.createdAt) : techSalesVendorQuotations.createdAt;
- }
- });
- }
-
- // 트랜잭션 내부에서 Repository 호출
- const { data, total } = await db.transaction(async (tx) => {
- const data = await selectTechSalesVendorQuotationsWithJoin(tx, {
- where: finalWhere,
- orderBy,
- offset,
- limit: input.perPage,
- });
-
- // 각 견적서의 첨부파일 정보 조회
- const dataWithAttachments = await Promise.all(
- data.map(async (quotation) => {
- const attachments = await db.query.techSalesVendorQuotationAttachments.findMany({
- where: eq(techSalesVendorQuotationAttachments.quotationId, quotation.id),
- orderBy: [desc(techSalesVendorQuotationAttachments.createdAt)],
- });
-
- return {
- ...quotation,
- quotationAttachments: attachments.map(att => ({
- id: att.id,
- fileName: att.fileName,
- fileSize: att.fileSize,
- filePath: att.filePath,
- description: att.description,
- }))
- };
- })
- );
-
- const total = await countTechSalesVendorQuotationsWithJoin(tx, finalWhere);
- return { data: dataWithAttachments, total };
- });
-
- const pageCount = Math.ceil(total / input.perPage);
-
- return { data, pageCount, total };
- } catch (err) {
- console.error("Error fetching vendor quotations with join:", err);
- return { data: [], pageCount: 0, total: 0 };
- }
- },
- [JSON.stringify(input)],
- {
- revalidate: 60,
- tags: [
- "techSalesVendorQuotations",
- ...(input.rfqId ? [`techSalesRfq-${input.rfqId}`] : [])
- ],
- }
- )();
-}
-
-/**
- * 직접 조인을 사용하여 RFQ 대시보드 데이터 조회하는 함수
- */
-export async function getTechSalesDashboardWithJoin(input: {
- search?: string;
- filters?: Filter<typeof techSalesRfqs>[];
- sort?: { id: string; desc: boolean }[];
- page: number;
- perPage: number;
- rfqType?: "SHIP" | "TOP" | "HULL"; // rfqType 매개변수 추가
-}) {
- unstable_noStore(); // 대시보드는 항상 최신 데이터를 보여주기 위해 캐시하지 않음
-
- try {
- const offset = (input.page - 1) * input.perPage;
-
- // Advanced filtering
- const advancedWhere = input.filters ? filterColumns({
- table: techSalesRfqs,
- filters: input.filters as Filter<typeof techSalesRfqs>[],
- joinOperator: 'and',
- }) : undefined;
-
- // Global search
- let globalWhere;
- if (input.search) {
- const s = `%${input.search}%`;
- globalWhere = or(
- ilike(techSalesRfqs.rfqCode, s),
- ilike(techSalesRfqs.materialCode, s),
- ilike(techSalesRfqs.description, s)
- );
- }
-
- const finalWhere = and(
- advancedWhere,
- globalWhere
- );
-
- // 정렬 기준 설정
- let orderBy: OrderByType[] = [desc(techSalesRfqs.updatedAt)]; // 기본 정렬
-
- if (input.sort?.length) {
- // 안전하게 접근하여 정렬 기준 설정
- orderBy = input.sort.map(item => {
- switch (item.id) {
- case 'id':
- return item.desc ? desc(techSalesRfqs.id) : techSalesRfqs.id;
- case 'rfqCode':
- return item.desc ? desc(techSalesRfqs.rfqCode) : techSalesRfqs.rfqCode;
- case 'status':
- return item.desc ? desc(techSalesRfqs.status) : techSalesRfqs.status;
- case 'dueDate':
- return item.desc ? desc(techSalesRfqs.dueDate) : techSalesRfqs.dueDate;
- case 'createdAt':
- return item.desc ? desc(techSalesRfqs.createdAt) : techSalesRfqs.createdAt;
- case 'updatedAt':
- return item.desc ? desc(techSalesRfqs.updatedAt) : techSalesRfqs.updatedAt;
- default:
- return item.desc ? desc(techSalesRfqs.updatedAt) : techSalesRfqs.updatedAt;
- }
- });
- }
-
- // 트랜잭션 내부에서 Repository 호출
- const data = await db.transaction(async (tx) => {
- return await selectTechSalesDashboardWithJoin(tx, {
- where: finalWhere,
- orderBy,
- offset,
- limit: input.perPage,
- rfqType: input.rfqType, // rfqType 매개변수 추가
- });
- });
-
- return { data, success: true };
- } catch (err) {
- console.error("Error fetching dashboard data with join:", err);
- return { data: [], success: false, error: getErrorMessage(err) };
- }
-}
-
-/**
- * 특정 RFQ의 벤더 목록 조회
- */
-export async function getTechSalesRfqVendors(rfqId: number) {
- unstable_noStore();
- try {
- // Repository 함수를 사용하여 벤더 견적 목록 조회
- const result = await getTechSalesVendorQuotationsWithJoin({
- rfqId,
- page: 1,
- perPage: 1000, // 충분히 큰 수로 설정하여 모든 벤더 조회
- });
-
- return { data: result.data, error: null };
- } catch (err) {
- console.error("Error fetching RFQ vendors:", err);
- return { data: [], error: getErrorMessage(err) };
- }
-}
-
-/**
- * 기술영업 RFQ 발송 (선택된 벤더들에게)
- */
-export async function sendTechSalesRfqToVendors(input: {
- rfqId: number;
- vendorIds: number[];
-}) {
- unstable_noStore();
- try {
- // 인증 확인
- const session = await getServerSession(authOptions);
-
- if (!session?.user) {
- return {
- success: false,
- message: "인증이 필요합니다",
- };
- }
-
- // RFQ 정보 조회
- const rfq = await db.query.techSalesRfqs.findFirst({
- where: eq(techSalesRfqs.id, input.rfqId),
- columns: {
- id: true,
- rfqCode: true,
- status: true,
- dueDate: true,
- rfqSendDate: true,
- remark: true,
- materialCode: true,
- description: true,
- rfqType: true,
- },
- with: {
- biddingProject: true,
- createdByUser: {
- columns: {
- id: true,
- name: true,
- email: true,
- }
- }
- }
- });
-
- if (!rfq) {
- return {
- success: false,
- message: "RFQ를 찾을 수 없습니다",
- };
- }
-
- // 발송 가능한 상태인지 확인
- if (rfq.status !== "RFQ Vendor Assignned" && rfq.status !== "RFQ Sent") {
- return {
- success: false,
- message: "벤더가 할당된 RFQ 또는 이미 전송된 RFQ만 다시 전송할 수 있습니다",
- };
- }
-
- const isResend = rfq.status === "RFQ Sent";
-
- // 현재 사용자 정보 조회
- const sender = await db.query.users.findFirst({
- where: eq(users.id, Number(session.user.id)),
- columns: {
- id: true,
- email: true,
- name: true,
- }
- });
-
- if (!sender || !sender.email) {
- return {
- success: false,
- message: "보내는 사람의 이메일 정보를 찾을 수 없습니다",
- };
- }
-
- // 선택된 벤더들의 견적서 정보 조회
- const vendorQuotations = await db.query.techSalesVendorQuotations.findMany({
- where: and(
- eq(techSalesVendorQuotations.rfqId, input.rfqId),
- inArray(techSalesVendorQuotations.vendorId, input.vendorIds)
- ),
- columns: {
- id: true,
- vendorId: true,
- status: true,
- currency: true,
- },
- with: {
- vendor: {
- columns: {
- id: true,
- vendorName: true,
- vendorCode: true,
- }
- }
- }
- });
-
- if (vendorQuotations.length === 0) {
- return {
- success: false,
- message: "선택된 벤더가 이 RFQ에 할당되어 있지 않습니다",
- };
- }
-
- // 트랜잭션 시작
- await db.transaction(async (tx) => {
- // 1. RFQ 상태 업데이트 (최초 발송인 경우 rfqSendDate 설정)
- const updateData: Partial<typeof techSalesRfqs.$inferInsert> = {
- status: "RFQ Sent",
- sentBy: Number(session.user.id),
- updatedBy: Number(session.user.id),
- updatedAt: new Date(),
- };
-
- // rfqSendDate가 null인 경우에만 최초 전송일 설정
- if (!rfq.rfqSendDate) {
- updateData.rfqSendDate = new Date();
- }
-
- await tx.update(techSalesRfqs)
- .set(updateData)
- .where(eq(techSalesRfqs.id, input.rfqId));
-
- // 2. 선택된 벤더들의 견적서 상태를 "Assigned"에서 "Draft"로 변경
- for (const quotation of vendorQuotations) {
- if (quotation.status === "Assigned") {
- await tx.update(techSalesVendorQuotations)
- .set({
- status: "Draft",
- updatedBy: Number(session.user.id),
- updatedAt: new Date(),
- })
- .where(eq(techSalesVendorQuotations.id, quotation.id));
- }
- }
-
- // 2. 각 벤더에 대해 이메일 발송 처리
- for (const quotation of vendorQuotations) {
- if (!quotation.vendorId || !quotation.vendor) continue;
-
- // 벤더에 속한 모든 사용자 조회
- const vendorUsers = await db.query.users.findMany({
- where: eq(users.companyId, quotation.vendor.id),
- columns: {
- id: true,
- email: true,
- name: true,
- language: true
- }
- });
-
- // 유효한 이메일 주소만 필터링
- const vendorEmailsString = vendorUsers
- .filter(user => user.email)
- .map(user => user.email)
- .join(", ");
-
- if (vendorEmailsString) {
- // 대표 언어 결정 (첫 번째 사용자의 언어 또는 기본값)
- const language = vendorUsers[0]?.language || "ko";
-
- // RFQ 아이템 목록 조회
- const rfqItemsResult = await getTechSalesRfqItems(rfq.id);
- const rfqItems = rfqItemsResult.data || [];
-
- // 이메일 컨텍스트 구성 (시리즈 정보 제거, 프로젝트 정보 간소화)
- const emailContext = {
- language: language,
- rfq: {
- id: rfq.id,
- code: rfq.rfqCode,
- title: rfqItems.length > 0 ? rfqItems.map(item => item.itemList).join(', ') : '',
- projectCode: rfq.biddingProject?.pspid || '',
- projectName: rfq.biddingProject?.projNm || '',
- description: rfq.remark || '',
- dueDate: rfq.dueDate ? formatDate(rfq.dueDate, "KR") : 'N/A',
- materialCode: rfq.materialCode || '',
- type: rfq.rfqType || 'SHIP',
- },
- items: rfqItems.map(item => ({
- itemCode: item.itemCode,
- itemList: item.itemList,
- workType: item.workType,
- shipType: item.shipType,
- subItemName: item.subItemName,
- itemType: item.itemType,
- })),
- vendor: {
- id: quotation.vendor.id,
- code: quotation.vendor.vendorCode || '',
- name: quotation.vendor.vendorName,
- },
- sender: {
- fullName: sender.name || '',
- email: sender.email,
- },
- project: {
- // 기본 정보만 유지
- id: rfq.biddingProject?.pspid || '',
- name: rfq.biddingProject?.projNm || '',
- sector: rfq.biddingProject?.sector || '',
- shipType: rfq.biddingProject?.ptypeNm || '',
- shipCount: rfq.biddingProject?.projMsrm || 0,
- ownerName: rfq.biddingProject?.kunnrNm || '',
- className: rfq.biddingProject?.cls1Nm || '',
- },
- details: {
- currency: quotation.currency || 'USD',
- },
- quotationCode: `${rfq.rfqCode}-${quotation.vendorId}`,
- systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://60.101.108.100/ko/partners',
- isResend: isResend,
- versionInfo: isResend ? '(재전송)' : '',
- };
-
- // 이메일 전송
- await sendEmail({
- to: vendorEmailsString,
- subject: isResend
- ? `[기술영업 RFQ 재전송] ${rfq.rfqCode} - ${rfqItems.length > 0 ? rfqItems.map(item => item.itemList).join(', ') : '견적 요청'} ${emailContext.versionInfo}`
- : `[기술영업 RFQ] ${rfq.rfqCode} - ${rfqItems.length > 0 ? rfqItems.map(item => item.itemList).join(', ') : '견적 요청'}`,
- template: 'tech-sales-rfq-invite-ko', // 기술영업용 템플릿
- context: emailContext,
- cc: sender.email, // 발신자를 CC에 추가
- });
- }
- }
- });
-
- // 캐시 무효화
- revalidateTag("techSalesRfqs");
- revalidateTag("techSalesVendorQuotations");
- revalidateTag(`techSalesRfq-${input.rfqId}`);
- revalidatePath(getTechSalesRevalidationPath(rfq?.rfqType || "SHIP"));
-
- return {
- success: true,
- message: `${vendorQuotations.length}개 벤더에게 RFQ가 성공적으로 발송되었습니다`,
- sentCount: vendorQuotations.length,
- };
- } catch (err) {
- console.error("기술영업 RFQ 발송 오류:", err);
- return {
- success: false,
- message: "RFQ 발송 중 오류가 발생했습니다",
- };
- }
-}
-
-/**
- * 벤더용 기술영업 RFQ 견적서 조회 (withJoin 사용)
- */
-export async function getTechSalesVendorQuotation(quotationId: number) {
- unstable_noStore();
- try {
- const quotation = await db.transaction(async (tx) => {
- return await selectSingleTechSalesVendorQuotationWithJoin(tx, quotationId);
- });
-
- if (!quotation) {
- return { data: null, error: "견적서를 찾을 수 없습니다." };
- }
-
- // RFQ 아이템 정보도 함께 조회
- const itemsResult = await getTechSalesRfqItems(quotation.rfqId);
- const items = itemsResult.data || [];
-
- // 견적서 첨부파일 조회
- const quotationAttachments = await db.query.techSalesVendorQuotationAttachments.findMany({
- where: eq(techSalesVendorQuotationAttachments.quotationId, quotationId),
- orderBy: [desc(techSalesVendorQuotationAttachments.createdAt)],
- });
-
- // 기존 구조와 호환되도록 데이터 재구성
- const formattedQuotation = {
- id: quotation.id,
- rfqId: quotation.rfqId,
- vendorId: quotation.vendorId,
- quotationCode: quotation.quotationCode,
- quotationVersion: quotation.quotationVersion,
- totalPrice: quotation.totalPrice,
- currency: quotation.currency,
- validUntil: quotation.validUntil,
- status: quotation.status,
- remark: quotation.remark,
- rejectionReason: quotation.rejectionReason,
- submittedAt: quotation.submittedAt,
- acceptedAt: quotation.acceptedAt,
- createdAt: quotation.createdAt,
- updatedAt: quotation.updatedAt,
- createdBy: quotation.createdBy,
- updatedBy: quotation.updatedBy,
-
- // RFQ 정보
- rfq: {
- id: quotation.rfqId,
- rfqCode: quotation.rfqCode,
- rfqType: quotation.rfqType,
- status: quotation.rfqStatus,
- dueDate: quotation.dueDate,
- rfqSendDate: quotation.rfqSendDate,
- materialCode: quotation.materialCode,
- description: quotation.description,
- remark: quotation.rfqRemark,
- picCode: quotation.picCode,
- createdBy: quotation.rfqCreatedBy,
- biddingProjectId: quotation.biddingProjectId,
-
- // 아이템 정보 추가
- items: items,
-
- // 생성자 정보
- createdByUser: {
- id: quotation.rfqCreatedBy,
- name: quotation.rfqCreatedByName,
- email: quotation.rfqCreatedByEmail,
- },
-
- // 프로젝트 정보
- biddingProject: quotation.biddingProjectId ? {
- id: quotation.biddingProjectId,
- pspid: quotation.pspid,
- projNm: quotation.projNm,
- sector: quotation.sector,
- projMsrm: quotation.projMsrm,
- ptypeNm: quotation.ptypeNm,
- } : null,
- },
-
- // 벤더 정보
- vendor: {
- id: quotation.vendorId,
- vendorName: quotation.vendorName,
- vendorCode: quotation.vendorCode,
- country: quotation.vendorCountry,
- email: quotation.vendorEmail,
- phone: quotation.vendorPhone,
- },
-
- // 첨부파일 정보
- quotationAttachments: quotationAttachments.map(attachment => ({
- id: attachment.id,
- fileName: attachment.fileName,
- fileSize: attachment.fileSize,
- filePath: attachment.filePath,
- description: attachment.description,
- }))
- };
-
- return { data: formattedQuotation, error: null };
- } catch (err) {
- console.error("Error fetching vendor quotation:", err);
- return { data: null, error: getErrorMessage(err) };
- }
-}
-
-/**
- * 기술영업 벤더 견적서 업데이트 (임시저장),
- * 현재는 submit으로 처리, revision 을 아래의 함수로 사용가능함.
- */
-export async function updateTechSalesVendorQuotation(data: {
- id: number
- currency: string
- totalPrice: string
- validUntil: Date
- remark?: string
- updatedBy: number
- changeReason?: string
-}) {
- try {
- return await db.transaction(async (tx) => {
- // 현재 견적서 전체 데이터 조회 (revision 저장용)
- const currentQuotation = await tx.query.techSalesVendorQuotations.findFirst({
- where: eq(techSalesVendorQuotations.id, data.id),
- });
-
- if (!currentQuotation) {
- return { data: null, error: "견적서를 찾을 수 없습니다." };
- }
-
- // Accepted나 Rejected 상태가 아니면 수정 가능
- if (["Rejected"].includes(currentQuotation.status)) {
- return { data: null, error: "승인되거나 거절된 견적서는 수정할 수 없습니다." };
- }
-
- // 실제 변경사항이 있는지 확인
- const hasChanges =
- currentQuotation.currency !== data.currency ||
- currentQuotation.totalPrice !== data.totalPrice ||
- currentQuotation.validUntil?.getTime() !== data.validUntil.getTime() ||
- currentQuotation.remark !== (data.remark || null);
-
- if (!hasChanges) {
- return { data: currentQuotation, error: null };
- }
-
- // 현재 버전을 revision history에 저장
- await tx.insert(techSalesVendorQuotationRevisions).values({
- quotationId: data.id,
- version: currentQuotation.quotationVersion || 1,
- snapshot: {
- currency: currentQuotation.currency,
- totalPrice: currentQuotation.totalPrice,
- validUntil: currentQuotation.validUntil,
- remark: currentQuotation.remark,
- status: currentQuotation.status,
- quotationVersion: currentQuotation.quotationVersion,
- submittedAt: currentQuotation.submittedAt,
- acceptedAt: currentQuotation.acceptedAt,
- updatedAt: currentQuotation.updatedAt,
- },
- changeReason: data.changeReason || "견적서 수정",
- revisedBy: data.updatedBy,
- });
-
- // 새로운 버전으로 업데이트
- const result = await tx
- .update(techSalesVendorQuotations)
- .set({
- currency: data.currency,
- totalPrice: data.totalPrice,
- validUntil: data.validUntil,
- remark: data.remark || null,
- quotationVersion: (currentQuotation.quotationVersion || 1) + 1,
- status: "Revised", // 수정된 상태로 변경
- updatedAt: new Date(),
- })
- .where(eq(techSalesVendorQuotations.id, data.id))
- .returning();
-
- return { data: result[0], error: null };
- });
- } catch (error) {
- console.error("Error updating tech sales vendor quotation:", error);
- return { data: null, error: "견적서 업데이트 중 오류가 발생했습니다" };
- } finally {
- // 캐시 무효화
- revalidateTag("techSalesVendorQuotations");
- revalidatePath(`/partners/techsales/rfq-ship/${data.id}`);
- }
-}
-
-/**
- * 기술영업 벤더 견적서 제출
- */
-export async function submitTechSalesVendorQuotation(data: {
- id: number
- currency: string
- totalPrice: string
- validUntil: Date
- remark?: string
- attachments?: Array<{
- fileName: string
- filePath: string
- fileSize: number
- }>
- updatedBy: number
-}) {
- try {
- return await db.transaction(async (tx) => {
- // 현재 견적서 전체 데이터 조회 (revision 저장용)
- const currentQuotation = await tx.query.techSalesVendorQuotations.findFirst({
- where: eq(techSalesVendorQuotations.id, data.id),
- });
-
- if (!currentQuotation) {
- return { data: null, error: "견적서를 찾을 수 없습니다." };
- }
-
- // Rejected 상태에서는 제출 불가
- if (["Rejected"].includes(currentQuotation.status)) {
- return { data: null, error: "거절된 견적서는 제출할 수 없습니다." };
- }
-
- // // 실제 변경사항이 있는지 확인
- // const hasChanges =
- // currentQuotation.currency !== data.currency ||
- // currentQuotation.totalPrice !== data.totalPrice ||
- // currentQuotation.validUntil?.getTime() !== data.validUntil.getTime() ||
- // currentQuotation.remark !== (data.remark || null);
-
- // // 변경사항이 있거나 처음 제출하는 경우 revision 저장
- // if (hasChanges || currentQuotation.status === "Draft") {
- // await tx.insert(techSalesVendorQuotationRevisions).values({
- // quotationId: data.id,
- // version: currentQuotation.quotationVersion || 1,
- // snapshot: {
- // currency: currentQuotation.currency,
- // totalPrice: currentQuotation.totalPrice,
- // validUntil: currentQuotation.validUntil,
- // remark: currentQuotation.remark,
- // status: currentQuotation.status,
- // quotationVersion: currentQuotation.quotationVersion,
- // submittedAt: currentQuotation.submittedAt,
- // acceptedAt: currentQuotation.acceptedAt,
- // updatedAt: currentQuotation.updatedAt,
- // },
- // changeReason: "견적서 제출",
- // revisedBy: data.updatedBy,
- // });
- // }
-
- // 첫 제출인지 확인 (quotationVersion이 null인 경우)
- const isFirstSubmission = currentQuotation.quotationVersion === null;
-
- // 첫 제출이 아닌 경우에만 revision 저장 (변경사항 이력 관리)
- if (!isFirstSubmission) {
- await tx.insert(techSalesVendorQuotationRevisions).values({
- quotationId: data.id,
- version: currentQuotation.quotationVersion || 1,
- snapshot: {
- currency: currentQuotation.currency,
- totalPrice: currentQuotation.totalPrice,
- validUntil: currentQuotation.validUntil,
- remark: currentQuotation.remark,
- status: currentQuotation.status,
- quotationVersion: currentQuotation.quotationVersion,
- submittedAt: currentQuotation.submittedAt,
- acceptedAt: currentQuotation.acceptedAt,
- updatedAt: currentQuotation.updatedAt,
- },
- changeReason: "견적서 제출",
- revisedBy: data.updatedBy,
- });
- }
-
- // 새로운 버전 번호 계산 (첫 제출은 1, 재제출은 1 증가)
- const newRevisionId = isFirstSubmission ? 1 : (currentQuotation.quotationVersion || 1) + 1;
-
- // 새로운 버전으로 업데이트
- const result = await tx
- .update(techSalesVendorQuotations)
- .set({
- currency: data.currency,
- totalPrice: data.totalPrice,
- validUntil: data.validUntil,
- remark: data.remark || null,
- quotationVersion: newRevisionId,
- status: "Submitted",
- submittedAt: new Date(),
- updatedAt: new Date(),
- })
- .where(eq(techSalesVendorQuotations.id, data.id))
- .returning();
-
- // 첨부파일 처리 (새로운 revisionId 사용)
- if (data.attachments && data.attachments.length > 0) {
- for (const attachment of data.attachments) {
- await tx.insert(techSalesVendorQuotationAttachments).values({
- quotationId: data.id,
- revisionId: newRevisionId, // 새로운 리비전 ID 사용
- fileName: attachment.fileName,
- originalFileName: attachment.fileName,
- fileSize: attachment.fileSize,
- filePath: attachment.filePath,
- fileType: attachment.fileName.split('.').pop() || 'unknown',
- uploadedBy: data.updatedBy,
- isVendorUpload: true,
- });
- }
- }
-
- // 메일 발송 (백그라운드에서 실행)
- if (result[0]) {
- // 벤더에게 견적 제출 확인 메일 발송
- sendQuotationSubmittedNotificationToVendor(data.id).catch(error => {
- console.error("벤더 견적 제출 확인 메일 발송 실패:", error);
- });
-
- // 담당자에게 견적 접수 알림 메일 발송
- sendQuotationSubmittedNotificationToManager(data.id).catch(error => {
- console.error("담당자 견적 접수 알림 메일 발송 실패:", error);
- });
- }
-
- return { data: result[0], error: null };
- });
- } catch (error) {
- console.error("Error submitting tech sales vendor quotation:", error);
- return { data: null, error: "견적서 제출 중 오류가 발생했습니다" };
- } finally {
- // 캐시 무효화
- revalidateTag("techSalesVendorQuotations");
- revalidatePath(`/partners/techsales/rfq-ship`);
- }
-}
-
-/**
- * 통화 목록 조회
- */
-export async function fetchCurrencies() {
- try {
- // 기본 통화 목록 (실제로는 DB에서 가져와야 함)
- const currencies = [
- { code: "USD", name: "미국 달러" },
- { code: "KRW", name: "한국 원" },
- { code: "EUR", name: "유로" },
- { code: "JPY", name: "일본 엔" },
- { code: "CNY", name: "중국 위안" },
- ]
-
- return { data: currencies, error: null }
- } catch (error) {
- console.error("Error fetching currencies:", error)
- return { data: null, error: "통화 목록 조회 중 오류가 발생했습니다" }
- }
-}
-
-/**
- * 벤더용 기술영업 견적서 목록 조회 (페이지네이션 포함)
- */
-export async function getVendorQuotations(input: {
- flags?: string[];
- page: number;
- perPage: number;
- sort?: { id: string; desc: boolean }[];
- filters?: Filter<typeof techSalesVendorQuotations>[];
- joinOperator?: "and" | "or";
- basicFilters?: Filter<typeof techSalesVendorQuotations>[];
- basicJoinOperator?: "and" | "or";
- search?: string;
- from?: string;
- to?: string;
- rfqType?: "SHIP" | "TOP" | "HULL";
-}, vendorId: string) {
- return unstable_cache(
- async () => {
- try {
- console.log('🔍 [getVendorQuotations] 호출됨:', {
- vendorId,
- vendorIdParsed: parseInt(vendorId),
- rfqType: input.rfqType,
- inputData: input
- });
-
- const { page, perPage, sort, filters = [], search = "", from = "", to = "" } = input;
- const offset = (page - 1) * perPage;
- const limit = perPage;
-
- // 기본 조건: 해당 벤더의 견적서만 조회 (Assigned 상태 제외)
- const vendorIdNum = parseInt(vendorId);
- if (isNaN(vendorIdNum)) {
- console.error('❌ [getVendorQuotations] Invalid vendorId:', vendorId);
- return { data: [], pageCount: 0, total: 0 };
- }
-
- const baseConditions = [
- eq(techSalesVendorQuotations.vendorId, vendorIdNum),
- sql`${techSalesVendorQuotations.status} != 'Assigned'` // Assigned 상태 제외
- ];
-
- // rfqType 필터링 추가
- if (input.rfqType) {
- baseConditions.push(eq(techSalesRfqs.rfqType, input.rfqType));
- }
-
- // 검색 조건 추가
- if (search) {
- const s = `%${search}%`;
- const searchCondition = or(
- ilike(techSalesVendorQuotations.currency, s),
- ilike(techSalesVendorQuotations.status, s)
- );
- if (searchCondition) {
- baseConditions.push(searchCondition);
- }
- }
-
- // 날짜 범위 필터
- if (from) {
- baseConditions.push(sql`${techSalesVendorQuotations.createdAt} >= ${from}`);
- }
- if (to) {
- baseConditions.push(sql`${techSalesVendorQuotations.createdAt} <= ${to}`);
- }
-
- // 고급 필터 처리
- if (filters.length > 0) {
- const filterWhere = filterColumns({
- table: techSalesVendorQuotations,
- filters: filters as Filter<typeof techSalesVendorQuotations>[],
- joinOperator: input.joinOperator || "and",
- });
- if (filterWhere) {
- baseConditions.push(filterWhere);
- }
- }
-
- // 최종 WHERE 조건
- const finalWhere = baseConditions.length > 0
- ? and(...baseConditions)
- : undefined;
-
- // 정렬 기준 설정
- let orderBy: OrderByType[] = [desc(techSalesVendorQuotations.updatedAt)];
-
- if (sort?.length) {
- orderBy = sort.map(item => {
- switch (item.id) {
- case 'id':
- return item.desc ? desc(techSalesVendorQuotations.id) : techSalesVendorQuotations.id;
- case 'status':
- return item.desc ? desc(techSalesVendorQuotations.status) : techSalesVendorQuotations.status;
- case 'currency':
- return item.desc ? desc(techSalesVendorQuotations.currency) : techSalesVendorQuotations.currency;
- case 'totalPrice':
- return item.desc ? desc(techSalesVendorQuotations.totalPrice) : techSalesVendorQuotations.totalPrice;
- case 'validUntil':
- return item.desc ? desc(techSalesVendorQuotations.validUntil) : techSalesVendorQuotations.validUntil;
- case 'submittedAt':
- return item.desc ? desc(techSalesVendorQuotations.submittedAt) : techSalesVendorQuotations.submittedAt;
- case 'createdAt':
- return item.desc ? desc(techSalesVendorQuotations.createdAt) : techSalesVendorQuotations.createdAt;
- case 'updatedAt':
- return item.desc ? desc(techSalesVendorQuotations.updatedAt) : techSalesVendorQuotations.updatedAt;
- case 'rfqCode':
- return item.desc ? desc(techSalesRfqs.rfqCode) : techSalesRfqs.rfqCode;
- case 'materialCode':
- return item.desc ? desc(techSalesRfqs.materialCode) : techSalesRfqs.materialCode;
- case 'dueDate':
- return item.desc ? desc(techSalesRfqs.dueDate) : techSalesRfqs.dueDate;
- case 'rfqStatus':
- return item.desc ? desc(techSalesRfqs.status) : techSalesRfqs.status;
- default:
- return item.desc ? desc(techSalesVendorQuotations.updatedAt) : techSalesVendorQuotations.updatedAt;
- }
- });
- }
-
- // 조인을 포함한 데이터 조회 (중복 제거를 위해 techSalesAttachments JOIN 제거)
- const data = await db
- .select({
- id: techSalesVendorQuotations.id,
- rfqId: techSalesVendorQuotations.rfqId,
- vendorId: techSalesVendorQuotations.vendorId,
- status: techSalesVendorQuotations.status,
- currency: techSalesVendorQuotations.currency,
- totalPrice: techSalesVendorQuotations.totalPrice,
- validUntil: techSalesVendorQuotations.validUntil,
- submittedAt: techSalesVendorQuotations.submittedAt,
- remark: techSalesVendorQuotations.remark,
- createdAt: techSalesVendorQuotations.createdAt,
- updatedAt: techSalesVendorQuotations.updatedAt,
- createdBy: techSalesVendorQuotations.createdBy,
- updatedBy: techSalesVendorQuotations.updatedBy,
- quotationCode: techSalesVendorQuotations.quotationCode,
- quotationVersion: techSalesVendorQuotations.quotationVersion,
- rejectionReason: techSalesVendorQuotations.rejectionReason,
- acceptedAt: techSalesVendorQuotations.acceptedAt,
- // RFQ 정보
- rfqCode: techSalesRfqs.rfqCode,
- materialCode: techSalesRfqs.materialCode,
- dueDate: techSalesRfqs.dueDate,
- rfqStatus: techSalesRfqs.status,
- description: techSalesRfqs.description,
- // 프로젝트 정보 (직접 조인)
- projNm: biddingProjects.projNm,
- // 아이템 개수
- itemCount: sql<number>`(
- SELECT COUNT(*)
- FROM tech_sales_rfq_items
- WHERE tech_sales_rfq_items.rfq_id = ${techSalesRfqs.id}
- )`,
- // RFQ 첨부파일 개수 (RFQ_COMMON 타입만 카운트)
- attachmentCount: sql<number>`(
- SELECT COUNT(*)
- FROM tech_sales_attachments
- WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id}
- AND tech_sales_attachments.attachment_type = 'RFQ_COMMON'
- )`,
- })
- .from(techSalesVendorQuotations)
- .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id))
- .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id))
- .where(finalWhere)
- .orderBy(...orderBy)
- .limit(limit)
- .offset(offset);
-
- // 총 개수 조회
- const totalResult = await db
- .select({ count: sql<number>`count(*)` })
- .from(techSalesVendorQuotations)
- .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id))
- .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id))
- .where(finalWhere);
-
- const total = totalResult[0]?.count || 0;
- const pageCount = Math.ceil(total / perPage);
-
- return { data, pageCount, total };
- } catch (err) {
- console.error("Error fetching vendor quotations:", err);
- return { data: [], pageCount: 0, total: 0 };
- }
- },
- [JSON.stringify(input), vendorId], // 캐싱 키
- {
- revalidate: 60, // 1분간 캐시
- tags: [
- "techSalesVendorQuotations",
- `vendor-${vendorId}-quotations`
- ],
- }
- )();
-}
-
-/**
- * 기술영업 벤더 견적 승인 (벤더 선택)
- */
-export async function acceptTechSalesVendorQuotation(quotationId: number) {
- try {
- const result = await db.transaction(async (tx) => {
- // 1. 선택된 견적 정보 조회
- const selectedQuotation = await tx
- .select()
- .from(techSalesVendorQuotations)
- .where(eq(techSalesVendorQuotations.id, quotationId))
- .limit(1)
-
- if (selectedQuotation.length === 0) {
- throw new Error("견적을 찾을 수 없습니다")
- }
-
- const quotation = selectedQuotation[0]
-
- // 2. 선택된 견적을 Accepted로 변경
- await tx
- .update(techSalesVendorQuotations)
- .set({
- status: "Accepted",
- acceptedAt: new Date(),
- updatedAt: new Date(),
- })
- .where(eq(techSalesVendorQuotations.id, quotationId))
-
- // 4. RFQ 상태를 Closed로 변경
- await tx
- .update(techSalesRfqs)
- .set({
- status: "Closed",
- updatedAt: new Date(),
- })
- .where(eq(techSalesRfqs.id, quotation.rfqId))
-
- return quotation
- })
-
- // 메일 발송 (백그라운드에서 실행)
- // 선택된 벤더에게 견적 선택 알림 메일 발송
- sendQuotationAcceptedNotification(quotationId).catch(error => {
- console.error("벤더 견적 선택 알림 메일 발송 실패:", error);
- });
-
- // 캐시 무효화
- revalidateTag("techSalesVendorQuotations")
- revalidateTag(`techSalesRfq-${result.rfqId}`)
- revalidateTag("techSalesRfqs")
-
- // 해당 RFQ의 모든 벤더 캐시 무효화 (선택된 벤더와 거절된 벤더들)
- const allVendorsInRfq = await db.query.techSalesVendorQuotations.findMany({
- where: eq(techSalesVendorQuotations.rfqId, result.rfqId),
- columns: { vendorId: true }
- });
-
- for (const vendorQuotation of allVendorsInRfq) {
- revalidateTag(`vendor-${vendorQuotation.vendorId}-quotations`);
- }
- revalidatePath("/evcp/budgetary-tech-sales-ship")
- revalidatePath("/partners/techsales")
-
-
- return { success: true, data: result }
- } catch (error) {
- console.error("벤더 견적 승인 오류:", error)
- return {
- success: false,
- error: error instanceof Error ? error.message : "벤더 견적 승인에 실패했습니다"
- }
- }
-}
-
-/**
- * 기술영업 RFQ 첨부파일 생성 (파일 업로드), 사용x
- */
-export async function createTechSalesRfqAttachments(params: {
- techSalesRfqId: number
- files: File[]
- createdBy: number
- attachmentType?: "RFQ_COMMON" | "VENDOR_SPECIFIC"
- description?: string
-}) {
- unstable_noStore();
- try {
- const { techSalesRfqId, files, createdBy, attachmentType = "RFQ_COMMON", description } = params;
-
- if (!files || files.length === 0) {
- return { data: null, error: "업로드할 파일이 없습니다." };
- }
-
- // RFQ 존재 확인
- const rfq = await db.query.techSalesRfqs.findFirst({
- where: eq(techSalesRfqs.id, techSalesRfqId),
- columns: { id: true, status: true }
- });
-
- if (!rfq) {
- return { data: null, error: "RFQ를 찾을 수 없습니다." };
- }
-
- // 편집 가능한 상태 확인
- if (!["RFQ Created", "RFQ Vendor Assignned"].includes(rfq.status)) {
- return { data: null, error: "현재 상태에서는 첨부파일을 추가할 수 없습니다." };
- }
-
- const results: typeof techSalesAttachments.$inferSelect[] = [];
-
- // 트랜잭션으로 처리
- await db.transaction(async (tx) => {
-
- for (const file of files) {
- const saveResult = await saveDRMFile(file, decryptWithServerAction,`techsales-rfq/${techSalesRfqId}` )
-
- // DB에 첨부파일 레코드 생성
- const [newAttachment] = await tx.insert(techSalesAttachments).values({
- techSalesRfqId,
- attachmentType,
- fileName: saveResult.fileName,
- originalFileName: file.name,
- filePath: saveResult.publicPath,
- fileSize: file.size,
- fileType: file.type || undefined,
- description: description || undefined,
- createdBy,
- }).returning();
-
- results.push(newAttachment);
- }
- });
-
- // RFQ 타입 조회하여 캐시 무효화
- const rfqType = await db.query.techSalesRfqs.findFirst({
- where: eq(techSalesRfqs.id, techSalesRfqId),
- columns: { rfqType: true }
- });
-
- revalidateTag("techSalesRfqs");
- revalidateTag(`techSalesRfq-${techSalesRfqId}`);
- revalidatePath(getTechSalesRevalidationPath(rfqType?.rfqType || "SHIP"));
-
- return { data: results, error: null };
- } catch (err) {
- console.error("기술영업 RFQ 첨부파일 생성 오류:", err);
- return { data: null, error: getErrorMessage(err) };
- }
-}
-
-/**
- * 기술영업 RFQ 첨부파일 조회
- */
-export async function getTechSalesRfqAttachments(techSalesRfqId: number) {
- unstable_noStore();
- try {
- const attachments = await db.query.techSalesAttachments.findMany({
- where: eq(techSalesAttachments.techSalesRfqId, techSalesRfqId),
- orderBy: [desc(techSalesAttachments.createdAt)],
- with: {
- createdByUser: {
- columns: {
- id: true,
- name: true,
- email: true,
- }
- }
- }
- });
-
- return { data: attachments, error: null };
- } catch (err) {
- console.error("기술영업 RFQ 첨부파일 조회 오류:", err);
- return { data: [], error: getErrorMessage(err) };
- }
-}
-
-/**
- * RFQ 첨부파일 타입별 조회
- */
-export async function getTechSalesRfqAttachmentsByType(
- techSalesRfqId: number,
- attachmentType: "RFQ_COMMON" | "VENDOR_SPECIFIC" | "TBE_RESULT" | "CBE_RESULT"
-) {
- unstable_noStore();
- try {
- const attachments = await db.query.techSalesAttachments.findMany({
- where: and(
- eq(techSalesAttachments.techSalesRfqId, techSalesRfqId),
- eq(techSalesAttachments.attachmentType, attachmentType)
- ),
- orderBy: [desc(techSalesAttachments.createdAt)],
- with: {
- createdByUser: {
- columns: {
- id: true,
- name: true,
- email: true,
- }
- }
- }
- });
-
- return { data: attachments, error: null };
- } catch (err) {
- console.error(`기술영업 RFQ ${attachmentType} 첨부파일 조회 오류:`, err);
- return { data: [], error: getErrorMessage(err) };
- }
-}
-
-/**
- * 기술영업 RFQ 첨부파일 삭제
- */
-export async function deleteTechSalesRfqAttachment(attachmentId: number) {
- unstable_noStore();
- try {
- // 첨부파일 정보 조회
- const attachment = await db.query.techSalesAttachments.findFirst({
- where: eq(techSalesAttachments.id, attachmentId),
- });
-
- if (!attachment) {
- return { data: null, error: "첨부파일을 찾을 수 없습니다." };
- }
-
- // RFQ 상태 확인
- const rfq = await db.query.techSalesRfqs.findFirst({
- where: eq(techSalesRfqs.id, attachment.techSalesRfqId!), // Non-null assertion since we know it exists
- columns: { id: true, status: true }
- });
-
- if (!rfq) {
- return { data: null, error: "RFQ를 찾을 수 없습니다." };
- }
-
- // 편집 가능한 상태 확인
- if (!["RFQ Created", "RFQ Vendor Assignned"].includes(rfq.status)) {
- return { data: null, error: "현재 상태에서는 첨부파일을 삭제할 수 없습니다." };
- }
-
- // 트랜잭션으로 처리
- const result = await db.transaction(async (tx) => {
- // DB에서 레코드 삭제
- const deletedAttachment = await tx.delete(techSalesAttachments)
- .where(eq(techSalesAttachments.id, attachmentId))
- .returning();
-
- // 파일 시스템에서 파일 삭제
- try {
- await deleteFile(`${attachment.filePath}`)
-
- } catch (fileError) {
- console.warn("파일 삭제 실패:", fileError);
- // 파일 삭제 실패는 심각한 오류가 아니므로 계속 진행
- }
-
- return deletedAttachment[0];
- });
-
- // RFQ 타입 조회하여 캐시 무효화
- const attachmentRfq = await db.query.techSalesRfqs.findFirst({
- where: eq(techSalesRfqs.id, attachment.techSalesRfqId!),
- columns: { rfqType: true }
- });
-
- revalidateTag("techSalesRfqs");
- revalidateTag(`techSalesRfq-${attachment.techSalesRfqId}`);
- revalidatePath(getTechSalesRevalidationPath(attachmentRfq?.rfqType || "SHIP"));
-
- return { data: result, error: null };
- } catch (err) {
- console.error("기술영업 RFQ 첨부파일 삭제 오류:", err);
- return { data: null, error: getErrorMessage(err) };
- }
-}
-
-/**
- * 기술영업 RFQ 첨부파일 일괄 처리 (업로드 + 삭제)
- */
-export async function processTechSalesRfqAttachments(params: {
- techSalesRfqId: number
- newFiles: { file: File; attachmentType: "RFQ_COMMON" | "VENDOR_SPECIFIC" | "TBE_RESULT" | "CBE_RESULT"; description?: string }[]
- deleteAttachmentIds: number[]
- createdBy: number
-}) {
- unstable_noStore();
- try {
- const { techSalesRfqId, newFiles, deleteAttachmentIds, createdBy } = params;
-
- // RFQ 존재 및 상태 확인
- const rfq = await db.query.techSalesRfqs.findFirst({
- where: eq(techSalesRfqs.id, techSalesRfqId),
- columns: { id: true, status: true }
- });
-
- if (!rfq) {
- return { data: null, error: "RFQ를 찾을 수 없습니다." };
- }
-
- if (!["RFQ Created", "RFQ Vendor Assignned"].includes(rfq.status)) {
- return { data: null, error: "현재 상태에서는 첨부파일을 수정할 수 없습니다." };
- }
-
- const results = {
- uploaded: [] as typeof techSalesAttachments.$inferSelect[],
- deleted: [] as typeof techSalesAttachments.$inferSelect[],
- };
-
- await db.transaction(async (tx) => {
-
- // 1. 삭제할 첨부파일 처리
- if (deleteAttachmentIds.length > 0) {
- const attachmentsToDelete = await tx.query.techSalesAttachments.findMany({
- where: sql`${techSalesAttachments.id} IN (${deleteAttachmentIds.join(',')})`
- });
-
- for (const attachment of attachmentsToDelete) {
- // DB에서 레코드 삭제
- const [deletedAttachment] = await tx.delete(techSalesAttachments)
- .where(eq(techSalesAttachments.id, attachment.id))
- .returning();
-
- results.deleted.push(deletedAttachment);
- await deleteFile(attachment.filePath)
-
- }
- }
-
- // 2. 새 파일 업로드 처리
- if (newFiles.length > 0) {
- for (const { file, attachmentType, description } of newFiles) {
- const saveResult = await saveDRMFile(file, decryptWithServerAction,`techsales-rfq/${techSalesRfqId}` )
-
- // DB에 첨부파일 레코드 생성
- const [newAttachment] = await tx.insert(techSalesAttachments).values({
- techSalesRfqId,
- attachmentType,
- fileName: saveResult.fileName,
- originalFileName: file.name,
- filePath: saveResult.publicPath,
- fileSize: file.size,
- fileType: file.type || undefined,
- description: description || undefined,
- createdBy,
- }).returning();
-
- results.uploaded.push(newAttachment);
- }
- }
- });
-
- // 캐시 무효화
- revalidateTag("techSalesRfqs");
- revalidateTag(`techSalesRfq-${techSalesRfqId}`);
- revalidatePath("/evcp/budgetary-tech-sales-ship");
-
- return {
- data: results,
- error: null,
- message: `${results.uploaded.length}개 업로드, ${results.deleted.length}개 삭제 완료`
- };
- } catch (err) {
- console.error("기술영업 RFQ 첨부파일 일괄 처리 오류:", err);
- return { data: null, error: getErrorMessage(err) };
- }
-}
-
-// ========================================
-// 메일 발송 관련 함수들
-// ========================================
-
-/**
- * 벤더 견적 제출 확인 메일 발송 (벤더용)
- */
-export async function sendQuotationSubmittedNotificationToVendor(quotationId: number) {
- try {
- // 견적서 정보 조회 (projectSeries 조인 추가)
- const quotation = await db.query.techSalesVendorQuotations.findFirst({
- where: eq(techSalesVendorQuotations.id, quotationId),
- with: {
- rfq: {
- with: {
- biddingProject: true,
- createdByUser: {
- columns: {
- id: true,
- name: true,
- email: true,
- }
- }
- }
- },
- vendor: {
- columns: {
- id: true,
- vendorName: true,
- vendorCode: true,
- }
- }
- }
- });
-
- if (!quotation || !quotation.rfq || !quotation.vendor) {
- console.error("견적서 또는 관련 정보를 찾을 수 없습니다");
- return { success: false, error: "견적서 정보를 찾을 수 없습니다" };
- }
-
- // 벤더 사용자들 조회
- const vendorUsers = await db.query.users.findMany({
- where: eq(users.companyId, quotation.vendor.id),
- columns: {
- id: true,
- email: true,
- name: true,
- language: true
- }
- });
-
- const vendorEmails = vendorUsers
- .filter(user => user.email)
- .map(user => user.email)
- .join(", ");
-
- if (!vendorEmails) {
- console.warn(`벤더 ID ${quotation.vendor.id}에 등록된 이메일 주소가 없습니다`);
- return { success: false, error: "벤더 이메일 주소가 없습니다" };
- }
-
- // RFQ 아이템 정보 조회
- const rfqItemsResult = await getTechSalesRfqItems(quotation.rfq.id);
- const rfqItems = rfqItemsResult.data || [];
-
- // 이메일 컨텍스트 구성 (시리즈 정보 제거, 프로젝트 정보 간소화)
- const emailContext = {
- language: vendorUsers[0]?.language || "ko",
- quotation: {
- id: quotation.id,
- currency: quotation.currency,
- totalPrice: quotation.totalPrice,
- validUntil: quotation.validUntil,
- submittedAt: quotation.submittedAt,
- remark: quotation.remark,
- },
- rfq: {
- id: quotation.rfq.id,
- code: quotation.rfq.rfqCode,
- title: quotation.rfq.description || '',
- projectCode: quotation.rfq.biddingProject?.pspid || '',
- projectName: quotation.rfq.biddingProject?.projNm || '',
- dueDate: quotation.rfq.dueDate,
- materialCode: quotation.rfq.materialCode,
- description: quotation.rfq.remark,
- },
- items: rfqItems.map(item => ({
- itemCode: item.itemCode,
- itemList: item.itemList,
- workType: item.workType,
- shipType: item.shipType,
- subItemName: item.subItemName,
- itemType: item.itemType,
- })),
- vendor: {
- id: quotation.vendor.id,
- code: quotation.vendor.vendorCode,
- name: quotation.vendor.vendorName,
- },
- project: {
- name: quotation.rfq.biddingProject?.projNm || '',
- sector: quotation.rfq.biddingProject?.sector || '',
- shipCount: quotation.rfq.biddingProject?.projMsrm ? Number(quotation.rfq.biddingProject.projMsrm) : 0,
- ownerName: quotation.rfq.biddingProject?.kunnrNm || '',
- className: quotation.rfq.biddingProject?.cls1Nm || '',
- },
- manager: {
- name: quotation.rfq.createdByUser?.name || '',
- email: quotation.rfq.createdByUser?.email || '',
- },
- systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://60.101.108.100/ko/partners',
- companyName: 'Samsung Heavy Industries',
- year: new Date().getFullYear(),
- };
-
- // 이메일 발송
- await sendEmail({
- to: vendorEmails,
- subject: `[견적 제출 확인] ${quotation.rfq.rfqCode} - 견적 요청`,
- template: 'tech-sales-quotation-submitted-vendor-ko',
- context: emailContext,
- });
-
- console.log(`벤더 견적 제출 확인 메일 발송 완료: ${vendorEmails}`);
- return { success: true };
- } catch (error) {
- console.error("벤더 견적 제출 확인 메일 발송 오류:", error);
- return { success: false, error: "메일 발송 중 오류가 발생했습니다" };
- }
-}
-
-/**
- * 벤더 견적 접수 알림 메일 발송 (담당자용)
- */
-export async function sendQuotationSubmittedNotificationToManager(quotationId: number) {
- try {
- // 견적서 정보 조회
- const quotation = await db.query.techSalesVendorQuotations.findFirst({
- where: eq(techSalesVendorQuotations.id, quotationId),
- with: {
- rfq: {
- with: {
- biddingProject: true,
- createdByUser: {
- columns: {
- id: true,
- name: true,
- email: true,
- }
- }
- }
- },
- vendor: {
- columns: {
- id: true,
- vendorName: true,
- vendorCode: true,
- }
- }
- }
- });
-
- if (!quotation || !quotation.rfq || !quotation.vendor) {
- console.error("견적서 또는 관련 정보를 찾을 수 없습니다");
- return { success: false, error: "견적서 정보를 찾을 수 없습니다" };
- }
-
- const manager = quotation.rfq.createdByUser;
- if (!manager?.email) {
- console.warn("담당자 이메일 주소가 없습니다");
- return { success: false, error: "담당자 이메일 주소가 없습니다" };
- }
-
- // RFQ 아이템 정보 조회
- const rfqItemsResult = await getTechSalesRfqItems(quotation.rfq.id);
- const rfqItems = rfqItemsResult.data || [];
-
- // 이메일 컨텍스트 구성 (시리즈 정보 제거, 프로젝트 정보 간소화)
- const emailContext = {
- language: "ko",
- quotation: {
- id: quotation.id,
- currency: quotation.currency,
- totalPrice: quotation.totalPrice,
- validUntil: quotation.validUntil,
- submittedAt: quotation.submittedAt,
- remark: quotation.remark,
- },
- rfq: {
- id: quotation.rfq.id,
- code: quotation.rfq.rfqCode,
- title: quotation.rfq.description || '',
- projectCode: quotation.rfq.biddingProject?.pspid || '',
- projectName: quotation.rfq.biddingProject?.projNm || '',
- dueDate: quotation.rfq.dueDate,
- materialCode: quotation.rfq.materialCode,
- description: quotation.rfq.remark,
- },
- items: rfqItems.map(item => ({
- itemCode: item.itemCode,
- itemList: item.itemList,
- workType: item.workType,
- shipType: item.shipType,
- subItemName: item.subItemName,
- itemType: item.itemType,
- })),
- vendor: {
- id: quotation.vendor.id,
- code: quotation.vendor.vendorCode,
- name: quotation.vendor.vendorName,
- },
- project: {
- name: quotation.rfq.biddingProject?.projNm || '',
- sector: quotation.rfq.biddingProject?.sector || '',
- shipCount: quotation.rfq.biddingProject?.projMsrm ? Number(quotation.rfq.biddingProject.projMsrm) : 0,
- ownerName: quotation.rfq.biddingProject?.kunnrNm || '',
- className: quotation.rfq.biddingProject?.cls1Nm || '',
- },
- manager: {
- name: manager.name || '',
- email: manager.email,
- },
- systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://60.101.108.100/ko/evcp',
- companyName: 'Samsung Heavy Industries',
- year: new Date().getFullYear(),
- };
-
- // 이메일 발송
- await sendEmail({
- to: manager.email,
- subject: `[견적 접수 알림] ${quotation.vendor.vendorName}에서 ${quotation.rfq.rfqCode} 견적서를 제출했습니다`,
- template: 'tech-sales-quotation-submitted-manager-ko',
- context: emailContext,
- });
-
- console.log(`담당자 견적 접수 알림 메일 발송 완료: ${manager.email}`);
- return { success: true };
- } catch (error) {
- console.error("담당자 견적 접수 알림 메일 발송 오류:", error);
- return { success: false, error: "메일 발송 중 오류가 발생했습니다" };
- }
-}
-
-/**
- * 벤더 견적 선택 알림 메일 발송
- */
-export async function sendQuotationAcceptedNotification(quotationId: number) {
- try {
- // 견적서 정보 조회
- const quotation = await db.query.techSalesVendorQuotations.findFirst({
- where: eq(techSalesVendorQuotations.id, quotationId),
- with: {
- rfq: {
- with: {
- biddingProject: true,
- createdByUser: {
- columns: {
- id: true,
- name: true,
- email: true,
- }
- }
- }
- },
- vendor: {
- columns: {
- id: true,
- vendorName: true,
- vendorCode: true,
- }
- }
- }
- });
-
- if (!quotation || !quotation.rfq || !quotation.vendor) {
- console.error("견적서 또는 관련 정보를 찾을 수 없습니다");
- return { success: false, error: "견적서 정보를 찾을 수 없습니다" };
- }
-
- // 벤더 사용자들 조회
- const vendorUsers = await db.query.users.findMany({
- where: eq(users.companyId, quotation.vendor.id),
- columns: {
- id: true,
- email: true,
- name: true,
- language: true
- }
- });
-
- const vendorEmails = vendorUsers
- .filter(user => user.email)
- .map(user => user.email)
- .join(", ");
-
- if (!vendorEmails) {
- console.warn(`벤더 ID ${quotation.vendor.id}에 등록된 이메일 주소가 없습니다`);
- return { success: false, error: "벤더 이메일 주소가 없습니다" };
- }
-
- // RFQ 아이템 정보 조회
- const rfqItemsResult = await getTechSalesRfqItems(quotation.rfq.id);
- const rfqItems = rfqItemsResult.data || [];
-
- // 이메일 컨텍스트 구성 (시리즈 정보 제거, 프로젝트 정보 간소화)
- const emailContext = {
- language: vendorUsers[0]?.language || "ko",
- quotation: {
- id: quotation.id,
- currency: quotation.currency,
- totalPrice: quotation.totalPrice,
- validUntil: quotation.validUntil,
- acceptedAt: quotation.acceptedAt,
- remark: quotation.remark,
- },
- rfq: {
- id: quotation.rfq.id,
- code: quotation.rfq.rfqCode,
- title: quotation.rfq.description || '',
- projectCode: quotation.rfq.biddingProject?.pspid || '',
- projectName: quotation.rfq.biddingProject?.projNm || '',
- dueDate: quotation.rfq.dueDate,
- materialCode: quotation.rfq.materialCode,
- description: quotation.rfq.remark,
- },
- items: rfqItems.map(item => ({
- itemCode: item.itemCode,
- itemList: item.itemList,
- workType: item.workType,
- shipType: item.shipType,
- subItemName: item.subItemName,
- itemType: item.itemType,
- })),
- vendor: {
- id: quotation.vendor.id,
- code: quotation.vendor.vendorCode,
- name: quotation.vendor.vendorName,
- },
- project: {
- name: quotation.rfq.biddingProject?.projNm || '',
- sector: quotation.rfq.biddingProject?.sector || '',
- shipCount: quotation.rfq.biddingProject?.projMsrm ? Number(quotation.rfq.biddingProject.projMsrm) : 0,
- ownerName: quotation.rfq.biddingProject?.kunnrNm || '',
- className: quotation.rfq.biddingProject?.cls1Nm || '',
- },
- manager: {
- name: quotation.rfq.createdByUser?.name || '',
- email: quotation.rfq.createdByUser?.email || '',
- },
- systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://60.101.108.100/ko/partners',
- companyName: 'Samsung Heavy Industries',
- year: new Date().getFullYear(),
- };
-
- // 이메일 발송
- await sendEmail({
- to: vendorEmails,
- subject: `[견적 선택 알림] ${quotation.rfq.rfqCode} - 귀하의 견적이 선택되었습니다`,
- template: 'tech-sales-quotation-accepted-ko',
- context: emailContext,
- });
-
- console.log(`벤더 견적 선택 알림 메일 발송 완료: ${vendorEmails}`);
- return { success: true };
- } catch (error) {
- console.error("벤더 견적 선택 알림 메일 발송 오류:", error);
- return { success: false, error: "메일 발송 중 오류가 발생했습니다" };
- }
-}
-
-// ==================== Vendor Communication 관련 ====================
-
-export interface TechSalesAttachment {
- id: number
- fileName: string
- fileSize: number
- fileType: string | null // <- null 허용
- filePath: string
- uploadedAt: Date
-}
-
-export interface TechSalesComment {
- id: number
- rfqId: number
- vendorId: number | null // null 허용으로 변경
- userId?: number | null // null 허용으로 변경
- content: string
- isVendorComment: boolean | null // null 허용으로 변경
- createdAt: Date
- updatedAt: Date
- userName?: string | null // null 허용으로 변경
- vendorName?: string | null // null 허용으로 변경
- attachments: TechSalesAttachment[]
- isRead: boolean | null // null 허용으로 변경
-}
-
-/**
- * 특정 RFQ의 벤더별 읽지 않은 메시지 개수를 조회하는 함수
- *
- * @param rfqId RFQ ID
- * @returns 벤더별 읽지 않은 메시지 개수 (vendorId: count)
- */
-export async function getTechSalesUnreadMessageCounts(rfqId: number): Promise<Record<number, number>> {
- try {
- // 벤더가 보낸 읽지 않은 메시지를 벤더별로 카운트
- const unreadCounts = await db
- .select({
- vendorId: techSalesRfqComments.vendorId,
- count: sql<number>`count(*)`,
- })
- .from(techSalesRfqComments)
- .where(
- and(
- eq(techSalesRfqComments.rfqId, rfqId),
- eq(techSalesRfqComments.isVendorComment, true), // 벤더가 보낸 메시지
- eq(techSalesRfqComments.isRead, false), // 읽지 않은 메시지
- sql`${techSalesRfqComments.vendorId} IS NOT NULL` // vendorId가 null이 아닌 것
- )
- )
- .groupBy(techSalesRfqComments.vendorId);
-
- // Record<number, number> 형태로 변환
- const result: Record<number, number> = {};
- unreadCounts.forEach(item => {
- if (item.vendorId) {
- result[item.vendorId] = item.count;
- }
- });
-
- return result;
- } catch (error) {
- console.error('techSales 읽지 않은 메시지 개수 조회 오류:', error);
- return {};
- }
-}
-
-/**
- * 특정 RFQ와 벤더 간의 커뮤니케이션 메시지를 가져오는 서버 액션
- *
- * @param rfqId RFQ ID
- * @param vendorId 벤더 ID
- * @returns 코멘트 목록
- */
-export async function fetchTechSalesVendorComments(rfqId: number, vendorId?: number): Promise<TechSalesComment[]> {
- if (!vendorId) {
- return []
- }
-
- try {
- // 인증 확인
- const session = await getServerSession(authOptions);
-
- if (!session?.user) {
- throw new Error("인증이 필요합니다")
- }
-
- // 코멘트 쿼리
- const comments = await db.query.techSalesRfqComments.findMany({
- where: and(
- eq(techSalesRfqComments.rfqId, rfqId),
- eq(techSalesRfqComments.vendorId, vendorId)
- ),
- orderBy: [techSalesRfqComments.createdAt],
- with: {
- user: {
- columns: {
- name: true
- }
- },
- vendor: {
- columns: {
- vendorName: true
- }
- },
- attachments: true,
- }
- })
-
- // 결과 매핑
- return comments.map(comment => ({
- id: comment.id,
- rfqId: comment.rfqId,
- vendorId: comment.vendorId,
- userId: comment.userId || undefined,
- content: comment.content,
- isVendorComment: comment.isVendorComment,
- createdAt: comment.createdAt,
- updatedAt: comment.updatedAt,
- userName: comment.user?.name,
- vendorName: comment.vendor?.vendorName,
- isRead: comment.isRead,
- attachments: comment.attachments.map(att => ({
- id: att.id,
- fileName: att.fileName,
- fileSize: att.fileSize,
- fileType: att.fileType,
- filePath: att.filePath,
- uploadedAt: att.uploadedAt
- }))
- }))
- } catch (error) {
- console.error('techSales 벤더 코멘트 가져오기 오류:', error)
- throw error
- }
-}
-
-/**
- * 코멘트를 읽음 상태로 표시하는 서버 액션
- *
- * @param rfqId RFQ ID
- * @param vendorId 벤더 ID
- */
-export async function markTechSalesMessagesAsRead(rfqId: number, vendorId?: number): Promise<void> {
- if (!vendorId) {
- return
- }
-
- try {
- // 인증 확인
- const session = await getServerSession(authOptions);
-
- if (!session?.user) {
- throw new Error("인증이 필요합니다")
- }
-
- // 벤더가 작성한 읽지 않은 코멘트 업데이트
- await db.update(techSalesRfqComments)
- .set({ isRead: true })
- .where(
- and(
- eq(techSalesRfqComments.rfqId, rfqId),
- eq(techSalesRfqComments.vendorId, vendorId),
- eq(techSalesRfqComments.isVendorComment, true),
- eq(techSalesRfqComments.isRead, false)
- )
- )
-
- // 캐시 무효화
- revalidateTag(`tech-sales-rfq-${rfqId}-comments`)
- } catch (error) {
- console.error('techSales 메시지 읽음 표시 오류:', error)
- throw error
- }
-}
-
-// ==================== RFQ 조선/해양 관련 ====================
-
-/**
- * 기술영업 조선 RFQ 생성 (1:N 관계)
- */
-export async function createTechSalesShipRfq(input: {
- biddingProjectId: number;
- itemIds: number[]; // 조선 아이템 ID 배열
- dueDate: Date;
- description?: string;
- createdBy: number;
-}) {
- unstable_noStore();
- try {
- return await db.transaction(async (tx) => {
- // 프로젝트 정보 조회 (유효성 검증)
- const biddingProject = await tx.query.biddingProjects.findFirst({
- where: (biddingProjects, { eq }) => eq(biddingProjects.id, input.biddingProjectId)
- });
-
- if (!biddingProject) {
- throw new Error(`프로젝트 ID ${input.biddingProjectId}를 찾을 수 없습니다.`);
- }
-
- // RFQ 코드 생성 (SHIP 타입)
- const rfqCode = await generateRfqCodes(tx, 1);
-
- // RFQ 생성
- const [rfq] = await tx
- .insert(techSalesRfqs)
- .values({
- rfqCode: rfqCode[0],
- biddingProjectId: input.biddingProjectId,
- description: input.description,
- dueDate: input.dueDate,
- status: "RFQ Created",
- rfqType: "SHIP",
- createdBy: input.createdBy,
- updatedBy: input.createdBy,
- })
- .returning({ id: techSalesRfqs.id });
-
- // 아이템들 추가
- for (const itemId of input.itemIds) {
- await tx
- .insert(techSalesRfqItems)
- .values({
- rfqId: rfq.id,
- itemShipbuildingId: itemId,
- itemType: "SHIP",
- });
- }
-
- // 캐시 무효화
- revalidateTag("techSalesRfqs");
- revalidatePath("/evcp/budgetary-tech-sales-ship");
-
- return { data: rfq, error: null };
- });
- } catch (err) {
- console.error("Error creating Ship RFQ:", err);
- return { data: null, error: getErrorMessage(err) };
- }
-}
-
-/**
- * 기술영업 해양 Hull RFQ 생성 (1:N 관계)
- */
-export async function createTechSalesHullRfq(input: {
- biddingProjectId: number;
- itemIds: number[]; // Hull 아이템 ID 배열
- dueDate: Date;
- description?: string;
- createdBy: number;
-}) {
- unstable_noStore();
- console.log('🔍 createTechSalesHullRfq 호출됨:', input);
-
- try {
- return await db.transaction(async (tx) => {
- // 프로젝트 정보 조회 (유효성 검증)
- const biddingProject = await tx.query.biddingProjects.findFirst({
- where: (biddingProjects, { eq }) => eq(biddingProjects.id, input.biddingProjectId)
- });
-
- if (!biddingProject) {
- throw new Error(`프로젝트 ID ${input.biddingProjectId}를 찾을 수 없습니다.`);
- }
-
- // RFQ 코드 생성 (HULL 타입)
- const hullRfqCode = await generateRfqCodes(tx, 1);
-
- // RFQ 생성
- const [rfq] = await tx
- .insert(techSalesRfqs)
- .values({
- rfqCode: hullRfqCode[0],
- biddingProjectId: input.biddingProjectId,
- description: input.description,
- dueDate: input.dueDate,
- status: "RFQ Created",
- rfqType: "HULL",
- createdBy: input.createdBy,
- updatedBy: input.createdBy,
- })
- .returning({ id: techSalesRfqs.id });
-
- // 아이템들 추가
- for (const itemId of input.itemIds) {
- await tx
- .insert(techSalesRfqItems)
- .values({
- rfqId: rfq.id,
- itemOffshoreHullId: itemId,
- itemType: "HULL",
- });
- }
-
- // 캐시 무효화
- revalidateTag("techSalesRfqs");
- revalidatePath("/evcp/budgetary-tech-sales-hull");
-
- return { data: rfq, error: null };
- });
- } catch (err) {
- console.error("Error creating Hull RFQ:", err);
- return { data: null, error: getErrorMessage(err) };
- }
-}
-
-/**
- * 기술영업 해양 TOP RFQ 생성 (1:N 관계)
- */
-export async function createTechSalesTopRfq(input: {
- biddingProjectId: number;
- itemIds: number[]; // TOP 아이템 ID 배열
- dueDate: Date;
- description?: string;
- createdBy: number;
-}) {
- unstable_noStore();
- console.log('🔍 createTechSalesTopRfq 호출됨:', input);
-
- try {
- return await db.transaction(async (tx) => {
- // 프로젝트 정보 조회 (유효성 검증)
- const biddingProject = await tx.query.biddingProjects.findFirst({
- where: (biddingProjects, { eq }) => eq(biddingProjects.id, input.biddingProjectId)
- });
-
- if (!biddingProject) {
- throw new Error(`프로젝트 ID ${input.biddingProjectId}를 찾을 수 없습니다.`);
- }
-
- // RFQ 코드 생성 (TOP 타입)
- const topRfqCode = await generateRfqCodes(tx, 1);
-
- // RFQ 생성
- const [rfq] = await tx
- .insert(techSalesRfqs)
- .values({
- rfqCode: topRfqCode[0],
- biddingProjectId: input.biddingProjectId,
- description: input.description,
- dueDate: input.dueDate,
- status: "RFQ Created",
- rfqType: "TOP",
- createdBy: input.createdBy,
- updatedBy: input.createdBy,
- })
- .returning({ id: techSalesRfqs.id });
-
- // 아이템들 추가
- for (const itemId of input.itemIds) {
- await tx
- .insert(techSalesRfqItems)
- .values({
- rfqId: rfq.id,
- itemOffshoreTopId: itemId,
- itemType: "TOP",
- });
- }
-
- // 캐시 무효화
- revalidateTag("techSalesRfqs");
- revalidatePath("/evcp/budgetary-tech-sales-top");
-
- return { data: rfq, error: null };
- });
- } catch (err) {
- console.error("Error creating TOP RFQ:", err);
- return { data: null, error: getErrorMessage(err) };
- }
-}
-
-/**
- * 조선 RFQ 전용 조회 함수
- */
-export async function getTechSalesShipRfqsWithJoin(input: GetTechSalesRfqsSchema) {
- return getTechSalesRfqsWithJoin({ ...input, rfqType: "SHIP" });
-}
-
-/**
- * 해양 TOP RFQ 전용 조회 함수
- */
-export async function getTechSalesTopRfqsWithJoin(input: GetTechSalesRfqsSchema) {
- return getTechSalesRfqsWithJoin({ ...input, rfqType: "TOP" });
-}
-
-/**
- * 해양 HULL RFQ 전용 조회 함수
- */
-export async function getTechSalesHullRfqsWithJoin(input: GetTechSalesRfqsSchema) {
- return getTechSalesRfqsWithJoin({ ...input, rfqType: "HULL" });
-}
-
-/**
- * 조선 벤더 견적서 전용 조회 함수
- */
-export async function getTechSalesShipVendorQuotationsWithJoin(input: {
- rfqId?: number;
- vendorId?: number;
- search?: string;
- filters?: Filter<typeof techSalesVendorQuotations>[];
- sort?: { id: string; desc: boolean }[];
- page: number;
- perPage: number;
-}) {
- return getTechSalesVendorQuotationsWithJoin({ ...input, rfqType: "SHIP" });
-}
-
-/**
- * 해양 TOP 벤더 견적서 전용 조회 함수
- */
-export async function getTechSalesTopVendorQuotationsWithJoin(input: {
- rfqId?: number;
- vendorId?: number;
- search?: string;
- filters?: Filter<typeof techSalesVendorQuotations>[];
- sort?: { id: string; desc: boolean }[];
- page: number;
- perPage: number;
-}) {
- return getTechSalesVendorQuotationsWithJoin({ ...input, rfqType: "TOP" });
-}
-
-/**
- * 해양 HULL 벤더 견적서 전용 조회 함수
- */
-export async function getTechSalesHullVendorQuotationsWithJoin(input: {
- rfqId?: number;
- vendorId?: number;
- search?: string;
- filters?: Filter<typeof techSalesVendorQuotations>[];
- sort?: { id: string; desc: boolean }[];
- page: number;
- perPage: number;
-}) {
- return getTechSalesVendorQuotationsWithJoin({ ...input, rfqType: "HULL" });
-}
-
-/**
- * 기술영업 RFQ의 아이템 목록 조회
- */
-export async function getTechSalesRfqItems(rfqId: number) {
- unstable_noStore();
- try {
- const items = await db.query.techSalesRfqItems.findMany({
- where: eq(techSalesRfqItems.rfqId, rfqId),
- with: {
- itemShipbuilding: {
- columns: {
- id: true,
- itemCode: true,
- itemList: true,
- workType: true,
- shipTypes: true,
- }
- },
- itemOffshoreTop: {
- columns: {
- id: true,
- itemCode: true,
- itemList: true,
- workType: true,
- subItemList: true,
- }
- },
- itemOffshoreHull: {
- columns: {
- id: true,
- itemCode: true,
- itemList: true,
- workType: true,
- subItemList: true,
- }
- }
- },
- orderBy: [techSalesRfqItems.id]
- });
-
- // 아이템 타입에 따라 정보 매핑
- const mappedItems = items.map(item => {
- let itemInfo = null;
-
- switch (item.itemType) {
- case 'SHIP':
- itemInfo = item.itemShipbuilding;
- break;
- case 'TOP':
- itemInfo = item.itemOffshoreTop;
- break;
- case 'HULL':
- itemInfo = item.itemOffshoreHull;
- break;
- }
-
- return {
- id: item.id,
- rfqId: item.rfqId,
- itemType: item.itemType,
- itemCode: itemInfo?.itemCode || '',
- itemList: itemInfo?.itemList || '',
- workType: itemInfo?.workType || '',
- // 조선이면 shipType, 해양이면 subItemList
- shipType: item.itemType === 'SHIP' ? (itemInfo as { shipTypes?: string })?.shipTypes || '' : undefined,
- subItemName: item.itemType !== 'SHIP' ? (itemInfo as { subItemList?: string })?.subItemList || '' : undefined,
- };
- });
-
- return { data: mappedItems, error: null };
- } catch (err) {
- console.error("Error fetching RFQ items:", err);
- return { data: [], error: getErrorMessage(err) };
- }
-}
-
-/**
- * RFQ 아이템들과 매칭되는 후보 벤더들을 찾는 함수
- */
-export async function getTechSalesRfqCandidateVendors(rfqId: number) {
- unstable_noStore();
-
- try {
- return await db.transaction(async (tx) => {
- // 1. RFQ 정보 조회 (타입 확인)
- const rfq = await tx.query.techSalesRfqs.findFirst({
- where: eq(techSalesRfqs.id, rfqId),
- columns: {
- id: true,
- rfqType: true
- }
- });
-
- if (!rfq) {
- return { data: [], error: "RFQ를 찾을 수 없습니다." };
- }
-
- // 2. RFQ 아이템들 조회
- const rfqItems = await tx.query.techSalesRfqItems.findMany({
- where: eq(techSalesRfqItems.rfqId, rfqId),
- with: {
- itemShipbuilding: true,
- itemOffshoreTop: true,
- itemOffshoreHull: true,
- }
- });
-
- if (rfqItems.length === 0) {
- return { data: [], error: null };
- }
-
- // 3. 아이템 코드들 추출
- const itemCodes: string[] = [];
- rfqItems.forEach(item => {
- if (item.itemType === "SHIP" && item.itemShipbuilding?.itemCode) {
- itemCodes.push(item.itemShipbuilding.itemCode);
- } else if (item.itemType === "TOP" && item.itemOffshoreTop?.itemCode) {
- itemCodes.push(item.itemOffshoreTop.itemCode);
- } else if (item.itemType === "HULL" && item.itemOffshoreHull?.itemCode) {
- itemCodes.push(item.itemOffshoreHull.itemCode);
- }
- });
-
- if (itemCodes.length === 0) {
- return { data: [], error: null };
- }
-
- // 4. RFQ 타입에 따른 벤더 타입 매핑
- const vendorTypeFilter = rfq.rfqType === "SHIP" ? "SHIP" :
- rfq.rfqType === "TOP" ? "OFFSHORE_TOP" :
- rfq.rfqType === "HULL" ? "OFFSHORE_HULL" : null;
-
- if (!vendorTypeFilter) {
- return { data: [], error: "지원되지 않는 RFQ 타입입니다." };
- }
-
- // 5. 매칭되는 벤더들 조회 (타입 필터링 포함)
- const candidateVendors = await tx
- .select({
- id: techVendors.id, // 벤더 ID를 id로 명명하여 key 문제 해결
- vendorId: techVendors.id, // 호환성을 위해 유지
- vendorName: techVendors.vendorName,
- vendorCode: techVendors.vendorCode,
- country: techVendors.country,
- email: techVendors.email,
- phone: techVendors.phone,
- status: techVendors.status,
- techVendorType: techVendors.techVendorType,
- matchedItemCodes: sql<string[]>`
- array_agg(DISTINCT ${techVendorPossibleItems.itemCode})
- `,
- matchedItemCount: sql<number>`
- count(DISTINCT ${techVendorPossibleItems.itemCode})
- `,
- })
- .from(techVendorPossibleItems)
- .innerJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id))
- .where(
- and(
- inArray(techVendorPossibleItems.itemCode, itemCodes),
- eq(techVendors.status, "ACTIVE")
- // 벤더 타입 필터링 임시 제거 - 데이터 확인 후 다시 추가
- // eq(techVendors.techVendorType, vendorTypeFilter)
- )
- )
- .groupBy(
- techVendorPossibleItems.vendorId,
- techVendors.id,
- techVendors.vendorName,
- techVendors.vendorCode,
- techVendors.country,
- techVendors.email,
- techVendors.phone,
- techVendors.status,
- techVendors.techVendorType
- )
- .orderBy(desc(sql`count(DISTINCT ${techVendorPossibleItems.itemCode})`));
-
- return { data: candidateVendors, error: null };
- });
- } catch (err) {
- console.error("Error fetching candidate vendors:", err);
- return { data: [], error: getErrorMessage(err) };
- }
-}
-
-/**
- * RFQ 타입에 따른 캐시 무효화 경로 반환
- */
-function getTechSalesRevalidationPath(rfqType: "SHIP" | "TOP" | "HULL"): string {
- switch (rfqType) {
- case "SHIP":
- return "/evcp/budgetary-tech-sales-ship";
- case "TOP":
- return "/evcp/budgetary-tech-sales-top";
- case "HULL":
- return "/evcp/budgetary-tech-sales-hull";
- default:
- return "/evcp/budgetary-tech-sales-ship";
- }
-}
-
-/**
- * 기술영업 RFQ에 여러 벤더 추가 (techVendors 기반)
- * 벤더 추가 시에는 견적서를 생성하지 않고, RFQ 전송 시에 견적서를 생성
- */
-export async function addTechVendorsToTechSalesRfq(input: {
- rfqId: number;
- vendorIds: number[];
- createdBy: number;
-}) {
- unstable_noStore();
-
- try {
- return await db.transaction(async (tx) => {
- const results = [];
- const errors: string[] = [];
-
- // 1. RFQ 상태 및 타입 확인
- const rfq = await tx.query.techSalesRfqs.findFirst({
- where: eq(techSalesRfqs.id, input.rfqId),
- columns: {
- id: true,
- status: true,
- rfqType: true,
- }
- });
-
- if (!rfq) {
- throw new Error("RFQ를 찾을 수 없습니다");
- }
-
- // 2. 각 벤더에 대해 처리 (이미 추가된 벤더는 견적서가 있는지 확인)
- for (const vendorId of input.vendorIds) {
- try {
- // 이미 추가된 벤더인지 확인 (견적서 존재 여부로 확인)
- const existingQuotation = await tx.query.techSalesVendorQuotations.findFirst({
- where: and(
- eq(techSalesVendorQuotations.rfqId, input.rfqId),
- eq(techSalesVendorQuotations.vendorId, vendorId)
- )
- });
-
- if (existingQuotation) {
- errors.push(`벤더 ID ${vendorId}는 이미 추가되어 있습니다.`);
- continue;
- }
-
- // 벤더가 실제로 존재하는지 확인
- const vendor = await tx.query.techVendors.findFirst({
- where: eq(techVendors.id, vendorId),
- columns: { id: true, vendorName: true }
- });
-
- if (!vendor) {
- errors.push(`벤더 ID ${vendorId}를 찾을 수 없습니다.`);
- continue;
- }
-
- // 🔥 중요: 벤더 추가 시에는 견적서를 생성하지 않고, "Assigned" 상태로만 생성
- // quotation_version은 null로 설정하여 벤더가 실제 견적 제출 시에만 리비전 생성
- const [quotation] = await tx
- .insert(techSalesVendorQuotations)
- .values({
- rfqId: input.rfqId,
- vendorId: vendorId,
- status: "Assigned", // Draft가 아닌 Assigned 상태로 생성
- quotationVersion: null, // 리비전은 견적 제출 시에만 생성
- createdBy: input.createdBy,
- updatedBy: input.createdBy,
- })
- .returning({ id: techSalesVendorQuotations.id });
-
- // 🆕 RFQ의 아이템 코드들을 tech_vendor_possible_items에 추가
- try {
- // RFQ의 아이템들 조회
- const rfqItemsResult = await getTechSalesRfqItems(input.rfqId);
-
- if (rfqItemsResult.data && rfqItemsResult.data.length > 0) {
- const itemCodes = rfqItemsResult.data
- .map(item => item.itemCode)
- .filter(code => code); // 빈 코드 제외
-
- // 각 아이템 코드에 대해 tech_vendor_possible_items에 추가 (중복 체크)
- for (const itemCode of itemCodes) {
- // 이미 존재하는지 확인
- const existing = await tx.query.techVendorPossibleItems.findFirst({
- where: and(
- eq(techVendorPossibleItems.vendorId, vendorId),
- eq(techVendorPossibleItems.itemCode, itemCode)
- )
- });
-
- // 존재하지 않으면 추가
- if (!existing) {
- await tx.insert(techVendorPossibleItems).values({
- vendorId: vendorId,
- itemCode: itemCode,
- });
- }
- }
- }
- } catch (possibleItemError) {
- // tech_vendor_possible_items 추가 실패는 전체 실패로 처리하지 않음
- console.warn(`벤더 ${vendorId}의 가능 아이템 추가 실패:`, possibleItemError);
- }
-
- results.push({ id: quotation.id, vendorId, vendorName: vendor.vendorName });
- } catch (vendorError) {
- console.error(`Error adding vendor ${vendorId}:`, vendorError);
- errors.push(`벤더 ID ${vendorId} 추가 중 오류가 발생했습니다.`);
- }
- }
-
- // 3. RFQ 상태가 "RFQ Created"이고 성공적으로 추가된 벤더가 있는 경우 상태 업데이트
- if (rfq.status === "RFQ Created" && results.length > 0) {
- await tx.update(techSalesRfqs)
- .set({
- status: "RFQ Vendor Assignned",
- updatedBy: input.createdBy,
- updatedAt: new Date()
- })
- .where(eq(techSalesRfqs.id, input.rfqId));
- }
-
- // 캐시 무효화 (RFQ 타입에 따른 동적 경로)
- revalidateTag("techSalesRfqs");
- revalidateTag("techSalesVendorQuotations");
- revalidateTag(`techSalesRfq-${input.rfqId}`);
- revalidatePath(getTechSalesRevalidationPath(rfq.rfqType || "SHIP"));
-
- return {
- data: results,
- error: errors.length > 0 ? errors.join(", ") : null,
- successCount: results.length,
- errorCount: errors.length
- };
- });
- } catch (err) {
- console.error("Error adding tech vendors to RFQ:", err);
- return { data: [], error: getErrorMessage(err) };
- }
-}
-
-/**
- * 기술영업 RFQ의 벤더 목록 조회 (techVendors 기반)
- */
-export async function getTechSalesRfqTechVendors(rfqId: number) {
- unstable_noStore();
-
- try {
- return await db.transaction(async (tx) => {
- const vendors = await tx
- .select({
- id: techSalesVendorQuotations.id,
- vendorId: techVendors.id,
- vendorName: techVendors.vendorName,
- vendorCode: techVendors.vendorCode,
- country: techVendors.country,
- email: techVendors.email,
- phone: techVendors.phone,
- status: techSalesVendorQuotations.status,
- totalPrice: techSalesVendorQuotations.totalPrice,
- currency: techSalesVendorQuotations.currency,
- validUntil: techSalesVendorQuotations.validUntil,
- submittedAt: techSalesVendorQuotations.submittedAt,
- createdAt: techSalesVendorQuotations.createdAt,
- })
- .from(techSalesVendorQuotations)
- .innerJoin(techVendors, eq(techSalesVendorQuotations.vendorId, techVendors.id))
- .where(eq(techSalesVendorQuotations.rfqId, rfqId))
- .orderBy(desc(techSalesVendorQuotations.createdAt));
-
- return { data: vendors, error: null };
- });
- } catch (err) {
- console.error("Error fetching RFQ tech vendors:", err);
- return { data: [], error: getErrorMessage(err) };
- }
-}
-
-/**
- * 기술영업 RFQ에서 기술영업 벤더 제거 (techVendors 기반)
- */
-export async function removeTechVendorFromTechSalesRfq(input: {
- rfqId: number;
- vendorId: number;
-}) {
- unstable_noStore();
-
- try {
- return await db.transaction(async (tx) => {
- // 해당 벤더의 견적서 상태 확인
- const existingQuotation = await tx.query.techSalesVendorQuotations.findFirst({
- where: and(
- eq(techSalesVendorQuotations.rfqId, input.rfqId),
- eq(techSalesVendorQuotations.vendorId, input.vendorId)
- )
- });
-
- if (!existingQuotation) {
- return { data: null, error: "해당 벤더가 이 RFQ에 존재하지 않습니다." };
- }
-
- // Assigned 상태가 아닌 경우 삭제 불가
- if (existingQuotation.status !== "Assigned") {
- return { data: null, error: "Assigned 상태의 벤더만 삭제할 수 있습니다." };
- }
-
- // 해당 벤더의 견적서 삭제
- const [deletedQuotation] = await tx
- .delete(techSalesVendorQuotations)
- .where(
- and(
- eq(techSalesVendorQuotations.rfqId, input.rfqId),
- eq(techSalesVendorQuotations.vendorId, input.vendorId)
- )
- )
- .returning({ id: techSalesVendorQuotations.id });
-
- // 캐시 무효화
- revalidateTag("techSalesRfqs");
- revalidateTag("techSalesVendorQuotations");
-
- return { data: deletedQuotation, error: null };
- });
- } catch (err) {
- console.error("Error removing tech vendor from RFQ:", err);
- return { data: null, error: getErrorMessage(err) };
- }
-}
-
-/**
- * 기술영업 RFQ에서 여러 기술영업 벤더 제거 (techVendors 기반)
- */
-export async function removeTechVendorsFromTechSalesRfq(input: {
- rfqId: number;
- vendorIds: number[];
-}) {
- unstable_noStore();
-
- try {
- return await db.transaction(async (tx) => {
- const results = [];
- const errors: string[] = [];
-
- for (const vendorId of input.vendorIds) {
- // 해당 벤더의 견적서 상태 확인
- const existingQuotation = await tx.query.techSalesVendorQuotations.findFirst({
- where: and(
- eq(techSalesVendorQuotations.rfqId, input.rfqId),
- eq(techSalesVendorQuotations.vendorId, vendorId)
- )
- });
-
- if (!existingQuotation) {
- errors.push(`벤더 ID ${vendorId}가 이 RFQ에 존재하지 않습니다.`);
- continue;
- }
-
- // Assigned 상태가 아닌 경우 삭제 불가
- if (existingQuotation.status !== "Assigned") {
- errors.push(`벤더 ID ${vendorId}는 Assigned 상태가 아니므로 삭제할 수 없습니다.`);
- continue;
- }
-
- // 해당 벤더의 견적서 삭제
- const [deletedQuotation] = await tx
- .delete(techSalesVendorQuotations)
- .where(
- and(
- eq(techSalesVendorQuotations.rfqId, input.rfqId),
- eq(techSalesVendorQuotations.vendorId, vendorId)
- )
- )
- .returning({ id: techSalesVendorQuotations.id });
-
- results.push(deletedQuotation);
- }
-
- // 캐시 무효화
- revalidateTag("techSalesRfqs");
- revalidateTag("techSalesVendorQuotations");
-
- return {
- data: results,
- error: errors.length > 0 ? errors.join(", ") : null,
- successCount: results.length,
- errorCount: errors.length
- };
- });
- } catch (err) {
- console.error("Error removing tech vendors from RFQ:", err);
- return { data: [], error: getErrorMessage(err) };
- }
-}
-
-/**
- * 기술영업 벤더 검색
- */
-export async function searchTechVendors(searchTerm: string, limit = 100, rfqType?: "SHIP" | "TOP" | "HULL") {
- unstable_noStore();
-
- try {
- // RFQ 타입에 따른 벤더 타입 매핑
- const vendorTypeFilter = rfqType === "SHIP" ? "조선" :
- rfqType === "TOP" ? "해양TOP" :
- rfqType === "HULL" ? "해양HULL" : null;
-
- const whereConditions = [
- eq(techVendors.status, "ACTIVE"),
- or(
- ilike(techVendors.vendorName, `%${searchTerm}%`),
- ilike(techVendors.vendorCode, `%${searchTerm}%`)
- )
- ];
-
- // RFQ 타입이 지정된 경우 벤더 타입 필터링 추가 (컴마 구분 문자열에서 검색)
- if (vendorTypeFilter) {
- whereConditions.push(sql`${techVendors.techVendorType} LIKE ${'%' + vendorTypeFilter + '%'}`);
- }
-
- const results = await db
- .select({
- id: techVendors.id,
- vendorName: techVendors.vendorName,
- vendorCode: techVendors.vendorCode,
- status: techVendors.status,
- country: techVendors.country,
- techVendorType: techVendors.techVendorType,
- })
- .from(techVendors)
- .where(and(...whereConditions))
- .limit(limit)
- .orderBy(techVendors.vendorName);
-
- return results;
- } catch (err) {
- console.error("Error searching tech vendors:", err);
- throw new Error(getErrorMessage(err));
- }
-}
-
-
-/**
- * 벤더 견적서 거절 처리 (벤더가 직접 거절)
- */
-export async function rejectTechSalesVendorQuotations(input: {
- quotationIds: number[];
- rejectionReason?: string;
-}) {
- try {
- const session = await getServerSession(authOptions);
- if (!session?.user?.id) {
- throw new Error("인증이 필요합니다.");
- }
-
- const result = await db.transaction(async (tx) => {
- // 견적서들이 존재하고 벤더가 권한이 있는지 확인
- const quotations = await tx
- .select({
- id: techSalesVendorQuotations.id,
- status: techSalesVendorQuotations.status,
- vendorId: techSalesVendorQuotations.vendorId,
- })
- .from(techSalesVendorQuotations)
- .where(inArray(techSalesVendorQuotations.id, input.quotationIds));
-
- if (quotations.length !== input.quotationIds.length) {
- throw new Error("일부 견적서를 찾을 수 없습니다.");
- }
-
- // 이미 거절된 견적서가 있는지 확인
- const alreadyRejected = quotations.filter(q => q.status === "Rejected");
- if (alreadyRejected.length > 0) {
- throw new Error("이미 거절된 견적서가 포함되어 있습니다.");
- }
-
- // 승인된 견적서가 있는지 확인
- const alreadyAccepted = quotations.filter(q => q.status === "Accepted");
- if (alreadyAccepted.length > 0) {
- throw new Error("이미 승인된 견적서는 거절할 수 없습니다.");
- }
-
- // 견적서 상태를 거절로 변경
- await tx
- .update(techSalesVendorQuotations)
- .set({
- status: "Rejected",
- rejectionReason: input.rejectionReason || null,
- updatedBy: parseInt(session.user.id),
- updatedAt: new Date(),
- })
- .where(inArray(techSalesVendorQuotations.id, input.quotationIds));
-
- return { success: true, updatedCount: quotations.length };
- });
- revalidateTag("techSalesRfqs");
- revalidateTag("techSalesVendorQuotations");
- revalidatePath("/partners/techsales/rfq-ship", "page");
- return {
- success: true,
- message: `${result.updatedCount}개의 견적서가 거절되었습니다.`,
- data: result
- };
- } catch (error) {
- console.error("견적서 거절 오류:", error);
- return {
- success: false,
- error: getErrorMessage(error)
- };
- }
-}
-
-// ==================== Revision 관련 ====================
-
-/**
- * 견적서 revision 히스토리 조회
- */
-export async function getTechSalesVendorQuotationRevisions(quotationId: number) {
- try {
- const revisions = await db
- .select({
- id: techSalesVendorQuotationRevisions.id,
- version: techSalesVendorQuotationRevisions.version,
- snapshot: techSalesVendorQuotationRevisions.snapshot,
- changeReason: techSalesVendorQuotationRevisions.changeReason,
- revisionNote: techSalesVendorQuotationRevisions.revisionNote,
- revisedBy: techSalesVendorQuotationRevisions.revisedBy,
- revisedAt: techSalesVendorQuotationRevisions.revisedAt,
- // 수정자 정보 조인
- revisedByName: users.name,
- })
- .from(techSalesVendorQuotationRevisions)
- .leftJoin(users, eq(techSalesVendorQuotationRevisions.revisedBy, users.id))
- .where(eq(techSalesVendorQuotationRevisions.quotationId, quotationId))
- .orderBy(desc(techSalesVendorQuotationRevisions.version));
-
- return { data: revisions, error: null };
- } catch (error) {
- console.error("견적서 revision 히스토리 조회 오류:", error);
- return { data: null, error: "견적서 히스토리를 조회하는 중 오류가 발생했습니다." };
- }
-}
-
-/**
- * 견적서의 현재 버전과 revision 히스토리를 함께 조회 (각 리비전의 첨부파일 포함)
- */
-export async function getTechSalesVendorQuotationWithRevisions(quotationId: number) {
- try {
- // 먼저 현재 견적서 조회
- const currentQuotation = await db.query.techSalesVendorQuotations.findFirst({
- where: eq(techSalesVendorQuotations.id, quotationId),
- with: {
- // 벤더 정보와 RFQ 정보도 함께 조회 (필요한 경우)
- }
- });
-
- if (!currentQuotation) {
- return { data: null, error: "견적서를 찾을 수 없습니다." };
- }
-
- // 이제 현재 견적서의 정보를 알고 있으므로 병렬로 나머지 정보 조회
- const [revisionsResult, currentAttachments] = await Promise.all([
- getTechSalesVendorQuotationRevisions(quotationId),
- getTechSalesVendorQuotationAttachmentsByRevision(quotationId, currentQuotation.quotationVersion || 0)
- ]);
-
- // 현재 견적서에 첨부파일 정보 추가
- const currentWithAttachments = {
- ...currentQuotation,
- attachments: currentAttachments.data || []
- };
-
- // 각 리비전의 첨부파일 정보 추가
- const revisionsWithAttachments = await Promise.all(
- (revisionsResult.data || []).map(async (revision) => {
- const attachmentsResult = await getTechSalesVendorQuotationAttachmentsByRevision(quotationId, revision.version);
- return {
- ...revision,
- attachments: attachmentsResult.data || []
- };
- })
- );
-
- return {
- data: {
- current: currentWithAttachments,
- revisions: revisionsWithAttachments
- },
- error: null
- };
- } catch (error) {
- console.error("견적서 전체 히스토리 조회 오류:", error);
- return { data: null, error: "견적서 정보를 조회하는 중 오류가 발생했습니다." };
- }
-}
-
-/**
- * 견적서 첨부파일 조회 (리비전 ID 기준 오름차순 정렬)
- */
-export async function getTechSalesVendorQuotationAttachments(quotationId: number) {
- return unstable_cache(
- async () => {
- try {
- const attachments = await db
- .select({
- id: techSalesVendorQuotationAttachments.id,
- quotationId: techSalesVendorQuotationAttachments.quotationId,
- revisionId: techSalesVendorQuotationAttachments.revisionId,
- fileName: techSalesVendorQuotationAttachments.fileName,
- originalFileName: techSalesVendorQuotationAttachments.originalFileName,
- fileSize: techSalesVendorQuotationAttachments.fileSize,
- fileType: techSalesVendorQuotationAttachments.fileType,
- filePath: techSalesVendorQuotationAttachments.filePath,
- description: techSalesVendorQuotationAttachments.description,
- uploadedBy: techSalesVendorQuotationAttachments.uploadedBy,
- vendorId: techSalesVendorQuotationAttachments.vendorId,
- isVendorUpload: techSalesVendorQuotationAttachments.isVendorUpload,
- createdAt: techSalesVendorQuotationAttachments.createdAt,
- updatedAt: techSalesVendorQuotationAttachments.updatedAt,
- })
- .from(techSalesVendorQuotationAttachments)
- .where(eq(techSalesVendorQuotationAttachments.quotationId, quotationId))
- .orderBy(desc(techSalesVendorQuotationAttachments.createdAt));
-
- return { data: attachments };
- } catch (error) {
- console.error("견적서 첨부파일 조회 오류:", error);
- return { error: "견적서 첨부파일 조회 중 오류가 발생했습니다." };
- }
- },
- [`quotation-attachments-${quotationId}`],
- {
- revalidate: 60,
- tags: [`quotation-${quotationId}`, "quotation-attachments"],
- }
- )();
-}
-
-/**
- * 특정 리비전의 견적서 첨부파일 조회
- */
-export async function getTechSalesVendorQuotationAttachmentsByRevision(quotationId: number, revisionId: number) {
- try {
- const attachments = await db
- .select({
- id: techSalesVendorQuotationAttachments.id,
- quotationId: techSalesVendorQuotationAttachments.quotationId,
- revisionId: techSalesVendorQuotationAttachments.revisionId,
- fileName: techSalesVendorQuotationAttachments.fileName,
- originalFileName: techSalesVendorQuotationAttachments.originalFileName,
- fileSize: techSalesVendorQuotationAttachments.fileSize,
- fileType: techSalesVendorQuotationAttachments.fileType,
- filePath: techSalesVendorQuotationAttachments.filePath,
- description: techSalesVendorQuotationAttachments.description,
- uploadedBy: techSalesVendorQuotationAttachments.uploadedBy,
- vendorId: techSalesVendorQuotationAttachments.vendorId,
- isVendorUpload: techSalesVendorQuotationAttachments.isVendorUpload,
- createdAt: techSalesVendorQuotationAttachments.createdAt,
- updatedAt: techSalesVendorQuotationAttachments.updatedAt,
- })
- .from(techSalesVendorQuotationAttachments)
- .where(and(
- eq(techSalesVendorQuotationAttachments.quotationId, quotationId),
- eq(techSalesVendorQuotationAttachments.revisionId, revisionId)
- ))
- .orderBy(desc(techSalesVendorQuotationAttachments.createdAt));
-
- return { data: attachments };
- } catch (error) {
- console.error("리비전별 견적서 첨부파일 조회 오류:", error);
- return { error: "첨부파일 조회 중 오류가 발생했습니다." };
- }
-}
-
-
-// ==================== Project AVL 관련 ====================
-
-/**
- * Accepted 상태의 Tech Sales Vendor Quotations 조회 (RFQ, Vendor 정보 포함)
- */
-export async function getAcceptedTechSalesVendorQuotations(input: {
- search?: string;
- filters?: Filter<typeof techSalesVendorQuotations>[];
- sort?: { id: string; desc: boolean }[];
- page: number;
- perPage: number;
- rfqType?: "SHIP" | "TOP" | "HULL";
-}) {
- unstable_noStore();
-
- try {
- const offset = (input.page - 1) * input.perPage;
-
- // 기본 WHERE 조건: status = 'Accepted'만 조회, rfqType이 'SHIP'이 아닌 것만
- const baseConditions = [
- eq(techSalesVendorQuotations.status, 'Accepted'),
- sql`${techSalesRfqs.rfqType} != 'SHIP'` // 조선 RFQ 타입 제외
- ];
-
- // 검색 조건 추가
- const searchConditions = [];
- if (input.search) {
- searchConditions.push(
- ilike(techSalesRfqs.rfqCode, `%${input.search}%`),
- ilike(techSalesRfqs.description, `%${input.search}%`),
- ilike(sql`vendors.vendor_name`, `%${input.search}%`),
- ilike(sql`vendors.vendor_code`, `%${input.search}%`)
- );
- }
-
- // 정렬 조건 변환
- const orderByConditions: OrderByType[] = [];
- if (input.sort?.length) {
- input.sort.forEach((sortItem) => {
- switch (sortItem.id) {
- case "rfqCode":
- orderByConditions.push(sortItem.desc ? desc(techSalesRfqs.rfqCode) : asc(techSalesRfqs.rfqCode));
- break;
- case "description":
- orderByConditions.push(sortItem.desc ? desc(techSalesRfqs.description) : asc(techSalesRfqs.description));
- break;
- case "vendorName":
- orderByConditions.push(sortItem.desc ? desc(sql`vendors.vendor_name`) : asc(sql`vendors.vendor_name`));
- break;
- case "vendorCode":
- orderByConditions.push(sortItem.desc ? desc(sql`vendors.vendor_code`) : asc(sql`vendors.vendor_code`));
- break;
- case "totalPrice":
- orderByConditions.push(sortItem.desc ? desc(techSalesVendorQuotations.totalPrice) : asc(techSalesVendorQuotations.totalPrice));
- break;
- case "acceptedAt":
- orderByConditions.push(sortItem.desc ? desc(techSalesVendorQuotations.acceptedAt) : asc(techSalesVendorQuotations.acceptedAt));
- break;
- default:
- orderByConditions.push(desc(techSalesVendorQuotations.acceptedAt));
- }
- });
- } else {
- orderByConditions.push(desc(techSalesVendorQuotations.acceptedAt));
- }
-
- // 필터 조건 추가
- const filterConditions = [];
- if (input.filters?.length) {
- const filterWhere = filterColumns({
- table: techSalesVendorQuotations,
- filters: input.filters,
- joinOperator: "and",
- });
- if (filterWhere) {
- filterConditions.push(filterWhere);
- }
- }
-
- // RFQ 타입 필터
- if (input.rfqType) {
- filterConditions.push(eq(techSalesRfqs.rfqType, input.rfqType));
- }
-
- // 모든 조건 결합
- const allConditions = [
- ...baseConditions,
- ...filterConditions,
- ...(searchConditions.length > 0 ? [or(...searchConditions)] : [])
- ];
-
- const whereCondition = allConditions.length > 1
- ? and(...allConditions)
- : allConditions[0];
-
- // 데이터 조회
- const data = await db
- .select({
- // Quotation 정보
- id: techSalesVendorQuotations.id,
- rfqId: techSalesVendorQuotations.rfqId,
- vendorId: techSalesVendorQuotations.vendorId,
- quotationCode: techSalesVendorQuotations.quotationCode,
- quotationVersion: techSalesVendorQuotations.quotationVersion,
- totalPrice: techSalesVendorQuotations.totalPrice,
- currency: techSalesVendorQuotations.currency,
- validUntil: techSalesVendorQuotations.validUntil,
- status: techSalesVendorQuotations.status,
- remark: techSalesVendorQuotations.remark,
- submittedAt: techSalesVendorQuotations.submittedAt,
- acceptedAt: techSalesVendorQuotations.acceptedAt,
- createdAt: techSalesVendorQuotations.createdAt,
- updatedAt: techSalesVendorQuotations.updatedAt,
-
- // RFQ 정보
- rfqCode: techSalesRfqs.rfqCode,
- rfqType: techSalesRfqs.rfqType,
- description: techSalesRfqs.description,
- dueDate: techSalesRfqs.dueDate,
- rfqStatus: techSalesRfqs.status,
- materialCode: techSalesRfqs.materialCode,
-
- // Vendor 정보
- vendorName: sql<string>`vendors.vendor_name`,
- vendorCode: sql<string | null>`vendors.vendor_code`,
- vendorEmail: sql<string | null>`vendors.email`,
- vendorCountry: sql<string | null>`vendors.country`,
-
- // Project 정보
- projNm: biddingProjects.projNm,
- pspid: biddingProjects.pspid,
- sector: biddingProjects.sector,
- })
- .from(techSalesVendorQuotations)
- .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id))
- .leftJoin(sql`vendors`, eq(techSalesVendorQuotations.vendorId, sql`vendors.id`))
- .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id))
- .where(whereCondition)
- .orderBy(...orderByConditions)
- .limit(input.perPage)
- .offset(offset);
-
- // 총 개수 조회
- const totalCount = await db
- .select({ count: count() })
- .from(techSalesVendorQuotations)
- .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id))
- .leftJoin(sql`vendors`, eq(techSalesVendorQuotations.vendorId, sql`vendors.id`))
- .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id))
- .where(whereCondition);
-
- const total = totalCount[0]?.count ?? 0;
- const pageCount = Math.ceil(total / input.perPage);
-
- return {
- data,
- pageCount,
- total,
- };
-
- } catch (error) {
- console.error("getAcceptedTechSalesVendorQuotations 오류:", error);
- throw new Error(`Accepted quotations 조회 실패: ${getErrorMessage(error)}`);
- }
-}
-
-export async function getBidProjects(pjtType: 'SHIP' | 'TOP' | 'HULL'): Promise<Project[]> {
- try {
- // 트랜잭션을 사용하여 프로젝트 데이터 조회
- const projectList = await db.transaction(async (tx) => {
- // 기본 쿼리 구성
- const query = tx
- .select({
- id: biddingProjects.id,
- projectCode: biddingProjects.pspid,
- projectName: biddingProjects.projNm,
- pjtType: biddingProjects.pjtType,
- })
- .from(biddingProjects)
- .where(eq(biddingProjects.pjtType, pjtType));
-
- const results = await query.orderBy(biddingProjects.id);
- return results;
- });
-
- // Handle null projectName values and ensure pjtType is not null
- const validProjectList = projectList.map(project => ({
- ...project,
- projectName: project.projectName || '', // Replace null with empty string
- pjtType: project.pjtType as "SHIP" | "TOP" | "HULL" // Type assertion since WHERE filters ensure non-null
- }));
-
- return validProjectList;
- } catch (error) {
- console.error("프로젝트 목록 가져오기 실패:", error);
- return []; // 오류 발생 시 빈 배열 반환
- }
+'use server'
+
+import { unstable_noStore, revalidateTag, revalidatePath } from "next/cache";
+import db from "@/db/db";
+import {
+ techSalesRfqs,
+ techSalesVendorQuotations,
+ techSalesVendorQuotationRevisions,
+ techSalesAttachments,
+ techSalesVendorQuotationAttachments,
+ techSalesVendorQuotationContacts,
+ techSalesContactPossibleItems,
+ users,
+ techSalesRfqComments,
+ techSalesRfqItems,
+ biddingProjects
+} from "@/db/schema";
+import { and, desc, eq, ilike, or, sql, inArray, count, asc } from "drizzle-orm";
+import { unstable_cache } from "@/lib/unstable-cache";
+import { filterColumns } from "@/lib/filter-columns";
+import { getErrorMessage } from "@/lib/handle-error";
+import type { Filter } from "@/types/table";
+import {
+ selectTechSalesRfqsWithJoin,
+ countTechSalesRfqsWithJoin,
+ selectTechSalesVendorQuotationsWithJoin,
+ countTechSalesVendorQuotationsWithJoin,
+ selectTechSalesDashboardWithJoin,
+ selectSingleTechSalesVendorQuotationWithJoin
+} from "./repository";
+import { GetTechSalesRfqsSchema } from "./validations";
+import { getServerSession } from "next-auth/next";
+import { authOptions } from "@/app/api/auth/[...nextauth]/route";
+import { sendEmail } from "../mail/sendEmail";
+import { formatDate } from "../utils";
+import { techVendors, techVendorPossibleItems, techVendorContacts } from "@/db/schema/techVendors";
+import { deleteFile, saveDRMFile, saveFile } from "@/lib/file-stroage";
+import { decryptWithServerAction } from "@/components/drm/drmUtils";
+
+// 정렬 타입 정의
+// 의도적으로 any 사용 - drizzle ORM의 orderBy 타입이 복잡함
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+type OrderByType = any;
+
+export type Project = {
+ id: number;
+ projectCode: string;
+ projectName: string;
+ pjtType: "SHIP" | "TOP" | "HULL";
+}
+
+/**
+ * 연도별 순차 RFQ 코드 생성 함수 (다중 생성 지원)
+ * 형식: RFQ-YYYY-001, RFQ-YYYY-002, ...
+ */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+async function generateRfqCodes(tx: any, count: number, year?: number): Promise<string[]> {
+ const currentYear = year || new Date().getFullYear();
+ const yearPrefix = `RFQ-${currentYear}-`;
+
+ // 해당 연도의 가장 최근 RFQ 코드 조회
+ const latestRfq = await tx
+ .select({ rfqCode: techSalesRfqs.rfqCode })
+ .from(techSalesRfqs)
+ .where(ilike(techSalesRfqs.rfqCode, `${yearPrefix}%`))
+ .orderBy(desc(techSalesRfqs.rfqCode))
+ .limit(1);
+
+ let nextNumber = 1;
+
+ if (latestRfq.length > 0) {
+ // 기존 코드에서 번호 추출 (RFQ-2024-001 -> 001)
+ const lastCode = latestRfq[0].rfqCode;
+ const numberPart = lastCode.split('-').pop();
+ if (numberPart) {
+ const lastNumber = parseInt(numberPart, 10);
+ if (!isNaN(lastNumber)) {
+ nextNumber = lastNumber + 1;
+ }
+ }
+ }
+
+ // 요청된 개수만큼 순차적으로 코드 생성
+ const codes: string[] = [];
+ for (let i = 0; i < count; i++) {
+ const paddedNumber = (nextNumber + i).toString().padStart(3, '0');
+ codes.push(`${yearPrefix}${paddedNumber}`);
+ }
+
+ return codes;
+}
+
+
+/**
+ * 직접 조인을 사용하여 RFQ 데이터 조회하는 함수
+ * 페이지네이션, 필터링, 정렬 등 지원
+ */
+export async function getTechSalesRfqsWithJoin(input: GetTechSalesRfqsSchema & { rfqType?: "SHIP" | "TOP" | "HULL" }) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // 기본 필터 처리 - RFQFilterBox에서 오는 필터
+ const basicFilters = input.basicFilters || [];
+ const basicJoinOperator = input.basicJoinOperator || "and";
+ // 고급 필터 처리 - 테이블의 DataTableFilterList에서 오는 필터
+ const advancedFilters = input.filters || [];
+ const advancedJoinOperator = input.joinOperator || "and";
+
+ // 기본 필터 조건 생성
+ let basicWhere;
+ if (basicFilters.length > 0) {
+ basicWhere = filterColumns({
+ table: techSalesRfqs,
+ filters: basicFilters,
+ joinOperator: basicJoinOperator,
+ });
+ }
+
+ // 고급 필터 조건 생성
+ let advancedWhere;
+ if (advancedFilters.length > 0) {
+ advancedWhere = filterColumns({
+ table: techSalesRfqs,
+ filters: advancedFilters,
+ joinOperator: advancedJoinOperator,
+ });
+ }
+
+ // 전역 검색 조건
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(techSalesRfqs.rfqCode, s),
+ ilike(techSalesRfqs.materialCode, s),
+ ilike(techSalesRfqs.description, s),
+ ilike(techSalesRfqs.remark, s)
+ );
+ }
+
+ // 모든 조건 결합
+ const whereConditions = [];
+ if (basicWhere) whereConditions.push(basicWhere);
+ if (advancedWhere) whereConditions.push(advancedWhere);
+ if (globalWhere) whereConditions.push(globalWhere);
+
+ // 조건이 있을 때만 and() 사용
+ const finalWhere = whereConditions.length > 0
+ ? and(...whereConditions)
+ : undefined;
+
+ // 정렬 기준 설정
+ let orderBy: OrderByType[] = [desc(techSalesRfqs.createdAt)]; // 기본 정렬
+
+ if (input.sort?.length) {
+ // 안전하게 접근하여 정렬 기준 설정
+ orderBy = input.sort.map(item => {
+ // TypeScript 에러 방지를 위한 타입 단언
+ const sortField = item.id as string;
+
+ switch (sortField) {
+ case 'id':
+ return item.desc ? desc(techSalesRfqs.id) : techSalesRfqs.id;
+ case 'rfqCode':
+ return item.desc ? desc(techSalesRfqs.rfqCode) : techSalesRfqs.rfqCode;
+ case 'materialCode':
+ return item.desc ? desc(techSalesRfqs.materialCode) : techSalesRfqs.materialCode;
+ case 'description':
+ return item.desc ? desc(techSalesRfqs.description) : techSalesRfqs.description;
+ case 'status':
+ return item.desc ? desc(techSalesRfqs.status) : techSalesRfqs.status;
+ case 'dueDate':
+ return item.desc ? desc(techSalesRfqs.dueDate) : techSalesRfqs.dueDate;
+ case 'rfqSendDate':
+ return item.desc ? desc(techSalesRfqs.rfqSendDate) : techSalesRfqs.rfqSendDate;
+ case 'remark':
+ return item.desc ? desc(techSalesRfqs.remark) : techSalesRfqs.remark;
+ case 'createdAt':
+ return item.desc ? desc(techSalesRfqs.createdAt) : techSalesRfqs.createdAt;
+ case 'updatedAt':
+ return item.desc ? desc(techSalesRfqs.updatedAt) : techSalesRfqs.updatedAt;
+ default:
+ return item.desc ? desc(techSalesRfqs.createdAt) : techSalesRfqs.createdAt;
+ }
+ });
+ }
+
+ // Repository 함수 호출 - rfqType 매개변수 추가
+ return await db.transaction(async (tx) => {
+ const [data, total] = await Promise.all([
+ selectTechSalesRfqsWithJoin(tx, {
+ where: finalWhere,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ rfqType: input.rfqType,
+ }),
+ countTechSalesRfqsWithJoin(tx, finalWhere, input.rfqType),
+ ]);
+
+ const pageCount = Math.ceil(Number(total) / input.perPage);
+ return { data, pageCount, total: Number(total) };
+ });
+ } catch (err) {
+ console.error("Error fetching RFQs with join:", err);
+ return { data: [], pageCount: 0, total: 0 };
+ }
+ },
+ [JSON.stringify(input)],
+ {
+ revalidate: 60,
+ tags: ["techSalesRfqs"],
+ }
+ )();
+}
+
+/**
+ * 직접 조인을 사용하여 벤더 견적서 조회하는 함수
+ */
+export async function getTechSalesVendorQuotationsWithJoin(input: {
+ rfqId?: number;
+ vendorId?: number;
+ search?: string;
+ filters?: Filter<typeof techSalesVendorQuotations>[];
+ sort?: { id: string; desc: boolean }[];
+ page: number;
+ perPage: number;
+ rfqType?: "SHIP" | "TOP" | "HULL"; // rfqType 매개변수 추가
+}) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // 기본 필터 조건들
+ const whereConditions = [];
+
+ // RFQ ID 필터
+ if (input.rfqId) {
+ whereConditions.push(eq(techSalesVendorQuotations.rfqId, input.rfqId));
+ }
+
+ // 벤더 ID 필터
+ if (input.vendorId) {
+ whereConditions.push(eq(techSalesVendorQuotations.vendorId, input.vendorId));
+ }
+
+ // 검색 조건
+ if (input.search) {
+ const s = `%${input.search}%`;
+ const searchCondition = or(
+ ilike(techSalesVendorQuotations.currency, s),
+ ilike(techSalesVendorQuotations.status, s)
+ );
+ if (searchCondition) {
+ whereConditions.push(searchCondition);
+ }
+ }
+
+ // 고급 필터 처리
+ if (input.filters && input.filters.length > 0) {
+ const filterWhere = filterColumns({
+ table: techSalesVendorQuotations,
+ filters: input.filters as Filter<typeof techSalesVendorQuotations>[],
+ joinOperator: "and",
+ });
+ if (filterWhere) {
+ whereConditions.push(filterWhere);
+ }
+ }
+
+ // 최종 WHERE 조건
+ const finalWhere = whereConditions.length > 0
+ ? and(...whereConditions)
+ : undefined;
+
+ // 정렬 기준 설정
+ let orderBy: OrderByType[] = [desc(techSalesVendorQuotations.createdAt)];
+
+ if (input.sort?.length) {
+ orderBy = input.sort.map(item => {
+ switch (item.id) {
+ case 'id':
+ return item.desc ? desc(techSalesVendorQuotations.id) : techSalesVendorQuotations.id;
+ case 'status':
+ return item.desc ? desc(techSalesVendorQuotations.status) : techSalesVendorQuotations.status;
+ case 'currency':
+ return item.desc ? desc(techSalesVendorQuotations.currency) : techSalesVendorQuotations.currency;
+ case 'totalPrice':
+ return item.desc ? desc(techSalesVendorQuotations.totalPrice) : techSalesVendorQuotations.totalPrice;
+ case 'createdAt':
+ return item.desc ? desc(techSalesVendorQuotations.createdAt) : techSalesVendorQuotations.createdAt;
+ case 'updatedAt':
+ return item.desc ? desc(techSalesVendorQuotations.updatedAt) : techSalesVendorQuotations.updatedAt;
+ default:
+ return item.desc ? desc(techSalesVendorQuotations.createdAt) : techSalesVendorQuotations.createdAt;
+ }
+ });
+ }
+
+ // 트랜잭션 내부에서 Repository 호출
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectTechSalesVendorQuotationsWithJoin(tx, {
+ where: finalWhere,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+
+ // 각 견적서의 첨부파일 정보 조회
+ const dataWithAttachments = await Promise.all(
+ data.map(async (quotation) => {
+ const attachments = await db.query.techSalesVendorQuotationAttachments.findMany({
+ where: eq(techSalesVendorQuotationAttachments.quotationId, quotation.id),
+ orderBy: [desc(techSalesVendorQuotationAttachments.createdAt)],
+ });
+
+ return {
+ ...quotation,
+ quotationAttachments: attachments.map(att => ({
+ id: att.id,
+ fileName: att.fileName,
+ fileSize: att.fileSize,
+ filePath: att.filePath,
+ description: att.description,
+ }))
+ };
+ })
+ );
+
+ const total = await countTechSalesVendorQuotationsWithJoin(tx, finalWhere);
+ return { data: dataWithAttachments, total };
+ });
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data, pageCount, total };
+ } catch (err) {
+ console.error("Error fetching vendor quotations with join:", err);
+ return { data: [], pageCount: 0, total: 0 };
+ }
+ },
+ [JSON.stringify(input)],
+ {
+ revalidate: 60,
+ tags: [
+ "techSalesVendorQuotations",
+ ...(input.rfqId ? [`techSalesRfq-${input.rfqId}`] : [])
+ ],
+ }
+ )();
+}
+
+/**
+ * 직접 조인을 사용하여 RFQ 대시보드 데이터 조회하는 함수
+ */
+export async function getTechSalesDashboardWithJoin(input: {
+ search?: string;
+ filters?: Filter<typeof techSalesRfqs>[];
+ sort?: { id: string; desc: boolean }[];
+ page: number;
+ perPage: number;
+ rfqType?: "SHIP" | "TOP" | "HULL"; // rfqType 매개변수 추가
+}) {
+ unstable_noStore(); // 대시보드는 항상 최신 데이터를 보여주기 위해 캐시하지 않음
+
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // Advanced filtering
+ const advancedWhere = input.filters ? filterColumns({
+ table: techSalesRfqs,
+ filters: input.filters as Filter<typeof techSalesRfqs>[],
+ joinOperator: 'and',
+ }) : undefined;
+
+ // Global search
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(techSalesRfqs.rfqCode, s),
+ ilike(techSalesRfqs.materialCode, s),
+ ilike(techSalesRfqs.description, s)
+ );
+ }
+
+ const finalWhere = and(
+ advancedWhere,
+ globalWhere
+ );
+
+ // 정렬 기준 설정
+ let orderBy: OrderByType[] = [desc(techSalesRfqs.updatedAt)]; // 기본 정렬
+
+ if (input.sort?.length) {
+ // 안전하게 접근하여 정렬 기준 설정
+ orderBy = input.sort.map(item => {
+ switch (item.id) {
+ case 'id':
+ return item.desc ? desc(techSalesRfqs.id) : techSalesRfqs.id;
+ case 'rfqCode':
+ return item.desc ? desc(techSalesRfqs.rfqCode) : techSalesRfqs.rfqCode;
+ case 'status':
+ return item.desc ? desc(techSalesRfqs.status) : techSalesRfqs.status;
+ case 'dueDate':
+ return item.desc ? desc(techSalesRfqs.dueDate) : techSalesRfqs.dueDate;
+ case 'createdAt':
+ return item.desc ? desc(techSalesRfqs.createdAt) : techSalesRfqs.createdAt;
+ case 'updatedAt':
+ return item.desc ? desc(techSalesRfqs.updatedAt) : techSalesRfqs.updatedAt;
+ default:
+ return item.desc ? desc(techSalesRfqs.updatedAt) : techSalesRfqs.updatedAt;
+ }
+ });
+ }
+
+ // 트랜잭션 내부에서 Repository 호출
+ const data = await db.transaction(async (tx) => {
+ return await selectTechSalesDashboardWithJoin(tx, {
+ where: finalWhere,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ rfqType: input.rfqType, // rfqType 매개변수 추가
+ });
+ });
+
+ return { data, success: true };
+ } catch (err) {
+ console.error("Error fetching dashboard data with join:", err);
+ return { data: [], success: false, error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * 특정 RFQ의 벤더 목록 조회
+ */
+export async function getTechSalesRfqVendors(rfqId: number) {
+ unstable_noStore();
+ try {
+ // Repository 함수를 사용하여 벤더 견적 목록 조회
+ const result = await getTechSalesVendorQuotationsWithJoin({
+ rfqId,
+ page: 1,
+ perPage: 1000, // 충분히 큰 수로 설정하여 모든 벤더 조회
+ });
+
+ return { data: result.data, error: null };
+ } catch (err) {
+ console.error("Error fetching RFQ vendors:", err);
+ return { data: [], error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * 기술영업 RFQ 발송 (선택된 벤더들의 선택된 contact들에게)
+ */
+export async function sendTechSalesRfqToVendors(input: {
+ rfqId: number;
+ vendorIds: number[];
+ selectedContacts?: Array<{
+ vendorId: number;
+ contactId: number;
+ contactEmail: string;
+ contactName: string;
+ }>;
+}) {
+ unstable_noStore();
+ try {
+ // 인증 확인
+ const session = await getServerSession(authOptions);
+
+ if (!session?.user) {
+ return {
+ success: false,
+ message: "인증이 필요합니다",
+ };
+ }
+
+ // RFQ 정보 조회
+ const rfq = await db.query.techSalesRfqs.findFirst({
+ where: eq(techSalesRfqs.id, input.rfqId),
+ columns: {
+ id: true,
+ rfqCode: true,
+ status: true,
+ dueDate: true,
+ rfqSendDate: true,
+ remark: true,
+ materialCode: true,
+ description: true,
+ rfqType: true,
+ },
+ with: {
+ biddingProject: true,
+ createdByUser: {
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ }
+ }
+ }
+ });
+
+ if (!rfq) {
+ return {
+ success: false,
+ message: "RFQ를 찾을 수 없습니다",
+ };
+ }
+
+ // 발송 가능한 상태인지 확인
+ if (rfq.status !== "RFQ Vendor Assignned" && rfq.status !== "RFQ Sent") {
+ return {
+ success: false,
+ message: "벤더가 할당된 RFQ 또는 이미 전송된 RFQ만 다시 전송할 수 있습니다",
+ };
+ }
+
+ const isResend = rfq.status === "RFQ Sent";
+
+ // 현재 사용자 정보 조회
+ const sender = await db.query.users.findFirst({
+ where: eq(users.id, Number(session.user.id)),
+ columns: {
+ id: true,
+ email: true,
+ name: true,
+ }
+ });
+
+ if (!sender || !sender.email) {
+ return {
+ success: false,
+ message: "보내는 사람의 이메일 정보를 찾을 수 없습니다",
+ };
+ }
+
+ // 선택된 벤더들의 견적서 정보 조회
+ const vendorQuotations = await db.query.techSalesVendorQuotations.findMany({
+ where: and(
+ eq(techSalesVendorQuotations.rfqId, input.rfqId),
+ inArray(techSalesVendorQuotations.vendorId, input.vendorIds)
+ ),
+ columns: {
+ id: true,
+ vendorId: true,
+ status: true,
+ currency: true,
+ },
+ with: {
+ vendor: {
+ columns: {
+ id: true,
+ vendorName: true,
+ vendorCode: true,
+ }
+ }
+ }
+ });
+
+ if (vendorQuotations.length === 0) {
+ return {
+ success: false,
+ message: "선택된 벤더가 이 RFQ에 할당되어 있지 않습니다",
+ };
+ }
+
+ // 트랜잭션 시작
+ await db.transaction(async (tx) => {
+ // 1. RFQ 상태 업데이트 (최초 발송인 경우 rfqSendDate 설정)
+ const updateData: Partial<typeof techSalesRfqs.$inferInsert> = {
+ status: "RFQ Sent",
+ sentBy: Number(session.user.id),
+ updatedBy: Number(session.user.id),
+ updatedAt: new Date(),
+ };
+
+ // rfqSendDate가 null인 경우에만 최초 전송일 설정
+ if (!rfq.rfqSendDate) {
+ updateData.rfqSendDate = new Date();
+ }
+
+ await tx.update(techSalesRfqs)
+ .set(updateData)
+ .where(eq(techSalesRfqs.id, input.rfqId));
+
+ // 2. 선택된 벤더들의 견적서 상태를 "Assigned"에서 "Draft"로 변경
+ for (const quotation of vendorQuotations) {
+ if (quotation.status === "Assigned") {
+ await tx.update(techSalesVendorQuotations)
+ .set({
+ status: "Draft",
+ updatedBy: Number(session.user.id),
+ updatedAt: new Date(),
+ })
+ .where(eq(techSalesVendorQuotations.id, quotation.id));
+ }
+ }
+
+ // 3. 각 벤더에 대해 이메일 발송 처리
+ for (const quotation of vendorQuotations) {
+ if (!quotation.vendorId || !quotation.vendor) continue;
+
+ let vendorEmailsString = "";
+
+ // contact 기반 발송 또는 기존 방식 (모든 벤더 사용자)
+ if (input.selectedContacts && input.selectedContacts.length > 0) {
+ // 선택된 contact들에게만 발송
+ const vendorContacts = input.selectedContacts.filter(
+ contact => contact.vendorId === quotation.vendor!.id
+ );
+
+ if (vendorContacts.length > 0) {
+ vendorEmailsString = vendorContacts
+ .map(contact => contact.contactEmail)
+ .join(", ");
+ }
+ } else {
+ // 기존 방식: 벤더에 속한 모든 사용자에게 발송
+ const vendorUsers = await db.query.users.findMany({
+ where: eq(users.companyId, quotation.vendor.id),
+ columns: {
+ id: true,
+ email: true,
+ name: true,
+ language: true
+ }
+ });
+
+ vendorEmailsString = vendorUsers
+ .filter(user => user.email)
+ .map(user => user.email)
+ .join(", ");
+ }
+
+ if (vendorEmailsString) {
+ // 대표 언어 결정 (기본값 한국어)
+ const language = "ko";
+
+ // RFQ 아이템 목록 조회
+ const rfqItemsResult = await getTechSalesRfqItems(rfq.id);
+ const rfqItems = rfqItemsResult.data || [];
+
+ // 이메일 컨텍스트 구성
+ const emailContext = {
+ language: language,
+ rfq: {
+ id: rfq.id,
+ code: rfq.rfqCode,
+ title: rfqItems.length > 0 ? rfqItems.map(item => item.itemList).join(', ') : '',
+ projectCode: rfq.biddingProject?.pspid || '',
+ projectName: rfq.biddingProject?.projNm || '',
+ description: rfq.remark || '',
+ dueDate: rfq.dueDate ? formatDate(rfq.dueDate, "KR") : 'N/A',
+ materialCode: rfq.materialCode || '',
+ type: rfq.rfqType || 'SHIP',
+ },
+ items: rfqItems.map(item => ({
+ itemCode: item.itemCode,
+ itemList: item.itemList,
+ workType: item.workType,
+ shipTypes: item.shipTypes,
+ subItemList: item.subItemList,
+ itemType: item.itemType,
+ })),
+ vendor: {
+ id: quotation.vendor.id,
+ code: quotation.vendor.vendorCode || '',
+ name: quotation.vendor.vendorName,
+ },
+ sender: {
+ fullName: sender.name || '',
+ email: sender.email,
+ },
+ project: {
+ id: rfq.biddingProject?.pspid || '',
+ name: rfq.biddingProject?.projNm || '',
+ sector: rfq.biddingProject?.sector || '',
+ shipType: rfq.biddingProject?.ptypeNm || '',
+ shipCount: rfq.biddingProject?.projMsrm || 0,
+ ownerName: rfq.biddingProject?.kunnrNm || '',
+ className: rfq.biddingProject?.cls1Nm || '',
+ },
+ details: {
+ currency: quotation.currency || 'USD',
+ },
+ quotationCode: `${rfq.rfqCode}-${quotation.vendorId}`,
+ systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://60.101.108.100/ko/partners',
+ isResend: isResend,
+ versionInfo: isResend ? '(재전송)' : '',
+ }
+
+
+
+ // 이메일 전송
+ await sendEmail({
+ to: vendorEmailsString,
+ subject: isResend
+ ? `[기술영업 RFQ 재전송] ${rfq.rfqCode} - ${rfqItems.length > 0 ? rfqItems.map(item => item.itemList).join(', ') : '견적 요청'} ${emailContext.versionInfo}`
+ : `[기술영업 RFQ] ${rfq.rfqCode} - ${rfqItems.length > 0 ? rfqItems.map(item => item.itemList).join(', ') : '견적 요청'}`,
+ template: 'tech-sales-rfq-invite-ko', // 기술영업용 템플릿
+ context: emailContext,
+ cc: sender.email, // 발신자를 CC에 추가
+ });
+
+ // 4. 선택된 담당자 정보를 quotation_contacts 테이블에 저장
+ if (input.selectedContacts && input.selectedContacts.length > 0) {
+ const vendorContacts = input.selectedContacts.filter(
+ contact => contact.vendorId === quotation.vendor!.id
+ );
+
+ for (const contact of vendorContacts) {
+ // quotation_contacts 중복 체크
+ const existingQuotationContact = await tx.query.techSalesVendorQuotationContacts.findFirst({
+ where: and(
+ eq(techSalesVendorQuotationContacts.quotationId, quotation.id),
+ eq(techSalesVendorQuotationContacts.contactId, contact.contactId)
+ )
+ });
+
+ if (!existingQuotationContact) {
+ await tx.insert(techSalesVendorQuotationContacts).values({
+ quotationId: quotation.id,
+ contactId: contact.contactId,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ });
+ }
+
+ // 5. 담당자별 아이템 매핑 정보 저장 (중복 방지)
+ for (const item of rfqItems) {
+ // tech_vendor_possible_items에서 해당 벤더의 아이템 찾기
+ const vendorPossibleItem = await tx.query.techVendorPossibleItems.findFirst({
+ where: and(
+ eq(techVendorPossibleItems.vendorId, quotation.vendor!.id),
+ eq(techVendorPossibleItems.itemCode, item.itemCode || '')
+ )
+ });
+
+ if (vendorPossibleItem) {
+ // contact_possible_items 중복 체크
+ const existingContactPossibleItem = await tx.query.techSalesContactPossibleItems.findFirst({
+ where: and(
+ eq(techSalesContactPossibleItems.contactId, contact.contactId),
+ eq(techSalesContactPossibleItems.vendorPossibleItemId, vendorPossibleItem.id)
+ )
+ });
+
+ if (!existingContactPossibleItem) {
+ await tx.insert(techSalesContactPossibleItems).values({
+ contactId: contact.contactId,
+ vendorPossibleItemId: vendorPossibleItem.id,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ });
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ });
+
+ // 캐시 무효화
+ revalidateTag("techSalesRfqs");
+ revalidateTag("techSalesVendorQuotations");
+ revalidateTag(`techSalesRfq-${input.rfqId}`);
+ revalidatePath(getTechSalesRevalidationPath(rfq?.rfqType || "SHIP"));
+
+ const sentContactCount = input.selectedContacts?.length || vendorQuotations.length;
+ const messageDetail = input.selectedContacts && input.selectedContacts.length > 0
+ ? `${sentContactCount}명의 연락처에게 RFQ가 성공적으로 발송되었습니다`
+ : `${vendorQuotations.length}개 벤더에게 RFQ가 성공적으로 발송되었습니다`;
+
+ return {
+ success: true,
+ message: messageDetail,
+ sentCount: sentContactCount,
+ };
+ } catch (err) {
+ console.error("기술영업 RFQ 발송 오류:", err);
+ return {
+ success: false,
+ message: "RFQ 발송 중 오류가 발생했습니다",
+ };
+ }
+}
+
+/**
+ * 벤더용 기술영업 RFQ 견적서 조회 (withJoin 사용)
+ */
+export async function getTechSalesVendorQuotation(quotationId: number) {
+ unstable_noStore();
+ try {
+ const quotation = await db.transaction(async (tx) => {
+ return await selectSingleTechSalesVendorQuotationWithJoin(tx, quotationId);
+ });
+
+ if (!quotation) {
+ return { data: null, error: "견적서를 찾을 수 없습니다." };
+ }
+
+ // RFQ 아이템 정보도 함께 조회
+ const itemsResult = await getTechSalesRfqItems(quotation.rfqId);
+ const items = itemsResult.data || [];
+
+ // 견적서 첨부파일 조회
+ const quotationAttachments = await db.query.techSalesVendorQuotationAttachments.findMany({
+ where: eq(techSalesVendorQuotationAttachments.quotationId, quotationId),
+ orderBy: [desc(techSalesVendorQuotationAttachments.createdAt)],
+ });
+
+ // 기존 구조와 호환되도록 데이터 재구성
+ const formattedQuotation = {
+ id: quotation.id,
+ rfqId: quotation.rfqId,
+ vendorId: quotation.vendorId,
+ quotationCode: quotation.quotationCode,
+ quotationVersion: quotation.quotationVersion,
+ totalPrice: quotation.totalPrice,
+ currency: quotation.currency,
+ validUntil: quotation.validUntil,
+ status: quotation.status,
+ remark: quotation.remark,
+ rejectionReason: quotation.rejectionReason,
+ submittedAt: quotation.submittedAt,
+ acceptedAt: quotation.acceptedAt,
+ createdAt: quotation.createdAt,
+ updatedAt: quotation.updatedAt,
+ createdBy: quotation.createdBy,
+ updatedBy: quotation.updatedBy,
+
+ // RFQ 정보
+ rfq: {
+ id: quotation.rfqId,
+ rfqCode: quotation.rfqCode,
+ rfqType: quotation.rfqType,
+ status: quotation.rfqStatus,
+ dueDate: quotation.dueDate,
+ rfqSendDate: quotation.rfqSendDate,
+ materialCode: quotation.materialCode,
+ description: quotation.description,
+ remark: quotation.rfqRemark,
+ picCode: quotation.picCode,
+ createdBy: quotation.rfqCreatedBy,
+ biddingProjectId: quotation.biddingProjectId,
+
+ // 아이템 정보 추가
+ items: items,
+
+ // 생성자 정보
+ createdByUser: {
+ id: quotation.rfqCreatedBy,
+ name: quotation.rfqCreatedByName,
+ email: quotation.rfqCreatedByEmail,
+ },
+
+ // 프로젝트 정보
+ biddingProject: quotation.biddingProjectId ? {
+ id: quotation.biddingProjectId,
+ pspid: quotation.pspid,
+ projNm: quotation.projNm,
+ sector: quotation.sector,
+ projMsrm: quotation.projMsrm,
+ ptypeNm: quotation.ptypeNm,
+ } : null,
+ },
+
+ // 벤더 정보
+ vendor: {
+ id: quotation.vendorId,
+ vendorName: quotation.vendorName,
+ vendorCode: quotation.vendorCode,
+ country: quotation.vendorCountry,
+ email: quotation.vendorEmail,
+ phone: quotation.vendorPhone,
+ },
+
+ // 첨부파일 정보
+ quotationAttachments: quotationAttachments.map(attachment => ({
+ id: attachment.id,
+ fileName: attachment.fileName,
+ fileSize: attachment.fileSize,
+ filePath: attachment.filePath,
+ description: attachment.description,
+ }))
+ };
+
+ return { data: formattedQuotation, error: null };
+ } catch (err) {
+ console.error("Error fetching vendor quotation:", err);
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * 기술영업 벤더 견적서 업데이트 (임시저장),
+ * 현재는 submit으로 처리, revision 을 아래의 함수로 사용가능함.
+ */
+export async function updateTechSalesVendorQuotation(data: {
+ id: number
+ currency: string
+ totalPrice: string
+ validUntil: Date
+ remark?: string
+ updatedBy: number
+ changeReason?: string
+}) {
+ try {
+ return await db.transaction(async (tx) => {
+ // 현재 견적서 전체 데이터 조회 (revision 저장용)
+ const currentQuotation = await tx.query.techSalesVendorQuotations.findFirst({
+ where: eq(techSalesVendorQuotations.id, data.id),
+ });
+
+ if (!currentQuotation) {
+ return { data: null, error: "견적서를 찾을 수 없습니다." };
+ }
+
+ // Accepted나 Rejected 상태가 아니면 수정 가능
+ if (["Rejected"].includes(currentQuotation.status)) {
+ return { data: null, error: "승인되거나 거절된 견적서는 수정할 수 없습니다." };
+ }
+
+ // 실제 변경사항이 있는지 확인
+ const hasChanges =
+ currentQuotation.currency !== data.currency ||
+ currentQuotation.totalPrice !== data.totalPrice ||
+ currentQuotation.validUntil?.getTime() !== data.validUntil.getTime() ||
+ currentQuotation.remark !== (data.remark || null);
+
+ if (!hasChanges) {
+ return { data: currentQuotation, error: null };
+ }
+
+ // 현재 버전을 revision history에 저장
+ await tx.insert(techSalesVendorQuotationRevisions).values({
+ quotationId: data.id,
+ version: currentQuotation.quotationVersion || 1,
+ snapshot: {
+ currency: currentQuotation.currency,
+ totalPrice: currentQuotation.totalPrice,
+ validUntil: currentQuotation.validUntil,
+ remark: currentQuotation.remark,
+ status: currentQuotation.status,
+ quotationVersion: currentQuotation.quotationVersion,
+ submittedAt: currentQuotation.submittedAt,
+ acceptedAt: currentQuotation.acceptedAt,
+ updatedAt: currentQuotation.updatedAt,
+ },
+ changeReason: data.changeReason || "견적서 수정",
+ revisedBy: data.updatedBy,
+ });
+
+ // 새로운 버전으로 업데이트
+ const result = await tx
+ .update(techSalesVendorQuotations)
+ .set({
+ currency: data.currency,
+ totalPrice: data.totalPrice,
+ validUntil: data.validUntil,
+ remark: data.remark || null,
+ quotationVersion: (currentQuotation.quotationVersion || 1) + 1,
+ status: "Revised", // 수정된 상태로 변경
+ updatedAt: new Date(),
+ })
+ .where(eq(techSalesVendorQuotations.id, data.id))
+ .returning();
+
+ return { data: result[0], error: null };
+ });
+ } catch (error) {
+ console.error("Error updating tech sales vendor quotation:", error);
+ return { data: null, error: "견적서 업데이트 중 오류가 발생했습니다" };
+ } finally {
+ // 캐시 무효화
+ revalidateTag("techSalesVendorQuotations");
+ revalidatePath(`/partners/techsales/rfq-ship/${data.id}`);
+ }
+}
+
+/**
+ * 기술영업 벤더 견적서 제출
+ */
+export async function submitTechSalesVendorQuotation(data: {
+ id: number
+ currency: string
+ totalPrice: string
+ validUntil: Date
+ remark?: string
+ attachments?: Array<{
+ fileName: string
+ originalFileName: string
+ filePath: string
+ fileSize: number
+ }>
+ updatedBy: number
+}) {
+ try {
+ return await db.transaction(async (tx) => {
+ // 현재 견적서 전체 데이터 조회 (revision 저장용)
+ const currentQuotation = await tx.query.techSalesVendorQuotations.findFirst({
+ where: eq(techSalesVendorQuotations.id, data.id),
+ });
+
+ if (!currentQuotation) {
+ return { data: null, error: "견적서를 찾을 수 없습니다." };
+ }
+
+ // Rejected 상태에서는 제출 불가
+ if (["Rejected"].includes(currentQuotation.status)) {
+ return { data: null, error: "거절된 견적서는 제출할 수 없습니다." };
+ }
+
+ // // 실제 변경사항이 있는지 확인
+ // const hasChanges =
+ // currentQuotation.currency !== data.currency ||
+ // currentQuotation.totalPrice !== data.totalPrice ||
+ // currentQuotation.validUntil?.getTime() !== data.validUntil.getTime() ||
+ // currentQuotation.remark !== (data.remark || null);
+
+ // // 변경사항이 있거나 처음 제출하는 경우 revision 저장
+ // if (hasChanges || currentQuotation.status === "Draft") {
+ // await tx.insert(techSalesVendorQuotationRevisions).values({
+ // quotationId: data.id,
+ // version: currentQuotation.quotationVersion || 1,
+ // snapshot: {
+ // currency: currentQuotation.currency,
+ // totalPrice: currentQuotation.totalPrice,
+ // validUntil: currentQuotation.validUntil,
+ // remark: currentQuotation.remark,
+ // status: currentQuotation.status,
+ // quotationVersion: currentQuotation.quotationVersion,
+ // submittedAt: currentQuotation.submittedAt,
+ // acceptedAt: currentQuotation.acceptedAt,
+ // updatedAt: currentQuotation.updatedAt,
+ // },
+ // changeReason: "견적서 제출",
+ // revisedBy: data.updatedBy,
+ // });
+ // }
+
+ // 첫 제출인지 확인 (quotationVersion이 null인 경우)
+ const isFirstSubmission = currentQuotation.quotationVersion === null;
+
+ // 첫 제출이 아닌 경우에만 revision 저장 (변경사항 이력 관리)
+ if (!isFirstSubmission) {
+ await tx.insert(techSalesVendorQuotationRevisions).values({
+ quotationId: data.id,
+ version: currentQuotation.quotationVersion || 1,
+ snapshot: {
+ currency: currentQuotation.currency,
+ totalPrice: currentQuotation.totalPrice,
+ validUntil: currentQuotation.validUntil,
+ remark: currentQuotation.remark,
+ status: currentQuotation.status,
+ quotationVersion: currentQuotation.quotationVersion,
+ submittedAt: currentQuotation.submittedAt,
+ acceptedAt: currentQuotation.acceptedAt,
+ updatedAt: currentQuotation.updatedAt,
+ },
+ changeReason: "견적서 제출",
+ revisedBy: data.updatedBy,
+ });
+ }
+
+ // 새로운 버전 번호 계산 (첫 제출은 1, 재제출은 1 증가)
+ const newRevisionId = isFirstSubmission ? 1 : (currentQuotation.quotationVersion || 1) + 1;
+
+ // 새로운 버전으로 업데이트
+ const result = await tx
+ .update(techSalesVendorQuotations)
+ .set({
+ currency: data.currency,
+ totalPrice: data.totalPrice,
+ validUntil: data.validUntil,
+ remark: data.remark || null,
+ quotationVersion: newRevisionId,
+ status: "Submitted",
+ submittedAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .where(eq(techSalesVendorQuotations.id, data.id))
+ .returning();
+
+ // 첨부파일 처리 (새로운 revisionId 사용)
+ if (data.attachments && data.attachments.length > 0) {
+ for (const attachment of data.attachments) {
+ await tx.insert(techSalesVendorQuotationAttachments).values({
+ quotationId: data.id,
+ revisionId: newRevisionId, // 새로운 리비전 ID 사용
+ fileName: attachment.fileName, // 해시된 파일명 (저장용)
+ originalFileName: attachment.originalFileName, // 원본 파일명 (표시용)
+ fileSize: attachment.fileSize,
+ filePath: attachment.filePath,
+ fileType: attachment.originalFileName.split('.').pop() || 'unknown',
+ uploadedBy: data.updatedBy,
+ isVendorUpload: true,
+ });
+ }
+ }
+
+ // 메일 발송 (백그라운드에서 실행)
+ if (result[0]) {
+ // 벤더에게 견적 제출 확인 메일 발송
+ sendQuotationSubmittedNotificationToVendor(data.id).catch(error => {
+ console.error("벤더 견적 제출 확인 메일 발송 실패:", error);
+ });
+
+ // 담당자에게 견적 접수 알림 메일 발송
+ sendQuotationSubmittedNotificationToManager(data.id).catch(error => {
+ console.error("담당자 견적 접수 알림 메일 발송 실패:", error);
+ });
+ }
+
+ return { data: result[0], error: null };
+ });
+ } catch (error) {
+ console.error("Error submitting tech sales vendor quotation:", error);
+ return { data: null, error: "견적서 제출 중 오류가 발생했습니다" };
+ } finally {
+ // 캐시 무효화
+ revalidateTag("techSalesVendorQuotations");
+ revalidatePath(`/partners/techsales/rfq-ship`);
+ }
+}
+
+/**
+ * 통화 목록 조회
+ */
+export async function fetchCurrencies() {
+ try {
+ // 기본 통화 목록 (실제로는 DB에서 가져와야 함)
+ const currencies = [
+ { code: "USD", name: "미국 달러" },
+ { code: "KRW", name: "한국 원" },
+ { code: "EUR", name: "유로" },
+ { code: "JPY", name: "일본 엔" },
+ { code: "CNY", name: "중국 위안" },
+ ]
+
+ return { data: currencies, error: null }
+ } catch (error) {
+ console.error("Error fetching currencies:", error)
+ return { data: null, error: "통화 목록 조회 중 오류가 발생했습니다" }
+ }
+}
+
+/**
+ * 벤더용 기술영업 견적서 목록 조회 (페이지네이션 포함)
+ */
+export async function getVendorQuotations(input: {
+ flags?: string[];
+ page: number;
+ perPage: number;
+ sort?: { id: string; desc: boolean }[];
+ filters?: Filter<typeof techSalesVendorQuotations>[];
+ joinOperator?: "and" | "or";
+ basicFilters?: Filter<typeof techSalesVendorQuotations>[];
+ basicJoinOperator?: "and" | "or";
+ search?: string;
+ from?: string;
+ to?: string;
+ rfqType?: "SHIP" | "TOP" | "HULL";
+}, vendorId: string) {
+ return unstable_cache(
+ async () => {
+ try {
+
+
+ const { page, perPage, sort, filters = [], search = "", from = "", to = "" } = input;
+ const offset = (page - 1) * perPage;
+ const limit = perPage;
+
+ // 기본 조건: 해당 벤더의 견적서만 조회 (Assigned 상태 제외)
+ const vendorIdNum = parseInt(vendorId);
+ if (isNaN(vendorIdNum)) {
+ console.error('❌ [getVendorQuotations] Invalid vendorId:', vendorId);
+ return { data: [], pageCount: 0, total: 0 };
+ }
+
+ const baseConditions = [
+ eq(techSalesVendorQuotations.vendorId, vendorIdNum),
+ sql`${techSalesVendorQuotations.status} != 'Assigned'` // Assigned 상태 제외
+ ];
+
+ // rfqType 필터링 추가
+ if (input.rfqType) {
+ baseConditions.push(eq(techSalesRfqs.rfqType, input.rfqType));
+ }
+
+ // 검색 조건 추가
+ if (search) {
+ const s = `%${search}%`;
+ const searchCondition = or(
+ ilike(techSalesVendorQuotations.currency, s),
+ ilike(techSalesVendorQuotations.status, s)
+ );
+ if (searchCondition) {
+ baseConditions.push(searchCondition);
+ }
+ }
+
+ // 날짜 범위 필터
+ if (from) {
+ baseConditions.push(sql`${techSalesVendorQuotations.createdAt} >= ${from}`);
+ }
+ if (to) {
+ baseConditions.push(sql`${techSalesVendorQuotations.createdAt} <= ${to}`);
+ }
+
+ // 고급 필터 처리
+ if (filters.length > 0) {
+ const filterWhere = filterColumns({
+ table: techSalesVendorQuotations,
+ filters: filters as Filter<typeof techSalesVendorQuotations>[],
+ joinOperator: input.joinOperator || "and",
+ });
+ if (filterWhere) {
+ baseConditions.push(filterWhere);
+ }
+ }
+
+ // 최종 WHERE 조건
+ const finalWhere = baseConditions.length > 0
+ ? and(...baseConditions)
+ : undefined;
+
+ // 정렬 기준 설정
+ let orderBy: OrderByType[] = [desc(techSalesVendorQuotations.updatedAt)];
+
+ if (sort?.length) {
+ orderBy = sort.map(item => {
+ switch (item.id) {
+ case 'id':
+ return item.desc ? desc(techSalesVendorQuotations.id) : techSalesVendorQuotations.id;
+ case 'status':
+ return item.desc ? desc(techSalesVendorQuotations.status) : techSalesVendorQuotations.status;
+ case 'currency':
+ return item.desc ? desc(techSalesVendorQuotations.currency) : techSalesVendorQuotations.currency;
+ case 'totalPrice':
+ return item.desc ? desc(techSalesVendorQuotations.totalPrice) : techSalesVendorQuotations.totalPrice;
+ case 'validUntil':
+ return item.desc ? desc(techSalesVendorQuotations.validUntil) : techSalesVendorQuotations.validUntil;
+ case 'submittedAt':
+ return item.desc ? desc(techSalesVendorQuotations.submittedAt) : techSalesVendorQuotations.submittedAt;
+ case 'createdAt':
+ return item.desc ? desc(techSalesVendorQuotations.createdAt) : techSalesVendorQuotations.createdAt;
+ case 'updatedAt':
+ return item.desc ? desc(techSalesVendorQuotations.updatedAt) : techSalesVendorQuotations.updatedAt;
+ case 'rfqCode':
+ return item.desc ? desc(techSalesRfqs.rfqCode) : techSalesRfqs.rfqCode;
+ case 'materialCode':
+ return item.desc ? desc(techSalesRfqs.materialCode) : techSalesRfqs.materialCode;
+ case 'dueDate':
+ return item.desc ? desc(techSalesRfqs.dueDate) : techSalesRfqs.dueDate;
+ case 'rfqStatus':
+ return item.desc ? desc(techSalesRfqs.status) : techSalesRfqs.status;
+ default:
+ return item.desc ? desc(techSalesVendorQuotations.updatedAt) : techSalesVendorQuotations.updatedAt;
+ }
+ });
+ }
+
+ // 조인을 포함한 데이터 조회 (중복 제거를 위해 techSalesAttachments JOIN 제거)
+ const data = await db
+ .select({
+ id: techSalesVendorQuotations.id,
+ rfqId: techSalesVendorQuotations.rfqId,
+ vendorId: techSalesVendorQuotations.vendorId,
+ status: techSalesVendorQuotations.status,
+ currency: techSalesVendorQuotations.currency,
+ totalPrice: techSalesVendorQuotations.totalPrice,
+ validUntil: techSalesVendorQuotations.validUntil,
+ submittedAt: techSalesVendorQuotations.submittedAt,
+ remark: techSalesVendorQuotations.remark,
+ createdAt: techSalesVendorQuotations.createdAt,
+ updatedAt: techSalesVendorQuotations.updatedAt,
+ createdBy: techSalesVendorQuotations.createdBy,
+ updatedBy: techSalesVendorQuotations.updatedBy,
+ quotationCode: techSalesVendorQuotations.quotationCode,
+ quotationVersion: techSalesVendorQuotations.quotationVersion,
+ rejectionReason: techSalesVendorQuotations.rejectionReason,
+ acceptedAt: techSalesVendorQuotations.acceptedAt,
+ // RFQ 정보
+ rfqCode: techSalesRfqs.rfqCode,
+ materialCode: techSalesRfqs.materialCode,
+ dueDate: techSalesRfqs.dueDate,
+ rfqStatus: techSalesRfqs.status,
+ description: techSalesRfqs.description,
+ // 프로젝트 정보 (직접 조인)
+ projNm: biddingProjects.projNm,
+ // 아이템 개수
+ itemCount: sql<number>`(
+ SELECT COUNT(*)
+ FROM tech_sales_rfq_items
+ WHERE tech_sales_rfq_items.rfq_id = ${techSalesRfqs.id}
+ )`,
+ // RFQ 첨부파일 개수 (RFQ_COMMON 타입만 카운트)
+ attachmentCount: sql<number>`(
+ SELECT COUNT(*)
+ FROM tech_sales_attachments
+ WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id}
+ AND tech_sales_attachments.attachment_type = 'RFQ_COMMON'
+ )`,
+ })
+ .from(techSalesVendorQuotations)
+ .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id))
+ .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id))
+ .where(finalWhere)
+ .orderBy(...orderBy)
+ .limit(limit)
+ .offset(offset);
+
+ // 총 개수 조회
+ const totalResult = await db
+ .select({ count: sql<number>`count(*)` })
+ .from(techSalesVendorQuotations)
+ .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id))
+ .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id))
+ .where(finalWhere);
+
+ const total = totalResult[0]?.count || 0;
+ const pageCount = Math.ceil(total / perPage);
+
+ return { data, pageCount, total };
+ } catch (err) {
+ console.error("Error fetching vendor quotations:", err);
+ return { data: [], pageCount: 0, total: 0 };
+ }
+ },
+ [JSON.stringify(input), vendorId], // 캐싱 키
+ {
+ revalidate: 60, // 1분간 캐시
+ tags: [
+ "techSalesVendorQuotations",
+ `vendor-${vendorId}-quotations`
+ ],
+ }
+ )();
+}
+
+/**
+ * 기술영업 벤더 견적 승인 (벤더 선택)
+ */
+export async function acceptTechSalesVendorQuotation(quotationId: number) {
+ try {
+ const result = await db.transaction(async (tx) => {
+ // 1. 선택된 견적 정보 조회
+ const selectedQuotation = await tx
+ .select()
+ .from(techSalesVendorQuotations)
+ .where(eq(techSalesVendorQuotations.id, quotationId))
+ .limit(1)
+
+ if (selectedQuotation.length === 0) {
+ throw new Error("견적을 찾을 수 없습니다")
+ }
+
+ const quotation = selectedQuotation[0]
+
+ // 2. 선택된 견적을 Accepted로 변경
+ await tx
+ .update(techSalesVendorQuotations)
+ .set({
+ status: "Accepted",
+ acceptedAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .where(eq(techSalesVendorQuotations.id, quotationId))
+
+ // 4. RFQ 상태를 Closed로 변경
+ await tx
+ .update(techSalesRfqs)
+ .set({
+ status: "Closed",
+ updatedAt: new Date(),
+ })
+ .where(eq(techSalesRfqs.id, quotation.rfqId))
+
+ return quotation
+ })
+
+ // 메일 발송 (백그라운드에서 실행)
+ // 선택된 벤더에게 견적 선택 알림 메일 발송
+ sendQuotationAcceptedNotification(quotationId).catch(error => {
+ console.error("벤더 견적 선택 알림 메일 발송 실패:", error);
+ });
+
+ // 캐시 무효화
+ revalidateTag("techSalesVendorQuotations")
+ revalidateTag(`techSalesRfq-${result.rfqId}`)
+ revalidateTag("techSalesRfqs")
+
+ // 해당 RFQ의 모든 벤더 캐시 무효화 (선택된 벤더와 거절된 벤더들)
+ const allVendorsInRfq = await db.query.techSalesVendorQuotations.findMany({
+ where: eq(techSalesVendorQuotations.rfqId, result.rfqId),
+ columns: { vendorId: true }
+ });
+
+ for (const vendorQuotation of allVendorsInRfq) {
+ revalidateTag(`vendor-${vendorQuotation.vendorId}-quotations`);
+ }
+ revalidatePath("/evcp/budgetary-tech-sales-ship")
+ revalidatePath("/partners/techsales")
+
+
+ return { success: true, data: result }
+ } catch (error) {
+ console.error("벤더 견적 승인 오류:", error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "벤더 견적 승인에 실패했습니다"
+ }
+ }
+}
+
+/**
+ * 기술영업 RFQ 첨부파일 생성
+ */
+export async function createTechSalesRfqAttachments(params: {
+ techSalesRfqId: number
+ files: File[]
+ createdBy: number
+ attachmentType?: "RFQ_COMMON" | "VENDOR_SPECIFIC"
+ description?: string
+}) {
+ unstable_noStore();
+ try {
+ const { techSalesRfqId, files, createdBy, attachmentType = "RFQ_COMMON", description } = params;
+
+
+
+ if (!files || files.length === 0) {
+ return { data: null, error: "업로드할 파일이 없습니다." };
+ }
+
+ // RFQ 존재 확인
+ const rfq = await db.query.techSalesRfqs.findFirst({
+ where: eq(techSalesRfqs.id, techSalesRfqId),
+ columns: { id: true, status: true }
+ });
+
+ if (!rfq) {
+ return { data: null, error: "RFQ를 찾을 수 없습니다." };
+ }
+
+ // // 편집 가능한 상태 확인
+ // if (!["RFQ Created", "RFQ Vendor Assignned"].includes(rfq.status)) {
+ // return { data: null, error: "현재 상태에서는 첨부파일을 추가할 수 없습니다." };
+ // }
+
+ const results: typeof techSalesAttachments.$inferSelect[] = [];
+
+ // 트랜잭션으로 처리
+ await db.transaction(async (tx) => {
+
+ for (const file of files) {
+
+
+ const saveResult = await saveDRMFile(
+ file,
+ decryptWithServerAction,
+ `techsales-rfq/${techSalesRfqId}`
+ );
+
+ if (!saveResult.success) {
+ throw new Error(saveResult.error || "파일 저장에 실패했습니다.");
+ }
+
+ // DB에 첨부파일 레코드 생성
+ const [newAttachment] = await tx.insert(techSalesAttachments).values({
+ techSalesRfqId,
+ attachmentType,
+ fileName: saveResult.fileName!,
+ originalFileName: file.name,
+ filePath: saveResult.publicPath!,
+ fileSize: file.size,
+ fileType: file.type || undefined,
+ description: description || undefined,
+ createdBy,
+ }).returning();
+
+ results.push(newAttachment);
+ }
+ });
+
+
+
+ // RFQ 타입 조회하여 캐시 무효화
+ const rfqType = await db.query.techSalesRfqs.findFirst({
+ where: eq(techSalesRfqs.id, techSalesRfqId),
+ columns: { rfqType: true }
+ });
+
+ revalidateTag("techSalesRfqs");
+ revalidateTag(`techSalesRfq-${techSalesRfqId}`);
+ revalidatePath(getTechSalesRevalidationPath(rfqType?.rfqType || "SHIP"));
+ revalidatePath("/partners/techsales");
+ return { data: results, error: null };
+ } catch (err) {
+ console.error("기술영업 RFQ 첨부파일 생성 오류:", err);
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * 기술영업 RFQ 첨부파일 조회
+ */
+export async function getTechSalesRfqAttachments(techSalesRfqId: number) {
+ unstable_noStore();
+ try {
+ const attachments = await db.query.techSalesAttachments.findMany({
+ where: eq(techSalesAttachments.techSalesRfqId, techSalesRfqId),
+ orderBy: [desc(techSalesAttachments.createdAt)],
+ with: {
+ createdByUser: {
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ }
+ }
+ }
+ });
+
+ return { data: attachments, error: null };
+ } catch (err) {
+ console.error("기술영업 RFQ 첨부파일 조회 오류:", err);
+ return { data: [], error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * RFQ 첨부파일 타입별 조회
+ */
+export async function getTechSalesRfqAttachmentsByType(
+ techSalesRfqId: number,
+ attachmentType: "RFQ_COMMON" | "VENDOR_SPECIFIC" | "TBE_RESULT" | "CBE_RESULT"
+) {
+ unstable_noStore();
+ try {
+ const attachments = await db.query.techSalesAttachments.findMany({
+ where: and(
+ eq(techSalesAttachments.techSalesRfqId, techSalesRfqId),
+ eq(techSalesAttachments.attachmentType, attachmentType)
+ ),
+ orderBy: [desc(techSalesAttachments.createdAt)],
+ with: {
+ createdByUser: {
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ }
+ }
+ }
+ });
+
+ return { data: attachments, error: null };
+ } catch (err) {
+ console.error(`기술영업 RFQ ${attachmentType} 첨부파일 조회 오류:`, err);
+ return { data: [], error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * 기술영업 RFQ 첨부파일 삭제
+ */
+export async function deleteTechSalesRfqAttachment(attachmentId: number) {
+ unstable_noStore();
+ try {
+ // 첨부파일 정보 조회
+ const attachment = await db.query.techSalesAttachments.findFirst({
+ where: eq(techSalesAttachments.id, attachmentId),
+ });
+
+ if (!attachment) {
+ return { data: null, error: "첨부파일을 찾을 수 없습니다." };
+ }
+
+ // RFQ 상태 확인
+ const rfq = await db.query.techSalesRfqs.findFirst({
+ where: eq(techSalesRfqs.id, attachment.techSalesRfqId!), // Non-null assertion since we know it exists
+ columns: { id: true, status: true }
+ });
+
+ if (!rfq) {
+ return { data: null, error: "RFQ를 찾을 수 없습니다." };
+ }
+
+ // // 편집 가능한 상태 확인
+ // if (!["RFQ Created", "RFQ Vendor Assignned"].includes(rfq.status)) {
+ // return { data: null, error: "현재 상태에서는 첨부파일을 삭제할 수 없습니다." };
+ // }
+
+ // 트랜잭션으로 처리
+ const result = await db.transaction(async (tx) => {
+ // DB에서 레코드 삭제
+ const deletedAttachment = await tx.delete(techSalesAttachments)
+ .where(eq(techSalesAttachments.id, attachmentId))
+ .returning();
+
+ // 파일 시스템에서 파일 삭제
+ try {
+ deleteFile(attachment.filePath)
+
+ } catch (fileError) {
+ console.warn("파일 삭제 실패:", fileError);
+ // 파일 삭제 실패는 심각한 오류가 아니므로 계속 진행
+ }
+
+ return deletedAttachment[0];
+ });
+
+ // RFQ 타입 조회하여 캐시 무효화
+ const attachmentRfq = await db.query.techSalesRfqs.findFirst({
+ where: eq(techSalesRfqs.id, attachment.techSalesRfqId!),
+ columns: { rfqType: true }
+ });
+
+ revalidateTag("techSalesRfqs");
+ revalidateTag(`techSalesRfq-${attachment.techSalesRfqId}`);
+ revalidatePath(getTechSalesRevalidationPath(attachmentRfq?.rfqType || "SHIP"));
+
+ return { data: result, error: null };
+ } catch (err) {
+ console.error("기술영업 RFQ 첨부파일 삭제 오류:", err);
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * 기술영업 RFQ 첨부파일 일괄 처리 (업로드 + 삭제)
+ */
+export async function processTechSalesRfqAttachments(params: {
+ techSalesRfqId: number
+ newFiles: { file: File; attachmentType: "RFQ_COMMON" | "VENDOR_SPECIFIC" | "TBE_RESULT" | "CBE_RESULT"; description?: string }[]
+ deleteAttachmentIds: number[]
+ createdBy: number
+}) {
+ unstable_noStore();
+ try {
+ const { techSalesRfqId, newFiles, deleteAttachmentIds, createdBy } = params;
+
+
+
+ // RFQ 존재 및 상태 확인
+ const rfq = await db.query.techSalesRfqs.findFirst({
+ where: eq(techSalesRfqs.id, techSalesRfqId),
+ columns: { id: true, status: true }
+ });
+
+ if (!rfq) {
+ return { data: null, error: "RFQ를 찾을 수 없습니다." };
+ }
+ // // 편집 가능한 상태 확인
+ // if (!["RFQ Created", "RFQ Vendor Assignned"].includes(rfq.status)) {
+ // return { data: null, error: "현재 상태에서는 첨부파일을 수정할 수 없습니다." };
+ // }
+
+ const results = {
+ uploaded: [] as typeof techSalesAttachments.$inferSelect[],
+ deleted: [] as typeof techSalesAttachments.$inferSelect[],
+ };
+
+ await db.transaction(async (tx) => {
+
+ // 1. 삭제할 첨부파일 처리
+ if (deleteAttachmentIds.length > 0) {
+ const attachmentsToDelete = await tx.query.techSalesAttachments.findMany({
+ where: sql`${techSalesAttachments.id} IN (${deleteAttachmentIds.join(',')})`
+ });
+
+ for (const attachment of attachmentsToDelete) {
+ // DB에서 레코드 삭제
+ const [deletedAttachment] = await tx.delete(techSalesAttachments)
+ .where(eq(techSalesAttachments.id, attachment.id))
+ .returning();
+
+ results.deleted.push(deletedAttachment);
+ await deleteFile(attachment.filePath);
+ }
+ }
+
+ // 2. 새 파일 업로드 처리
+ if (newFiles.length > 0) {
+ for (const { file, attachmentType, description } of newFiles) {
+ const saveResult = await saveDRMFile(
+ file,
+ decryptWithServerAction,
+ `techsales-rfq/${techSalesRfqId}`
+ );
+
+ if (!saveResult.success) {
+ throw new Error(saveResult.error || "파일 저장에 실패했습니다.");
+ }
+
+ // DB에 첨부파일 레코드 생성
+ const [newAttachment] = await tx.insert(techSalesAttachments).values({
+ techSalesRfqId,
+ attachmentType,
+ fileName: saveResult.fileName!,
+ originalFileName: file.name,
+ filePath: saveResult.publicPath!,
+ fileSize: file.size,
+ fileType: file.type || undefined,
+ description: description || undefined,
+ createdBy,
+ }).returning();
+
+ results.uploaded.push(newAttachment);
+ }
+ }
+ });
+
+
+
+ // 캐시 무효화
+ revalidateTag("techSalesRfqs");
+ revalidateTag(`techSalesRfq-${techSalesRfqId}`);
+ revalidatePath("/evcp/budgetary-tech-sales-ship");
+
+ return {
+ data: results,
+ error: null,
+ message: `${results.uploaded.length}개 업로드, ${results.deleted.length}개 삭제 완료`
+ };
+ } catch (err) {
+ console.error("기술영업 RFQ 첨부파일 일괄 처리 오류:", err);
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+// ========================================
+// 메일 발송 관련 함수들
+// ========================================
+
+/**
+ * 벤더 견적 제출 확인 메일 발송 (벤더용)
+ */
+export async function sendQuotationSubmittedNotificationToVendor(quotationId: number) {
+ try {
+ // 견적서 정보 조회 (projectSeries 조인 추가)
+ const quotation = await db.query.techSalesVendorQuotations.findFirst({
+ where: eq(techSalesVendorQuotations.id, quotationId),
+ with: {
+ rfq: {
+ with: {
+ biddingProject: true,
+ createdByUser: {
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ }
+ }
+ }
+ },
+ vendor: {
+ columns: {
+ id: true,
+ vendorName: true,
+ vendorCode: true,
+ }
+ }
+ }
+ });
+
+ if (!quotation || !quotation.rfq || !quotation.vendor) {
+ console.error("견적서 또는 관련 정보를 찾을 수 없습니다");
+ return { success: false, error: "견적서 정보를 찾을 수 없습니다" };
+ }
+
+ // 벤더 사용자들 조회
+ const vendorUsers = await db.query.users.findMany({
+ where: eq(users.companyId, quotation.vendor.id),
+ columns: {
+ id: true,
+ email: true,
+ name: true,
+ language: true
+ }
+ });
+
+ const vendorEmails = vendorUsers
+ .filter(user => user.email)
+ .map(user => user.email)
+ .join(", ");
+
+ if (!vendorEmails) {
+ console.warn(`벤더 ID ${quotation.vendor.id}에 등록된 이메일 주소가 없습니다`);
+ return { success: false, error: "벤더 이메일 주소가 없습니다" };
+ }
+
+ // RFQ 아이템 정보 조회
+ const rfqItemsResult = await getTechSalesRfqItems(quotation.rfq.id);
+ const rfqItems = rfqItemsResult.data || [];
+
+ // 이메일 컨텍스트 구성 (시리즈 정보 제거, 프로젝트 정보 간소화)
+ const emailContext = {
+ language: vendorUsers[0]?.language || "ko",
+ quotation: {
+ id: quotation.id,
+ currency: quotation.currency,
+ totalPrice: quotation.totalPrice,
+ validUntil: quotation.validUntil,
+ submittedAt: quotation.submittedAt,
+ remark: quotation.remark,
+ },
+ rfq: {
+ id: quotation.rfq.id,
+ code: quotation.rfq.rfqCode,
+ title: quotation.rfq.description || '',
+ projectCode: quotation.rfq.biddingProject?.pspid || '',
+ projectName: quotation.rfq.biddingProject?.projNm || '',
+ dueDate: quotation.rfq.dueDate,
+ materialCode: quotation.rfq.materialCode,
+ description: quotation.rfq.remark,
+ },
+ items: rfqItems.map(item => ({
+ itemCode: item.itemCode,
+ itemList: item.itemList,
+ workType: item.workType,
+ shipTypes: item.shipTypes,
+ subItemList: item.subItemList,
+ itemType: item.itemType,
+ })),
+ vendor: {
+ id: quotation.vendor.id,
+ code: quotation.vendor.vendorCode,
+ name: quotation.vendor.vendorName,
+ },
+ project: {
+ name: quotation.rfq.biddingProject?.projNm || '',
+ sector: quotation.rfq.biddingProject?.sector || '',
+ shipCount: quotation.rfq.biddingProject?.projMsrm ? Number(quotation.rfq.biddingProject.projMsrm) : 0,
+ ownerName: quotation.rfq.biddingProject?.kunnrNm || '',
+ className: quotation.rfq.biddingProject?.cls1Nm || '',
+ },
+ manager: {
+ name: quotation.rfq.createdByUser?.name || '',
+ email: quotation.rfq.createdByUser?.email || '',
+ },
+ systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://60.101.108.100/ko/partners',
+ companyName: 'Samsung Heavy Industries',
+ year: new Date().getFullYear(),
+ };
+
+ // 이메일 발송
+ await sendEmail({
+ to: vendorEmails,
+ subject: `[견적 제출 확인] ${quotation.rfq.rfqCode} - 견적 요청`,
+ template: 'tech-sales-quotation-submitted-vendor-ko',
+ context: emailContext,
+ });
+
+ console.log(`벤더 견적 제출 확인 메일 발송 완료: ${vendorEmails}`);
+ return { success: true };
+ } catch (error) {
+ console.error("벤더 견적 제출 확인 메일 발송 오류:", error);
+ return { success: false, error: "메일 발송 중 오류가 발생했습니다" };
+ }
+}
+
+/**
+ * 벤더 견적 접수 알림 메일 발송 (담당자용)
+ */
+export async function sendQuotationSubmittedNotificationToManager(quotationId: number) {
+ try {
+ // 견적서 정보 조회
+ const quotation = await db.query.techSalesVendorQuotations.findFirst({
+ where: eq(techSalesVendorQuotations.id, quotationId),
+ with: {
+ rfq: {
+ with: {
+ biddingProject: true,
+ createdByUser: {
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ }
+ }
+ }
+ },
+ vendor: {
+ columns: {
+ id: true,
+ vendorName: true,
+ vendorCode: true,
+ }
+ }
+ }
+ });
+
+ if (!quotation || !quotation.rfq || !quotation.vendor) {
+ console.error("견적서 또는 관련 정보를 찾을 수 없습니다");
+ return { success: false, error: "견적서 정보를 찾을 수 없습니다" };
+ }
+
+ const manager = quotation.rfq.createdByUser;
+ if (!manager?.email) {
+ console.warn("담당자 이메일 주소가 없습니다");
+ return { success: false, error: "담당자 이메일 주소가 없습니다" };
+ }
+
+ // RFQ 아이템 정보 조회
+ const rfqItemsResult = await getTechSalesRfqItems(quotation.rfq.id);
+ const rfqItems = rfqItemsResult.data || [];
+
+ // 이메일 컨텍스트 구성 (시리즈 정보 제거, 프로젝트 정보 간소화)
+ const emailContext = {
+ language: "ko",
+ quotation: {
+ id: quotation.id,
+ currency: quotation.currency,
+ totalPrice: quotation.totalPrice,
+ validUntil: quotation.validUntil,
+ submittedAt: quotation.submittedAt,
+ remark: quotation.remark,
+ },
+ rfq: {
+ id: quotation.rfq.id,
+ code: quotation.rfq.rfqCode,
+ title: quotation.rfq.description || '',
+ projectCode: quotation.rfq.biddingProject?.pspid || '',
+ projectName: quotation.rfq.biddingProject?.projNm || '',
+ dueDate: quotation.rfq.dueDate,
+ materialCode: quotation.rfq.materialCode,
+ description: quotation.rfq.remark,
+ },
+ items: rfqItems.map(item => ({
+ itemCode: item.itemCode,
+ itemList: item.itemList,
+ workType: item.workType,
+ shipTypes: item.shipTypes,
+ subItemList: item.subItemList,
+ itemType: item.itemType,
+ })),
+ vendor: {
+ id: quotation.vendor.id,
+ code: quotation.vendor.vendorCode,
+ name: quotation.vendor.vendorName,
+ },
+ project: {
+ name: quotation.rfq.biddingProject?.projNm || '',
+ sector: quotation.rfq.biddingProject?.sector || '',
+ shipCount: quotation.rfq.biddingProject?.projMsrm ? Number(quotation.rfq.biddingProject.projMsrm) : 0,
+ ownerName: quotation.rfq.biddingProject?.kunnrNm || '',
+ className: quotation.rfq.biddingProject?.cls1Nm || '',
+ },
+ manager: {
+ name: manager.name || '',
+ email: manager.email,
+ },
+ systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://60.101.108.100/ko/evcp',
+ companyName: 'Samsung Heavy Industries',
+ year: new Date().getFullYear(),
+ };
+
+ // 이메일 발송
+ await sendEmail({
+ to: manager.email,
+ subject: `[견적 접수 알림] ${quotation.vendor.vendorName}에서 ${quotation.rfq.rfqCode} 견적서를 제출했습니다`,
+ template: 'tech-sales-quotation-submitted-manager-ko',
+ context: emailContext,
+ });
+
+ console.log(`담당자 견적 접수 알림 메일 발송 완료: ${manager.email}`);
+ return { success: true };
+ } catch (error) {
+ console.error("담당자 견적 접수 알림 메일 발송 오류:", error);
+ return { success: false, error: "메일 발송 중 오류가 발생했습니다" };
+ }
+}
+
+/**
+ * 벤더 견적 선택 알림 메일 발송
+ */
+export async function sendQuotationAcceptedNotification(quotationId: number) {
+ try {
+ // 견적서 정보 조회
+ const quotation = await db.query.techSalesVendorQuotations.findFirst({
+ where: eq(techSalesVendorQuotations.id, quotationId),
+ with: {
+ rfq: {
+ with: {
+ biddingProject: true,
+ createdByUser: {
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ }
+ }
+ }
+ },
+ vendor: {
+ columns: {
+ id: true,
+ vendorName: true,
+ vendorCode: true,
+ }
+ }
+ }
+ });
+
+ if (!quotation || !quotation.rfq || !quotation.vendor) {
+ console.error("견적서 또는 관련 정보를 찾을 수 없습니다");
+ return { success: false, error: "견적서 정보를 찾을 수 없습니다" };
+ }
+
+ // 벤더 사용자들 조회
+ const vendorUsers = await db.query.users.findMany({
+ where: eq(users.companyId, quotation.vendor.id),
+ columns: {
+ id: true,
+ email: true,
+ name: true,
+ language: true
+ }
+ });
+
+ const vendorEmails = vendorUsers
+ .filter(user => user.email)
+ .map(user => user.email)
+ .join(", ");
+
+ if (!vendorEmails) {
+ console.warn(`벤더 ID ${quotation.vendor.id}에 등록된 이메일 주소가 없습니다`);
+ return { success: false, error: "벤더 이메일 주소가 없습니다" };
+ }
+
+ // RFQ 아이템 정보 조회
+ const rfqItemsResult = await getTechSalesRfqItems(quotation.rfq.id);
+ const rfqItems = rfqItemsResult.data || [];
+
+ // 이메일 컨텍스트 구성 (시리즈 정보 제거, 프로젝트 정보 간소화)
+ const emailContext = {
+ language: vendorUsers[0]?.language || "ko",
+ quotation: {
+ id: quotation.id,
+ currency: quotation.currency,
+ totalPrice: quotation.totalPrice,
+ validUntil: quotation.validUntil,
+ acceptedAt: quotation.acceptedAt,
+ remark: quotation.remark,
+ },
+ rfq: {
+ id: quotation.rfq.id,
+ code: quotation.rfq.rfqCode,
+ title: quotation.rfq.description || '',
+ projectCode: quotation.rfq.biddingProject?.pspid || '',
+ projectName: quotation.rfq.biddingProject?.projNm || '',
+ dueDate: quotation.rfq.dueDate,
+ materialCode: quotation.rfq.materialCode,
+ description: quotation.rfq.remark,
+ },
+ items: rfqItems.map(item => ({
+ itemCode: item.itemCode,
+ itemList: item.itemList,
+ workType: item.workType,
+ shipTypes: item.shipTypes,
+ subItemList: item.subItemList,
+ itemType: item.itemType,
+ })),
+ vendor: {
+ id: quotation.vendor.id,
+ code: quotation.vendor.vendorCode,
+ name: quotation.vendor.vendorName,
+ },
+ project: {
+ name: quotation.rfq.biddingProject?.projNm || '',
+ sector: quotation.rfq.biddingProject?.sector || '',
+ shipCount: quotation.rfq.biddingProject?.projMsrm ? Number(quotation.rfq.biddingProject.projMsrm) : 0,
+ ownerName: quotation.rfq.biddingProject?.kunnrNm || '',
+ className: quotation.rfq.biddingProject?.cls1Nm || '',
+ },
+ manager: {
+ name: quotation.rfq.createdByUser?.name || '',
+ email: quotation.rfq.createdByUser?.email || '',
+ },
+ systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://60.101.108.100/ko/partners',
+ companyName: 'Samsung Heavy Industries',
+ year: new Date().getFullYear(),
+ };
+
+ // 이메일 발송
+ await sendEmail({
+ to: vendorEmails,
+ subject: `[견적 선택 알림] ${quotation.rfq.rfqCode} - 귀하의 견적이 선택되었습니다`,
+ template: 'tech-sales-quotation-accepted-ko',
+ context: emailContext,
+ });
+
+ console.log(`벤더 견적 선택 알림 메일 발송 완료: ${vendorEmails}`);
+ return { success: true };
+ } catch (error) {
+ console.error("벤더 견적 선택 알림 메일 발송 오류:", error);
+ return { success: false, error: "메일 발송 중 오류가 발생했습니다" };
+ }
+}
+
+// ==================== Vendor Communication 관련 ====================
+
+export interface TechSalesAttachment {
+ id: number
+ fileName: string
+ fileSize: number
+ fileType: string | null // <- null 허용
+ filePath: string
+ uploadedAt: Date
+}
+
+export interface TechSalesComment {
+ id: number
+ rfqId: number
+ vendorId: number | null // null 허용으로 변경
+ userId?: number | null // null 허용으로 변경
+ content: string
+ isVendorComment: boolean | null // null 허용으로 변경
+ createdAt: Date
+ updatedAt: Date
+ userName?: string | null // null 허용으로 변경
+ vendorName?: string | null // null 허용으로 변경
+ attachments: TechSalesAttachment[]
+ isRead: boolean | null // null 허용으로 변경
+}
+
+/**
+ * 특정 RFQ의 벤더별 읽지 않은 메시지 개수를 조회하는 함수
+ *
+ * @param rfqId RFQ ID
+ * @returns 벤더별 읽지 않은 메시지 개수 (vendorId: count)
+ */
+export async function getTechSalesUnreadMessageCounts(rfqId: number): Promise<Record<number, number>> {
+ try {
+ // 벤더가 보낸 읽지 않은 메시지를 벤더별로 카운트
+ const unreadCounts = await db
+ .select({
+ vendorId: techSalesRfqComments.vendorId,
+ count: sql<number>`count(*)`,
+ })
+ .from(techSalesRfqComments)
+ .where(
+ and(
+ eq(techSalesRfqComments.rfqId, rfqId),
+ eq(techSalesRfqComments.isVendorComment, true), // 벤더가 보낸 메시지
+ eq(techSalesRfqComments.isRead, false), // 읽지 않은 메시지
+ sql`${techSalesRfqComments.vendorId} IS NOT NULL` // vendorId가 null이 아닌 것
+ )
+ )
+ .groupBy(techSalesRfqComments.vendorId);
+
+ // Record<number, number> 형태로 변환
+ const result: Record<number, number> = {};
+ unreadCounts.forEach(item => {
+ if (item.vendorId) {
+ result[item.vendorId] = item.count;
+ }
+ });
+
+ return result;
+ } catch (error) {
+ console.error('techSales 읽지 않은 메시지 개수 조회 오류:', error);
+ return {};
+ }
+}
+
+/**
+ * 특정 RFQ와 벤더 간의 커뮤니케이션 메시지를 가져오는 서버 액션
+ *
+ * @param rfqId RFQ ID
+ * @param vendorId 벤더 ID
+ * @returns 코멘트 목록
+ */
+export async function fetchTechSalesVendorComments(rfqId: number, vendorId?: number): Promise<TechSalesComment[]> {
+ if (!vendorId) {
+ return []
+ }
+
+ try {
+ // 인증 확인
+ const session = await getServerSession(authOptions);
+
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다")
+ }
+
+ // 코멘트 쿼리
+ const comments = await db.query.techSalesRfqComments.findMany({
+ where: and(
+ eq(techSalesRfqComments.rfqId, rfqId),
+ eq(techSalesRfqComments.vendorId, vendorId)
+ ),
+ orderBy: [techSalesRfqComments.createdAt],
+ with: {
+ user: {
+ columns: {
+ name: true
+ }
+ },
+ vendor: {
+ columns: {
+ vendorName: true
+ }
+ },
+ attachments: true,
+ }
+ })
+
+ // 결과 매핑
+ return comments.map(comment => ({
+ id: comment.id,
+ rfqId: comment.rfqId,
+ vendorId: comment.vendorId,
+ userId: comment.userId || undefined,
+ content: comment.content,
+ isVendorComment: comment.isVendorComment,
+ createdAt: comment.createdAt,
+ updatedAt: comment.updatedAt,
+ userName: comment.user?.name,
+ vendorName: comment.vendor?.vendorName,
+ isRead: comment.isRead,
+ attachments: comment.attachments.map(att => ({
+ id: att.id,
+ fileName: att.fileName,
+ fileSize: att.fileSize,
+ fileType: att.fileType,
+ filePath: att.filePath,
+ originalFileName: att.originalFileName,
+ uploadedAt: att.uploadedAt
+ }))
+ }))
+ } catch (error) {
+ console.error('techSales 벤더 코멘트 가져오기 오류:', error)
+ throw error
+ }
+}
+
+/**
+ * 코멘트를 읽음 상태로 표시하는 서버 액션
+ *
+ * @param rfqId RFQ ID
+ * @param vendorId 벤더 ID
+ */
+export async function markTechSalesMessagesAsRead(rfqId: number, vendorId?: number): Promise<void> {
+ if (!vendorId) {
+ return
+ }
+
+ try {
+ // 인증 확인
+ const session = await getServerSession(authOptions);
+
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다")
+ }
+
+ // 벤더가 작성한 읽지 않은 코멘트 업데이트
+ await db.update(techSalesRfqComments)
+ .set({ isRead: true })
+ .where(
+ and(
+ eq(techSalesRfqComments.rfqId, rfqId),
+ eq(techSalesRfqComments.vendorId, vendorId),
+ eq(techSalesRfqComments.isVendorComment, true),
+ eq(techSalesRfqComments.isRead, false)
+ )
+ )
+
+ // 캐시 무효화
+ revalidateTag(`tech-sales-rfq-${rfqId}-comments`)
+ } catch (error) {
+ console.error('techSales 메시지 읽음 표시 오류:', error)
+ throw error
+ }
+}
+
+// ==================== RFQ 조선/해양 관련 ====================
+
+/**
+ * 기술영업 조선 RFQ 생성 (1:N 관계)
+ */
+export async function createTechSalesShipRfq(input: {
+ biddingProjectId: number;
+ itemIds: number[]; // 조선 아이템 ID 배열
+ dueDate: Date;
+ description?: string;
+ createdBy: number;
+}) {
+ unstable_noStore();
+ try {
+ return await db.transaction(async (tx) => {
+ // 프로젝트 정보 조회 (유효성 검증)
+ const biddingProject = await tx.query.biddingProjects.findFirst({
+ where: (biddingProjects, { eq }) => eq(biddingProjects.id, input.biddingProjectId)
+ });
+
+ if (!biddingProject) {
+ throw new Error(`프로젝트 ID ${input.biddingProjectId}를 찾을 수 없습니다.`);
+ }
+
+ // RFQ 코드 생성 (SHIP 타입)
+ const rfqCode = await generateRfqCodes(tx, 1);
+
+ // RFQ 생성
+ const [rfq] = await tx
+ .insert(techSalesRfqs)
+ .values({
+ rfqCode: rfqCode[0],
+ biddingProjectId: input.biddingProjectId,
+ description: input.description,
+ dueDate: input.dueDate,
+ status: "RFQ Created",
+ rfqType: "SHIP",
+ createdBy: input.createdBy,
+ updatedBy: input.createdBy,
+ })
+ .returning({ id: techSalesRfqs.id });
+
+ // 아이템들 추가
+ for (const itemId of input.itemIds) {
+ await tx
+ .insert(techSalesRfqItems)
+ .values({
+ rfqId: rfq.id,
+ itemShipbuildingId: itemId,
+ itemType: "SHIP",
+ });
+ }
+
+ // 캐시 무효화
+ revalidateTag("techSalesRfqs");
+ revalidatePath("/evcp/budgetary-tech-sales-ship");
+
+ return { data: rfq, error: null };
+ });
+ } catch (err) {
+ console.error("Error creating Ship RFQ:", err);
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * 기술영업 해양 Hull RFQ 생성 (1:N 관계)
+ */
+export async function createTechSalesHullRfq(input: {
+ biddingProjectId: number;
+ itemIds: number[]; // Hull 아이템 ID 배열
+ dueDate: Date;
+ description?: string;
+ createdBy: number;
+}) {
+ unstable_noStore();
+ console.log('🔍 createTechSalesHullRfq 호출됨:', input);
+
+ try {
+ return await db.transaction(async (tx) => {
+ // 프로젝트 정보 조회 (유효성 검증)
+ const biddingProject = await tx.query.biddingProjects.findFirst({
+ where: (biddingProjects, { eq }) => eq(biddingProjects.id, input.biddingProjectId)
+ });
+
+ if (!biddingProject) {
+ throw new Error(`프로젝트 ID ${input.biddingProjectId}를 찾을 수 없습니다.`);
+ }
+
+ // RFQ 코드 생성 (HULL 타입)
+ const hullRfqCode = await generateRfqCodes(tx, 1);
+
+ // RFQ 생성
+ const [rfq] = await tx
+ .insert(techSalesRfqs)
+ .values({
+ rfqCode: hullRfqCode[0],
+ biddingProjectId: input.biddingProjectId,
+ description: input.description,
+ dueDate: input.dueDate,
+ status: "RFQ Created",
+ rfqType: "HULL",
+ createdBy: input.createdBy,
+ updatedBy: input.createdBy,
+ })
+ .returning({ id: techSalesRfqs.id });
+
+ // 아이템들 추가
+ for (const itemId of input.itemIds) {
+ await tx
+ .insert(techSalesRfqItems)
+ .values({
+ rfqId: rfq.id,
+ itemOffshoreHullId: itemId,
+ itemType: "HULL",
+ });
+ }
+
+ // 캐시 무효화
+ revalidateTag("techSalesRfqs");
+ revalidatePath("/evcp/budgetary-tech-sales-hull");
+
+ return { data: rfq, error: null };
+ });
+ } catch (err) {
+ console.error("Error creating Hull RFQ:", err);
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * 기술영업 해양 TOP RFQ 생성 (1:N 관계)
+ */
+export async function createTechSalesTopRfq(input: {
+ biddingProjectId: number;
+ itemIds: number[]; // TOP 아이템 ID 배열
+ dueDate: Date;
+ description?: string;
+ createdBy: number;
+}) {
+ unstable_noStore();
+ console.log('🔍 createTechSalesTopRfq 호출됨:', input);
+
+ try {
+ return await db.transaction(async (tx) => {
+ // 프로젝트 정보 조회 (유효성 검증)
+ const biddingProject = await tx.query.biddingProjects.findFirst({
+ where: (biddingProjects, { eq }) => eq(biddingProjects.id, input.biddingProjectId)
+ });
+
+ if (!biddingProject) {
+ throw new Error(`프로젝트 ID ${input.biddingProjectId}를 찾을 수 없습니다.`);
+ }
+
+ // RFQ 코드 생성 (TOP 타입)
+ const topRfqCode = await generateRfqCodes(tx, 1);
+
+ // RFQ 생성
+ const [rfq] = await tx
+ .insert(techSalesRfqs)
+ .values({
+ rfqCode: topRfqCode[0],
+ biddingProjectId: input.biddingProjectId,
+ description: input.description,
+ dueDate: input.dueDate,
+ status: "RFQ Created",
+ rfqType: "TOP",
+ createdBy: input.createdBy,
+ updatedBy: input.createdBy,
+ })
+ .returning({ id: techSalesRfqs.id });
+
+ // 아이템들 추가
+ for (const itemId of input.itemIds) {
+ await tx
+ .insert(techSalesRfqItems)
+ .values({
+ rfqId: rfq.id,
+ itemOffshoreTopId: itemId,
+ itemType: "TOP",
+ });
+ }
+
+ // 캐시 무효화
+ revalidateTag("techSalesRfqs");
+ revalidatePath("/evcp/budgetary-tech-sales-top");
+
+ return { data: rfq, error: null };
+ });
+ } catch (err) {
+ console.error("Error creating TOP RFQ:", err);
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * 조선 RFQ 전용 조회 함수
+ */
+export async function getTechSalesShipRfqsWithJoin(input: GetTechSalesRfqsSchema) {
+ return getTechSalesRfqsWithJoin({ ...input, rfqType: "SHIP" });
+}
+
+/**
+ * 해양 TOP RFQ 전용 조회 함수
+ */
+export async function getTechSalesTopRfqsWithJoin(input: GetTechSalesRfqsSchema) {
+ return getTechSalesRfqsWithJoin({ ...input, rfqType: "TOP" });
+}
+
+/**
+ * 해양 HULL RFQ 전용 조회 함수
+ */
+export async function getTechSalesHullRfqsWithJoin(input: GetTechSalesRfqsSchema) {
+ return getTechSalesRfqsWithJoin({ ...input, rfqType: "HULL" });
+}
+
+/**
+ * 조선 벤더 견적서 전용 조회 함수
+ */
+export async function getTechSalesShipVendorQuotationsWithJoin(input: {
+ rfqId?: number;
+ vendorId?: number;
+ search?: string;
+ filters?: Filter<typeof techSalesVendorQuotations>[];
+ sort?: { id: string; desc: boolean }[];
+ page: number;
+ perPage: number;
+}) {
+ return getTechSalesVendorQuotationsWithJoin({ ...input, rfqType: "SHIP" });
+}
+
+/**
+ * 해양 TOP 벤더 견적서 전용 조회 함수
+ */
+export async function getTechSalesTopVendorQuotationsWithJoin(input: {
+ rfqId?: number;
+ vendorId?: number;
+ search?: string;
+ filters?: Filter<typeof techSalesVendorQuotations>[];
+ sort?: { id: string; desc: boolean }[];
+ page: number;
+ perPage: number;
+}) {
+ return getTechSalesVendorQuotationsWithJoin({ ...input, rfqType: "TOP" });
+}
+
+/**
+ * 해양 HULL 벤더 견적서 전용 조회 함수
+ */
+export async function getTechSalesHullVendorQuotationsWithJoin(input: {
+ rfqId?: number;
+ vendorId?: number;
+ search?: string;
+ filters?: Filter<typeof techSalesVendorQuotations>[];
+ sort?: { id: string; desc: boolean }[];
+ page: number;
+ perPage: number;
+}) {
+ return getTechSalesVendorQuotationsWithJoin({ ...input, rfqType: "HULL" });
+}
+
+/**
+ * 기술영업 RFQ의 아이템 목록 조회
+ */
+export async function getTechSalesRfqItems(rfqId: number) {
+ unstable_noStore();
+ try {
+ const items = await db.query.techSalesRfqItems.findMany({
+ where: eq(techSalesRfqItems.rfqId, rfqId),
+ with: {
+ itemShipbuilding: {
+ columns: {
+ id: true,
+ itemCode: true,
+ itemList: true,
+ workType: true,
+ shipTypes: true,
+ }
+ },
+ itemOffshoreTop: {
+ columns: {
+ id: true,
+ itemCode: true,
+ itemList: true,
+ workType: true,
+ subItemList: true,
+ }
+ },
+ itemOffshoreHull: {
+ columns: {
+ id: true,
+ itemCode: true,
+ itemList: true,
+ workType: true,
+ subItemList: true,
+ }
+ }
+ },
+ orderBy: [techSalesRfqItems.id]
+ });
+
+ // 아이템 타입에 따라 정보 매핑
+ const mappedItems = items.map(item => {
+ let itemInfo = null;
+
+ switch (item.itemType) {
+ case 'SHIP':
+ itemInfo = item.itemShipbuilding;
+ break;
+ case 'TOP':
+ itemInfo = item.itemOffshoreTop;
+ break;
+ case 'HULL':
+ itemInfo = item.itemOffshoreHull;
+ break;
+ }
+
+ return {
+ id: item.id,
+ rfqId: item.rfqId,
+ itemType: item.itemType,
+ itemCode: itemInfo?.itemCode || '',
+ itemList: itemInfo?.itemList || '',
+ workType: itemInfo?.workType || '',
+ // 조선이면 shipType, 해양이면 subItemList
+ shipTypes: item.itemType === 'SHIP' ? (itemInfo as { shipTypes?: string })?.shipTypes || '' : undefined,
+ subItemList: item.itemType !== 'SHIP' ? (itemInfo as { subItemList?: string })?.subItemList || '' : undefined,
+ };
+ });
+
+ return { data: mappedItems, error: null };
+ } catch (err) {
+ console.error("Error fetching RFQ items:", err);
+ return { data: [], error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * RFQ 아이템들과 매칭되는 후보 벤더들을 찾는 함수
+ */
+export async function getTechSalesRfqCandidateVendors(rfqId: number) {
+ unstable_noStore();
+
+ try {
+ return await db.transaction(async (tx) => {
+ // 1. RFQ 정보 조회 (타입 확인)
+ const rfq = await tx.query.techSalesRfqs.findFirst({
+ where: eq(techSalesRfqs.id, rfqId),
+ columns: {
+ id: true,
+ rfqType: true
+ }
+ });
+
+ if (!rfq) {
+ return { data: [], error: "RFQ를 찾을 수 없습니다." };
+ }
+
+ // 2. RFQ 아이템들 조회
+ const rfqItems = await tx.query.techSalesRfqItems.findMany({
+ where: eq(techSalesRfqItems.rfqId, rfqId),
+ with: {
+ itemShipbuilding: true,
+ itemOffshoreTop: true,
+ itemOffshoreHull: true,
+ }
+ });
+
+ if (rfqItems.length === 0) {
+ return { data: [], error: null };
+ }
+
+ // 3. 아이템 코드들 추출
+ const itemCodes: string[] = [];
+ rfqItems.forEach(item => {
+ if (item.itemType === "SHIP" && item.itemShipbuilding?.itemCode) {
+ itemCodes.push(item.itemShipbuilding.itemCode);
+ } else if (item.itemType === "TOP" && item.itemOffshoreTop?.itemCode) {
+ itemCodes.push(item.itemOffshoreTop.itemCode);
+ } else if (item.itemType === "HULL" && item.itemOffshoreHull?.itemCode) {
+ itemCodes.push(item.itemOffshoreHull.itemCode);
+ }
+ });
+
+ if (itemCodes.length === 0) {
+ return { data: [], error: null };
+ }
+
+ // 4. RFQ 타입에 따른 벤더 타입 매핑
+ const vendorTypeFilter = rfq.rfqType === "SHIP" ? "SHIP" :
+ rfq.rfqType === "TOP" ? "OFFSHORE_TOP" :
+ rfq.rfqType === "HULL" ? "OFFSHORE_HULL" : null;
+
+ if (!vendorTypeFilter) {
+ return { data: [], error: "지원되지 않는 RFQ 타입입니다." };
+ }
+
+ // 5. 매칭되는 벤더들 조회 (타입 필터링 포함)
+ const candidateVendors = await tx
+ .select({
+ id: techVendors.id, // 벤더 ID를 id로 명명하여 key 문제 해결
+ vendorId: techVendors.id, // 호환성을 위해 유지
+ vendorName: techVendors.vendorName,
+ vendorCode: techVendors.vendorCode,
+ country: techVendors.country,
+ email: techVendors.email,
+ phone: techVendors.phone,
+ status: techVendors.status,
+ techVendorType: techVendors.techVendorType,
+ matchedItemCodes: sql<string[]>`
+ array_agg(DISTINCT ${techVendorPossibleItems.itemCode})
+ `,
+ matchedItemCount: sql<number>`
+ count(DISTINCT ${techVendorPossibleItems.itemCode})
+ `,
+ })
+ .from(techVendorPossibleItems)
+ .innerJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id))
+ .where(
+ and(
+ inArray(techVendorPossibleItems.itemCode, itemCodes),
+ or(
+ eq(techVendors.status, "ACTIVE"),
+ eq(techVendors.status, "QUOTE_COMPARISON") // 견적비교용 벤더도 RFQ 초대 가능
+ )
+ // 벤더 타입 필터링 임시 제거 - 데이터 확인 후 다시 추가
+ // eq(techVendors.techVendorType, vendorTypeFilter)
+ )
+ )
+ .groupBy(
+ techVendorPossibleItems.vendorId,
+ techVendors.id,
+ techVendors.vendorName,
+ techVendors.vendorCode,
+ techVendors.country,
+ techVendors.email,
+ techVendors.phone,
+ techVendors.status,
+ techVendors.techVendorType
+ )
+ .orderBy(desc(sql`count(DISTINCT ${techVendorPossibleItems.itemCode})`));
+
+ return { data: candidateVendors, error: null };
+ });
+ } catch (err) {
+ console.error("Error fetching candidate vendors:", err);
+ return { data: [], error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * RFQ 타입에 따른 캐시 무효화 경로 반환
+ */
+function getTechSalesRevalidationPath(rfqType: "SHIP" | "TOP" | "HULL"): string {
+ switch (rfqType) {
+ case "SHIP":
+ return "/evcp/budgetary-tech-sales-ship";
+ case "TOP":
+ return "/evcp/budgetary-tech-sales-top";
+ case "HULL":
+ return "/evcp/budgetary-tech-sales-hull";
+ default:
+ return "/evcp/budgetary-tech-sales-ship";
+ }
+}
+
+/**
+ * 기술영업 RFQ에 여러 벤더 추가 (techVendors 기반)
+ * 벤더 추가 시에는 견적서를 생성하지 않고, RFQ 전송 시에 견적서를 생성
+ */
+export async function addTechVendorsToTechSalesRfq(input: {
+ rfqId: number;
+ vendorIds: number[];
+ createdBy: number;
+}) {
+ unstable_noStore();
+
+ try {
+ return await db.transaction(async (tx) => {
+ const results = [];
+ const errors: string[] = [];
+
+ // 1. RFQ 상태 및 타입 확인
+ const rfq = await tx.query.techSalesRfqs.findFirst({
+ where: eq(techSalesRfqs.id, input.rfqId),
+ columns: {
+ id: true,
+ status: true,
+ rfqType: true,
+ }
+ });
+
+ if (!rfq) {
+ throw new Error("RFQ를 찾을 수 없습니다");
+ }
+
+ // 2. 각 벤더에 대해 처리 (이미 추가된 벤더는 견적서가 있는지 확인)
+ for (const vendorId of input.vendorIds) {
+ try {
+ // 이미 추가된 벤더인지 확인 (견적서 존재 여부로 확인)
+ const existingQuotation = await tx.query.techSalesVendorQuotations.findFirst({
+ where: and(
+ eq(techSalesVendorQuotations.rfqId, input.rfqId),
+ eq(techSalesVendorQuotations.vendorId, vendorId)
+ )
+ });
+
+ if (existingQuotation) {
+ errors.push(`벤더 ID ${vendorId}는 이미 추가되어 있습니다.`);
+ continue;
+ }
+
+ // 벤더가 실제로 존재하는지 확인
+ const vendor = await tx.query.techVendors.findFirst({
+ where: eq(techVendors.id, vendorId),
+ columns: { id: true, vendorName: true }
+ });
+
+ if (!vendor) {
+ errors.push(`벤더 ID ${vendorId}를 찾을 수 없습니다.`);
+ continue;
+ }
+
+ // 🔥 중요: 벤더 추가 시에는 견적서를 생성하지 않고, "Assigned" 상태로만 생성
+ // quotation_version은 null로 설정하여 벤더가 실제 견적 제출 시에만 리비전 생성
+ const [quotation] = await tx
+ .insert(techSalesVendorQuotations)
+ .values({
+ rfqId: input.rfqId,
+ vendorId: vendorId,
+ status: "Assigned", // Draft가 아닌 Assigned 상태로 생성
+ quotationVersion: null, // 리비전은 견적 제출 시에만 생성
+ createdBy: input.createdBy,
+ updatedBy: input.createdBy,
+ })
+ .returning({ id: techSalesVendorQuotations.id });
+
+ // 🆕 RFQ의 아이템 코드들을 tech_vendor_possible_items에 추가
+ try {
+ // RFQ의 아이템들 조회
+ const rfqItemsResult = await getTechSalesRfqItems(input.rfqId);
+
+ if (rfqItemsResult.data && rfqItemsResult.data.length > 0) {
+ for (const item of rfqItemsResult.data) {
+ const {
+ itemCode,
+ itemList,
+ workType, // 공종
+ shipTypes, // 선종 (배열일 수 있음)
+ subItemList // 서브아이템리스트 (있을 수도 있음)
+ } = item;
+
+ // 동적 where 조건 생성: 값이 있으면 비교, 없으면 비교하지 않음
+ const whereConds = [
+ eq(techVendorPossibleItems.vendorId, vendorId),
+ itemCode ? eq(techVendorPossibleItems.itemCode, itemCode) : undefined,
+ itemList ? eq(techVendorPossibleItems.itemList, itemList) : undefined,
+ workType ? eq(techVendorPossibleItems.workType, workType) : undefined,
+ shipTypes ? eq(techVendorPossibleItems.shipTypes, shipTypes) : undefined,
+ subItemList ? eq(techVendorPossibleItems.subItemList, subItemList) : undefined,
+ ].filter(Boolean);
+
+ const existing = await tx.query.techVendorPossibleItems.findFirst({
+ where: and(...whereConds)
+ });
+
+ if (!existing) {
+ await tx.insert(techVendorPossibleItems).values({
+ vendorId : vendorId,
+ itemCode: itemCode ?? null,
+ itemList: itemList ?? null,
+ workType: workType ?? null,
+ shipTypes: shipTypes ?? null,
+ subItemList: subItemList ?? null,
+ });
+ }
+ }
+ }
+ } catch (possibleItemError) {
+ // tech_vendor_possible_items 추가 실패는 전체 실패로 처리하지 않음
+ console.warn(`벤더 ${vendorId}의 가능 아이템 추가 실패:`, possibleItemError);
+ }
+
+ results.push({ id: quotation.id, vendorId, vendorName: vendor.vendorName });
+ } catch (vendorError) {
+ console.error(`Error adding vendor ${vendorId}:`, vendorError);
+ errors.push(`벤더 ID ${vendorId} 추가 중 오류가 발생했습니다.`);
+ }
+ }
+
+ // 3. RFQ 상태가 "RFQ Created"이고 성공적으로 추가된 벤더가 있는 경우 상태 업데이트
+ if (rfq.status === "RFQ Created" && results.length > 0) {
+ await tx.update(techSalesRfqs)
+ .set({
+ status: "RFQ Vendor Assignned",
+ updatedBy: input.createdBy,
+ updatedAt: new Date()
+ })
+ .where(eq(techSalesRfqs.id, input.rfqId));
+ }
+
+ // 캐시 무효화 (RFQ 타입에 따른 동적 경로)
+ revalidateTag("techSalesRfqs");
+ revalidateTag("techSalesVendorQuotations");
+ revalidateTag(`techSalesRfq-${input.rfqId}`);
+ revalidatePath(getTechSalesRevalidationPath(rfq.rfqType || "SHIP"));
+
+ return {
+ data: results,
+ error: errors.length > 0 ? errors.join(", ") : null,
+ successCount: results.length,
+ errorCount: errors.length
+ };
+ });
+ } catch (err) {
+ console.error("Error adding tech vendors to RFQ:", err);
+ return { data: [], error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * 기술영업 RFQ의 벤더 목록 조회 (techVendors 기반)
+ */
+export async function getTechSalesRfqTechVendors(rfqId: number) {
+ unstable_noStore();
+
+ try {
+ return await db.transaction(async (tx) => {
+ const vendors = await tx
+ .select({
+ id: techSalesVendorQuotations.id,
+ vendorId: techVendors.id,
+ vendorName: techVendors.vendorName,
+ vendorCode: techVendors.vendorCode,
+ country: techVendors.country,
+ email: techVendors.email,
+ phone: techVendors.phone,
+ status: techSalesVendorQuotations.status,
+ totalPrice: techSalesVendorQuotations.totalPrice,
+ currency: techSalesVendorQuotations.currency,
+ validUntil: techSalesVendorQuotations.validUntil,
+ submittedAt: techSalesVendorQuotations.submittedAt,
+ createdAt: techSalesVendorQuotations.createdAt,
+ })
+ .from(techSalesVendorQuotations)
+ .innerJoin(techVendors, eq(techSalesVendorQuotations.vendorId, techVendors.id))
+ .where(eq(techSalesVendorQuotations.rfqId, rfqId))
+ .orderBy(desc(techSalesVendorQuotations.createdAt));
+
+ return { data: vendors, error: null };
+ });
+ } catch (err) {
+ console.error("Error fetching RFQ tech vendors:", err);
+ return { data: [], error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * 기술영업 RFQ에서 기술영업 벤더 제거 (techVendors 기반)
+ */
+export async function removeTechVendorFromTechSalesRfq(input: {
+ rfqId: number;
+ vendorId: number;
+}) {
+ unstable_noStore();
+
+ try {
+ return await db.transaction(async (tx) => {
+ // 해당 벤더의 견적서 상태 확인
+ const existingQuotation = await tx.query.techSalesVendorQuotations.findFirst({
+ where: and(
+ eq(techSalesVendorQuotations.rfqId, input.rfqId),
+ eq(techSalesVendorQuotations.vendorId, input.vendorId)
+ )
+ });
+
+ if (!existingQuotation) {
+ return { data: null, error: "해당 벤더가 이 RFQ에 존재하지 않습니다." };
+ }
+
+ // Assigned 상태가 아닌 경우 삭제 불가
+ if (existingQuotation.status !== "Assigned") {
+ return { data: null, error: "Assigned 상태의 벤더만 삭제할 수 있습니다." };
+ }
+
+ // 해당 벤더의 견적서 삭제
+ const [deletedQuotation] = await tx
+ .delete(techSalesVendorQuotations)
+ .where(
+ and(
+ eq(techSalesVendorQuotations.rfqId, input.rfqId),
+ eq(techSalesVendorQuotations.vendorId, input.vendorId)
+ )
+ )
+ .returning({ id: techSalesVendorQuotations.id });
+
+ // 캐시 무효화
+ revalidateTag("techSalesRfqs");
+ revalidateTag("techSalesVendorQuotations");
+
+ return { data: deletedQuotation, error: null };
+ });
+ } catch (err) {
+ console.error("Error removing tech vendor from RFQ:", err);
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * 기술영업 RFQ에서 여러 기술영업 벤더 제거 (techVendors 기반)
+ */
+export async function removeTechVendorsFromTechSalesRfq(input: {
+ rfqId: number;
+ vendorIds: number[];
+}) {
+ unstable_noStore();
+
+ try {
+ return await db.transaction(async (tx) => {
+ const results = [];
+ const errors: string[] = [];
+
+ for (const vendorId of input.vendorIds) {
+ // 해당 벤더의 견적서 상태 확인
+ const existingQuotation = await tx.query.techSalesVendorQuotations.findFirst({
+ where: and(
+ eq(techSalesVendorQuotations.rfqId, input.rfqId),
+ eq(techSalesVendorQuotations.vendorId, vendorId)
+ )
+ });
+
+ if (!existingQuotation) {
+ errors.push(`벤더 ID ${vendorId}가 이 RFQ에 존재하지 않습니다.`);
+ continue;
+ }
+
+ // Assigned 상태가 아닌 경우 삭제 불가
+ if (existingQuotation.status !== "Assigned") {
+ errors.push(`벤더 ID ${vendorId}는 Assigned 상태가 아니므로 삭제할 수 없습니다.`);
+ continue;
+ }
+
+ // 해당 벤더의 견적서 삭제
+ const [deletedQuotation] = await tx
+ .delete(techSalesVendorQuotations)
+ .where(
+ and(
+ eq(techSalesVendorQuotations.rfqId, input.rfqId),
+ eq(techSalesVendorQuotations.vendorId, vendorId)
+ )
+ )
+ .returning({ id: techSalesVendorQuotations.id });
+
+ results.push(deletedQuotation);
+ }
+
+ // 캐시 무효화
+ revalidateTag("techSalesRfqs");
+ revalidateTag("techSalesVendorQuotations");
+
+ return {
+ data: results,
+ error: errors.length > 0 ? errors.join(", ") : null,
+ successCount: results.length,
+ errorCount: errors.length
+ };
+ });
+ } catch (err) {
+ console.error("Error removing tech vendors from RFQ:", err);
+ return { data: [], error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * 기술영업 벤더 검색
+ */
+export async function searchTechVendors(searchTerm: string, limit = 100, rfqType?: "SHIP" | "TOP" | "HULL") {
+ unstable_noStore();
+
+ try {
+ // RFQ 타입에 따른 벤더 타입 매핑
+ const vendorTypeFilter = rfqType === "SHIP" ? "조선" :
+ rfqType === "TOP" ? "해양TOP" :
+ rfqType === "HULL" ? "해양HULL" : null;
+
+ const whereConditions = [
+ or(
+ eq(techVendors.status, "ACTIVE"),
+ eq(techVendors.status, "QUOTE_COMPARISON")
+ ),
+ or(
+ ilike(techVendors.vendorName, `%${searchTerm}%`),
+ ilike(techVendors.vendorCode, `%${searchTerm}%`)
+ )
+ ];
+
+ // RFQ 타입이 지정된 경우 벤더 타입 필터링 추가 (컴마 구분 문자열에서 검색)
+ if (vendorTypeFilter) {
+ whereConditions.push(sql`${techVendors.techVendorType} LIKE ${'%' + vendorTypeFilter + '%'}`);
+ }
+
+ const results = await db
+ .select({
+ id: techVendors.id,
+ vendorName: techVendors.vendorName,
+ vendorCode: techVendors.vendorCode,
+ status: techVendors.status,
+ country: techVendors.country,
+ techVendorType: techVendors.techVendorType,
+ })
+ .from(techVendors)
+ .where(and(...whereConditions))
+ .limit(limit)
+ .orderBy(techVendors.vendorName);
+
+ return results;
+ } catch (err) {
+ console.error("Error searching tech vendors:", err);
+ throw new Error(getErrorMessage(err));
+ }
+}
+
+
+/**
+ * 벤더 견적서 거절 처리 (벤더가 직접 거절)
+ */
+export async function rejectTechSalesVendorQuotations(input: {
+ quotationIds: number[];
+ rejectionReason?: string;
+}) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ throw new Error("인증이 필요합니다.");
+ }
+
+ const result = await db.transaction(async (tx) => {
+ // 견적서들이 존재하고 벤더가 권한이 있는지 확인
+ const quotations = await tx
+ .select({
+ id: techSalesVendorQuotations.id,
+ status: techSalesVendorQuotations.status,
+ vendorId: techSalesVendorQuotations.vendorId,
+ })
+ .from(techSalesVendorQuotations)
+ .where(inArray(techSalesVendorQuotations.id, input.quotationIds));
+
+ if (quotations.length !== input.quotationIds.length) {
+ throw new Error("일부 견적서를 찾을 수 없습니다.");
+ }
+
+ // 이미 거절된 견적서가 있는지 확인
+ const alreadyRejected = quotations.filter(q => q.status === "Rejected");
+ if (alreadyRejected.length > 0) {
+ throw new Error("이미 거절된 견적서가 포함되어 있습니다.");
+ }
+
+ // 승인된 견적서가 있는지 확인
+ const alreadyAccepted = quotations.filter(q => q.status === "Accepted");
+ if (alreadyAccepted.length > 0) {
+ throw new Error("이미 승인된 견적서는 거절할 수 없습니다.");
+ }
+
+ // 견적서 상태를 거절로 변경
+ await tx
+ .update(techSalesVendorQuotations)
+ .set({
+ status: "Rejected",
+ rejectionReason: input.rejectionReason || null,
+ updatedBy: parseInt(session.user.id),
+ updatedAt: new Date(),
+ })
+ .where(inArray(techSalesVendorQuotations.id, input.quotationIds));
+
+ return { success: true, updatedCount: quotations.length };
+ });
+ revalidateTag("techSalesRfqs");
+ revalidateTag("techSalesVendorQuotations");
+ revalidatePath("/partners/techsales/rfq-ship", "page");
+ return {
+ success: true,
+ message: `${result.updatedCount}개의 견적서가 거절되었습니다.`,
+ data: result
+ };
+ } catch (error) {
+ console.error("견적서 거절 오류:", error);
+ return {
+ success: false,
+ error: getErrorMessage(error)
+ };
+ }
+}
+
+// ==================== Revision 관련 ====================
+
+/**
+ * 견적서 revision 히스토리 조회
+ */
+export async function getTechSalesVendorQuotationRevisions(quotationId: number) {
+ try {
+ const revisions = await db
+ .select({
+ id: techSalesVendorQuotationRevisions.id,
+ version: techSalesVendorQuotationRevisions.version,
+ snapshot: techSalesVendorQuotationRevisions.snapshot,
+ changeReason: techSalesVendorQuotationRevisions.changeReason,
+ revisionNote: techSalesVendorQuotationRevisions.revisionNote,
+ revisedBy: techSalesVendorQuotationRevisions.revisedBy,
+ revisedAt: techSalesVendorQuotationRevisions.revisedAt,
+ // 수정자 정보 조인
+ revisedByName: users.name,
+ })
+ .from(techSalesVendorQuotationRevisions)
+ .leftJoin(users, eq(techSalesVendorQuotationRevisions.revisedBy, users.id))
+ .where(eq(techSalesVendorQuotationRevisions.quotationId, quotationId))
+ .orderBy(desc(techSalesVendorQuotationRevisions.version));
+
+ return { data: revisions, error: null };
+ } catch (error) {
+ console.error("견적서 revision 히스토리 조회 오류:", error);
+ return { data: null, error: "견적서 히스토리를 조회하는 중 오류가 발생했습니다." };
+ }
+}
+
+/**
+ * 견적서의 현재 버전과 revision 히스토리를 함께 조회 (각 리비전의 첨부파일 포함)
+ */
+export async function getTechSalesVendorQuotationWithRevisions(quotationId: number) {
+ try {
+ // 먼저 현재 견적서 조회
+ const currentQuotation = await db.query.techSalesVendorQuotations.findFirst({
+ where: eq(techSalesVendorQuotations.id, quotationId),
+ with: {
+ // 벤더 정보와 RFQ 정보도 함께 조회 (필요한 경우)
+ }
+ });
+
+ if (!currentQuotation) {
+ return { data: null, error: "견적서를 찾을 수 없습니다." };
+ }
+
+ // 이제 현재 견적서의 정보를 알고 있으므로 병렬로 나머지 정보 조회
+ const [revisionsResult, currentAttachments] = await Promise.all([
+ getTechSalesVendorQuotationRevisions(quotationId),
+ getTechSalesVendorQuotationAttachmentsByRevision(quotationId, currentQuotation.quotationVersion || 0)
+ ]);
+
+ // 현재 견적서에 첨부파일 정보 추가
+ const currentWithAttachments = {
+ ...currentQuotation,
+ attachments: currentAttachments.data || []
+ };
+
+ // 각 리비전의 첨부파일 정보 추가
+ const revisionsWithAttachments = await Promise.all(
+ (revisionsResult.data || []).map(async (revision) => {
+ const attachmentsResult = await getTechSalesVendorQuotationAttachmentsByRevision(quotationId, revision.version);
+ return {
+ ...revision,
+ attachments: attachmentsResult.data || []
+ };
+ })
+ );
+
+ return {
+ data: {
+ current: currentWithAttachments,
+ revisions: revisionsWithAttachments
+ },
+ error: null
+ };
+ } catch (error) {
+ console.error("견적서 전체 히스토리 조회 오류:", error);
+ return { data: null, error: "견적서 정보를 조회하는 중 오류가 발생했습니다." };
+ }
+}
+
+/**
+ * 견적서 첨부파일 조회 (리비전 ID 기준 오름차순 정렬)
+ */
+export async function getTechSalesVendorQuotationAttachments(quotationId: number) {
+ return unstable_cache(
+ async () => {
+ try {
+ const attachments = await db
+ .select({
+ id: techSalesVendorQuotationAttachments.id,
+ quotationId: techSalesVendorQuotationAttachments.quotationId,
+ revisionId: techSalesVendorQuotationAttachments.revisionId,
+ fileName: techSalesVendorQuotationAttachments.fileName,
+ originalFileName: techSalesVendorQuotationAttachments.originalFileName,
+ fileSize: techSalesVendorQuotationAttachments.fileSize,
+ fileType: techSalesVendorQuotationAttachments.fileType,
+ filePath: techSalesVendorQuotationAttachments.filePath,
+ description: techSalesVendorQuotationAttachments.description,
+ uploadedBy: techSalesVendorQuotationAttachments.uploadedBy,
+ vendorId: techSalesVendorQuotationAttachments.vendorId,
+ isVendorUpload: techSalesVendorQuotationAttachments.isVendorUpload,
+ createdAt: techSalesVendorQuotationAttachments.createdAt,
+ updatedAt: techSalesVendorQuotationAttachments.updatedAt,
+ })
+ .from(techSalesVendorQuotationAttachments)
+ .where(eq(techSalesVendorQuotationAttachments.quotationId, quotationId))
+ .orderBy(desc(techSalesVendorQuotationAttachments.createdAt));
+
+ return { data: attachments };
+ } catch (error) {
+ console.error("견적서 첨부파일 조회 오류:", error);
+ return { error: "견적서 첨부파일 조회 중 오류가 발생했습니다." };
+ }
+ },
+ [`quotation-attachments-${quotationId}`],
+ {
+ revalidate: 60,
+ tags: [`quotation-${quotationId}`, "quotation-attachments"],
+ }
+ )();
+}
+
+/**
+ * 특정 리비전의 견적서 첨부파일 조회
+ */
+export async function getTechSalesVendorQuotationAttachmentsByRevision(quotationId: number, revisionId: number) {
+ try {
+ const attachments = await db
+ .select({
+ id: techSalesVendorQuotationAttachments.id,
+ quotationId: techSalesVendorQuotationAttachments.quotationId,
+ revisionId: techSalesVendorQuotationAttachments.revisionId,
+ fileName: techSalesVendorQuotationAttachments.fileName,
+ originalFileName: techSalesVendorQuotationAttachments.originalFileName,
+ fileSize: techSalesVendorQuotationAttachments.fileSize,
+ fileType: techSalesVendorQuotationAttachments.fileType,
+ filePath: techSalesVendorQuotationAttachments.filePath,
+ description: techSalesVendorQuotationAttachments.description,
+ uploadedBy: techSalesVendorQuotationAttachments.uploadedBy,
+ vendorId: techSalesVendorQuotationAttachments.vendorId,
+ isVendorUpload: techSalesVendorQuotationAttachments.isVendorUpload,
+ createdAt: techSalesVendorQuotationAttachments.createdAt,
+ updatedAt: techSalesVendorQuotationAttachments.updatedAt,
+ })
+ .from(techSalesVendorQuotationAttachments)
+ .where(and(
+ eq(techSalesVendorQuotationAttachments.quotationId, quotationId),
+ eq(techSalesVendorQuotationAttachments.revisionId, revisionId)
+ ))
+ .orderBy(desc(techSalesVendorQuotationAttachments.createdAt));
+
+ return { data: attachments };
+ } catch (error) {
+ console.error("리비전별 견적서 첨부파일 조회 오류:", error);
+ return { error: "첨부파일 조회 중 오류가 발생했습니다." };
+ }
+}
+
+
+// ==================== Project AVL 관련 ====================
+
+/**
+ * Accepted 상태의 Tech Sales Vendor Quotations 조회 (RFQ, Vendor 정보 포함)
+ */
+export async function getAcceptedTechSalesVendorQuotations(input: {
+ search?: string;
+ filters?: Filter<typeof techSalesVendorQuotations>[];
+ sort?: { id: string; desc: boolean }[];
+ page: number;
+ perPage: number;
+ rfqType?: "SHIP" | "TOP" | "HULL";
+}) {
+ unstable_noStore();
+
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // 기본 WHERE 조건: status = 'Accepted'만 조회, rfqType이 'SHIP'이 아닌 것만
+ const baseConditions = [
+ eq(techSalesVendorQuotations.status, 'Accepted'),
+ sql`${techSalesRfqs.rfqType} != 'SHIP'` // 조선 RFQ 타입 제외
+ ];
+
+ // 검색 조건 추가
+ const searchConditions = [];
+ if (input.search) {
+ searchConditions.push(
+ ilike(techSalesRfqs.rfqCode, `%${input.search}%`),
+ ilike(techSalesRfqs.description, `%${input.search}%`),
+ ilike(sql`vendors.vendor_name`, `%${input.search}%`),
+ ilike(sql`vendors.vendor_code`, `%${input.search}%`)
+ );
+ }
+
+ // 정렬 조건 변환
+ const orderByConditions: OrderByType[] = [];
+ if (input.sort?.length) {
+ input.sort.forEach((sortItem) => {
+ switch (sortItem.id) {
+ case "rfqCode":
+ orderByConditions.push(sortItem.desc ? desc(techSalesRfqs.rfqCode) : asc(techSalesRfqs.rfqCode));
+ break;
+ case "description":
+ orderByConditions.push(sortItem.desc ? desc(techSalesRfqs.description) : asc(techSalesRfqs.description));
+ break;
+ case "vendorName":
+ orderByConditions.push(sortItem.desc ? desc(sql`vendors.vendor_name`) : asc(sql`vendors.vendor_name`));
+ break;
+ case "vendorCode":
+ orderByConditions.push(sortItem.desc ? desc(sql`vendors.vendor_code`) : asc(sql`vendors.vendor_code`));
+ break;
+ case "totalPrice":
+ orderByConditions.push(sortItem.desc ? desc(techSalesVendorQuotations.totalPrice) : asc(techSalesVendorQuotations.totalPrice));
+ break;
+ case "acceptedAt":
+ orderByConditions.push(sortItem.desc ? desc(techSalesVendorQuotations.acceptedAt) : asc(techSalesVendorQuotations.acceptedAt));
+ break;
+ default:
+ orderByConditions.push(desc(techSalesVendorQuotations.acceptedAt));
+ }
+ });
+ } else {
+ orderByConditions.push(desc(techSalesVendorQuotations.acceptedAt));
+ }
+
+ // 필터 조건 추가
+ const filterConditions = [];
+ if (input.filters?.length) {
+ const filterWhere = filterColumns({
+ table: techSalesVendorQuotations,
+ filters: input.filters,
+ joinOperator: "and",
+ });
+ if (filterWhere) {
+ filterConditions.push(filterWhere);
+ }
+ }
+
+ // RFQ 타입 필터
+ if (input.rfqType) {
+ filterConditions.push(eq(techSalesRfqs.rfqType, input.rfqType));
+ }
+
+ // 모든 조건 결합
+ const allConditions = [
+ ...baseConditions,
+ ...filterConditions,
+ ...(searchConditions.length > 0 ? [or(...searchConditions)] : [])
+ ];
+
+ const whereCondition = allConditions.length > 1
+ ? and(...allConditions)
+ : allConditions[0];
+
+ // 데이터 조회
+ const data = await db
+ .select({
+ // Quotation 정보
+ id: techSalesVendorQuotations.id,
+ rfqId: techSalesVendorQuotations.rfqId,
+ vendorId: techSalesVendorQuotations.vendorId,
+ quotationCode: techSalesVendorQuotations.quotationCode,
+ quotationVersion: techSalesVendorQuotations.quotationVersion,
+ totalPrice: techSalesVendorQuotations.totalPrice,
+ currency: techSalesVendorQuotations.currency,
+ validUntil: techSalesVendorQuotations.validUntil,
+ status: techSalesVendorQuotations.status,
+ remark: techSalesVendorQuotations.remark,
+ submittedAt: techSalesVendorQuotations.submittedAt,
+ acceptedAt: techSalesVendorQuotations.acceptedAt,
+ createdAt: techSalesVendorQuotations.createdAt,
+ updatedAt: techSalesVendorQuotations.updatedAt,
+
+ // RFQ 정보
+ rfqCode: techSalesRfqs.rfqCode,
+ rfqType: techSalesRfqs.rfqType,
+ description: techSalesRfqs.description,
+ dueDate: techSalesRfqs.dueDate,
+ rfqStatus: techSalesRfqs.status,
+ materialCode: techSalesRfqs.materialCode,
+
+ // Vendor 정보
+ vendorName: sql<string>`vendors.vendor_name`,
+ vendorCode: sql<string | null>`vendors.vendor_code`,
+ vendorEmail: sql<string | null>`vendors.email`,
+ vendorCountry: sql<string | null>`vendors.country`,
+
+ // Project 정보
+ projNm: biddingProjects.projNm,
+ pspid: biddingProjects.pspid,
+ sector: biddingProjects.sector,
+ })
+ .from(techSalesVendorQuotations)
+ .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id))
+ .leftJoin(sql`vendors`, eq(techSalesVendorQuotations.vendorId, sql`vendors.id`))
+ .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id))
+ .where(whereCondition)
+ .orderBy(...orderByConditions)
+ .limit(input.perPage)
+ .offset(offset);
+
+ // 총 개수 조회
+ const totalCount = await db
+ .select({ count: count() })
+ .from(techSalesVendorQuotations)
+ .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id))
+ .leftJoin(sql`vendors`, eq(techSalesVendorQuotations.vendorId, sql`vendors.id`))
+ .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id))
+ .where(whereCondition);
+
+ const total = totalCount[0]?.count ?? 0;
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return {
+ data,
+ pageCount,
+ total,
+ };
+
+ } catch (error) {
+ console.error("getAcceptedTechSalesVendorQuotations 오류:", error);
+ throw new Error(`Accepted quotations 조회 실패: ${getErrorMessage(error)}`);
+ }
+}
+
+export async function getBidProjects(pjtType: 'SHIP' | 'TOP' | 'HULL'): Promise<Project[]> {
+ try {
+ // 트랜잭션을 사용하여 프로젝트 데이터 조회
+ const projectList = await db.transaction(async (tx) => {
+ // 기본 쿼리 구성
+ const query = tx
+ .select({
+ id: biddingProjects.id,
+ projectCode: biddingProjects.pspid,
+ projectName: biddingProjects.projNm,
+ pjtType: biddingProjects.pjtType,
+ })
+ .from(biddingProjects)
+ .where(eq(biddingProjects.pjtType, pjtType));
+
+ const results = await query.orderBy(biddingProjects.id);
+ return results;
+ });
+
+ // Handle null projectName values and ensure pjtType is not null
+ const validProjectList = projectList.map(project => ({
+ ...project,
+ projectName: project.projectName || '', // Replace null with empty string
+ pjtType: project.pjtType as "SHIP" | "TOP" | "HULL" // Type assertion since WHERE filters ensure non-null
+ }));
+
+ return validProjectList;
+ } catch (error) {
+ console.error("프로젝트 목록 가져오기 실패:", error);
+ return []; // 오류 발생 시 빈 배열 반환
+ }
+}
+
+/**
+ * 여러 벤더의 contact 정보 조회
+ */
+export async function getTechVendorsContacts(vendorIds: number[]) {
+ unstable_noStore();
+ try {
+ // 직접 조인으로 벤더와 contact 정보 조회
+ const contactsWithVendor = await db
+ .select({
+ contactId: techVendorContacts.id,
+ contactName: techVendorContacts.contactName,
+ contactPosition: techVendorContacts.contactPosition,
+ contactEmail: techVendorContacts.contactEmail,
+ contactPhone: techVendorContacts.contactPhone,
+ isPrimary: techVendorContacts.isPrimary,
+ vendorId: techVendorContacts.vendorId,
+ vendorName: techVendors.vendorName,
+ vendorCode: techVendors.vendorCode
+ })
+ .from(techVendorContacts)
+ .leftJoin(techVendors, eq(techVendorContacts.vendorId, techVendors.id))
+ .where(inArray(techVendorContacts.vendorId, vendorIds))
+ .orderBy(
+ asc(techVendorContacts.vendorId),
+ desc(techVendorContacts.isPrimary),
+ asc(techVendorContacts.contactName)
+ );
+
+ // 벤더별로 그룹화
+ const contactsByVendor = contactsWithVendor.reduce((acc, row) => {
+ const vendorId = row.vendorId;
+ if (!acc[vendorId]) {
+ acc[vendorId] = {
+ vendor: {
+ id: vendorId,
+ vendorName: row.vendorName || '',
+ vendorCode: row.vendorCode || ''
+ },
+ contacts: []
+ };
+ }
+ acc[vendorId].contacts.push({
+ id: row.contactId,
+ contactName: row.contactName,
+ contactPosition: row.contactPosition,
+ contactEmail: row.contactEmail,
+ contactPhone: row.contactPhone,
+ isPrimary: row.isPrimary
+ });
+ return acc;
+ }, {} as Record<number, {
+ vendor: {
+ id: number;
+ vendorName: string;
+ vendorCode: string | null;
+ };
+ contacts: Array<{
+ id: number;
+ contactName: string;
+ contactPosition: string | null;
+ contactEmail: string;
+ contactPhone: string | null;
+ isPrimary: boolean;
+ }>;
+ }>);
+
+ return { data: contactsByVendor, error: null };
+ } catch (err) {
+ console.error("벤더 contact 조회 오류:", err);
+ return { data: {}, error: getErrorMessage(err) };
+ }
+}
+
+/**
+ * quotation별 발송된 담당자 정보 조회
+ */
+export async function getQuotationContacts(quotationId: number) {
+ unstable_noStore();
+ try {
+ // quotation에 연결된 담당자들 조회
+ const quotationContacts = await db
+ .select({
+ id: techSalesVendorQuotationContacts.id,
+ contactId: techSalesVendorQuotationContacts.contactId,
+ contactName: techVendorContacts.contactName,
+ contactPosition: techVendorContacts.contactPosition,
+ contactEmail: techVendorContacts.contactEmail,
+ contactPhone: techVendorContacts.contactPhone,
+ contactCountry: techVendorContacts.contactCountry,
+ isPrimary: techVendorContacts.isPrimary,
+ createdAt: techSalesVendorQuotationContacts.createdAt,
+ })
+ .from(techSalesVendorQuotationContacts)
+ .innerJoin(
+ techVendorContacts,
+ eq(techSalesVendorQuotationContacts.contactId, techVendorContacts.id)
+ )
+ .where(eq(techSalesVendorQuotationContacts.quotationId, quotationId))
+ .orderBy(techSalesVendorQuotationContacts.createdAt);
+
+ return {
+ success: true,
+ data: quotationContacts,
+ error: null,
+ };
+ } catch (error) {
+ console.error("Quotation contacts 조회 오류:", error);
+ return {
+ success: false,
+ data: [],
+ error: getErrorMessage(error),
+ };
+ }
+}
+
+/**
+ * 견적서 첨부파일 업로드 (클라이언트용)
+ */
+export async function uploadQuotationAttachments(
+ quotationId: number,
+ files: File[],
+ userId: number
+): Promise<{ success: boolean; attachments?: Array<{ fileName: string; originalFileName: string; filePath: string; fileSize: number }>; error?: string }> {
+ try {
+ const uploadedAttachments = [];
+
+ for (const file of files) {
+ const saveResult = await saveFile({
+ file,
+ directory: `techsales-quotations/${quotationId}`,
+ userId: userId.toString(),
+ });
+
+ if (!saveResult.success) {
+ throw new Error(saveResult.error || '파일 저장에 실패했습니다.');
+ }
+
+ uploadedAttachments.push({
+ fileName: saveResult.fileName!, // 해시된 파일명 (저장용)
+ originalFileName: saveResult.originalName!, // 원본 파일명 (표시용)
+ filePath: saveResult.publicPath!,
+ fileSize: file.size,
+ });
+ }
+
+ return {
+ success: true,
+ attachments: uploadedAttachments
+ };
+ } catch (error) {
+ console.error('견적서 첨부파일 업로드 오류:', error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '파일 업로드 중 오류가 발생했습니다.'
+ };
+ }
} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/README.md b/lib/techsales-rfq/table/README.md
deleted file mode 100644
index 74d0005f..00000000
--- a/lib/techsales-rfq/table/README.md
+++ /dev/null
@@ -1,41 +0,0 @@
-
-# 기술영업 RFQ
-
-1. 마스터 테이블
----컬럼---
-상태
-견적프로젝트 이름
-rfqCode (RFQ-YYYY-001)
-프로젝트 상세보기 액션컬럼 >> 다이얼로그로 해당 프로젝트 정보 보여줌. (SHI/벤더 동일)
-
-- 견적 프로젝트명
-- 척수
-- 선주명
-- 선급코드(선급명)
-- 선종명
-- 선형명
-- 시리즈 상세보기 >> 시리즈별 K/L 연도분기 >> 2026.2Q 형식
-dueDate (마감일)
-sentDate (발송일)
-sentBy (발송자)
-createdBy (생성자)
-updatedBy (수정자)
-createdAt (생성일)
-updatedAt (수정일)
-첨부파일 첨부 테이블
-취소 이유 (삼중이 취소했을 때)
-데이터 없으면 취소하기 버튼으로 보여주기.
-코멘트 액션컬럼
----컬럼---
-
-2. 디테일 테이블
-디테일 테이블에서는 마스터 테이블의 레코드를 선택했을 때 해당 레코드의 상세내역을 보여줌.
-여기서는 벤더별 rfq 송신 및 현황 확인을 응답을 확인할 수 있도록, 발주용 견적과 유사하게 처리
----컬럼---
-벤더명
-상태
-응답 (가격)
-발송일
-발송자
-응답일
-응답자
diff --git a/lib/techsales-rfq/table/create-rfq-hull-dialog.tsx b/lib/techsales-rfq/table/create-rfq-hull-dialog.tsx
index 23c57491..5870c785 100644
--- a/lib/techsales-rfq/table/create-rfq-hull-dialog.tsx
+++ b/lib/techsales-rfq/table/create-rfq-hull-dialog.tsx
@@ -1,648 +1,648 @@
-"use client"
-
-import * as React from "react"
-import { toast } from "sonner"
-import { ArrowUpDown, CheckSquare, Plus, Search, Square, X, Loader2 } from "lucide-react"
-import { Input } from "@/components/ui/input"
-import { Calendar } from "@/components/ui/calendar"
-import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
-import { CalendarIcon } from "lucide-react"
-import { format } from "date-fns"
-import { ko } from "date-fns/locale"
-
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
- FormDescription,
-} from "@/components/ui/form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { useForm } from "react-hook-form"
-import * as z from "zod"
-import { EstimateProjectSelector } from "@/components/BidProjectSelector"
-import { type Project } from "@/lib/rfqs/service"
-import { createTechSalesHullRfq } from "@/lib/techsales-rfq/service"
-import { useSession } from "next-auth/react"
-import { Separator } from "@/components/ui/separator"
-import {
- DropdownMenu,
- DropdownMenuCheckboxItem,
- DropdownMenuContent,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu"
-import { cn } from "@/lib/utils"
-import { ScrollArea } from "@/components/ui/scroll-area"
-// import {
-// Table,
-// TableBody,
-// TableCell,
-// TableHead,
-// TableHeader,
-// TableRow,
-// } from "@/components/ui/table"
-
-// 공종 타입 import
-import {
- getOffshoreHullWorkTypes,
- getAllOffshoreHullItemsForCache,
- type OffshoreHullWorkType,
- type OffshoreHullTechItem,
-} from "@/lib/items-tech/service"
-
-// 해양 HULL 아이템 타입 정의 (이미 service에서 import하므로 제거)
-
-// 유효성 검증 스키마
-const createHullRfqSchema = z.object({
- biddingProjectId: z.number({
- required_error: "프로젝트를 선택해주세요.",
- }),
- itemIds: z.array(z.number()).min(1, {
- message: "적어도 하나의 아이템을 선택해야 합니다.",
- }),
- dueDate: z.date({
- required_error: "마감일을 선택해주세요.",
- }),
- description: z.string().optional(),
-})
-
-// 폼 데이터 타입
-type CreateHullRfqFormValues = z.infer<typeof createHullRfqSchema>
-
-// 공종 타입 정의
-interface WorkTypeOption {
- code: OffshoreHullWorkType
- name: string
-}
-
-interface CreateHullRfqDialogProps {
- onCreated?: () => void;
-}
-
-export function CreateHullRfqDialog({ onCreated }: CreateHullRfqDialogProps) {
- const { data: session } = useSession()
-
- const [isProcessing, setIsProcessing] = React.useState(false)
- const [isDialogOpen, setIsDialogOpen] = React.useState(false)
- const [selectedProject, setSelectedProject] = React.useState<Project | null>(null)
-
- // 검색 및 필터링 상태
- const [itemSearchQuery, setItemSearchQuery] = React.useState("")
- const [selectedWorkType, setSelectedWorkType] = React.useState<OffshoreHullWorkType | null>(null)
- const [selectedItems, setSelectedItems] = React.useState<OffshoreHullTechItem[]>([])
-
- // 데이터 상태
- const [workTypes, setWorkTypes] = React.useState<WorkTypeOption[]>([])
- const [allItems, setAllItems] = React.useState<OffshoreHullTechItem[]>([])
- const [isLoadingItems, setIsLoadingItems] = React.useState(false)
- const [dataLoadError, setDataLoadError] = React.useState<string | null>(null)
- const [retryCount, setRetryCount] = React.useState(0)
-
- // 데이터 로딩 함수
- const loadData = React.useCallback(async (isRetry = false) => {
- try {
- if (!isRetry) {
- setIsLoadingItems(true)
- setDataLoadError(null)
- }
-
- console.log(`해양 Hull RFQ 데이터 로딩 시작... ${isRetry ? `(재시도 ${retryCount + 1}회)` : ''}`)
-
- const [workTypesResult, hullItemsResult] = await Promise.all([
- getOffshoreHullWorkTypes(),
- getAllOffshoreHullItemsForCache()
- ])
-
- console.log("Hull - WorkTypes 결과:", workTypesResult)
- console.log("Hull - Items 결과:", hullItemsResult)
-
- // WorkTypes 설정
- if (Array.isArray(workTypesResult)) {
- setWorkTypes(workTypesResult)
- } else {
- throw new Error("공종 데이터를 불러올 수 없습니다.")
- }
-
- // Hull Items 설정
- if (hullItemsResult.data && Array.isArray(hullItemsResult.data)) {
- setAllItems(hullItemsResult.data as OffshoreHullTechItem[])
- console.log("Hull 아이템 설정 완료:", hullItemsResult.data.length, "개")
- } else {
- throw new Error(hullItemsResult.error || "Hull 아이템 데이터를 불러올 수 없습니다.")
- }
-
- // 성공 시 재시도 카운터 리셋
- setRetryCount(0)
- setDataLoadError(null)
- console.log("해양 Hull RFQ 데이터 로딩 완료")
-
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'
- console.error("해양 Hull RFQ 데이터 로딩 오류:", errorMessage)
-
- setDataLoadError(errorMessage)
-
- // 3회까지 자동 재시도 (500ms 간격)
- if (retryCount < 2) {
- console.log(`${500 * (retryCount + 1)}ms 후 재시도...`)
- setTimeout(() => {
- setRetryCount(prev => prev + 1)
- loadData(true)
- }, 500 * (retryCount + 1))
- } else {
- // 재시도 실패 시 사용자에게 알림
- toast.error(`데이터 로딩에 실패했습니다: ${errorMessage}`)
- }
- } finally {
- if (!isRetry) {
- setIsLoadingItems(false)
- }
- }
- }, [retryCount])
-
- // 다이얼로그가 열릴 때마다 데이터 로딩
- React.useEffect(() => {
- if (isDialogOpen) {
- setDataLoadError(null)
- setRetryCount(0)
- loadData()
- }
- }, [isDialogOpen, loadData])
-
- // 수동 새로고침 함수
- const handleRefreshData = React.useCallback(() => {
- setDataLoadError(null)
- setRetryCount(0)
- loadData()
- }, [loadData])
-
- // RFQ 생성 폼
- const form = useForm<CreateHullRfqFormValues>({
- resolver: zodResolver(createHullRfqSchema),
- defaultValues: {
- biddingProjectId: undefined,
- itemIds: [],
- dueDate: undefined,
- description: "",
- }
- })
-
- // 필터링된 아이템 목록 가져오기
- const availableItems = React.useMemo(() => {
- let filtered = [...allItems]
-
- // 공종 필터
- if (selectedWorkType) {
- filtered = filtered.filter(item => item.workType === selectedWorkType as OffshoreHullTechItem['workType'])
- }
-
- // 검색어 필터
- if (itemSearchQuery && itemSearchQuery.trim()) {
- const query = itemSearchQuery.toLowerCase().trim()
- filtered = filtered.filter(item =>
- item.itemCode.toLowerCase().includes(query) ||
- (item.itemList && item.itemList.toLowerCase().includes(query)) ||
- (item.subItemList && item.subItemList.toLowerCase().includes(query))
- )
- }
-
- return filtered
- }, [allItems, itemSearchQuery, selectedWorkType])
-
- // 프로젝트 선택 처리
- const handleProjectSelect = (project: Project) => {
- setSelectedProject(project)
- form.setValue("biddingProjectId", project.id)
- // 선택 초기화
- setSelectedItems([])
- setSelectedWorkType(null)
- setItemSearchQuery("")
- form.setValue("itemIds", [])
- }
-
- // 아이템 선택/해제 처리
- const handleItemToggle = (item: OffshoreHullTechItem) => {
- const isSelected = selectedItems.some(selected => selected.id === item.id)
-
- if (isSelected) {
- const newSelectedItems = selectedItems.filter(selected => selected.id !== item.id)
- setSelectedItems(newSelectedItems)
- form.setValue("itemIds", newSelectedItems.map(item => item.id))
- } else {
- const newSelectedItems = [...selectedItems, item]
- setSelectedItems(newSelectedItems)
- form.setValue("itemIds", newSelectedItems.map(item => item.id))
- }
- }
-
- // RFQ 생성 함수
- const handleCreateRfq = async (data: CreateHullRfqFormValues) => {
- try {
- setIsProcessing(true)
-
- // 사용자 인증 확인
- if (!session?.user?.id) {
- throw new Error("로그인이 필요합니다")
- }
-
- // 해양 Hull RFQ 생성 - 1:N 관계로 한 번에 생성
- const result = await createTechSalesHullRfq({
- biddingProjectId: data.biddingProjectId,
- itemIds: data.itemIds,
- dueDate: data.dueDate,
- description: data.description,
- createdBy: Number(session.user.id),
- })
-
- if (result.error) {
- throw new Error(result.error)
- }
-
- // 성공적으로 생성되면 다이얼로그 닫기 및 메시지 표시
- toast.success(`${selectedItems.length}개 아이템으로 해양 Hull RFQ가 성공적으로 생성되었습니다`)
-
- setIsDialogOpen(false)
- form.reset({
- biddingProjectId: undefined,
- itemIds: [],
- dueDate: undefined,
- description: "",
- })
- setSelectedProject(null)
- setItemSearchQuery("")
- setSelectedWorkType(null)
- setSelectedItems([])
- setDataLoadError(null)
- setRetryCount(0)
-
- // 생성 후 콜백 실행
- if (onCreated) {
- onCreated()
- }
-
- } catch (error) {
- console.error("해양 Hull RFQ 생성 오류:", error)
- toast.error(`해양 Hull RFQ 생성 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`)
- } finally {
- setIsProcessing(false)
- }
- }
-
- return (
- <Dialog
- open={isDialogOpen}
- onOpenChange={(open) => {
- setIsDialogOpen(open)
- if (!open) {
- form.reset({
- biddingProjectId: undefined,
- itemIds: [],
- dueDate: undefined,
- description: "",
- })
- setSelectedProject(null)
- setItemSearchQuery("")
- setSelectedWorkType(null)
- setSelectedItems([])
- setDataLoadError(null)
- setRetryCount(0)
- }
- }}
- >
- <DialogTrigger asChild>
- <Button
- variant="default"
- size="sm"
- className="gap-2"
- disabled={isProcessing}
- >
- <Plus className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">해양 Hull RFQ 생성</span>
- </Button>
- </DialogTrigger>
- <DialogContent
- className="max-w-none h-[90vh] overflow-y-auto flex flex-col"
- style={{ width: '1200px' }}
- >
- <DialogHeader className="border-b pb-4">
- <DialogTitle>해양 Hull RFQ 생성</DialogTitle>
- </DialogHeader>
-
- <div className="space-y-6 p-1 overflow-y-auto">
- <Form {...form}>
- <form onSubmit={form.handleSubmit(handleCreateRfq)} className="space-y-6">
- {/* 프로젝트 선택 */}
- <div className="space-y-4">
- <FormField
- control={form.control}
- name="biddingProjectId"
- render={({ field }) => (
- <FormItem>
- <FormLabel>입찰 프로젝트</FormLabel>
- <FormControl>
- <EstimateProjectSelector
- selectedProjectId={field.value}
- onProjectSelect={handleProjectSelect}
- placeholder="입찰 프로젝트를 선택하세요"
- pjtType="HULL"
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* RFQ 설명 */}
- <FormField
- control={form.control}
- name="description"
- render={({ field }) => (
- <FormItem>
- <FormLabel>RFQ Title</FormLabel>
- <FormControl>
- <Input
- placeholder="RFQ Title을 입력하세요 (선택사항)"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- <Separator className="my-4" />
- {/* 마감일 설정 */}
- <FormField
- control={form.control}
- name="dueDate"
- render={({ field }) => (
- <FormItem className="flex flex-col">
- <FormLabel>마감일</FormLabel>
- <Popover>
- <PopoverTrigger asChild>
- <FormControl>
- <Button
- variant="outline"
- className={cn(
- "w-full pl-3 text-left font-normal",
- !field.value && "text-muted-foreground"
- )}
- >
- {field.value ? (
- format(field.value, "PPP", { locale: ko })
- ) : (
- <span>마감일을 선택하세요</span>
- )}
- <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
- </Button>
- </FormControl>
- </PopoverTrigger>
- <PopoverContent className="w-auto p-0" align="start">
- <Calendar
- mode="single"
- selected={field.value}
- onSelect={field.onChange}
- disabled={(date) =>
- date < new Date() || date < new Date("1900-01-01")
- }
- initialFocus
- />
- </PopoverContent>
- </Popover>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <Separator className="my-4" />
-
- <div className="space-y-6">
- {/* 아이템 선택 영역 */}
- <div className="space-y-4">
- <div>
- <FormLabel>해양 Hull 아이템 선택</FormLabel>
- <FormDescription>
- 해양 Hull 아이템을 선택하세요
- </FormDescription>
- </div>
-
- {/* 데이터 로딩 에러 표시 */}
- {dataLoadError && (
- <div className="p-3 bg-destructive/10 border border-destructive/20 rounded-md">
- <div className="flex items-center justify-between">
- <div className="flex items-center gap-2">
- <X className="h-4 w-4 text-destructive" />
- <span className="text-sm text-destructive">{dataLoadError}</span>
- </div>
- <Button
- variant="outline"
- size="sm"
- onClick={handleRefreshData}
- disabled={isLoadingItems}
- className="h-8 text-xs"
- >
- {isLoadingItems ? (
- <>
- <Loader2 className="h-3 w-3 animate-spin mr-1" />
- 재시도 중...
- </>
- ) : (
- "다시 시도"
- )}
- </Button>
- </div>
- </div>
- )}
-
- {/* 아이템 검색 및 필터 */}
- <div className="space-y-2">
- <div className="flex space-x-2">
- <div className="relative flex-1">
- <Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
- <Input
- placeholder="아이템 검색..."
- value={itemSearchQuery}
- onChange={(e) => setItemSearchQuery(e.target.value)}
- className="pl-8 pr-8"
- disabled={isLoadingItems || dataLoadError !== null}
- />
- {itemSearchQuery && (
- <Button
- variant="ghost"
- size="sm"
- className="absolute right-0 top-0 h-full px-3"
- onClick={() => setItemSearchQuery("")}
- disabled={isLoadingItems || dataLoadError !== null}
- >
- <X className="h-4 w-4" />
- </Button>
- )}
- </div>
-
- {/* 공종 필터 */}
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- variant="outline"
- className="gap-1"
- disabled={isLoadingItems || dataLoadError !== null}
- >
- {selectedWorkType ? workTypes.find(wt => wt.code === selectedWorkType)?.name : "전체 공종"}
- <ArrowUpDown className="ml-2 h-4 w-4 opacity-50" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end">
- <DropdownMenuCheckboxItem
- checked={selectedWorkType === null}
- onCheckedChange={() => setSelectedWorkType(null)}
- >
- 전체 공종
- </DropdownMenuCheckboxItem>
- {workTypes.map(workType => (
- <DropdownMenuCheckboxItem
- key={workType.code}
- checked={selectedWorkType === workType.code}
- onCheckedChange={() => setSelectedWorkType(workType.code)}
- >
- {workType.name}
- </DropdownMenuCheckboxItem>
- ))}
- </DropdownMenuContent>
- </DropdownMenu>
- </div>
- </div>
-
- {/* 아이템 목록 */}
- <div className="border rounded-md">
- <ScrollArea className="h-[300px]">
- <div className="p-2 space-y-1">
- {dataLoadError ? (
- <div className="text-center py-8">
- <div className="p-4 bg-destructive/10 border border-destructive/20 rounded-md mx-4">
- <div className="flex flex-col items-center gap-3">
- <X className="h-8 w-8 text-destructive" />
- <div className="text-center">
- <p className="text-sm text-destructive font-medium">데이터 로딩에 실패했습니다</p>
- <p className="text-xs text-muted-foreground mt-1">{dataLoadError}</p>
- </div>
- <Button
- variant="outline"
- size="sm"
- onClick={handleRefreshData}
- disabled={isLoadingItems}
- className="h-8"
- >
- {isLoadingItems ? (
- <>
- <Loader2 className="h-3 w-3 animate-spin mr-1" />
- 재시도 중...
- </>
- ) : (
- "다시 시도"
- )}
- </Button>
- </div>
- </div>
- </div>
- ) : isLoadingItems ? (
- <div className="text-center py-8 text-muted-foreground">
- <Loader2 className="h-6 w-6 animate-spin mx-auto mb-2" />
- 아이템을 불러오는 중...
- {retryCount > 0 && (
- <p className="text-xs mt-1">재시도 {retryCount}회</p>
- )}
- </div>
- ) : availableItems.length > 0 ? (
- [...availableItems]
- .sort((a, b) => {
- const aName = a.itemList || 'zzz'
- const bName = b.itemList || 'zzz'
- return aName.localeCompare(bName, 'ko', { numeric: true })
- })
- .map((item) => {
- const isSelected = selectedItems.some(selected => selected.id === item.id)
-
- return (
- <div
- key={item.id}
- className={cn(
- "flex items-center space-x-2 p-2 rounded-md cursor-pointer hover:bg-muted",
- isSelected && "bg-muted"
- )}
- onClick={() => handleItemToggle(item)}
- >
- <div className="flex items-center space-x-2 flex-1">
- {isSelected ? (
- <CheckSquare className="h-4 w-4" />
- ) : (
- <Square className="h-4 w-4" />
- )}
- <div className="flex-1">
- {/* Hull 아이템 표시: "item_list / sub_item_list" / item_code / 공종 */}
- <div className="font-medium">
- {item.itemList || '아이템명 없음'}
- {item.subItemList && ` / ${item.subItemList}`}
- </div>
- <div className="text-sm text-muted-foreground">
- {item.itemCode || '아이템코드 없음'}
- </div>
- <div className="text-xs text-muted-foreground">
- 공종: {item.workType}
- </div>
- </div>
- </div>
- </div>
- )
- })
- ) : (
- <div className="text-center py-8 text-muted-foreground">
- {itemSearchQuery ? "검색 결과가 없습니다" : "아이템이 없습니다"}
- </div>
- )}
- </div>
- </ScrollArea>
- </div>
- </div>
- </div>
- </div>
- </form>
- </Form>
- </div>
-
- {/* Footer - Sticky 버튼 영역 */}
- <div className="sticky bottom-0 left-0 z-20 bg-background border-t pt-4 mt-4">
- <div className="flex justify-end space-x-2">
- <Button
- type="button"
- variant="outline"
- onClick={() => setIsDialogOpen(false)}
- disabled={isProcessing}
- >
- 취소
- </Button>
- <Button
- type="button"
- onClick={form.handleSubmit(handleCreateRfq)}
- disabled={
- isProcessing ||
- !selectedProject ||
- selectedItems.length === 0
- }
- >
- {isProcessing ? "처리 중..." : `${selectedItems.length}개 아이템으로 해양 Hull RFQ 생성하기`}
- </Button>
- </div>
- </div>
- </DialogContent>
- </Dialog>
- )
+"use client"
+
+import * as React from "react"
+import { toast } from "sonner"
+import { ArrowUpDown, CheckSquare, Plus, Search, Square, X, Loader2 } from "lucide-react"
+import { Input } from "@/components/ui/input"
+import { Calendar } from "@/components/ui/calendar"
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
+import { CalendarIcon } from "lucide-react"
+import { format } from "date-fns"
+import { ko } from "date-fns/locale"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ FormDescription,
+} from "@/components/ui/form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useForm } from "react-hook-form"
+import * as z from "zod"
+import { EstimateProjectSelector } from "@/components/BidProjectSelector"
+import { type Project } from "@/lib/rfqs/service"
+import { createTechSalesHullRfq } from "@/lib/techsales-rfq/service"
+import { useSession } from "next-auth/react"
+import { Separator } from "@/components/ui/separator"
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { cn } from "@/lib/utils"
+import { ScrollArea } from "@/components/ui/scroll-area"
+// import {
+// Table,
+// TableBody,
+// TableCell,
+// TableHead,
+// TableHeader,
+// TableRow,
+// } from "@/components/ui/table"
+
+// 공종 타입 import
+import {
+ getOffshoreHullWorkTypes,
+ getAllOffshoreHullItemsForCache,
+ type OffshoreHullWorkType,
+ type OffshoreHullTechItem,
+} from "@/lib/items-tech/service"
+
+// 해양 HULL 아이템 타입 정의 (이미 service에서 import하므로 제거)
+
+// 유효성 검증 스키마
+const createHullRfqSchema = z.object({
+ biddingProjectId: z.number({
+ required_error: "프로젝트를 선택해주세요.",
+ }),
+ itemIds: z.array(z.number()).min(1, {
+ message: "적어도 하나의 아이템을 선택해야 합니다.",
+ }),
+ dueDate: z.date({
+ required_error: "마감일을 선택해주세요.",
+ }),
+ description: z.string().optional(),
+})
+
+// 폼 데이터 타입
+type CreateHullRfqFormValues = z.infer<typeof createHullRfqSchema>
+
+// 공종 타입 정의
+interface WorkTypeOption {
+ code: OffshoreHullWorkType
+ name: string
+}
+
+interface CreateHullRfqDialogProps {
+ onCreated?: () => void;
+}
+
+export function CreateHullRfqDialog({ onCreated }: CreateHullRfqDialogProps) {
+ const { data: session } = useSession()
+
+ const [isProcessing, setIsProcessing] = React.useState(false)
+ const [isDialogOpen, setIsDialogOpen] = React.useState(false)
+ const [selectedProject, setSelectedProject] = React.useState<Project | null>(null)
+
+ // 검색 및 필터링 상태
+ const [itemSearchQuery, setItemSearchQuery] = React.useState("")
+ const [selectedWorkType, setSelectedWorkType] = React.useState<OffshoreHullWorkType | null>(null)
+ const [selectedItems, setSelectedItems] = React.useState<OffshoreHullTechItem[]>([])
+
+ // 데이터 상태
+ const [workTypes, setWorkTypes] = React.useState<WorkTypeOption[]>([])
+ const [allItems, setAllItems] = React.useState<OffshoreHullTechItem[]>([])
+ const [isLoadingItems, setIsLoadingItems] = React.useState(false)
+ const [dataLoadError, setDataLoadError] = React.useState<string | null>(null)
+ const [retryCount, setRetryCount] = React.useState(0)
+
+ // 데이터 로딩 함수
+ const loadData = React.useCallback(async (isRetry = false) => {
+ try {
+ if (!isRetry) {
+ setIsLoadingItems(true)
+ setDataLoadError(null)
+ }
+
+ console.log(`해양 Hull RFQ 데이터 로딩 시작... ${isRetry ? `(재시도 ${retryCount + 1}회)` : ''}`)
+
+ const [workTypesResult, hullItemsResult] = await Promise.all([
+ getOffshoreHullWorkTypes(),
+ getAllOffshoreHullItemsForCache()
+ ])
+
+ console.log("Hull - WorkTypes 결과:", workTypesResult)
+ console.log("Hull - Items 결과:", hullItemsResult)
+
+ // WorkTypes 설정
+ if (Array.isArray(workTypesResult)) {
+ setWorkTypes(workTypesResult)
+ } else {
+ throw new Error("공종 데이터를 불러올 수 없습니다.")
+ }
+
+ // Hull Items 설정
+ if (hullItemsResult.data && Array.isArray(hullItemsResult.data)) {
+ setAllItems(hullItemsResult.data as OffshoreHullTechItem[])
+ console.log("Hull 아이템 설정 완료:", hullItemsResult.data.length, "개")
+ } else {
+ throw new Error(hullItemsResult.error || "Hull 아이템 데이터를 불러올 수 없습니다.")
+ }
+
+ // 성공 시 재시도 카운터 리셋
+ setRetryCount(0)
+ setDataLoadError(null)
+ console.log("해양 Hull RFQ 데이터 로딩 완료")
+
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'
+ console.error("해양 Hull RFQ 데이터 로딩 오류:", errorMessage)
+
+ setDataLoadError(errorMessage)
+
+ // 3회까지 자동 재시도 (500ms 간격)
+ if (retryCount < 2) {
+ console.log(`${500 * (retryCount + 1)}ms 후 재시도...`)
+ setTimeout(() => {
+ setRetryCount(prev => prev + 1)
+ loadData(true)
+ }, 500 * (retryCount + 1))
+ } else {
+ // 재시도 실패 시 사용자에게 알림
+ toast.error(`데이터 로딩에 실패했습니다: ${errorMessage}`)
+ }
+ } finally {
+ if (!isRetry) {
+ setIsLoadingItems(false)
+ }
+ }
+ }, [retryCount])
+
+ // 다이얼로그가 열릴 때마다 데이터 로딩
+ React.useEffect(() => {
+ if (isDialogOpen) {
+ setDataLoadError(null)
+ setRetryCount(0)
+ loadData()
+ }
+ }, [isDialogOpen, loadData])
+
+ // 수동 새로고침 함수
+ const handleRefreshData = React.useCallback(() => {
+ setDataLoadError(null)
+ setRetryCount(0)
+ loadData()
+ }, [loadData])
+
+ // RFQ 생성 폼
+ const form = useForm<CreateHullRfqFormValues>({
+ resolver: zodResolver(createHullRfqSchema),
+ defaultValues: {
+ biddingProjectId: undefined,
+ itemIds: [],
+ dueDate: undefined,
+ description: "",
+ }
+ })
+
+ // 필터링된 아이템 목록 가져오기
+ const availableItems = React.useMemo(() => {
+ let filtered = [...allItems]
+
+ // 공종 필터
+ if (selectedWorkType) {
+ filtered = filtered.filter(item => item.workType === selectedWorkType as OffshoreHullTechItem['workType'])
+ }
+
+ // 검색어 필터
+ if (itemSearchQuery && itemSearchQuery.trim()) {
+ const query = itemSearchQuery.toLowerCase().trim()
+ filtered = filtered.filter(item =>
+ item.itemCode.toLowerCase().includes(query) ||
+ (item.itemList && item.itemList.toLowerCase().includes(query)) ||
+ (item.subItemList && item.subItemList.toLowerCase().includes(query))
+ )
+ }
+
+ return filtered
+ }, [allItems, itemSearchQuery, selectedWorkType])
+
+ // 프로젝트 선택 처리
+ const handleProjectSelect = (project: Project) => {
+ setSelectedProject(project)
+ form.setValue("biddingProjectId", project.id)
+ // 선택 초기화
+ setSelectedItems([])
+ setSelectedWorkType(null)
+ setItemSearchQuery("")
+ form.setValue("itemIds", [])
+ }
+
+ // 아이템 선택/해제 처리
+ const handleItemToggle = (item: OffshoreHullTechItem) => {
+ const isSelected = selectedItems.some(selected => selected.id === item.id)
+
+ if (isSelected) {
+ const newSelectedItems = selectedItems.filter(selected => selected.id !== item.id)
+ setSelectedItems(newSelectedItems)
+ form.setValue("itemIds", newSelectedItems.map(item => item.id))
+ } else {
+ const newSelectedItems = [...selectedItems, item]
+ setSelectedItems(newSelectedItems)
+ form.setValue("itemIds", newSelectedItems.map(item => item.id))
+ }
+ }
+
+ // RFQ 생성 함수
+ const handleCreateRfq = async (data: CreateHullRfqFormValues) => {
+ try {
+ setIsProcessing(true)
+
+ // 사용자 인증 확인
+ if (!session?.user?.id) {
+ throw new Error("로그인이 필요합니다")
+ }
+
+ // 해양 Hull RFQ 생성 - 1:N 관계로 한 번에 생성
+ const result = await createTechSalesHullRfq({
+ biddingProjectId: data.biddingProjectId,
+ itemIds: data.itemIds,
+ dueDate: data.dueDate,
+ description: data.description,
+ createdBy: Number(session.user.id),
+ })
+
+ if (result.error) {
+ throw new Error(result.error)
+ }
+
+ // 성공적으로 생성되면 다이얼로그 닫기 및 메시지 표시
+ toast.success(`${selectedItems.length}개 아이템으로 해양 Hull RFQ가 성공적으로 생성되었습니다`)
+
+ setIsDialogOpen(false)
+ form.reset({
+ biddingProjectId: undefined,
+ itemIds: [],
+ dueDate: undefined,
+ description: "",
+ })
+ setSelectedProject(null)
+ setItemSearchQuery("")
+ setSelectedWorkType(null)
+ setSelectedItems([])
+ setDataLoadError(null)
+ setRetryCount(0)
+
+ // 생성 후 콜백 실행
+ if (onCreated) {
+ onCreated()
+ }
+
+ } catch (error) {
+ console.error("해양 Hull RFQ 생성 오류:", error)
+ toast.error(`해양 Hull RFQ 생성 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`)
+ } finally {
+ setIsProcessing(false)
+ }
+ }
+
+ return (
+ <Dialog
+ open={isDialogOpen}
+ onOpenChange={(open) => {
+ setIsDialogOpen(open)
+ if (!open) {
+ form.reset({
+ biddingProjectId: undefined,
+ itemIds: [],
+ dueDate: undefined,
+ description: "",
+ })
+ setSelectedProject(null)
+ setItemSearchQuery("")
+ setSelectedWorkType(null)
+ setSelectedItems([])
+ setDataLoadError(null)
+ setRetryCount(0)
+ }
+ }}
+ >
+ <DialogTrigger asChild>
+ <Button
+ variant="default"
+ size="sm"
+ className="gap-2"
+ disabled={isProcessing}
+ >
+ <Plus className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">해양 Hull RFQ 생성</span>
+ </Button>
+ </DialogTrigger>
+ <DialogContent
+ className="max-w-none h-[90vh] overflow-y-auto flex flex-col"
+ style={{ width: '1200px' }}
+ >
+ <DialogHeader className="border-b pb-4">
+ <DialogTitle>해양 Hull RFQ 생성</DialogTitle>
+ </DialogHeader>
+
+ <div className="space-y-6 p-1 overflow-y-auto">
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(handleCreateRfq)} className="space-y-6">
+ {/* 프로젝트 선택 */}
+ <div className="space-y-4">
+ <FormField
+ control={form.control}
+ name="biddingProjectId"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>입찰 프로젝트</FormLabel>
+ <FormControl>
+ <EstimateProjectSelector
+ selectedProjectId={field.value}
+ onProjectSelect={handleProjectSelect}
+ placeholder="입찰 프로젝트를 선택하세요"
+ pjtType="HULL"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* RFQ 설명 */}
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>RFQ Title</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="RFQ Title을 입력하세요 (선택사항)"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <Separator className="my-4" />
+ {/* 마감일 설정 */}
+ <FormField
+ control={form.control}
+ name="dueDate"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>마감일</FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ className={cn(
+ "w-full pl-3 text-left font-normal",
+ !field.value && "text-muted-foreground"
+ )}
+ >
+ {field.value ? (
+ format(field.value, "PPP", { locale: ko })
+ ) : (
+ <span>마감일을 선택하세요</span>
+ )}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={field.value}
+ onSelect={field.onChange}
+ disabled={(date) =>
+ date < new Date() || date < new Date("1900-01-01")
+ }
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <Separator className="my-4" />
+
+ <div className="space-y-6">
+ {/* 아이템 선택 영역 */}
+ <div className="space-y-4">
+ <div>
+ <FormLabel>해양 Hull 아이템 선택</FormLabel>
+ <FormDescription>
+ 해양 Hull 아이템을 선택하세요
+ </FormDescription>
+ </div>
+
+ {/* 데이터 로딩 에러 표시 */}
+ {dataLoadError && (
+ <div className="p-3 bg-destructive/10 border border-destructive/20 rounded-md">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <X className="h-4 w-4 text-destructive" />
+ <span className="text-sm text-destructive">{dataLoadError}</span>
+ </div>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleRefreshData}
+ disabled={isLoadingItems}
+ className="h-8 text-xs"
+ >
+ {isLoadingItems ? (
+ <>
+ <Loader2 className="h-3 w-3 animate-spin mr-1" />
+ 재시도 중...
+ </>
+ ) : (
+ "다시 시도"
+ )}
+ </Button>
+ </div>
+ </div>
+ )}
+
+ {/* 아이템 검색 및 필터 */}
+ <div className="space-y-2">
+ <div className="flex space-x-2">
+ <div className="relative flex-1">
+ <Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
+ <Input
+ placeholder="아이템 검색..."
+ value={itemSearchQuery}
+ onChange={(e) => setItemSearchQuery(e.target.value)}
+ className="pl-8 pr-8"
+ disabled={isLoadingItems || dataLoadError !== null}
+ />
+ {itemSearchQuery && (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="absolute right-0 top-0 h-full px-3"
+ onClick={() => setItemSearchQuery("")}
+ disabled={isLoadingItems || dataLoadError !== null}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+
+ {/* 공종 필터 */}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="outline"
+ className="gap-1"
+ disabled={isLoadingItems || dataLoadError !== null}
+ >
+ {selectedWorkType ? workTypes.find(wt => wt.code === selectedWorkType)?.name : "전체 공종"}
+ <ArrowUpDown className="ml-2 h-4 w-4 opacity-50" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuCheckboxItem
+ checked={selectedWorkType === null}
+ onCheckedChange={() => setSelectedWorkType(null)}
+ >
+ 전체 공종
+ </DropdownMenuCheckboxItem>
+ {workTypes.map(workType => (
+ <DropdownMenuCheckboxItem
+ key={workType.code}
+ checked={selectedWorkType === workType.code}
+ onCheckedChange={() => setSelectedWorkType(workType.code)}
+ >
+ {workType.name}
+ </DropdownMenuCheckboxItem>
+ ))}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+ </div>
+
+ {/* 아이템 목록 */}
+ <div className="border rounded-md">
+ <ScrollArea className="h-[300px]">
+ <div className="p-2 space-y-1">
+ {dataLoadError ? (
+ <div className="text-center py-8">
+ <div className="p-4 bg-destructive/10 border border-destructive/20 rounded-md mx-4">
+ <div className="flex flex-col items-center gap-3">
+ <X className="h-8 w-8 text-destructive" />
+ <div className="text-center">
+ <p className="text-sm text-destructive font-medium">데이터 로딩에 실패했습니다</p>
+ <p className="text-xs text-muted-foreground mt-1">{dataLoadError}</p>
+ </div>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleRefreshData}
+ disabled={isLoadingItems}
+ className="h-8"
+ >
+ {isLoadingItems ? (
+ <>
+ <Loader2 className="h-3 w-3 animate-spin mr-1" />
+ 재시도 중...
+ </>
+ ) : (
+ "다시 시도"
+ )}
+ </Button>
+ </div>
+ </div>
+ </div>
+ ) : isLoadingItems ? (
+ <div className="text-center py-8 text-muted-foreground">
+ <Loader2 className="h-6 w-6 animate-spin mx-auto mb-2" />
+ 아이템을 불러오는 중...
+ {retryCount > 0 && (
+ <p className="text-xs mt-1">재시도 {retryCount}회</p>
+ )}
+ </div>
+ ) : availableItems.length > 0 ? (
+ [...availableItems]
+ .sort((a, b) => {
+ const aCode = a.itemCode || 'zzz'
+ const bCode = b.itemCode || 'zzz'
+ return aCode.localeCompare(bCode, 'ko', { numeric: true })
+ })
+ .map((item) => {
+ const isSelected = selectedItems.some(selected => selected.id === item.id)
+
+ return (
+ <div
+ key={item.id}
+ className={cn(
+ "flex items-center space-x-2 p-2 rounded-md cursor-pointer hover:bg-muted",
+ isSelected && "bg-muted"
+ )}
+ onClick={() => handleItemToggle(item)}
+ >
+ <div className="flex items-center space-x-2 flex-1">
+ {isSelected ? (
+ <CheckSquare className="h-4 w-4" />
+ ) : (
+ <Square className="h-4 w-4" />
+ )}
+ <div className="flex-1">
+ {/* Hull 아이템 표시: "item_list / sub_item_list" / item_code / 공종 */}
+ <div className="font-medium">
+ {item.itemList || '아이템명 없음'}
+ {item.subItemList && ` / ${item.subItemList}`}
+ </div>
+ <div className="text-sm text-muted-foreground">
+ {item.itemCode || '아이템코드 없음'}
+ </div>
+ <div className="text-xs text-muted-foreground">
+ 공종: {item.workType}
+ </div>
+ </div>
+ </div>
+ </div>
+ )
+ })
+ ) : (
+ <div className="text-center py-8 text-muted-foreground">
+ {itemSearchQuery ? "검색 결과가 없습니다" : "아이템이 없습니다"}
+ </div>
+ )}
+ </div>
+ </ScrollArea>
+ </div>
+ </div>
+ </div>
+ </div>
+ </form>
+ </Form>
+ </div>
+
+ {/* Footer - Sticky 버튼 영역 */}
+ <div className="sticky bottom-0 left-0 z-20 bg-background border-t pt-4 mt-4">
+ <div className="flex justify-end space-x-2">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setIsDialogOpen(false)}
+ disabled={isProcessing}
+ >
+ 취소
+ </Button>
+ <Button
+ type="button"
+ onClick={form.handleSubmit(handleCreateRfq)}
+ disabled={
+ isProcessing ||
+ !selectedProject ||
+ selectedItems.length === 0
+ }
+ >
+ {isProcessing ? "처리 중..." : `${selectedItems.length}개 아이템으로 해양 Hull RFQ 생성하기`}
+ </Button>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/create-rfq-ship-dialog.tsx b/lib/techsales-rfq/table/create-rfq-ship-dialog.tsx
index efa4e164..114bd04d 100644
--- a/lib/techsales-rfq/table/create-rfq-ship-dialog.tsx
+++ b/lib/techsales-rfq/table/create-rfq-ship-dialog.tsx
@@ -1,726 +1,726 @@
-"use client"
-
-import * as React from "react"
-import { toast } from "sonner"
-import { ArrowUpDown, CheckSquare, Plus, Search, Square, X, Loader2 } from "lucide-react"
-import { Input } from "@/components/ui/input"
-import { Calendar } from "@/components/ui/calendar"
-import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
-import { CalendarIcon } from "lucide-react"
-import { format } from "date-fns"
-import { ko } from "date-fns/locale"
-
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
- FormDescription,
-} from "@/components/ui/form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { useForm } from "react-hook-form"
-import * as z from "zod"
-import { EstimateProjectSelector } from "@/components/BidProjectSelector"
-import { type Project } from "@/lib/rfqs/service"
-import { createTechSalesShipRfq } from "@/lib/techsales-rfq/service"
-import { useSession } from "next-auth/react"
-import { Separator } from "@/components/ui/separator"
-import {
- DropdownMenu,
- DropdownMenuCheckboxItem,
- DropdownMenuContent,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu"
-import { cn } from "@/lib/utils"
-import { ScrollArea } from "@/components/ui/scroll-area"
-
-// 조선 아이템 서비스 import
-import {
- getWorkTypes,
- getAllShipbuildingItemsForCache,
- getShipTypes,
- type ShipbuildingItem,
- type ShipbuildingWorkType
-} from "@/lib/items-tech/service"
-
-
-// 유효성 검증 스키마
-const createShipRfqSchema = z.object({
- biddingProjectId: z.number({
- required_error: "프로젝트를 선택해주세요.",
- }),
- itemIds: z.array(z.number()).min(1, {
- message: "적어도 하나의 아이템을 선택해야 합니다.",
- }),
- dueDate: z.date({
- required_error: "마감일을 선택해주세요.",
- }),
- description: z.string().optional(),
-})
-
-// 폼 데이터 타입
-type CreateShipRfqFormValues = z.infer<typeof createShipRfqSchema>
-
-// 공종 타입 정의
-interface WorkTypeOption {
- code: ShipbuildingWorkType
- name: string
-}
-
-interface CreateShipRfqDialogProps {
- onCreated?: () => void;
-}
-
-export function CreateShipRfqDialog({ onCreated }: CreateShipRfqDialogProps) {
- const { data: session } = useSession()
- const [isProcessing, setIsProcessing] = React.useState(false)
- const [isDialogOpen, setIsDialogOpen] = React.useState(false)
- const [selectedProject, setSelectedProject] = React.useState<Project | null>(null)
-
- // 검색 및 필터링 상태
- const [itemSearchQuery, setItemSearchQuery] = React.useState("")
- const [selectedWorkType, setSelectedWorkType] = React.useState<ShipbuildingWorkType | null>(null)
- const [selectedShipType, setSelectedShipType] = React.useState<string | null>(null)
- const [selectedItems, setSelectedItems] = React.useState<ShipbuildingItem[]>([])
-
- // 데이터 상태
- const [workTypes, setWorkTypes] = React.useState<WorkTypeOption[]>([])
- const [allItems, setAllItems] = React.useState<ShipbuildingItem[]>([])
- const [shipTypes, setShipTypes] = React.useState<string[]>([])
- const [isLoadingItems, setIsLoadingItems] = React.useState(false)
- const [dataLoadError, setDataLoadError] = React.useState<string | null>(null)
- const [retryCount, setRetryCount] = React.useState(0)
-
- // 데이터 로딩 함수
- const loadData = React.useCallback(async (isRetry = false) => {
- try {
- if (!isRetry) {
- setIsLoadingItems(true)
- setDataLoadError(null)
- }
-
- console.log(`조선 RFQ 데이터 로딩 시작... ${isRetry ? `(재시도 ${retryCount + 1}회)` : ''}`)
-
- const [workTypesResult, itemsResult, shipTypesResult] = await Promise.all([
- getWorkTypes(),
- getAllShipbuildingItemsForCache(),
- getShipTypes()
- ])
-
- console.log("Ship - WorkTypes 결과:", workTypesResult)
- console.log("Ship - Items 결과:", itemsResult)
- console.log("Ship - ShipTypes 결과:", shipTypesResult)
-
- // WorkTypes 설정
- if (Array.isArray(workTypesResult)) {
- setWorkTypes(workTypesResult)
- } else {
- throw new Error("공종 데이터를 불러올 수 없습니다.")
- }
-
- // Items 설정
- if (!itemsResult.error && itemsResult.data && Array.isArray(itemsResult.data)) {
- setAllItems(itemsResult.data)
- console.log("Ship 아이템 설정 완료:", itemsResult.data.length, "개")
- } else {
- throw new Error(itemsResult.error || "Ship 아이템 데이터를 불러올 수 없습니다.")
- }
-
- // ShipTypes 설정
- if (!shipTypesResult.error && shipTypesResult.data && Array.isArray(shipTypesResult.data)) {
- setShipTypes(shipTypesResult.data)
- console.log("선종 설정 완료:", shipTypesResult.data)
- } else {
- throw new Error(shipTypesResult.error || "선종 데이터를 불러올 수 없습니다.")
- }
-
- // 성공 시 재시도 카운터 리셋
- setRetryCount(0)
- setDataLoadError(null)
- console.log("조선 RFQ 데이터 로딩 완료")
-
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'
- console.error("조선 RFQ 데이터 로딩 오류:", errorMessage)
-
- setDataLoadError(errorMessage)
-
- // 3회까지 자동 재시도 (500ms 간격)
- if (retryCount < 2) {
- console.log(`${500 * (retryCount + 1)}ms 후 재시도...`)
- setTimeout(() => {
- setRetryCount(prev => prev + 1)
- loadData(true)
- }, 500 * (retryCount + 1))
- } else {
- // 재시도 실패 시 사용자에게 알림
- toast.error(`데이터 로딩에 실패했습니다: ${errorMessage}`)
- }
- } finally {
- if (!isRetry) {
- setIsLoadingItems(false)
- }
- }
- }, [retryCount])
-
- // 다이얼로그가 열릴 때마다 데이터 로딩
- React.useEffect(() => {
- if (isDialogOpen) {
- setDataLoadError(null)
- setRetryCount(0)
- loadData()
- }
- }, [isDialogOpen, loadData])
-
- // 수동 새로고침 함수
- const handleRefreshData = React.useCallback(() => {
- setDataLoadError(null)
- setRetryCount(0)
- loadData()
- }, [loadData])
-
- // RFQ 생성 폼
- const form = useForm<CreateShipRfqFormValues>({
- resolver: zodResolver(createShipRfqSchema),
- defaultValues: {
- biddingProjectId: undefined,
- itemIds: [],
- dueDate: undefined,
- description: "",
- }
- })
-
- // 필터링된 아이템 목록 가져오기
- const availableItems = React.useMemo(() => {
- let filtered = [...allItems]
-
- // 선종 필터
- if (selectedShipType) {
- filtered = filtered.filter(item => item.shipTypes === selectedShipType)
- }
-
- // 공종 필터
- if (selectedWorkType) {
- filtered = filtered.filter(item => item.workType === selectedWorkType)
- }
-
- // 검색어 필터
- if (itemSearchQuery && itemSearchQuery.trim()) {
- const query = itemSearchQuery.toLowerCase().trim()
- filtered = filtered.filter(item =>
- item.itemCode.toLowerCase().includes(query) ||
- (item.itemList && item.itemList.toLowerCase().includes(query))
- )
- }
-
- return filtered
- }, [allItems, itemSearchQuery, selectedWorkType, selectedShipType])
-
- // 사용 가능한 선종 목록 가져오기
- const availableShipTypes = React.useMemo(() => {
- return shipTypes
- }, [shipTypes])
-
- // 프로젝트 선택 처리
- const handleProjectSelect = (project: Project) => {
- setSelectedProject(project)
- form.setValue("biddingProjectId", project.id)
- // 선택 초기화
- setSelectedItems([])
- setSelectedShipType(null)
- setSelectedWorkType(null)
- setItemSearchQuery("")
- form.setValue("itemIds", [])
- }
-
- // 아이템 선택/해제 처리
- const handleItemToggle = (item: ShipbuildingItem) => {
- const isSelected = selectedItems.some(selected => selected.id === item.id)
-
- if (isSelected) {
- const newSelectedItems = selectedItems.filter(selected => selected.id !== item.id)
- setSelectedItems(newSelectedItems)
- form.setValue("itemIds", newSelectedItems.map(item => item.id))
- } else {
- const newSelectedItems = [...selectedItems, item]
- setSelectedItems(newSelectedItems)
- form.setValue("itemIds", newSelectedItems.map(item => item.id))
- }
- }
-
- // RFQ 생성 함수
- const handleCreateRfq = async (data: CreateShipRfqFormValues) => {
- try {
- setIsProcessing(true)
-
- // 사용자 인증 확인
- if (!session?.user?.id) {
- throw new Error("로그인이 필요합니다")
- }
-
- // 조선 RFQ 생성 - 1:N 관계로 한 번에 생성
- const result = await createTechSalesShipRfq({
- biddingProjectId: data.biddingProjectId,
- itemIds: data.itemIds,
- dueDate: data.dueDate,
- description: data.description,
- createdBy: Number(session.user.id),
- })
-
- if (result.error) {
- throw new Error(result.error)
- }
-
- // 성공적으로 생성되면 다이얼로그 닫기 및 메시지 표시
- toast.success(`${selectedItems.length}개 아이템으로 조선 RFQ가 성공적으로 생성되었습니다`)
-
- setIsDialogOpen(false)
- form.reset({
- biddingProjectId: undefined,
- itemIds: [],
- dueDate: undefined,
- description: "",
- })
- setSelectedProject(null)
- setItemSearchQuery("")
- setSelectedWorkType(null)
- setSelectedShipType(null)
- setSelectedItems([])
- setDataLoadError(null)
- setRetryCount(0)
-
- // 생성 후 콜백 실행
- if (onCreated) {
- onCreated()
- }
-
- } catch (error) {
- console.error("조선 RFQ 생성 오류:", error)
- toast.error(`조선 RFQ 생성 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`)
- } finally {
- setIsProcessing(false)
- }
- }
-
- return (
- <Dialog
- open={isDialogOpen}
- onOpenChange={(open) => {
- setIsDialogOpen(open)
- if (!open) {
- form.reset({
- biddingProjectId: undefined,
- itemIds: [],
- dueDate: undefined,
- description: "",
- })
- setSelectedProject(null)
- setItemSearchQuery("")
- setSelectedWorkType(null)
- setSelectedShipType(null)
- setSelectedItems([])
- setDataLoadError(null)
- setRetryCount(0)
- }
- }}
- >
- <DialogTrigger asChild>
- <Button
- variant="default"
- size="sm"
- className="gap-2"
- disabled={isProcessing}
- >
- <Plus className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">조선 RFQ 생성</span>
- </Button>
- </DialogTrigger>
- <DialogContent
- className="max-w-none h-[90vh] overflow-y-auto flex flex-col"
- style={{ width: '1200px' }}
- >
- <DialogHeader className="border-b pb-4">
- <DialogTitle>조선 RFQ 생성</DialogTitle>
- </DialogHeader>
-
- <div className="space-y-6 p-1 overflow-y-auto">
- <Form {...form}>
- <form onSubmit={form.handleSubmit(handleCreateRfq)} className="space-y-6">
- {/* 프로젝트 선택 */}
- <div className="space-y-4">
- <FormField
- control={form.control}
- name="biddingProjectId"
- render={({ field }) => (
- <FormItem>
- <FormLabel>입찰 프로젝트</FormLabel>
- <FormControl>
- <EstimateProjectSelector
- selectedProjectId={field.value}
- onProjectSelect={handleProjectSelect}
- placeholder="입찰 프로젝트를 선택하세요"
- pjtType="SHIP"
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <Separator className="my-4" />
-
- {/* RFQ 설명 */}
- <FormField
- control={form.control}
- name="description"
- render={({ field }) => (
- <FormItem>
- <FormLabel>RFQ Title</FormLabel>
- <FormControl>
- <Input
- placeholder="RFQ Title을 입력하세요 (선택사항)"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <Separator className="my-4" />
-
- {/* 선종 선택 */}
- <div className="space-y-4">
- <div>
- <FormLabel>선종 선택</FormLabel>
- </div>
-
- {/* 데이터 로딩 에러 표시 */}
- {dataLoadError && (
- <div className="p-3 bg-destructive/10 border border-destructive/20 rounded-md">
- <div className="flex items-center justify-between">
- <div className="flex items-center gap-2">
- <X className="h-4 w-4 text-destructive" />
- <span className="text-sm text-destructive">{dataLoadError}</span>
- </div>
- <Button
- variant="outline"
- size="sm"
- onClick={handleRefreshData}
- disabled={isLoadingItems}
- className="h-8 text-xs"
- >
- {isLoadingItems ? (
- <>
- <Loader2 className="h-3 w-3 animate-spin mr-1" />
- 재시도 중...
- </>
- ) : (
- "다시 시도"
- )}
- </Button>
- </div>
- </div>
- )}
-
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- variant="outline"
- className="w-full justify-between"
- disabled={!selectedProject || isLoadingItems || dataLoadError !== null}
- >
- {isLoadingItems ? (
- <>
- <Loader2 className="h-4 w-4 animate-spin mr-2" />
- 데이터 로딩 중...
- </>
- ) : dataLoadError ? (
- "데이터 로딩 실패"
- ) : selectedShipType ? (
- selectedShipType
- ) : (
- "선종을 선택하세요"
- )}
- <ArrowUpDown className="ml-2 h-4 w-4 opacity-50" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent className="w-full max-h-60 overflow-y-auto">
- <DropdownMenuCheckboxItem
- checked={selectedShipType === null}
- onCheckedChange={() => {
- setSelectedShipType(null)
- setSelectedItems([])
- form.setValue("itemIds", [])
- }}
- >
- 전체 선종
- </DropdownMenuCheckboxItem>
- {availableShipTypes.map(shipType => (
- <DropdownMenuCheckboxItem
- key={shipType}
- checked={selectedShipType === shipType}
- onCheckedChange={() => {
- setSelectedShipType(shipType)
- setSelectedItems([])
- form.setValue("itemIds", [])
- }}
- >
- {shipType}
- </DropdownMenuCheckboxItem>
- ))}
- </DropdownMenuContent>
- </DropdownMenu>
- </div>
-
- <Separator className="my-4" />
-
- {/* 마감일 설정 */}
- <FormField
- control={form.control}
- name="dueDate"
- render={({ field }) => (
- <FormItem className="flex flex-col">
- <FormLabel>마감일</FormLabel>
- <Popover>
- <PopoverTrigger asChild>
- <FormControl>
- <Button
- variant="outline"
- className={cn(
- "w-full pl-3 text-left font-normal",
- !field.value && "text-muted-foreground"
- )}
- >
- {field.value ? (
- format(field.value, "PPP", { locale: ko })
- ) : (
- <span>마감일을 선택하세요</span>
- )}
- <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
- </Button>
- </FormControl>
- </PopoverTrigger>
- <PopoverContent className="w-auto p-0" align="start">
- <Calendar
- mode="single"
- selected={field.value}
- onSelect={field.onChange}
- disabled={(date) =>
- date < new Date() || date < new Date("1900-01-01")
- }
- initialFocus
- />
- </PopoverContent>
- </Popover>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <Separator className="my-4" />
-
- <div className="space-y-6">
- {/* 아이템 선택 영역 */}
- <div className="space-y-4">
- <div>
- <FormLabel>조선 아이템 선택</FormLabel>
- <FormDescription>
- {selectedShipType
- ? `선종 ${selectedShipType}의 공종별 아이템을 선택하세요`
- : "먼저 선종을 선택해주세요"
- }
- </FormDescription>
- </div>
-
- {/* 아이템 검색 및 필터 */}
- <div className="space-y-2">
- <div className="flex space-x-2">
- <div className="relative flex-1">
- <Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
- <Input
- placeholder="아이템 검색..."
- value={itemSearchQuery}
- onChange={(e) => setItemSearchQuery(e.target.value)}
- className="pl-8 pr-8"
- disabled={!selectedShipType || isLoadingItems || dataLoadError !== null}
- />
- {itemSearchQuery && (
- <Button
- variant="ghost"
- size="sm"
- className="absolute right-0 top-0 h-full px-3"
- onClick={() => setItemSearchQuery("")}
- disabled={!selectedShipType || isLoadingItems || dataLoadError !== null}
- >
- <X className="h-4 w-4" />
- </Button>
- )}
- </div>
-
- {/* 공종 필터 */}
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- variant="outline"
- className="gap-1"
- disabled={!selectedShipType || isLoadingItems || dataLoadError !== null}
- >
- {selectedWorkType ? workTypes.find(wt => wt.code === selectedWorkType)?.name : "전체 공종"}
- <ArrowUpDown className="ml-2 h-4 w-4 opacity-50" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end">
- <DropdownMenuCheckboxItem
- checked={selectedWorkType === null}
- onCheckedChange={() => setSelectedWorkType(null)}
- >
- 전체 공종
- </DropdownMenuCheckboxItem>
- {workTypes.map(workType => (
- <DropdownMenuCheckboxItem
- key={workType.code}
- checked={selectedWorkType === workType.code}
- onCheckedChange={() => setSelectedWorkType(workType.code)}
- >
- {workType.name}
- </DropdownMenuCheckboxItem>
- ))}
- </DropdownMenuContent>
- </DropdownMenu>
- </div>
- </div>
-
- {/* 아이템 목록 */}
- <div className="border rounded-md">
- <ScrollArea className="h-[300px]">
- <div className="p-2 space-y-1">
- {dataLoadError ? (
- <div className="text-center py-8">
- <div className="p-4 bg-destructive/10 border border-destructive/20 rounded-md mx-4">
- <div className="flex flex-col items-center gap-3">
- <X className="h-8 w-8 text-destructive" />
- <div className="text-center">
- <p className="text-sm text-destructive font-medium">데이터 로딩에 실패했습니다</p>
- <p className="text-xs text-muted-foreground mt-1">{dataLoadError}</p>
- </div>
- <Button
- variant="outline"
- size="sm"
- onClick={handleRefreshData}
- disabled={isLoadingItems}
- className="h-8"
- >
- {isLoadingItems ? (
- <>
- <Loader2 className="h-3 w-3 animate-spin mr-1" />
- 재시도 중...
- </>
- ) : (
- "다시 시도"
- )}
- </Button>
- </div>
- </div>
- </div>
- ) : isLoadingItems ? (
- <div className="text-center py-8 text-muted-foreground">
- <Loader2 className="h-6 w-6 animate-spin mx-auto mb-2" />
- 아이템을 불러오는 중...
- {retryCount > 0 && (
- <p className="text-xs mt-1">재시도 {retryCount}회</p>
- )}
- </div>
- ) : availableItems.length > 0 ? (
- [...availableItems]
- .sort((a, b) => {
- const aName = a.itemList || 'zzz'
- const bName = b.itemList || 'zzz'
- return aName.localeCompare(bName, 'ko', { numeric: true })
- })
- .map((item) => {
- const isSelected = selectedItems.some(selected => selected.id === item.id)
-
- return (
- <div
- key={item.id}
- className={cn(
- "flex items-center space-x-2 p-2 rounded-md cursor-pointer hover:bg-muted",
- isSelected && "bg-muted"
- )}
- onClick={() => handleItemToggle(item)}
- >
- <div className="flex items-center space-x-2 flex-1">
- {isSelected ? (
- <CheckSquare className="h-4 w-4" />
- ) : (
- <Square className="h-4 w-4" />
- )}
- <div className="flex-1">
- <div className="font-medium">
- {item.itemList || '아이템명 없음'}
- </div>
- <div className="text-sm text-muted-foreground">
- {item.itemCode || '자재그룹코드 없음'}
- </div>
- <div className="text-xs text-muted-foreground">
- 공종: {item.workType} • 선종: {item.shipTypes}
- </div>
- </div>
- </div>
- </div>
- )
- })
- ) : (
- <div className="text-center py-8 text-muted-foreground">
- {itemSearchQuery ? "검색 결과가 없습니다" : "아이템이 없습니다"}
- </div>
- )}
- </div>
- </ScrollArea>
- </div>
- </div>
- </div>
- </div>
- </form>
- </Form>
- </div>
-
- {/* Footer - Sticky 버튼 영역 */}
- <div className="sticky bottom-0 left-0 z-20 bg-background border-t pt-4 mt-4">
- <div className="flex justify-end space-x-2">
- <Button
- type="button"
- variant="outline"
- onClick={() => setIsDialogOpen(false)}
- disabled={isProcessing}
- >
- 취소
- </Button>
- <Button
- type="button"
- onClick={form.handleSubmit(handleCreateRfq)}
- disabled={
- isProcessing ||
- !selectedProject ||
- selectedItems.length === 0
- }
- >
- {isProcessing ? "처리 중..." : `${selectedItems.length}개 아이템으로 조선 RFQ 생성하기`}
- </Button>
- </div>
- </div>
- </DialogContent>
- </Dialog>
- )
+"use client"
+
+import * as React from "react"
+import { toast } from "sonner"
+import { ArrowUpDown, CheckSquare, Plus, Search, Square, X, Loader2 } from "lucide-react"
+import { Input } from "@/components/ui/input"
+import { Calendar } from "@/components/ui/calendar"
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
+import { CalendarIcon } from "lucide-react"
+import { format } from "date-fns"
+import { ko } from "date-fns/locale"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ FormDescription,
+} from "@/components/ui/form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useForm } from "react-hook-form"
+import * as z from "zod"
+import { EstimateProjectSelector } from "@/components/BidProjectSelector"
+import { type Project } from "@/lib/rfqs/service"
+import { createTechSalesShipRfq } from "@/lib/techsales-rfq/service"
+import { useSession } from "next-auth/react"
+import { Separator } from "@/components/ui/separator"
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { cn } from "@/lib/utils"
+import { ScrollArea } from "@/components/ui/scroll-area"
+
+// 조선 아이템 서비스 import
+import {
+ getWorkTypes,
+ getAllShipbuildingItemsForCache,
+ getShipTypes,
+ type ShipbuildingItem,
+ type ShipbuildingWorkType
+} from "@/lib/items-tech/service"
+
+
+// 유효성 검증 스키마
+const createShipRfqSchema = z.object({
+ biddingProjectId: z.number({
+ required_error: "프로젝트를 선택해주세요.",
+ }),
+ itemIds: z.array(z.number()).min(1, {
+ message: "적어도 하나의 아이템을 선택해야 합니다.",
+ }),
+ dueDate: z.date({
+ required_error: "마감일을 선택해주세요.",
+ }),
+ description: z.string().optional(),
+})
+
+// 폼 데이터 타입
+type CreateShipRfqFormValues = z.infer<typeof createShipRfqSchema>
+
+// 공종 타입 정의
+interface WorkTypeOption {
+ code: ShipbuildingWorkType
+ name: string
+}
+
+interface CreateShipRfqDialogProps {
+ onCreated?: () => void;
+}
+
+export function CreateShipRfqDialog({ onCreated }: CreateShipRfqDialogProps) {
+ const { data: session } = useSession()
+ const [isProcessing, setIsProcessing] = React.useState(false)
+ const [isDialogOpen, setIsDialogOpen] = React.useState(false)
+ const [selectedProject, setSelectedProject] = React.useState<Project | null>(null)
+
+ // 검색 및 필터링 상태
+ const [itemSearchQuery, setItemSearchQuery] = React.useState("")
+ const [selectedWorkType, setSelectedWorkType] = React.useState<ShipbuildingWorkType | null>(null)
+ const [selectedShipType, setSelectedShipType] = React.useState<string | null>(null)
+ const [selectedItems, setSelectedItems] = React.useState<ShipbuildingItem[]>([])
+
+ // 데이터 상태
+ const [workTypes, setWorkTypes] = React.useState<WorkTypeOption[]>([])
+ const [allItems, setAllItems] = React.useState<ShipbuildingItem[]>([])
+ const [shipTypes, setShipTypes] = React.useState<string[]>([])
+ const [isLoadingItems, setIsLoadingItems] = React.useState(false)
+ const [dataLoadError, setDataLoadError] = React.useState<string | null>(null)
+ const [retryCount, setRetryCount] = React.useState(0)
+
+ // 데이터 로딩 함수
+ const loadData = React.useCallback(async (isRetry = false) => {
+ try {
+ if (!isRetry) {
+ setIsLoadingItems(true)
+ setDataLoadError(null)
+ }
+
+ console.log(`조선 RFQ 데이터 로딩 시작... ${isRetry ? `(재시도 ${retryCount + 1}회)` : ''}`)
+
+ const [workTypesResult, itemsResult, shipTypesResult] = await Promise.all([
+ getWorkTypes(),
+ getAllShipbuildingItemsForCache(),
+ getShipTypes()
+ ])
+
+ console.log("Ship - WorkTypes 결과:", workTypesResult)
+ console.log("Ship - Items 결과:", itemsResult)
+ console.log("Ship - ShipTypes 결과:", shipTypesResult)
+
+ // WorkTypes 설정
+ if (Array.isArray(workTypesResult)) {
+ setWorkTypes(workTypesResult)
+ } else {
+ throw new Error("공종 데이터를 불러올 수 없습니다.")
+ }
+
+ // Items 설정
+ if (!itemsResult.error && itemsResult.data && Array.isArray(itemsResult.data)) {
+ setAllItems(itemsResult.data)
+ console.log("Ship 아이템 설정 완료:", itemsResult.data.length, "개")
+ } else {
+ throw new Error(itemsResult.error || "Ship 아이템 데이터를 불러올 수 없습니다.")
+ }
+
+ // ShipTypes 설정
+ if (!shipTypesResult.error && shipTypesResult.data && Array.isArray(shipTypesResult.data)) {
+ setShipTypes(shipTypesResult.data)
+ console.log("선종 설정 완료:", shipTypesResult.data)
+ } else {
+ throw new Error(shipTypesResult.error || "선종 데이터를 불러올 수 없습니다.")
+ }
+
+ // 성공 시 재시도 카운터 리셋
+ setRetryCount(0)
+ setDataLoadError(null)
+ console.log("조선 RFQ 데이터 로딩 완료")
+
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'
+ console.error("조선 RFQ 데이터 로딩 오류:", errorMessage)
+
+ setDataLoadError(errorMessage)
+
+ // 3회까지 자동 재시도 (500ms 간격)
+ if (retryCount < 2) {
+ console.log(`${500 * (retryCount + 1)}ms 후 재시도...`)
+ setTimeout(() => {
+ setRetryCount(prev => prev + 1)
+ loadData(true)
+ }, 500 * (retryCount + 1))
+ } else {
+ // 재시도 실패 시 사용자에게 알림
+ toast.error(`데이터 로딩에 실패했습니다: ${errorMessage}`)
+ }
+ } finally {
+ if (!isRetry) {
+ setIsLoadingItems(false)
+ }
+ }
+ }, [retryCount])
+
+ // 다이얼로그가 열릴 때마다 데이터 로딩
+ React.useEffect(() => {
+ if (isDialogOpen) {
+ setDataLoadError(null)
+ setRetryCount(0)
+ loadData()
+ }
+ }, [isDialogOpen, loadData])
+
+ // 수동 새로고침 함수
+ const handleRefreshData = React.useCallback(() => {
+ setDataLoadError(null)
+ setRetryCount(0)
+ loadData()
+ }, [loadData])
+
+ // RFQ 생성 폼
+ const form = useForm<CreateShipRfqFormValues>({
+ resolver: zodResolver(createShipRfqSchema),
+ defaultValues: {
+ biddingProjectId: undefined,
+ itemIds: [],
+ dueDate: undefined,
+ description: "",
+ }
+ })
+
+ // 필터링된 아이템 목록 가져오기
+ const availableItems = React.useMemo(() => {
+ let filtered = [...allItems]
+
+ // 선종 필터
+ if (selectedShipType) {
+ filtered = filtered.filter(item => item.shipTypes === selectedShipType)
+ }
+
+ // 공종 필터
+ if (selectedWorkType) {
+ filtered = filtered.filter(item => item.workType === selectedWorkType)
+ }
+
+ // 검색어 필터
+ if (itemSearchQuery && itemSearchQuery.trim()) {
+ const query = itemSearchQuery.toLowerCase().trim()
+ filtered = filtered.filter(item =>
+ item.itemCode.toLowerCase().includes(query) ||
+ (item.itemList && item.itemList.toLowerCase().includes(query))
+ )
+ }
+
+ return filtered
+ }, [allItems, itemSearchQuery, selectedWorkType, selectedShipType])
+
+ // 사용 가능한 선종 목록 가져오기
+ const availableShipTypes = React.useMemo(() => {
+ return shipTypes
+ }, [shipTypes])
+
+ // 프로젝트 선택 처리
+ const handleProjectSelect = (project: Project) => {
+ setSelectedProject(project)
+ form.setValue("biddingProjectId", project.id)
+ // 선택 초기화
+ setSelectedItems([])
+ setSelectedShipType(null)
+ setSelectedWorkType(null)
+ setItemSearchQuery("")
+ form.setValue("itemIds", [])
+ }
+
+ // 아이템 선택/해제 처리
+ const handleItemToggle = (item: ShipbuildingItem) => {
+ const isSelected = selectedItems.some(selected => selected.id === item.id)
+
+ if (isSelected) {
+ const newSelectedItems = selectedItems.filter(selected => selected.id !== item.id)
+ setSelectedItems(newSelectedItems)
+ form.setValue("itemIds", newSelectedItems.map(item => item.id))
+ } else {
+ const newSelectedItems = [...selectedItems, item]
+ setSelectedItems(newSelectedItems)
+ form.setValue("itemIds", newSelectedItems.map(item => item.id))
+ }
+ }
+
+ // RFQ 생성 함수
+ const handleCreateRfq = async (data: CreateShipRfqFormValues) => {
+ try {
+ setIsProcessing(true)
+
+ // 사용자 인증 확인
+ if (!session?.user?.id) {
+ throw new Error("로그인이 필요합니다")
+ }
+
+ // 조선 RFQ 생성 - 1:N 관계로 한 번에 생성
+ const result = await createTechSalesShipRfq({
+ biddingProjectId: data.biddingProjectId,
+ itemIds: data.itemIds,
+ dueDate: data.dueDate,
+ description: data.description,
+ createdBy: Number(session.user.id),
+ })
+
+ if (result.error) {
+ throw new Error(result.error)
+ }
+
+ // 성공적으로 생성되면 다이얼로그 닫기 및 메시지 표시
+ toast.success(`${selectedItems.length}개 아이템으로 조선 RFQ가 성공적으로 생성되었습니다`)
+
+ setIsDialogOpen(false)
+ form.reset({
+ biddingProjectId: undefined,
+ itemIds: [],
+ dueDate: undefined,
+ description: "",
+ })
+ setSelectedProject(null)
+ setItemSearchQuery("")
+ setSelectedWorkType(null)
+ setSelectedShipType(null)
+ setSelectedItems([])
+ setDataLoadError(null)
+ setRetryCount(0)
+
+ // 생성 후 콜백 실행
+ if (onCreated) {
+ onCreated()
+ }
+
+ } catch (error) {
+ console.error("조선 RFQ 생성 오류:", error)
+ toast.error(`조선 RFQ 생성 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`)
+ } finally {
+ setIsProcessing(false)
+ }
+ }
+
+ return (
+ <Dialog
+ open={isDialogOpen}
+ onOpenChange={(open) => {
+ setIsDialogOpen(open)
+ if (!open) {
+ form.reset({
+ biddingProjectId: undefined,
+ itemIds: [],
+ dueDate: undefined,
+ description: "",
+ })
+ setSelectedProject(null)
+ setItemSearchQuery("")
+ setSelectedWorkType(null)
+ setSelectedShipType(null)
+ setSelectedItems([])
+ setDataLoadError(null)
+ setRetryCount(0)
+ }
+ }}
+ >
+ <DialogTrigger asChild>
+ <Button
+ variant="default"
+ size="sm"
+ className="gap-2"
+ disabled={isProcessing}
+ >
+ <Plus className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">조선 RFQ 생성</span>
+ </Button>
+ </DialogTrigger>
+ <DialogContent
+ className="max-w-none h-[90vh] overflow-y-auto flex flex-col"
+ style={{ width: '1200px' }}
+ >
+ <DialogHeader className="border-b pb-4">
+ <DialogTitle>조선 RFQ 생성</DialogTitle>
+ </DialogHeader>
+
+ <div className="space-y-6 p-1 overflow-y-auto">
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(handleCreateRfq)} className="space-y-6">
+ {/* 프로젝트 선택 */}
+ <div className="space-y-4">
+ <FormField
+ control={form.control}
+ name="biddingProjectId"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>입찰 프로젝트</FormLabel>
+ <FormControl>
+ <EstimateProjectSelector
+ selectedProjectId={field.value}
+ onProjectSelect={handleProjectSelect}
+ placeholder="입찰 프로젝트를 선택하세요"
+ pjtType="SHIP"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <Separator className="my-4" />
+
+ {/* RFQ 설명 */}
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>RFQ Title</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="RFQ Title을 입력하세요 (선택사항)"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <Separator className="my-4" />
+
+ {/* 선종 선택 */}
+ <div className="space-y-4">
+ <div>
+ <FormLabel>선종 선택</FormLabel>
+ </div>
+
+ {/* 데이터 로딩 에러 표시 */}
+ {dataLoadError && (
+ <div className="p-3 bg-destructive/10 border border-destructive/20 rounded-md">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <X className="h-4 w-4 text-destructive" />
+ <span className="text-sm text-destructive">{dataLoadError}</span>
+ </div>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleRefreshData}
+ disabled={isLoadingItems}
+ className="h-8 text-xs"
+ >
+ {isLoadingItems ? (
+ <>
+ <Loader2 className="h-3 w-3 animate-spin mr-1" />
+ 재시도 중...
+ </>
+ ) : (
+ "다시 시도"
+ )}
+ </Button>
+ </div>
+ </div>
+ )}
+
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="outline"
+ className="w-full justify-between"
+ disabled={!selectedProject || isLoadingItems || dataLoadError !== null}
+ >
+ {isLoadingItems ? (
+ <>
+ <Loader2 className="h-4 w-4 animate-spin mr-2" />
+ 데이터 로딩 중...
+ </>
+ ) : dataLoadError ? (
+ "데이터 로딩 실패"
+ ) : selectedShipType ? (
+ selectedShipType
+ ) : (
+ "선종을 선택하세요"
+ )}
+ <ArrowUpDown className="ml-2 h-4 w-4 opacity-50" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent className="w-full max-h-60 overflow-y-auto">
+ <DropdownMenuCheckboxItem
+ checked={selectedShipType === null}
+ onCheckedChange={() => {
+ setSelectedShipType(null)
+ setSelectedItems([])
+ form.setValue("itemIds", [])
+ }}
+ >
+ 전체 선종
+ </DropdownMenuCheckboxItem>
+ {availableShipTypes.map(shipType => (
+ <DropdownMenuCheckboxItem
+ key={shipType}
+ checked={selectedShipType === shipType}
+ onCheckedChange={() => {
+ setSelectedShipType(shipType)
+ setSelectedItems([])
+ form.setValue("itemIds", [])
+ }}
+ >
+ {shipType}
+ </DropdownMenuCheckboxItem>
+ ))}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+
+ <Separator className="my-4" />
+
+ {/* 마감일 설정 */}
+ <FormField
+ control={form.control}
+ name="dueDate"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>마감일</FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ className={cn(
+ "w-full pl-3 text-left font-normal",
+ !field.value && "text-muted-foreground"
+ )}
+ >
+ {field.value ? (
+ format(field.value, "PPP", { locale: ko })
+ ) : (
+ <span>마감일을 선택하세요</span>
+ )}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={field.value}
+ onSelect={field.onChange}
+ disabled={(date) =>
+ date < new Date() || date < new Date("1900-01-01")
+ }
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <Separator className="my-4" />
+
+ <div className="space-y-6">
+ {/* 아이템 선택 영역 */}
+ <div className="space-y-4">
+ <div>
+ <FormLabel>조선 아이템 선택</FormLabel>
+ <FormDescription>
+ {selectedShipType
+ ? `선종 ${selectedShipType}의 공종별 아이템을 선택하세요`
+ : "먼저 선종을 선택해주세요"
+ }
+ </FormDescription>
+ </div>
+
+ {/* 아이템 검색 및 필터 */}
+ <div className="space-y-2">
+ <div className="flex space-x-2">
+ <div className="relative flex-1">
+ <Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
+ <Input
+ placeholder="아이템 검색..."
+ value={itemSearchQuery}
+ onChange={(e) => setItemSearchQuery(e.target.value)}
+ className="pl-8 pr-8"
+ disabled={!selectedShipType || isLoadingItems || dataLoadError !== null}
+ />
+ {itemSearchQuery && (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="absolute right-0 top-0 h-full px-3"
+ onClick={() => setItemSearchQuery("")}
+ disabled={!selectedShipType || isLoadingItems || dataLoadError !== null}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+
+ {/* 공종 필터 */}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="outline"
+ className="gap-1"
+ disabled={!selectedShipType || isLoadingItems || dataLoadError !== null}
+ >
+ {selectedWorkType ? workTypes.find(wt => wt.code === selectedWorkType)?.name : "전체 공종"}
+ <ArrowUpDown className="ml-2 h-4 w-4 opacity-50" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuCheckboxItem
+ checked={selectedWorkType === null}
+ onCheckedChange={() => setSelectedWorkType(null)}
+ >
+ 전체 공종
+ </DropdownMenuCheckboxItem>
+ {workTypes.map(workType => (
+ <DropdownMenuCheckboxItem
+ key={workType.code}
+ checked={selectedWorkType === workType.code}
+ onCheckedChange={() => setSelectedWorkType(workType.code)}
+ >
+ {workType.name}
+ </DropdownMenuCheckboxItem>
+ ))}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+ </div>
+
+ {/* 아이템 목록 */}
+ <div className="border rounded-md">
+ <ScrollArea className="h-[300px]">
+ <div className="p-2 space-y-1">
+ {dataLoadError ? (
+ <div className="text-center py-8">
+ <div className="p-4 bg-destructive/10 border border-destructive/20 rounded-md mx-4">
+ <div className="flex flex-col items-center gap-3">
+ <X className="h-8 w-8 text-destructive" />
+ <div className="text-center">
+ <p className="text-sm text-destructive font-medium">데이터 로딩에 실패했습니다</p>
+ <p className="text-xs text-muted-foreground mt-1">{dataLoadError}</p>
+ </div>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleRefreshData}
+ disabled={isLoadingItems}
+ className="h-8"
+ >
+ {isLoadingItems ? (
+ <>
+ <Loader2 className="h-3 w-3 animate-spin mr-1" />
+ 재시도 중...
+ </>
+ ) : (
+ "다시 시도"
+ )}
+ </Button>
+ </div>
+ </div>
+ </div>
+ ) : isLoadingItems ? (
+ <div className="text-center py-8 text-muted-foreground">
+ <Loader2 className="h-6 w-6 animate-spin mx-auto mb-2" />
+ 아이템을 불러오는 중...
+ {retryCount > 0 && (
+ <p className="text-xs mt-1">재시도 {retryCount}회</p>
+ )}
+ </div>
+ ) : availableItems.length > 0 ? (
+ [...availableItems]
+ .sort((a, b) => {
+ const aName = a.itemCode || 'zzz'
+ const bName = b.itemCode || 'zzz'
+ return aName.localeCompare(bName, 'ko', { numeric: true })
+ })
+ .map((item) => {
+ const isSelected = selectedItems.some(selected => selected.id === item.id)
+
+ return (
+ <div
+ key={item.id}
+ className={cn(
+ "flex items-center space-x-2 p-2 rounded-md cursor-pointer hover:bg-muted",
+ isSelected && "bg-muted"
+ )}
+ onClick={() => handleItemToggle(item)}
+ >
+ <div className="flex items-center space-x-2 flex-1">
+ {isSelected ? (
+ <CheckSquare className="h-4 w-4" />
+ ) : (
+ <Square className="h-4 w-4" />
+ )}
+ <div className="flex-1">
+ <div className="font-medium">
+ {item.itemList || '아이템명 없음'}
+ </div>
+ <div className="text-sm text-muted-foreground">
+ {item.itemCode || '자재그룹코드 없음'}
+ </div>
+ <div className="text-xs text-muted-foreground">
+ 공종: {item.workType} • 선종: {item.shipTypes}
+ </div>
+ </div>
+ </div>
+ </div>
+ )
+ })
+ ) : (
+ <div className="text-center py-8 text-muted-foreground">
+ {itemSearchQuery ? "검색 결과가 없습니다" : "아이템이 없습니다"}
+ </div>
+ )}
+ </div>
+ </ScrollArea>
+ </div>
+ </div>
+ </div>
+ </div>
+ </form>
+ </Form>
+ </div>
+
+ {/* Footer - Sticky 버튼 영역 */}
+ <div className="sticky bottom-0 left-0 z-20 bg-background border-t pt-4 mt-4">
+ <div className="flex justify-end space-x-2">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setIsDialogOpen(false)}
+ disabled={isProcessing}
+ >
+ 취소
+ </Button>
+ <Button
+ type="button"
+ onClick={form.handleSubmit(handleCreateRfq)}
+ disabled={
+ isProcessing ||
+ !selectedProject ||
+ selectedItems.length === 0
+ }
+ >
+ {isProcessing ? "처리 중..." : `${selectedItems.length}개 아이템으로 조선 RFQ 생성하기`}
+ </Button>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/create-rfq-top-dialog.tsx b/lib/techsales-rfq/table/create-rfq-top-dialog.tsx
index ef2229ac..49fb35ca 100644
--- a/lib/techsales-rfq/table/create-rfq-top-dialog.tsx
+++ b/lib/techsales-rfq/table/create-rfq-top-dialog.tsx
@@ -1,611 +1,611 @@
-"use client"
-
-import * as React from "react"
-import { toast } from "sonner"
-import { ArrowUpDown, CheckSquare, Plus, Search, Square, X, Loader2 } from "lucide-react"
-import { Calendar } from "@/components/ui/calendar"
-import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
-import { CalendarIcon } from "lucide-react"
-import { format } from "date-fns"
-import { ko } from "date-fns/locale"
-
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
- FormDescription,
-} from "@/components/ui/form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { useForm } from "react-hook-form"
-import * as z from "zod"
-import { EstimateProjectSelector } from "@/components/BidProjectSelector"
-import { type Project } from "@/lib/rfqs/service"
-import { createTechSalesTopRfq } from "@/lib/techsales-rfq/service"
-import { useSession } from "next-auth/react"
-import { Separator } from "@/components/ui/separator"
-import {
- DropdownMenu,
- DropdownMenuCheckboxItem,
- DropdownMenuContent,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu"
-import { cn } from "@/lib/utils"
-import { ScrollArea } from "@/components/ui/scroll-area"
-import { Input } from "@/components/ui/input"
-
-// 공종 타입 import
-import {
- getOffshoreTopWorkTypes,
- getAllOffshoreTopItemsForCache,
- type OffshoreTopWorkType,
- type OffshoreTopTechItem
-} from "@/lib/items-tech/service"
-
-// 해양 TOP 아이템 타입 정의 (이미 service에서 import하므로 제거)
-
-// 유효성 검증 스키마
-const createTopRfqSchema = z.object({
- biddingProjectId: z.number({
- required_error: "프로젝트를 선택해주세요.",
- }),
- itemIds: z.array(z.number()).min(1, {
- message: "적어도 하나의 아이템을 선택해야 합니다.",
- }),
- dueDate: z.date({
- required_error: "마감일을 선택해주세요.",
- }),
- description: z.string().optional(),
-})
-
-// 폼 데이터 타입
-type CreateTopRfqFormValues = z.infer<typeof createTopRfqSchema>
-
-// 공종 타입 정의
-interface WorkTypeOption {
- code: OffshoreTopWorkType
- name: string
-}
-
-interface CreateTopRfqDialogProps {
- onCreated?: () => void;
-}
-
-export function CreateTopRfqDialog({ onCreated }: CreateTopRfqDialogProps) {
- const { data: session } = useSession()
- const [isProcessing, setIsProcessing] = React.useState(false)
- const [isDialogOpen, setIsDialogOpen] = React.useState(false)
- const [selectedProject, setSelectedProject] = React.useState<Project | null>(null)
-
- // 검색 및 필터링 상태
- const [itemSearchQuery, setItemSearchQuery] = React.useState("")
- const [selectedWorkType, setSelectedWorkType] = React.useState<OffshoreTopWorkType | null>(null)
- const [selectedItems, setSelectedItems] = React.useState<OffshoreTopTechItem[]>([])
-
- // 데이터 상태
- const [workTypes, setWorkTypes] = React.useState<WorkTypeOption[]>([])
- const [allItems, setAllItems] = React.useState<OffshoreTopTechItem[]>([])
- const [isLoadingItems, setIsLoadingItems] = React.useState(false)
- const [dataLoadError, setDataLoadError] = React.useState<string | null>(null)
- const [retryCount, setRetryCount] = React.useState(0)
-
- // 데이터 로딩 함수
- const loadData = React.useCallback(async (isRetry = false) => {
- try {
- if (!isRetry) {
- setIsLoadingItems(true)
- setDataLoadError(null)
- }
-
- console.log(`해양 TOP RFQ 데이터 로딩 시작... ${isRetry ? `(재시도 ${retryCount + 1}회)` : ''}`)
-
- const [workTypesResult, topItemsResult] = await Promise.all([
- getOffshoreTopWorkTypes(),
- getAllOffshoreTopItemsForCache()
- ])
-
- console.log("TOP - WorkTypes 결과:", workTypesResult)
- console.log("TOP - Items 결과:", topItemsResult)
-
- // WorkTypes 설정
- if (Array.isArray(workTypesResult)) {
- setWorkTypes(workTypesResult)
- } else {
- throw new Error("공종 데이터를 불러올 수 없습니다.")
- }
-
- // TOP Items 설정
- if (topItemsResult.data && Array.isArray(topItemsResult.data)) {
- setAllItems(topItemsResult.data as OffshoreTopTechItem[])
- console.log("TOP 아이템 설정 완료:", topItemsResult.data.length, "개")
- } else {
- throw new Error("TOP 아이템 데이터를 불러올 수 없습니다.")
- }
-
- // 성공 시 재시도 카운터 리셋
- setRetryCount(0)
- setDataLoadError(null)
- console.log("해양 TOP RFQ 데이터 로딩 완료")
-
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'
- console.error("해양 TOP RFQ 데이터 로딩 오류:", errorMessage)
-
- setDataLoadError(errorMessage)
-
- // 3회까지 자동 재시도 (500ms 간격)
- if (retryCount < 2) {
- console.log(`${500 * (retryCount + 1)}ms 후 재시도...`)
- setTimeout(() => {
- setRetryCount(prev => prev + 1)
- loadData(true)
- }, 500 * (retryCount + 1))
- } else {
- // 재시도 실패 시 사용자에게 알림
- toast.error(`데이터 로딩에 실패했습니다: ${errorMessage}`)
- }
- } finally {
- if (!isRetry) {
- setIsLoadingItems(false)
- }
- }
- }, [retryCount])
-
- // 다이얼로그가 열릴 때마다 데이터 로딩
- React.useEffect(() => {
- if (isDialogOpen) {
- setDataLoadError(null)
- setRetryCount(0)
- loadData()
- }
- }, [isDialogOpen, loadData])
-
- // 수동 새로고침 함수
- const handleRefreshData = React.useCallback(() => {
- setDataLoadError(null)
- setRetryCount(0)
- loadData()
- }, [loadData])
-
- // RFQ 생성 폼
- const form = useForm<CreateTopRfqFormValues>({
- resolver: zodResolver(createTopRfqSchema),
- defaultValues: {
- biddingProjectId: undefined,
- itemIds: [],
- dueDate: undefined,
- description: "",
- }
- })
-
- // 필터링된 아이템 목록 가져오기
- const availableItems = React.useMemo(() => {
- let filtered = [...allItems]
-
- // 공종 필터
- if (selectedWorkType) {
- filtered = filtered.filter(item => item.workType === selectedWorkType as OffshoreTopTechItem['workType'])
- }
-
- // 검색어 필터
- if (itemSearchQuery && itemSearchQuery.trim()) {
- const query = itemSearchQuery.toLowerCase().trim()
- filtered = filtered.filter(item =>
- item.itemCode.toLowerCase().includes(query) ||
- (item.itemList && item.itemList.toLowerCase().includes(query)) ||
- (item.subItemList && item.subItemList.toLowerCase().includes(query))
- )
- }
-
- return filtered
- }, [allItems, itemSearchQuery, selectedWorkType])
-
- // 프로젝트 선택 처리
- const handleProjectSelect = (project: Project) => {
- setSelectedProject(project)
- form.setValue("biddingProjectId", project.id)
- // 선택 초기화
- setSelectedItems([])
- setSelectedWorkType(null)
- setItemSearchQuery("")
- form.setValue("itemIds", [])
- }
-
- // 아이템 선택/해제 처리
- const handleItemToggle = (item: OffshoreTopTechItem) => {
- const isSelected = selectedItems.some(selected => selected.id === item.id)
-
- if (isSelected) {
- const newSelectedItems = selectedItems.filter(selected => selected.id !== item.id)
- setSelectedItems(newSelectedItems)
- form.setValue("itemIds", newSelectedItems.map(item => item.id))
- } else {
- const newSelectedItems = [...selectedItems, item]
- setSelectedItems(newSelectedItems)
- form.setValue("itemIds", newSelectedItems.map(item => item.id))
- }
- }
-
- // RFQ 생성 함수
- const handleCreateRfq = async (data: CreateTopRfqFormValues) => {
- try {
- setIsProcessing(true)
-
- // 사용자 인증 확인
- if (!session?.user?.id) {
- throw new Error("로그인이 필요합니다")
- }
-
- // 해양 TOP RFQ 생성 - 1:N 관계로 한 번에 생성
- const result = await createTechSalesTopRfq({
- biddingProjectId: data.biddingProjectId,
- itemIds: data.itemIds,
- dueDate: data.dueDate,
- description: data.description,
- createdBy: Number(session.user.id),
- })
-
- if (result.error) {
- throw new Error(result.error)
- }
-
- // 성공적으로 생성되면 다이얼로그 닫기 및 메시지 표시
- toast.success(`${selectedItems.length}개 아이템으로 해양 TOP RFQ가 성공적으로 생성되었습니다`)
-
- setIsDialogOpen(false)
- form.reset({
- biddingProjectId: undefined,
- itemIds: [],
- dueDate: undefined,
- description: "",
- })
- setSelectedProject(null)
- setItemSearchQuery("")
- setSelectedWorkType(null)
- setSelectedItems([])
- setDataLoadError(null)
- setRetryCount(0)
-
- // 생성 후 콜백 실행
- if (onCreated) {
- onCreated()
- }
-
- } catch (error) {
- console.error("해양 TOP RFQ 생성 오류:", error)
- toast.error(`해양 TOP RFQ 생성 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`)
- } finally {
- setIsProcessing(false)
- }
- }
-
- return (
- <Dialog
- open={isDialogOpen}
- onOpenChange={(open) => {
- setIsDialogOpen(open)
- if (!open) {
- form.reset({
- biddingProjectId: undefined,
- itemIds: [],
- dueDate: undefined,
- description: "",
- })
- setSelectedProject(null)
- setItemSearchQuery("")
- setSelectedWorkType(null)
- setSelectedItems([])
- setDataLoadError(null)
- setRetryCount(0)
- }
- }}
- >
- <DialogTrigger asChild>
- <Button
- variant="default"
- size="sm"
- className="gap-2"
- disabled={isProcessing}
- >
- <Plus className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">해양 TOP RFQ 생성</span>
- </Button>
- </DialogTrigger>
- <DialogContent
- className="max-w-none h-[90vh] overflow-y-auto flex flex-col"
- style={{ width: '1200px' }}
- >
- <DialogHeader className="border-b pb-4">
- <DialogTitle>해양 TOP RFQ 생성</DialogTitle>
- </DialogHeader>
-
- <div className="space-y-6 p-1 overflow-y-auto">
- <Form {...form}>
- <form onSubmit={form.handleSubmit(handleCreateRfq)} className="space-y-6">
- {/* 프로젝트 선택 */}
- <div className="space-y-4">
- <FormField
- control={form.control}
- name="biddingProjectId"
- render={({ field }) => (
- <FormItem>
- <FormLabel>입찰 프로젝트</FormLabel>
- <FormControl>
- <EstimateProjectSelector
- selectedProjectId={field.value}
- onProjectSelect={handleProjectSelect}
- placeholder="입찰 프로젝트를 선택하세요"
- pjtType="TOP"
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <Separator className="my-4" />
- {/* RFQ 설명 */}
- <FormField
- control={form.control}
- name="description"
- render={({ field }) => (
- <FormItem>
- <FormLabel>RFQ Title</FormLabel>
- <FormControl>
- <Input
- placeholder="RFQ Title을 입력하세요 (선택사항)"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- <Separator className="my-4" />
- {/* 마감일 설정 */}
- <FormField
- control={form.control}
- name="dueDate"
- render={({ field }) => (
- <FormItem className="flex flex-col">
- <FormLabel>마감일</FormLabel>
- <Popover>
- <PopoverTrigger asChild>
- <FormControl>
- <Button
- variant="outline"
- className={cn(
- "w-full pl-3 text-left font-normal",
- !field.value && "text-muted-foreground"
- )}
- >
- {field.value ? (
- format(field.value, "PPP", { locale: ko })
- ) : (
- <span>마감일을 선택하세요</span>
- )}
- <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
- </Button>
- </FormControl>
- </PopoverTrigger>
- <PopoverContent className="w-auto p-0" align="start">
- <Calendar
- mode="single"
- selected={field.value}
- onSelect={field.onChange}
- disabled={(date) =>
- date < new Date() || date < new Date("1900-01-01")
- }
- initialFocus
- />
- </PopoverContent>
- </Popover>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <Separator className="my-4" />
-
- <div className="space-y-6">
- {/* 아이템 선택 영역 */}
- <div className="space-y-4">
- <div>
- <FormLabel>아이템 선택</FormLabel>
- <FormDescription>
- 해양 TOP RFQ를 생성하려면 아이템을 선택하세요
- </FormDescription>
- </div>
-
- {/* 아이템 검색 및 필터 */}
- <div className="space-y-2">
- <div className="flex space-x-2">
- <div className="relative flex-1">
- <Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
- <Input
- placeholder="아이템 검색..."
- value={itemSearchQuery}
- onChange={(e) => setItemSearchQuery(e.target.value)}
- className="pl-8 pr-8"
- disabled={isLoadingItems || dataLoadError !== null}
- />
- {itemSearchQuery && (
- <Button
- variant="ghost"
- size="sm"
- className="absolute right-0 top-0 h-full px-3"
- onClick={() => setItemSearchQuery("")}
- disabled={isLoadingItems || dataLoadError !== null}
- >
- <X className="h-4 w-4" />
- </Button>
- )}
- </div>
-
- {/* 공종 필터 */}
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- variant="outline"
- className="gap-1"
- disabled={isLoadingItems || dataLoadError !== null}
- >
- {selectedWorkType ? workTypes.find(wt => wt.code === selectedWorkType)?.name : "전체 공종"}
- <ArrowUpDown className="ml-2 h-4 w-4 opacity-50" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end">
- <DropdownMenuCheckboxItem
- checked={selectedWorkType === null}
- onCheckedChange={() => setSelectedWorkType(null)}
- >
- 전체 공종
- </DropdownMenuCheckboxItem>
- {workTypes.map(workType => (
- <DropdownMenuCheckboxItem
- key={workType.code}
- checked={selectedWorkType === workType.code}
- onCheckedChange={() => setSelectedWorkType(workType.code)}
- >
- {workType.name}
- </DropdownMenuCheckboxItem>
- ))}
- </DropdownMenuContent>
- </DropdownMenu>
- </div>
- </div>
-
- {/* 아이템 목록 */}
- <div className="border rounded-md">
- <ScrollArea className="h-[300px]">
- <div className="p-2 space-y-1">
- {dataLoadError ? (
- <div className="text-center py-8">
- <div className="p-4 bg-destructive/10 border border-destructive/20 rounded-md mx-4">
- <div className="flex flex-col items-center gap-3">
- <X className="h-8 w-8 text-destructive" />
- <div className="text-center">
- <p className="text-sm text-destructive font-medium">데이터 로딩에 실패했습니다</p>
- <p className="text-xs text-muted-foreground mt-1">{dataLoadError}</p>
- </div>
- <Button
- variant="outline"
- size="sm"
- onClick={handleRefreshData}
- disabled={isLoadingItems}
- className="h-8"
- >
- {isLoadingItems ? (
- <>
- <Loader2 className="h-3 w-3 animate-spin mr-1" />
- 재시도 중...
- </>
- ) : (
- "다시 시도"
- )}
- </Button>
- </div>
- </div>
- </div>
- ) : isLoadingItems ? (
- <div className="text-center py-8 text-muted-foreground">
- <Loader2 className="h-6 w-6 animate-spin mx-auto mb-2" />
- 아이템을 불러오는 중...
- {retryCount > 0 && (
- <p className="text-xs mt-1">재시도 {retryCount}회</p>
- )}
- </div>
- ) : availableItems.length > 0 ? (
- [...availableItems]
- .sort((a, b) => {
- const aName = a.itemList || 'zzz'
- const bName = b.itemList || 'zzz'
- return aName.localeCompare(bName, 'ko', { numeric: true })
- })
- .map((item) => {
- const isSelected = selectedItems.some(selected => selected.id === item.id)
-
- return (
- <div
- key={item.id}
- className={cn(
- "flex items-center space-x-2 p-2 rounded-md cursor-pointer hover:bg-muted",
- isSelected && "bg-muted"
- )}
- onClick={() => handleItemToggle(item)}
- >
- <div className="flex items-center space-x-2 flex-1">
- {isSelected ? (
- <CheckSquare className="h-4 w-4" />
- ) : (
- <Square className="h-4 w-4" />
- )}
- <div className="flex-1">
- <div className="font-medium">
- {item.itemList || '아이템명 없음'}
- {item.subItemList && ` / ${item.subItemList}`}
- </div>
- <div className="text-sm text-muted-foreground">
- {item.itemCode || '아이템코드 없음'}
- </div>
- <div className="text-xs text-muted-foreground">
- 공종: {item.workType}
- </div>
- </div>
- </div>
- </div>
- )
- })
- ) : (
- <div className="text-center py-8 text-muted-foreground">
- {itemSearchQuery ? "검색 결과가 없습니다" : "아이템이 없습니다"}
- </div>
- )}
- </div>
- </ScrollArea>
- </div>
- </div>
- </div>
- </div>
- </form>
- </Form>
- </div>
-
- {/* Footer - Sticky 버튼 영역 */}
- <div className="sticky bottom-0 left-0 z-20 bg-background border-t pt-4 mt-4">
- <div className="flex justify-end space-x-2">
- <Button
- type="button"
- variant="outline"
- onClick={() => setIsDialogOpen(false)}
- disabled={isProcessing}
- >
- 취소
- </Button>
- <Button
- type="button"
- onClick={form.handleSubmit(handleCreateRfq)}
- disabled={
- isProcessing ||
- !selectedProject ||
- selectedItems.length === 0
- }
- >
- {isProcessing ? "처리 중..." : `${selectedItems.length}개 아이템으로 해양 TOP RFQ 생성하기`}
- </Button>
- </div>
- </div>
- </DialogContent>
- </Dialog>
- )
+"use client"
+
+import * as React from "react"
+import { toast } from "sonner"
+import { ArrowUpDown, CheckSquare, Plus, Search, Square, X, Loader2 } from "lucide-react"
+import { Calendar } from "@/components/ui/calendar"
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
+import { CalendarIcon } from "lucide-react"
+import { format } from "date-fns"
+import { ko } from "date-fns/locale"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ FormDescription,
+} from "@/components/ui/form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useForm } from "react-hook-form"
+import * as z from "zod"
+import { EstimateProjectSelector } from "@/components/BidProjectSelector"
+import { type Project } from "@/lib/rfqs/service"
+import { createTechSalesTopRfq } from "@/lib/techsales-rfq/service"
+import { useSession } from "next-auth/react"
+import { Separator } from "@/components/ui/separator"
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { cn } from "@/lib/utils"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { Input } from "@/components/ui/input"
+
+// 공종 타입 import
+import {
+ getOffshoreTopWorkTypes,
+ getAllOffshoreTopItemsForCache,
+ type OffshoreTopWorkType,
+ type OffshoreTopTechItem
+} from "@/lib/items-tech/service"
+
+// 해양 TOP 아이템 타입 정의 (이미 service에서 import하므로 제거)
+
+// 유효성 검증 스키마
+const createTopRfqSchema = z.object({
+ biddingProjectId: z.number({
+ required_error: "프로젝트를 선택해주세요.",
+ }),
+ itemIds: z.array(z.number()).min(1, {
+ message: "적어도 하나의 아이템을 선택해야 합니다.",
+ }),
+ dueDate: z.date({
+ required_error: "마감일을 선택해주세요.",
+ }),
+ description: z.string().optional(),
+})
+
+// 폼 데이터 타입
+type CreateTopRfqFormValues = z.infer<typeof createTopRfqSchema>
+
+// 공종 타입 정의
+interface WorkTypeOption {
+ code: OffshoreTopWorkType
+ name: string
+}
+
+interface CreateTopRfqDialogProps {
+ onCreated?: () => void;
+}
+
+export function CreateTopRfqDialog({ onCreated }: CreateTopRfqDialogProps) {
+ const { data: session } = useSession()
+ const [isProcessing, setIsProcessing] = React.useState(false)
+ const [isDialogOpen, setIsDialogOpen] = React.useState(false)
+ const [selectedProject, setSelectedProject] = React.useState<Project | null>(null)
+
+ // 검색 및 필터링 상태
+ const [itemSearchQuery, setItemSearchQuery] = React.useState("")
+ const [selectedWorkType, setSelectedWorkType] = React.useState<OffshoreTopWorkType | null>(null)
+ const [selectedItems, setSelectedItems] = React.useState<OffshoreTopTechItem[]>([])
+
+ // 데이터 상태
+ const [workTypes, setWorkTypes] = React.useState<WorkTypeOption[]>([])
+ const [allItems, setAllItems] = React.useState<OffshoreTopTechItem[]>([])
+ const [isLoadingItems, setIsLoadingItems] = React.useState(false)
+ const [dataLoadError, setDataLoadError] = React.useState<string | null>(null)
+ const [retryCount, setRetryCount] = React.useState(0)
+
+ // 데이터 로딩 함수
+ const loadData = React.useCallback(async (isRetry = false) => {
+ try {
+ if (!isRetry) {
+ setIsLoadingItems(true)
+ setDataLoadError(null)
+ }
+
+ console.log(`해양 TOP RFQ 데이터 로딩 시작... ${isRetry ? `(재시도 ${retryCount + 1}회)` : ''}`)
+
+ const [workTypesResult, topItemsResult] = await Promise.all([
+ getOffshoreTopWorkTypes(),
+ getAllOffshoreTopItemsForCache()
+ ])
+
+ console.log("TOP - WorkTypes 결과:", workTypesResult)
+ console.log("TOP - Items 결과:", topItemsResult)
+
+ // WorkTypes 설정
+ if (Array.isArray(workTypesResult)) {
+ setWorkTypes(workTypesResult)
+ } else {
+ throw new Error("공종 데이터를 불러올 수 없습니다.")
+ }
+
+ // TOP Items 설정
+ if (topItemsResult.data && Array.isArray(topItemsResult.data)) {
+ setAllItems(topItemsResult.data as OffshoreTopTechItem[])
+ console.log("TOP 아이템 설정 완료:", topItemsResult.data.length, "개")
+ } else {
+ throw new Error("TOP 아이템 데이터를 불러올 수 없습니다.")
+ }
+
+ // 성공 시 재시도 카운터 리셋
+ setRetryCount(0)
+ setDataLoadError(null)
+ console.log("해양 TOP RFQ 데이터 로딩 완료")
+
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'
+ console.error("해양 TOP RFQ 데이터 로딩 오류:", errorMessage)
+
+ setDataLoadError(errorMessage)
+
+ // 3회까지 자동 재시도 (500ms 간격)
+ if (retryCount < 2) {
+ console.log(`${500 * (retryCount + 1)}ms 후 재시도...`)
+ setTimeout(() => {
+ setRetryCount(prev => prev + 1)
+ loadData(true)
+ }, 500 * (retryCount + 1))
+ } else {
+ // 재시도 실패 시 사용자에게 알림
+ toast.error(`데이터 로딩에 실패했습니다: ${errorMessage}`)
+ }
+ } finally {
+ if (!isRetry) {
+ setIsLoadingItems(false)
+ }
+ }
+ }, [retryCount])
+
+ // 다이얼로그가 열릴 때마다 데이터 로딩
+ React.useEffect(() => {
+ if (isDialogOpen) {
+ setDataLoadError(null)
+ setRetryCount(0)
+ loadData()
+ }
+ }, [isDialogOpen, loadData])
+
+ // 수동 새로고침 함수
+ const handleRefreshData = React.useCallback(() => {
+ setDataLoadError(null)
+ setRetryCount(0)
+ loadData()
+ }, [loadData])
+
+ // RFQ 생성 폼
+ const form = useForm<CreateTopRfqFormValues>({
+ resolver: zodResolver(createTopRfqSchema),
+ defaultValues: {
+ biddingProjectId: undefined,
+ itemIds: [],
+ dueDate: undefined,
+ description: "",
+ }
+ })
+
+ // 필터링된 아이템 목록 가져오기
+ const availableItems = React.useMemo(() => {
+ let filtered = [...allItems]
+
+ // 공종 필터
+ if (selectedWorkType) {
+ filtered = filtered.filter(item => item.workType === selectedWorkType as OffshoreTopTechItem['workType'])
+ }
+
+ // 검색어 필터
+ if (itemSearchQuery && itemSearchQuery.trim()) {
+ const query = itemSearchQuery.toLowerCase().trim()
+ filtered = filtered.filter(item =>
+ item.itemCode.toLowerCase().includes(query) ||
+ (item.itemList && item.itemList.toLowerCase().includes(query)) ||
+ (item.subItemList && item.subItemList.toLowerCase().includes(query))
+ )
+ }
+
+ return filtered
+ }, [allItems, itemSearchQuery, selectedWorkType])
+
+ // 프로젝트 선택 처리
+ const handleProjectSelect = (project: Project) => {
+ setSelectedProject(project)
+ form.setValue("biddingProjectId", project.id)
+ // 선택 초기화
+ setSelectedItems([])
+ setSelectedWorkType(null)
+ setItemSearchQuery("")
+ form.setValue("itemIds", [])
+ }
+
+ // 아이템 선택/해제 처리
+ const handleItemToggle = (item: OffshoreTopTechItem) => {
+ const isSelected = selectedItems.some(selected => selected.id === item.id)
+
+ if (isSelected) {
+ const newSelectedItems = selectedItems.filter(selected => selected.id !== item.id)
+ setSelectedItems(newSelectedItems)
+ form.setValue("itemIds", newSelectedItems.map(item => item.id))
+ } else {
+ const newSelectedItems = [...selectedItems, item]
+ setSelectedItems(newSelectedItems)
+ form.setValue("itemIds", newSelectedItems.map(item => item.id))
+ }
+ }
+
+ // RFQ 생성 함수
+ const handleCreateRfq = async (data: CreateTopRfqFormValues) => {
+ try {
+ setIsProcessing(true)
+
+ // 사용자 인증 확인
+ if (!session?.user?.id) {
+ throw new Error("로그인이 필요합니다")
+ }
+
+ // 해양 TOP RFQ 생성 - 1:N 관계로 한 번에 생성
+ const result = await createTechSalesTopRfq({
+ biddingProjectId: data.biddingProjectId,
+ itemIds: data.itemIds,
+ dueDate: data.dueDate,
+ description: data.description,
+ createdBy: Number(session.user.id),
+ })
+
+ if (result.error) {
+ throw new Error(result.error)
+ }
+
+ // 성공적으로 생성되면 다이얼로그 닫기 및 메시지 표시
+ toast.success(`${selectedItems.length}개 아이템으로 해양 TOP RFQ가 성공적으로 생성되었습니다`)
+
+ setIsDialogOpen(false)
+ form.reset({
+ biddingProjectId: undefined,
+ itemIds: [],
+ dueDate: undefined,
+ description: "",
+ })
+ setSelectedProject(null)
+ setItemSearchQuery("")
+ setSelectedWorkType(null)
+ setSelectedItems([])
+ setDataLoadError(null)
+ setRetryCount(0)
+
+ // 생성 후 콜백 실행
+ if (onCreated) {
+ onCreated()
+ }
+
+ } catch (error) {
+ console.error("해양 TOP RFQ 생성 오류:", error)
+ toast.error(`해양 TOP RFQ 생성 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`)
+ } finally {
+ setIsProcessing(false)
+ }
+ }
+
+ return (
+ <Dialog
+ open={isDialogOpen}
+ onOpenChange={(open) => {
+ setIsDialogOpen(open)
+ if (!open) {
+ form.reset({
+ biddingProjectId: undefined,
+ itemIds: [],
+ dueDate: undefined,
+ description: "",
+ })
+ setSelectedProject(null)
+ setItemSearchQuery("")
+ setSelectedWorkType(null)
+ setSelectedItems([])
+ setDataLoadError(null)
+ setRetryCount(0)
+ }
+ }}
+ >
+ <DialogTrigger asChild>
+ <Button
+ variant="default"
+ size="sm"
+ className="gap-2"
+ disabled={isProcessing}
+ >
+ <Plus className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">해양 TOP RFQ 생성</span>
+ </Button>
+ </DialogTrigger>
+ <DialogContent
+ className="max-w-none h-[90vh] overflow-y-auto flex flex-col"
+ style={{ width: '1200px' }}
+ >
+ <DialogHeader className="border-b pb-4">
+ <DialogTitle>해양 TOP RFQ 생성</DialogTitle>
+ </DialogHeader>
+
+ <div className="space-y-6 p-1 overflow-y-auto">
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(handleCreateRfq)} className="space-y-6">
+ {/* 프로젝트 선택 */}
+ <div className="space-y-4">
+ <FormField
+ control={form.control}
+ name="biddingProjectId"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>입찰 프로젝트</FormLabel>
+ <FormControl>
+ <EstimateProjectSelector
+ selectedProjectId={field.value}
+ onProjectSelect={handleProjectSelect}
+ placeholder="입찰 프로젝트를 선택하세요"
+ pjtType="TOP"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <Separator className="my-4" />
+ {/* RFQ 설명 */}
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>RFQ Title</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="RFQ Title을 입력하세요 (선택사항)"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <Separator className="my-4" />
+ {/* 마감일 설정 */}
+ <FormField
+ control={form.control}
+ name="dueDate"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>마감일</FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ className={cn(
+ "w-full pl-3 text-left font-normal",
+ !field.value && "text-muted-foreground"
+ )}
+ >
+ {field.value ? (
+ format(field.value, "PPP", { locale: ko })
+ ) : (
+ <span>마감일을 선택하세요</span>
+ )}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={field.value}
+ onSelect={field.onChange}
+ disabled={(date) =>
+ date < new Date() || date < new Date("1900-01-01")
+ }
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <Separator className="my-4" />
+
+ <div className="space-y-6">
+ {/* 아이템 선택 영역 */}
+ <div className="space-y-4">
+ <div>
+ <FormLabel>아이템 선택</FormLabel>
+ <FormDescription>
+ 해양 TOP RFQ를 생성하려면 아이템을 선택하세요
+ </FormDescription>
+ </div>
+
+ {/* 아이템 검색 및 필터 */}
+ <div className="space-y-2">
+ <div className="flex space-x-2">
+ <div className="relative flex-1">
+ <Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
+ <Input
+ placeholder="아이템 검색..."
+ value={itemSearchQuery}
+ onChange={(e) => setItemSearchQuery(e.target.value)}
+ className="pl-8 pr-8"
+ disabled={isLoadingItems || dataLoadError !== null}
+ />
+ {itemSearchQuery && (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="absolute right-0 top-0 h-full px-3"
+ onClick={() => setItemSearchQuery("")}
+ disabled={isLoadingItems || dataLoadError !== null}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+
+ {/* 공종 필터 */}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="outline"
+ className="gap-1"
+ disabled={isLoadingItems || dataLoadError !== null}
+ >
+ {selectedWorkType ? workTypes.find(wt => wt.code === selectedWorkType)?.name : "전체 공종"}
+ <ArrowUpDown className="ml-2 h-4 w-4 opacity-50" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuCheckboxItem
+ checked={selectedWorkType === null}
+ onCheckedChange={() => setSelectedWorkType(null)}
+ >
+ 전체 공종
+ </DropdownMenuCheckboxItem>
+ {workTypes.map(workType => (
+ <DropdownMenuCheckboxItem
+ key={workType.code}
+ checked={selectedWorkType === workType.code}
+ onCheckedChange={() => setSelectedWorkType(workType.code)}
+ >
+ {workType.name}
+ </DropdownMenuCheckboxItem>
+ ))}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+ </div>
+
+ {/* 아이템 목록 */}
+ <div className="border rounded-md">
+ <ScrollArea className="h-[300px]">
+ <div className="p-2 space-y-1">
+ {dataLoadError ? (
+ <div className="text-center py-8">
+ <div className="p-4 bg-destructive/10 border border-destructive/20 rounded-md mx-4">
+ <div className="flex flex-col items-center gap-3">
+ <X className="h-8 w-8 text-destructive" />
+ <div className="text-center">
+ <p className="text-sm text-destructive font-medium">데이터 로딩에 실패했습니다</p>
+ <p className="text-xs text-muted-foreground mt-1">{dataLoadError}</p>
+ </div>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleRefreshData}
+ disabled={isLoadingItems}
+ className="h-8"
+ >
+ {isLoadingItems ? (
+ <>
+ <Loader2 className="h-3 w-3 animate-spin mr-1" />
+ 재시도 중...
+ </>
+ ) : (
+ "다시 시도"
+ )}
+ </Button>
+ </div>
+ </div>
+ </div>
+ ) : isLoadingItems ? (
+ <div className="text-center py-8 text-muted-foreground">
+ <Loader2 className="h-6 w-6 animate-spin mx-auto mb-2" />
+ 아이템을 불러오는 중...
+ {retryCount > 0 && (
+ <p className="text-xs mt-1">재시도 {retryCount}회</p>
+ )}
+ </div>
+ ) : availableItems.length > 0 ? (
+ [...availableItems]
+ .sort((a, b) => {
+ const aName = a.itemCode || 'zzz'
+ const bName = b.itemCode || 'zzz'
+ return aName.localeCompare(bName, 'ko', { numeric: true })
+ })
+ .map((item) => {
+ const isSelected = selectedItems.some(selected => selected.id === item.id)
+
+ return (
+ <div
+ key={item.id}
+ className={cn(
+ "flex items-center space-x-2 p-2 rounded-md cursor-pointer hover:bg-muted",
+ isSelected && "bg-muted"
+ )}
+ onClick={() => handleItemToggle(item)}
+ >
+ <div className="flex items-center space-x-2 flex-1">
+ {isSelected ? (
+ <CheckSquare className="h-4 w-4" />
+ ) : (
+ <Square className="h-4 w-4" />
+ )}
+ <div className="flex-1">
+ <div className="font-medium">
+ {item.itemList || '아이템명 없음'}
+ {item.subItemList && ` / ${item.subItemList}`}
+ </div>
+ <div className="text-sm text-muted-foreground">
+ {item.itemCode || '아이템코드 없음'}
+ </div>
+ <div className="text-xs text-muted-foreground">
+ 공종: {item.workType}
+ </div>
+ </div>
+ </div>
+ </div>
+ )
+ })
+ ) : (
+ <div className="text-center py-8 text-muted-foreground">
+ {itemSearchQuery ? "검색 결과가 없습니다" : "아이템이 없습니다"}
+ </div>
+ )}
+ </div>
+ </ScrollArea>
+ </div>
+ </div>
+ </div>
+ </div>
+ </form>
+ </Form>
+ </div>
+
+ {/* Footer - Sticky 버튼 영역 */}
+ <div className="sticky bottom-0 left-0 z-20 bg-background border-t pt-4 mt-4">
+ <div className="flex justify-end space-x-2">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setIsDialogOpen(false)}
+ disabled={isProcessing}
+ >
+ 취소
+ </Button>
+ <Button
+ type="button"
+ onClick={form.handleSubmit(handleCreateRfq)}
+ disabled={
+ isProcessing ||
+ !selectedProject ||
+ selectedItems.length === 0
+ }
+ >
+ {isProcessing ? "처리 중..." : `${selectedItems.length}개 아이템으로 해양 TOP RFQ 생성하기`}
+ </Button>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/delete-vendors-dialog.tsx b/lib/techsales-rfq/table/delete-vendors-dialog.tsx
index 35c3b067..788ef1cc 100644
--- a/lib/techsales-rfq/table/delete-vendors-dialog.tsx
+++ b/lib/techsales-rfq/table/delete-vendors-dialog.tsx
@@ -1,119 +1,119 @@
-"use client"
-
-import * as React from "react"
-import { type RfqDetailView } from "./rfq-detail-column"
-import { Loader, Trash } from "lucide-react"
-
-import { useMediaQuery } from "@/hooks/use-media-query"
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogClose,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
-import {
- Drawer,
- DrawerClose,
- DrawerContent,
- DrawerDescription,
- DrawerFooter,
- DrawerHeader,
- DrawerTitle,
-} from "@/components/ui/drawer"
-
-interface DeleteVendorsDialogProps
- extends React.ComponentPropsWithoutRef<typeof Dialog> {
- vendors: RfqDetailView[]
- onConfirm: () => void
- isLoading?: boolean
-}
-
-export function DeleteVendorsDialog({
- vendors,
- onConfirm,
- isLoading = false,
- ...props
-}: DeleteVendorsDialogProps) {
- const isDesktop = useMediaQuery("(min-width: 640px)")
-
- const vendorNames = vendors.map(v => v.vendorName).filter(Boolean).join(", ")
-
- if (isDesktop) {
- return (
- <Dialog {...props}>
- <DialogContent>
- <DialogHeader>
- <DialogTitle>벤더 삭제 확인</DialogTitle>
- <DialogDescription>
- 정말로 선택한 <span className="font-medium">{vendors.length}개</span>의 벤더를 삭제하시겠습니까?
- <br />
- <br />
- 삭제될 벤더: <span className="font-medium">{vendorNames}</span>
- <br />
- <br />
- 이 작업은 되돌릴 수 없습니다.
- </DialogDescription>
- </DialogHeader>
- <DialogFooter className="gap-2 sm:space-x-0">
- <DialogClose asChild>
- <Button variant="outline" disabled={isLoading}>취소</Button>
- </DialogClose>
- <Button
- aria-label="선택한 벤더들 삭제"
- variant="destructive"
- onClick={onConfirm}
- disabled={isLoading}
- >
- {isLoading && (
- <Loader
- className="mr-2 size-4 animate-spin"
- aria-hidden="true"
- />
- )}
- 삭제
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
- }
-
- return (
- <Drawer {...props}>
- <DrawerContent>
- <DrawerHeader>
- <DrawerTitle>벤더 삭제 확인</DrawerTitle>
- <DrawerDescription>
- 정말로 선택한 <span className="font-medium">{vendors.length}개</span>의 벤더를 삭제하시겠습니까?
- <br />
- <br />
- 삭제될 벤더: <span className="font-medium">{vendorNames}</span>
- <br />
- <br />
- 이 작업은 되돌릴 수 없습니다.
- </DrawerDescription>
- </DrawerHeader>
- <DrawerFooter className="gap-2 sm:space-x-0">
- <DrawerClose asChild>
- <Button variant="outline" disabled={isLoading}>취소</Button>
- </DrawerClose>
- <Button
- aria-label="선택한 벤더들 삭제"
- variant="destructive"
- onClick={onConfirm}
- disabled={isLoading}
- >
- {isLoading && (
- <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
- )}
- 삭제
- </Button>
- </DrawerFooter>
- </DrawerContent>
- </Drawer>
- )
+"use client"
+
+import * as React from "react"
+import { type RfqDetailView } from "./detail-table/rfq-detail-column"
+import { Loader } from "lucide-react"
+
+import { useMediaQuery } from "@/hooks/use-media-query"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+} from "@/components/ui/drawer"
+
+interface DeleteVendorsDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ vendors: RfqDetailView[]
+ onConfirm: () => void
+ isLoading?: boolean
+}
+
+export function DeleteVendorsDialog({
+ vendors,
+ onConfirm,
+ isLoading = false,
+ ...props
+}: DeleteVendorsDialogProps) {
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ const vendorNames = vendors.map(v => v.vendorName).filter(Boolean).join(", ")
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>벤더 삭제 확인</DialogTitle>
+ <DialogDescription>
+ 정말로 선택한 <span className="font-medium">{vendors.length}개</span>의 벤더를 삭제하시겠습니까?
+ <br />
+ <br />
+ 삭제될 벤더: <span className="font-medium">{vendorNames}</span>
+ <br />
+ <br />
+ 이 작업은 되돌릴 수 없습니다.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline" disabled={isLoading}>취소</Button>
+ </DialogClose>
+ <Button
+ aria-label="선택한 벤더들 삭제"
+ variant="destructive"
+ onClick={onConfirm}
+ disabled={isLoading}
+ >
+ {isLoading && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 삭제
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>벤더 삭제 확인</DrawerTitle>
+ <DrawerDescription>
+ 정말로 선택한 <span className="font-medium">{vendors.length}개</span>의 벤더를 삭제하시겠습니까?
+ <br />
+ <br />
+ 삭제될 벤더: <span className="font-medium">{vendorNames}</span>
+ <br />
+ <br />
+ 이 작업은 되돌릴 수 없습니다.
+ </DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline" disabled={isLoading}>취소</Button>
+ </DrawerClose>
+ <Button
+ aria-label="선택한 벤더들 삭제"
+ variant="destructive"
+ onClick={onConfirm}
+ disabled={isLoading}
+ >
+ {isLoading && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ 삭제
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx b/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx
index 8f2fe948..69953217 100644
--- a/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx
+++ b/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx
@@ -1,474 +1,474 @@
-"use client"
-
-import * as React from "react"
-import { useState, useEffect, useCallback } from "react"
-import { useForm } from "react-hook-form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { z } from "zod"
-import { toast } from "sonner"
-import { Check, X, Search, Loader2, Star } from "lucide-react"
-import { useSession } from "next-auth/react"
-
-import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
-import { Button } from "@/components/ui/button"
-import { Form, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
-import { Input } from "@/components/ui/input"
-import { ScrollArea } from "@/components/ui/scroll-area"
-import { Badge } from "@/components/ui/badge"
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
-import { addTechVendorsToTechSalesRfq, getTechSalesRfqCandidateVendors, searchTechVendors } from "@/lib/techsales-rfq/service"
-
-// 폼 유효성 검증 스키마 - 간단화
-const vendorFormSchema = z.object({
- vendorIds: z.array(z.number()).min(1, "최소 하나의 벤더를 선택해주세요"),
-})
-
-type VendorFormValues = z.infer<typeof vendorFormSchema>
-
-// 기술영업 RFQ 타입 정의
-type TechSalesRfq = {
- id: number
- rfqCode: string | null
- rfqType: "SHIP" | "TOP" | "HULL" | null
- ptypeNm: string | null // 프로젝트 타입명 추가
- status: string
- [key: string]: any // eslint-disable-line @typescript-eslint/no-explicit-any
-}
-
-// 벤더 검색 결과 타입 (techVendor 기반)
-type VendorSearchResult = {
- id: number
- vendorName: string
- vendorCode: string | null
- status: string
- country: string | null
- techVendorType?: string | null
- matchedItemCount?: number // 후보 벤더 정보
-}
-
-interface AddVendorDialogProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- selectedRfq: TechSalesRfq | null
- onSuccess?: () => void
- existingVendorIds?: number[]
-}
-
-export function AddVendorDialog({
- open,
- onOpenChange,
- selectedRfq,
- onSuccess,
- existingVendorIds = [],
-}: AddVendorDialogProps) {
- const { data: session } = useSession()
- const [isSubmitting, setIsSubmitting] = useState(false)
- const [searchTerm, setSearchTerm] = useState("")
- const [searchResults, setSearchResults] = useState<VendorSearchResult[]>([])
- const [candidateVendors, setCandidateVendors] = useState<VendorSearchResult[]>([])
- const [isSearching, setIsSearching] = useState(false)
- const [isLoadingCandidates, setIsLoadingCandidates] = useState(false)
- const [hasSearched, setHasSearched] = useState(false)
- const [hasCandidatesLoaded, setHasCandidatesLoaded] = useState(false)
- // 선택된 벤더들을 별도로 관리하여 검색과 독립적으로 유지
- const [selectedVendorData, setSelectedVendorData] = useState<VendorSearchResult[]>([])
- const [activeTab, setActiveTab] = useState("candidates")
-
- const form = useForm<VendorFormValues>({
- resolver: zodResolver(vendorFormSchema),
- defaultValues: {
- vendorIds: [],
- },
- })
-
- const selectedVendorIds = form.watch("vendorIds")
-
- // 후보 벤더 로드 함수
- const loadCandidateVendors = useCallback(async () => {
- if (!selectedRfq?.id) return
-
- setIsLoadingCandidates(true)
- try {
- const result = await getTechSalesRfqCandidateVendors(selectedRfq.id)
- if (result.error) {
- toast.error(result.error)
- setCandidateVendors([])
- } else {
- // 이미 추가된 벤더 제외
- const filteredCandidates = result.data?.filter(vendor => !existingVendorIds.includes(vendor.id)) || []
- setCandidateVendors(filteredCandidates)
- }
- setHasCandidatesLoaded(true)
- } catch (error) {
- console.error("후보 벤더 로드 오류:", error)
- toast.error("후보 벤더를 불러오는 중 오류가 발생했습니다")
- setCandidateVendors([])
- } finally {
- setIsLoadingCandidates(false)
- }
- }, [selectedRfq?.id, existingVendorIds])
-
- // 벤더 검색 함수 (techVendor 기반)
- const searchVendorsDebounced = useCallback(
- async (term: string) => {
- if (!term.trim()) {
- setSearchResults([])
- setHasSearched(false)
- return
- }
-
- setIsSearching(true)
- try {
- // 선택된 RFQ의 타입을 기반으로 벤더 검색
- const rfqType = selectedRfq?.rfqType || undefined;
- console.log("rfqType", rfqType) // 디버깅용
- const results = await searchTechVendors(term, 100, rfqType)
-
- // 이미 추가된 벤더 제외
- const filteredResults = results.filter((vendor: VendorSearchResult) => !existingVendorIds.includes(vendor.id))
- setSearchResults(filteredResults)
- setHasSearched(true)
- } catch (error) {
- console.error("벤더 검색 오류:", error)
- toast.error("벤더 검색 중 오류가 발생했습니다")
- setSearchResults([])
- } finally {
- setIsSearching(false)
- }
- },
- [existingVendorIds, selectedRfq?.rfqType]
- )
-
- // 검색어 변경 시 디바운스 적용
- useEffect(() => {
- const timer = setTimeout(() => {
- searchVendorsDebounced(searchTerm)
- }, 300)
-
- return () => clearTimeout(timer)
- }, [searchTerm, searchVendorsDebounced])
-
- // 다이얼로그 열릴 때 후보 벤더 로드
- useEffect(() => {
- if (open && selectedRfq?.id && !hasCandidatesLoaded) {
- loadCandidateVendors()
- }
- }, [open, selectedRfq?.id, hasCandidatesLoaded, loadCandidateVendors])
-
- // 벤더 선택/해제 핸들러
- const handleVendorToggle = (vendor: VendorSearchResult) => {
- const currentIds = form.getValues("vendorIds")
- const isSelected = currentIds.includes(vendor.id)
-
- if (isSelected) {
- // 선택 해제
- const newIds = currentIds.filter(id => id !== vendor.id)
- const newSelectedData = selectedVendorData.filter(v => v.id !== vendor.id)
- form.setValue("vendorIds", newIds, { shouldValidate: true })
- setSelectedVendorData(newSelectedData)
- } else {
- // 선택 추가
- const newIds = [...currentIds, vendor.id]
- const newSelectedData = [...selectedVendorData, vendor]
- form.setValue("vendorIds", newIds, { shouldValidate: true })
- setSelectedVendorData(newSelectedData)
- }
- }
-
- // 선택된 벤더 제거 핸들러
- const handleRemoveVendor = (vendorId: number) => {
- const currentIds = form.getValues("vendorIds")
- const newIds = currentIds.filter(id => id !== vendorId)
- const newSelectedData = selectedVendorData.filter(v => v.id !== vendorId)
- form.setValue("vendorIds", newIds, { shouldValidate: true })
- setSelectedVendorData(newSelectedData)
- }
-
- // 폼 제출 핸들러
- async function onSubmit(values: VendorFormValues) {
- if (!selectedRfq) {
- toast.error("선택된 RFQ가 없습니다")
- return
- }
-
- if (!session?.user?.id) {
- toast.error("로그인이 필요합니다")
- return
- }
-
- try {
- setIsSubmitting(true)
-
- // 새로운 서비스 함수 호출
- const result = await addTechVendorsToTechSalesRfq({
- rfqId: selectedRfq.id,
- vendorIds: values.vendorIds,
- createdBy: Number(session.user.id),
- })
-
- if (result.error) {
- toast.error(result.error)
- } else {
- const successCount = result.data?.length || 0
- toast.success(`${successCount}개의 벤더가 성공적으로 추가되었습니다`)
-
- onOpenChange(false)
- form.reset()
- setSearchTerm("")
- setSearchResults([])
- setCandidateVendors([])
- setHasSearched(false)
- setHasCandidatesLoaded(false)
- setSelectedVendorData([])
- onSuccess?.()
- }
- } catch (error) {
- console.error("벤더 추가 오류:", error)
- toast.error("벤더 추가 중 오류가 발생했습니다")
- } finally {
- setIsSubmitting(false)
- }
- }
-
- // 다이얼로그 닫기 시 폼 리셋
- React.useEffect(() => {
- if (!open) {
- form.reset()
- setSearchTerm("")
- setSearchResults([])
- setCandidateVendors([])
- setHasSearched(false)
- setHasCandidatesLoaded(false)
- setSelectedVendorData([])
- setActiveTab("candidates")
- }
- }, [open, form])
-
- // 벤더 목록 렌더링 함수
- const renderVendorList = (vendors: VendorSearchResult[], showMatchCount = false) => (
- <ScrollArea className="h-60 border rounded-md">
- <div className="p-2 space-y-1">
- {vendors.length > 0 ? (
- vendors.map((vendor, index) => (
- <div
- key={`${vendor.id}-${index}`} // 고유한 키 생성
- className={`flex items-center space-x-2 p-2 rounded-md cursor-pointer hover:bg-muted ${
- selectedVendorIds.includes(vendor.id) ? "bg-muted" : ""
- }`}
- onClick={() => handleVendorToggle(vendor)}
- >
- <div className="flex items-center space-x-2 flex-1">
- <Check
- className={`h-4 w-4 ${
- selectedVendorIds.includes(vendor.id)
- ? "opacity-100"
- : "opacity-0"
- }`}
- />
- <div className="flex-1">
- <div className="flex items-center gap-2">
- <span className="font-medium">{vendor.vendorName}</span>
- {showMatchCount && vendor.matchedItemCount && vendor.matchedItemCount > 0 && (
- <Badge variant="secondary" className="text-xs flex items-center gap-1">
- <Star className="h-3 w-3" />
- {vendor.matchedItemCount}개 매칭
- </Badge>
- )}
- {vendor.techVendorType && (
- <Badge variant="outline" className="text-xs">
- {vendor.techVendorType}
- </Badge>
- )}
- </div>
- <div className="text-sm text-muted-foreground">
- {vendor.vendorCode || 'N/A'} {vendor.country && `• ${vendor.country}`}
- </div>
- </div>
- </div>
- </div>
- ))
- ) : (
- <div className="text-center py-8 text-muted-foreground">
- {showMatchCount ? "매칭되는 후보 벤더가 없습니다" : "검색 결과가 없습니다"}
- </div>
- )}
- </div>
- </ScrollArea>
- )
-
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="sm:max-w-[800px] max-h-[80vh] flex flex-col">
- {/* 헤더 */}
- <DialogHeader>
- <DialogTitle>벤더 추가</DialogTitle>
- <DialogDescription>
- {selectedRfq ? (
- <>
- <span className="font-medium">{selectedRfq.rfqCode}</span> RFQ에 벤더를 추가합니다.
- </>
- ) : (
- "RFQ에 벤더를 추가합니다."
- )}
- </DialogDescription>
- </DialogHeader>
-
- {/* 콘텐츠 */}
- <div className="flex-1 overflow-y-auto">
- <Form {...form}>
- <form id="vendor-form" onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
- {/* 탭 메뉴 */}
- <Tabs value={activeTab} onValueChange={setActiveTab}>
- <TabsList className="grid w-full grid-cols-2">
- <TabsTrigger value="candidates">
- 후보 벤더 ({candidateVendors.length})
- </TabsTrigger>
- <TabsTrigger value="search">
- 벤더 검색
- </TabsTrigger>
- </TabsList>
-
- {/* 후보 벤더 탭 */}
- <TabsContent value="candidates" className="space-y-4">
- <div className="space-y-2">
- <div className="flex items-center justify-between">
- <label className="text-sm font-medium">추천 후보 벤더</label>
- <Button
- type="button"
- variant="outline"
- size="sm"
- onClick={() => {
- setHasCandidatesLoaded(false)
- loadCandidateVendors()
- }}
- disabled={isLoadingCandidates}
- >
- {isLoadingCandidates ? (
- <Loader2 className="h-4 w-4 animate-spin" />
- ) : (
- "새로고침"
- )}
- </Button>
- </div>
-
- {isLoadingCandidates ? (
- <div className="h-60 border rounded-md flex items-center justify-center">
- <div className="flex items-center gap-2">
- <Loader2 className="h-4 w-4 animate-spin" />
- <span>후보 벤더를 불러오는 중...</span>
- </div>
- </div>
- ) : (
- renderVendorList(candidateVendors, true)
- )}
-
- <div className="text-xs text-muted-foreground bg-blue-50 p-2 rounded">
- 💡 RFQ 아이템과 매칭되는 벤더들이 매칭 아이템 수가 많은 순으로 표시됩니다.
- </div>
- </div>
- </TabsContent>
-
- {/* 벤더 검색 탭 */}
- <TabsContent value="search" className="space-y-4">
- {/* 벤더 검색 필드 */}
- <div className="space-y-2">
- <label className="text-sm font-medium">벤더 검색</label>
- <div className="relative">
- <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
- <Input
- placeholder="벤더명 또는 벤더코드로 검색..."
- value={searchTerm}
- onChange={(e) => setSearchTerm(e.target.value)}
- className="pl-10"
- />
- {isSearching && (
- <Loader2 className="absolute right-3 top-1/2 transform -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground" />
- )}
- </div>
- </div>
-
- {/* 검색 결과 */}
- {hasSearched ? (
- <div className="space-y-2">
- <div className="text-sm font-medium">
- 검색 결과 ({searchResults.length}개)
- </div>
- {renderVendorList(searchResults)}
- </div>
- ) : (
- <div className="text-center py-8 text-muted-foreground border rounded-md">
- 벤더명 또는 벤더코드를 입력하여 검색해주세요
- </div>
- )}
- </TabsContent>
- </Tabs>
-
- {/* 선택된 벤더 목록 - 하단에 항상 표시 */}
- <FormField
- control={form.control}
- name="vendorIds"
- render={() => (
- <FormItem>
- <div className="space-y-2">
- <FormLabel>선택된 벤더 ({selectedVendorData.length}개)</FormLabel>
- <div className="min-h-[60px] p-3 border rounded-md bg-muted/50">
- {selectedVendorData.length > 0 ? (
- <div className="flex flex-wrap gap-2">
- {selectedVendorData.map((vendor) => (
- <Badge
- key={vendor.id}
- variant="secondary"
- className="flex items-center gap-1"
- >
- {vendor.vendorName} ({vendor.vendorCode || 'N/A'})
- <X
- className="h-3 w-3 cursor-pointer hover:text-destructive"
- onClick={() => handleRemoveVendor(vendor.id)}
- />
- </Badge>
- ))}
- </div>
- ) : (
- <div className="flex items-center justify-center h-full text-sm text-muted-foreground">
- 선택된 벤더가 없습니다
- </div>
- )}
- </div>
- </div>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 안내 메시지 */}
- <div className="text-sm text-muted-foreground bg-muted p-3 rounded-md">
- <p>• 후보 벤더는 RFQ 아이템 코드와 매칭되는 기술영업 벤더들입니다.</p>
- <p>• 선택된 벤더들은 Draft 상태로 추가됩니다.</p>
- <p>• 벤더별 견적 정보는 추가 후 개별적으로 입력할 수 있습니다.</p>
- </div>
- </form>
- </Form>
- </div>
-
- {/* 푸터 */}
- <DialogFooter>
- <Button
- type="button"
- variant="outline"
- onClick={() => onOpenChange(false)}
- disabled={isSubmitting}
- >
- 취소
- </Button>
- <Button
- type="submit"
- form="vendor-form"
- disabled={isSubmitting || selectedVendorIds.length === 0}
- >
- {isSubmitting ? "처리 중..." : `${selectedVendorIds.length}개 벤더 추가`}
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
+"use client"
+
+import * as React from "react"
+import { useState, useEffect, useCallback } from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { z } from "zod"
+import { toast } from "sonner"
+import { Check, X, Search, Loader2, Star } from "lucide-react"
+import { useSession } from "next-auth/react"
+
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Form, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { Badge } from "@/components/ui/badge"
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import { addTechVendorsToTechSalesRfq, getTechSalesRfqCandidateVendors, searchTechVendors } from "@/lib/techsales-rfq/service"
+
+// 폼 유효성 검증 스키마 - 간단화
+const vendorFormSchema = z.object({
+ vendorIds: z.array(z.number()).min(1, "최소 하나의 벤더를 선택해주세요"),
+})
+
+type VendorFormValues = z.infer<typeof vendorFormSchema>
+
+// 기술영업 RFQ 타입 정의
+type TechSalesRfq = {
+ id: number
+ rfqCode: string | null
+ rfqType: "SHIP" | "TOP" | "HULL" | null
+ ptypeNm: string | null // 프로젝트 타입명 추가
+ status: string
+ [key: string]: any // eslint-disable-line @typescript-eslint/no-explicit-any
+}
+
+// 벤더 검색 결과 타입 (techVendor 기반)
+type VendorSearchResult = {
+ id: number
+ vendorName: string
+ vendorCode: string | null
+ status: string
+ country: string | null
+ techVendorType?: string | null
+ matchedItemCount?: number // 후보 벤더 정보
+}
+
+interface AddVendorDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ selectedRfq: TechSalesRfq | null
+ onSuccess?: () => void
+ existingVendorIds?: number[]
+}
+
+export function AddVendorDialog({
+ open,
+ onOpenChange,
+ selectedRfq,
+ onSuccess,
+ existingVendorIds = [],
+}: AddVendorDialogProps) {
+ const { data: session } = useSession()
+ const [isSubmitting, setIsSubmitting] = useState(false)
+ const [searchTerm, setSearchTerm] = useState("")
+ const [searchResults, setSearchResults] = useState<VendorSearchResult[]>([])
+ const [candidateVendors, setCandidateVendors] = useState<VendorSearchResult[]>([])
+ const [isSearching, setIsSearching] = useState(false)
+ const [isLoadingCandidates, setIsLoadingCandidates] = useState(false)
+ const [hasSearched, setHasSearched] = useState(false)
+ const [hasCandidatesLoaded, setHasCandidatesLoaded] = useState(false)
+ // 선택된 벤더들을 별도로 관리하여 검색과 독립적으로 유지
+ const [selectedVendorData, setSelectedVendorData] = useState<VendorSearchResult[]>([])
+ const [activeTab, setActiveTab] = useState("candidates")
+
+ const form = useForm<VendorFormValues>({
+ resolver: zodResolver(vendorFormSchema),
+ defaultValues: {
+ vendorIds: [],
+ },
+ })
+
+ const selectedVendorIds = form.watch("vendorIds")
+
+ // 후보 벤더 로드 함수
+ const loadCandidateVendors = useCallback(async () => {
+ if (!selectedRfq?.id) return
+
+ setIsLoadingCandidates(true)
+ try {
+ const result = await getTechSalesRfqCandidateVendors(selectedRfq.id)
+ if (result.error) {
+ toast.error(result.error)
+ setCandidateVendors([])
+ } else {
+ // 이미 추가된 벤더 제외
+ const filteredCandidates = result.data?.filter(vendor => !existingVendorIds.includes(vendor.id)) || []
+ setCandidateVendors(filteredCandidates)
+ }
+ setHasCandidatesLoaded(true)
+ } catch (error) {
+ console.error("후보 벤더 로드 오류:", error)
+ toast.error("후보 벤더를 불러오는 중 오류가 발생했습니다")
+ setCandidateVendors([])
+ } finally {
+ setIsLoadingCandidates(false)
+ }
+ }, [selectedRfq?.id, existingVendorIds])
+
+ // 벤더 검색 함수 (techVendor 기반)
+ const searchVendorsDebounced = useCallback(
+ async (term: string) => {
+ if (!term.trim()) {
+ setSearchResults([])
+ setHasSearched(false)
+ return
+ }
+
+ setIsSearching(true)
+ try {
+ // 선택된 RFQ의 타입을 기반으로 벤더 검색
+ const rfqType = selectedRfq?.rfqType || undefined;
+ console.log("rfqType", rfqType) // 디버깅용
+ const results = await searchTechVendors(term, 100, rfqType)
+
+ // 이미 추가된 벤더 제외
+ const filteredResults = results.filter((vendor: VendorSearchResult) => !existingVendorIds.includes(vendor.id))
+ setSearchResults(filteredResults)
+ setHasSearched(true)
+ } catch (error) {
+ console.error("벤더 검색 오류:", error)
+ toast.error("벤더 검색 중 오류가 발생했습니다")
+ setSearchResults([])
+ } finally {
+ setIsSearching(false)
+ }
+ },
+ [existingVendorIds, selectedRfq?.rfqType]
+ )
+
+ // 검색어 변경 시 디바운스 적용
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ searchVendorsDebounced(searchTerm)
+ }, 300)
+
+ return () => clearTimeout(timer)
+ }, [searchTerm, searchVendorsDebounced])
+
+ // 다이얼로그 열릴 때 후보 벤더 로드
+ useEffect(() => {
+ if (open && selectedRfq?.id && !hasCandidatesLoaded) {
+ loadCandidateVendors()
+ }
+ }, [open, selectedRfq?.id, hasCandidatesLoaded, loadCandidateVendors])
+
+ // 벤더 선택/해제 핸들러
+ const handleVendorToggle = (vendor: VendorSearchResult) => {
+ const currentIds = form.getValues("vendorIds")
+ const isSelected = currentIds.includes(vendor.id)
+
+ if (isSelected) {
+ // 선택 해제
+ const newIds = currentIds.filter(id => id !== vendor.id)
+ const newSelectedData = selectedVendorData.filter(v => v.id !== vendor.id)
+ form.setValue("vendorIds", newIds, { shouldValidate: true })
+ setSelectedVendorData(newSelectedData)
+ } else {
+ // 선택 추가
+ const newIds = [...currentIds, vendor.id]
+ const newSelectedData = [...selectedVendorData, vendor]
+ form.setValue("vendorIds", newIds, { shouldValidate: true })
+ setSelectedVendorData(newSelectedData)
+ }
+ }
+
+ // 선택된 벤더 제거 핸들러
+ const handleRemoveVendor = (vendorId: number) => {
+ const currentIds = form.getValues("vendorIds")
+ const newIds = currentIds.filter(id => id !== vendorId)
+ const newSelectedData = selectedVendorData.filter(v => v.id !== vendorId)
+ form.setValue("vendorIds", newIds, { shouldValidate: true })
+ setSelectedVendorData(newSelectedData)
+ }
+
+ // 폼 제출 핸들러
+ async function onSubmit(values: VendorFormValues) {
+ if (!selectedRfq) {
+ toast.error("선택된 RFQ가 없습니다")
+ return
+ }
+
+ if (!session?.user?.id) {
+ toast.error("로그인이 필요합니다")
+ return
+ }
+
+ try {
+ setIsSubmitting(true)
+
+ // 새로운 서비스 함수 호출
+ const result = await addTechVendorsToTechSalesRfq({
+ rfqId: selectedRfq.id,
+ vendorIds: values.vendorIds,
+ createdBy: Number(session.user.id),
+ })
+
+ if (result.error) {
+ toast.error(result.error)
+ } else {
+ const successCount = result.data?.length || 0
+ toast.success(`${successCount}개의 벤더가 성공적으로 추가되었습니다`)
+
+ onOpenChange(false)
+ form.reset()
+ setSearchTerm("")
+ setSearchResults([])
+ setCandidateVendors([])
+ setHasSearched(false)
+ setHasCandidatesLoaded(false)
+ setSelectedVendorData([])
+ onSuccess?.()
+ }
+ } catch (error) {
+ console.error("벤더 추가 오류:", error)
+ toast.error("벤더 추가 중 오류가 발생했습니다")
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ // 다이얼로그 닫기 시 폼 리셋
+ React.useEffect(() => {
+ if (!open) {
+ form.reset()
+ setSearchTerm("")
+ setSearchResults([])
+ setCandidateVendors([])
+ setHasSearched(false)
+ setHasCandidatesLoaded(false)
+ setSelectedVendorData([])
+ setActiveTab("candidates")
+ }
+ }, [open, form])
+
+ // 벤더 목록 렌더링 함수
+ const renderVendorList = (vendors: VendorSearchResult[], showMatchCount = false) => (
+ <ScrollArea className="h-60 border rounded-md">
+ <div className="p-2 space-y-1">
+ {vendors.length > 0 ? (
+ vendors.map((vendor, index) => (
+ <div
+ key={`${vendor.id}-${index}`} // 고유한 키 생성
+ className={`flex items-center space-x-2 p-2 rounded-md cursor-pointer hover:bg-muted ${
+ selectedVendorIds.includes(vendor.id) ? "bg-muted" : ""
+ }`}
+ onClick={() => handleVendorToggle(vendor)}
+ >
+ <div className="flex items-center space-x-2 flex-1">
+ <Check
+ className={`h-4 w-4 ${
+ selectedVendorIds.includes(vendor.id)
+ ? "opacity-100"
+ : "opacity-0"
+ }`}
+ />
+ <div className="flex-1">
+ <div className="flex items-center gap-2">
+ <span className="font-medium">{vendor.vendorName}</span>
+ {showMatchCount && vendor.matchedItemCount && vendor.matchedItemCount > 0 && (
+ <Badge variant="secondary" className="text-xs flex items-center gap-1">
+ <Star className="h-3 w-3" />
+ {vendor.matchedItemCount}개 매칭
+ </Badge>
+ )}
+ {vendor.techVendorType && (
+ <Badge variant="outline" className="text-xs">
+ {vendor.techVendorType}
+ </Badge>
+ )}
+ </div>
+ <div className="text-sm text-muted-foreground">
+ {vendor.vendorCode || 'N/A'} {vendor.country && `• ${vendor.country}`}
+ </div>
+ </div>
+ </div>
+ </div>
+ ))
+ ) : (
+ <div className="text-center py-8 text-muted-foreground">
+ {showMatchCount ? "매칭되는 후보 벤더가 없습니다" : "검색 결과가 없습니다"}
+ </div>
+ )}
+ </div>
+ </ScrollArea>
+ )
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[800px] max-h-[80vh] flex flex-col">
+ {/* 헤더 */}
+ <DialogHeader>
+ <DialogTitle>벤더 추가</DialogTitle>
+ <DialogDescription>
+ {selectedRfq ? (
+ <>
+ <span className="font-medium">{selectedRfq.rfqCode}</span> RFQ에 벤더를 추가합니다.
+ </>
+ ) : (
+ "RFQ에 벤더를 추가합니다."
+ )}
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* 콘텐츠 */}
+ <div className="flex-1 overflow-y-auto">
+ <Form {...form}>
+ <form id="vendor-form" onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+ {/* 탭 메뉴 */}
+ <Tabs value={activeTab} onValueChange={setActiveTab}>
+ <TabsList className="grid w-full grid-cols-2">
+ <TabsTrigger value="candidates">
+ 후보 벤더 ({candidateVendors.length})
+ </TabsTrigger>
+ <TabsTrigger value="search">
+ 벤더 검색
+ </TabsTrigger>
+ </TabsList>
+
+ {/* 후보 벤더 탭 */}
+ <TabsContent value="candidates" className="space-y-4">
+ <div className="space-y-2">
+ <div className="flex items-center justify-between">
+ <label className="text-sm font-medium">추천 후보 벤더</label>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ setHasCandidatesLoaded(false)
+ loadCandidateVendors()
+ }}
+ disabled={isLoadingCandidates}
+ >
+ {isLoadingCandidates ? (
+ <Loader2 className="h-4 w-4 animate-spin" />
+ ) : (
+ "새로고침"
+ )}
+ </Button>
+ </div>
+
+ {isLoadingCandidates ? (
+ <div className="h-60 border rounded-md flex items-center justify-center">
+ <div className="flex items-center gap-2">
+ <Loader2 className="h-4 w-4 animate-spin" />
+ <span>후보 벤더를 불러오는 중...</span>
+ </div>
+ </div>
+ ) : (
+ renderVendorList(candidateVendors, true)
+ )}
+
+ <div className="text-xs text-muted-foreground bg-blue-50 p-2 rounded">
+ 💡 RFQ 아이템과 매칭되는 벤더들이 매칭 아이템 수가 많은 순으로 표시됩니다.
+ </div>
+ </div>
+ </TabsContent>
+
+ {/* 벤더 검색 탭 */}
+ <TabsContent value="search" className="space-y-4">
+ {/* 벤더 검색 필드 */}
+ <div className="space-y-2">
+ <label className="text-sm font-medium">벤더 검색</label>
+ <div className="relative">
+ <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
+ <Input
+ placeholder="벤더명 또는 벤더코드로 검색..."
+ value={searchTerm}
+ onChange={(e) => setSearchTerm(e.target.value)}
+ className="pl-10"
+ />
+ {isSearching && (
+ <Loader2 className="absolute right-3 top-1/2 transform -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground" />
+ )}
+ </div>
+ </div>
+
+ {/* 검색 결과 */}
+ {hasSearched ? (
+ <div className="space-y-2">
+ <div className="text-sm font-medium">
+ 검색 결과 ({searchResults.length}개)
+ </div>
+ {renderVendorList(searchResults)}
+ </div>
+ ) : (
+ <div className="text-center py-8 text-muted-foreground border rounded-md">
+ 벤더명 또는 벤더코드를 입력하여 검색해주세요
+ </div>
+ )}
+ </TabsContent>
+ </Tabs>
+
+ {/* 선택된 벤더 목록 - 하단에 항상 표시 */}
+ <FormField
+ control={form.control}
+ name="vendorIds"
+ render={() => (
+ <FormItem>
+ <div className="space-y-2">
+ <FormLabel>선택된 벤더 ({selectedVendorData.length}개)</FormLabel>
+ <div className="min-h-[60px] p-3 border rounded-md bg-muted/50">
+ {selectedVendorData.length > 0 ? (
+ <div className="flex flex-wrap gap-2">
+ {selectedVendorData.map((vendor) => (
+ <Badge
+ key={vendor.id}
+ variant="secondary"
+ className="flex items-center gap-1"
+ >
+ {vendor.vendorName} ({vendor.vendorCode || 'N/A'})
+ <X
+ className="h-3 w-3 cursor-pointer hover:text-destructive"
+ onClick={() => handleRemoveVendor(vendor.id)}
+ />
+ </Badge>
+ ))}
+ </div>
+ ) : (
+ <div className="flex items-center justify-center h-full text-sm text-muted-foreground">
+ 선택된 벤더가 없습니다
+ </div>
+ )}
+ </div>
+ </div>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 안내 메시지
+ <div className="text-sm text-muted-foreground bg-muted p-3 rounded-md">
+ <p>• 후보 벤더는 RFQ 아이템 코드와 매칭되는 기술영업 벤더들입니다.</p>
+ <p>• 선택된 벤더들은 Draft 상태로 추가됩니다.</p>
+ <p>• 벤더별 견적 정보는 추가 후 개별적으로 입력할 수 있습니다.</p>
+ </div> */}
+ </form>
+ </Form>
+ </div>
+
+ {/* 푸터 */}
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isSubmitting}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ form="vendor-form"
+ disabled={isSubmitting || selectedVendorIds.length === 0}
+ >
+ {isSubmitting ? "처리 중..." : `${selectedVendorIds.length}개 벤더 추가`}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/detail-table/delete-vendors-dialog.tsx b/lib/techsales-rfq/table/detail-table/delete-vendors-dialog.tsx
index d7e3403b..d86dcea2 100644
--- a/lib/techsales-rfq/table/detail-table/delete-vendors-dialog.tsx
+++ b/lib/techsales-rfq/table/detail-table/delete-vendors-dialog.tsx
@@ -1,150 +1,149 @@
-"use client"
-
-import * as React from "react"
-import { type RfqDetailView } from "./rfq-detail-column"
-import { type Row } from "@tanstack/react-table"
-import { Loader, Trash } from "lucide-react"
-import { toast } from "sonner"
-
-import { useMediaQuery } from "@/hooks/use-media-query"
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogClose,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog"
-import {
- Drawer,
- DrawerClose,
- DrawerContent,
- DrawerDescription,
- DrawerFooter,
- DrawerHeader,
- DrawerTitle,
- DrawerTrigger,
-} from "@/components/ui/drawer"
-import { deleteRfqDetail } from "@/lib/procurement-rfqs/services"
-
-
-interface DeleteRfqDetailDialogProps
- extends React.ComponentPropsWithoutRef<typeof Dialog> {
- detail: RfqDetailView | null
- showTrigger?: boolean
- onSuccess?: () => void
-}
-
-export function DeleteVendorDialog({
- detail,
- showTrigger = true,
- onSuccess,
- ...props
-}: DeleteRfqDetailDialogProps) {
- const [isDeletePending, startDeleteTransition] = React.useTransition()
- const isDesktop = useMediaQuery("(min-width: 640px)")
-
- function onDelete() {
- if (!detail) return
-
- startDeleteTransition(async () => {
- try {
- const result = await deleteRfqDetail(detail.id)
-
- if (!result.success) {
- toast.error(result.message || "삭제 중 오류가 발생했습니다")
- return
- }
-
- props.onOpenChange?.(false)
- toast.success("RFQ 벤더 정보가 삭제되었습니다")
- onSuccess?.()
- } catch (error) {
- console.error("RFQ 벤더 삭제 오류:", error)
- toast.error("삭제 중 오류가 발생했습니다")
- }
- })
- }
-
- if (isDesktop) {
- return (
- <Dialog {...props}>
- {showTrigger ? (
- <DialogTrigger asChild>
- <Button variant="destructive" size="sm">
- <Trash className="mr-2 size-4" aria-hidden="true" />
- 삭제
- </Button>
- </DialogTrigger>
- ) : null}
- <DialogContent>
- <DialogHeader>
- <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle>
- <DialogDescription>
- 이 작업은 되돌릴 수 없습니다. 벤더 &quot;{detail?.vendorName}&quot;({detail?.vendorCode})의 RFQ 정보가 영구적으로 삭제됩니다.
- </DialogDescription>
- </DialogHeader>
- <DialogFooter className="gap-2 sm:space-x-0">
- <DialogClose asChild>
- <Button variant="outline">취소</Button>
- </DialogClose>
- <Button
- aria-label="선택한 RFQ 벤더 정보 삭제"
- variant="destructive"
- onClick={onDelete}
- disabled={isDeletePending}
- >
- {isDeletePending && (
- <Loader
- className="mr-2 size-4 animate-spin"
- aria-hidden="true"
- />
- )}
- 삭제
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
- }
-
- return (
- <Drawer {...props}>
- {showTrigger ? (
- <DrawerTrigger asChild>
- <Button variant="destructive" size="sm">
- <Trash className="mr-2 size-4" aria-hidden="true" />
- 삭제
- </Button>
- </DrawerTrigger>
- ) : null}
- <DrawerContent>
- <DrawerHeader>
- <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle>
- <DrawerDescription>
- 이 작업은 되돌릴 수 없습니다. 벤더 &quot;{detail?.vendorName}&quot;({detail?.vendorCode})의 RFQ 정보가 영구적으로 삭제됩니다.
- </DrawerDescription>
- </DrawerHeader>
- <DrawerFooter className="gap-2 sm:space-x-0">
- <DrawerClose asChild>
- <Button variant="outline">취소</Button>
- </DrawerClose>
- <Button
- aria-label="선택한 RFQ 벤더 정보 삭제"
- variant="destructive"
- onClick={onDelete}
- disabled={isDeletePending}
- >
- {isDeletePending && (
- <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
- )}
- 삭제
- </Button>
- </DrawerFooter>
- </DrawerContent>
- </Drawer>
- )
+"use client"
+
+import * as React from "react"
+import { type RfqDetailView } from "./rfq-detail-column"
+import { Loader, Trash } from "lucide-react"
+import { toast } from "sonner"
+
+import { useMediaQuery } from "@/hooks/use-media-query"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+import { deleteRfqDetail } from "@/lib/procurement-rfqs/services"
+
+
+interface DeleteRfqDetailDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ detail: RfqDetailView | null
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function DeleteVendorDialog({
+ detail,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: DeleteRfqDetailDialogProps) {
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ function onDelete() {
+ if (!detail) return
+
+ startDeleteTransition(async () => {
+ try {
+ const result = await deleteRfqDetail(detail.id)
+
+ if (!result.success) {
+ toast.error(result.message || "삭제 중 오류가 발생했습니다")
+ return
+ }
+
+ props.onOpenChange?.(false)
+ toast.success("RFQ 벤더 정보가 삭제되었습니다")
+ onSuccess?.()
+ } catch (error) {
+ console.error("RFQ 벤더 삭제 오류:", error)
+ toast.error("삭제 중 오류가 발생했습니다")
+ }
+ })
+ }
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="destructive" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ 삭제
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle>
+ <DialogDescription>
+ 이 작업은 되돌릴 수 없습니다. 벤더 &quot;{detail?.vendorName}&quot;({detail?.vendorCode})의 RFQ 정보가 영구적으로 삭제됩니다.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">취소</Button>
+ </DialogClose>
+ <Button
+ aria-label="선택한 RFQ 벤더 정보 삭제"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 삭제
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="destructive" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ 삭제
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle>
+ <DrawerDescription>
+ 이 작업은 되돌릴 수 없습니다. 벤더 &quot;{detail?.vendorName}&quot;({detail?.vendorCode})의 RFQ 정보가 영구적으로 삭제됩니다.
+ </DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">취소</Button>
+ </DrawerClose>
+ <Button
+ aria-label="선택한 RFQ 벤더 정보 삭제"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ 삭제
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/detail-table/quotation-contacts-view-dialog.tsx b/lib/techsales-rfq/table/detail-table/quotation-contacts-view-dialog.tsx
new file mode 100644
index 00000000..3e793b62
--- /dev/null
+++ b/lib/techsales-rfq/table/detail-table/quotation-contacts-view-dialog.tsx
@@ -0,0 +1,173 @@
+"use client"
+
+import * as React from "react"
+import { useState, useEffect, useCallback } from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { Skeleton } from "@/components/ui/skeleton"
+import { Mail, Phone, User, Users } from "lucide-react"
+import { getQuotationContacts } from "../../service"
+
+interface QuotationContact {
+ id: number
+ contactId: number
+ contactName: string
+ contactPosition: string | null
+ contactEmail: string
+ contactPhone: string | null
+ contactCountry: string | null
+ isPrimary: boolean
+ createdAt: Date
+}
+
+interface QuotationContactsViewDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ quotationId: number | null
+ vendorName?: string
+}
+
+export function QuotationContactsViewDialog({
+ open,
+ onOpenChange,
+ quotationId,
+ vendorName
+}: QuotationContactsViewDialogProps) {
+ const [contacts, setContacts] = useState<QuotationContact[]>([])
+ const [isLoading, setIsLoading] = useState(false)
+
+ // 담당자 정보 로드
+ const loadQuotationContacts = useCallback(async () => {
+ if (!quotationId) return
+
+ setIsLoading(true)
+ try {
+ const result = await getQuotationContacts(quotationId)
+ if (result.success) {
+ setContacts(result.data || [])
+ } else {
+ console.error("담당자 정보 로드 실패:", result.error)
+ setContacts([])
+ }
+ } catch (error) {
+ console.error("담당자 정보 로드 오류:", error)
+ setContacts([])
+ } finally {
+ setIsLoading(false)
+ }
+ }, [quotationId])
+
+ // Dialog가 열릴 때 데이터 로드
+ useEffect(() => {
+ if (open && quotationId) {
+ loadQuotationContacts()
+ }
+ }, [open, quotationId, loadQuotationContacts])
+
+ // Dialog가 닫힐 때 상태 초기화
+ useEffect(() => {
+ if (!open) {
+ setContacts([])
+ }
+ }, [open])
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-2xl max-h-[70vh] overflow-hidden flex flex-col">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <Users className="size-5" />
+ RFQ 발송 담당자 목록
+ </DialogTitle>
+ <DialogDescription>
+ {vendorName && (
+ <span className="font-medium">{vendorName}</span>
+ )} 에게 발송된 RFQ의 담당자 정보입니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="flex-1 overflow-y-auto">
+ {isLoading ? (
+ <div className="space-y-3">
+ {[1, 2, 3].map((i) => (
+ <Skeleton key={i} className="h-20 w-full" />
+ ))}
+ </div>
+ ) : contacts.length === 0 ? (
+ <div className="text-center py-8 text-muted-foreground">
+ <Mail className="size-12 mx-auto mb-2 opacity-50" />
+ <p>발송된 담당자 정보가 없습니다.</p>
+ <p className="text-sm">아직 RFQ가 발송되지 않았거나 담당자 정보가 기록되지 않았습니다.</p>
+ </div>
+ ) : (
+ <div className="space-y-3">
+ {contacts.map((contact) => (
+ <div
+ key={contact.id}
+ className="flex items-center justify-between p-4 border rounded-lg bg-gray-50"
+ >
+ <div className="flex items-center gap-3">
+ <User className="size-4 text-muted-foreground" />
+ <div>
+ <div className="flex items-center gap-2">
+ <span className="font-medium">{contact.contactName}</span>
+ {contact.isPrimary && (
+ <Badge variant="secondary" className="text-xs">
+ 주담당자
+ </Badge>
+ )}
+ </div>
+ {contact.contactPosition && (
+ <p className="text-sm text-muted-foreground">
+ {contact.contactPosition}
+ </p>
+ )}
+ {contact.contactCountry && (
+ <p className="text-xs text-muted-foreground">
+ {contact.contactCountry}
+ </p>
+ )}
+ </div>
+ </div>
+
+ <div className="flex flex-col items-end gap-1 text-sm">
+ <div className="flex items-center gap-1">
+ <Mail className="size-4 text-muted-foreground" />
+ <span>{contact.contactEmail}</span>
+ </div>
+ {contact.contactPhone && (
+ <div className="flex items-center gap-1">
+ <Phone className="size-4 text-muted-foreground" />
+ <span>{contact.contactPhone}</span>
+ </div>
+ )}
+ <div className="text-xs text-muted-foreground">
+ 발송일: {new Date(contact.createdAt).toLocaleDateString('ko-KR')}
+ </div>
+ </div>
+ </div>
+ ))}
+
+ <div className="text-center pt-4 text-sm text-muted-foreground border-t">
+ 총 {contacts.length}명의 담당자에게 발송됨
+ </div>
+ </div>
+ )}
+ </div>
+
+ <div className="flex justify-end pt-4">
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ 닫기
+ </Button>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx b/lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx
index ce701e13..0f5158d9 100644
--- a/lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx
+++ b/lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx
@@ -13,7 +13,7 @@ import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Separator } from "@/components/ui/separator"
import { Skeleton } from "@/components/ui/skeleton"
-import { Clock, User, FileText, AlertCircle, Paperclip } from "lucide-react"
+import { Clock, User, AlertCircle, Paperclip } from "lucide-react"
import { formatDate } from "@/lib/utils"
import { toast } from "sonner"
@@ -91,7 +91,6 @@ function QuotationCard({
data,
version,
isCurrent = false,
- changeReason,
revisedBy,
revisedAt,
attachments
@@ -99,7 +98,6 @@ function QuotationCard({
data: QuotationSnapshot | QuotationHistoryData["current"]
version: number
isCurrent?: boolean
- changeReason?: string | null
revisedBy?: string | null
revisedAt?: Date
attachments?: QuotationAttachment[]
@@ -137,7 +135,7 @@ function QuotationCard({
<div>
<p className="text-sm font-medium text-muted-foreground">유효 기한</p>
<p className="text-sm">
- {data.validUntil ? formatDate(data.validUntil, "KR") : "미설정"}
+ {data.validUntil ? formatDate(data.validUntil) : "미설정"}
</p>
</div>
</div>
@@ -187,8 +185,8 @@ function QuotationCard({
<Clock className="size-3" />
<span>
{isCurrent
- ? `수정: ${data.updatedAt ? formatDate(data.updatedAt, "KR") : "N/A"}`
- : `변경: ${revisedAt ? formatDate(revisedAt, "KR") : "N/A"}`
+ ? `수정: ${data.updatedAt ? formatDate(data.updatedAt) : "N/A"}`
+ : `변경: ${revisedAt ? formatDate(revisedAt) : "N/A"}`
}
</span>
</div>
diff --git a/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx b/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx
index e921fcaa..e4141520 100644
--- a/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx
+++ b/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx
@@ -1,401 +1,451 @@
-"use client"
-
-import * as React from "react"
-import type { ColumnDef, Row } from "@tanstack/react-table";
-import { formatDate } from "@/lib/utils"
-import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
-import { Checkbox } from "@/components/ui/checkbox";
-import { MessageCircle, MoreHorizontal, Trash2, Paperclip } from "lucide-react";
-import { Button } from "@/components/ui/button";
-import { Badge } from "@/components/ui/badge";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-
-export interface DataTableRowAction<TData> {
- row: Row<TData>;
- type: "communicate" | "delete";
-}
-
-// 벤더 견적 데이터 타입 정의
-export interface RfqDetailView {
- id: number
- rfqId: number
- vendorId?: number | null
- vendorName: string | null
- vendorCode: string | null
- totalPrice: string | number | null
- currency: string | null
- validUntil: Date | null
- status: string | null
- remark: string | null
- submittedAt: Date | null
- acceptedAt: Date | null
- rejectionReason: string | null
- createdAt: Date | null
- updatedAt: Date | null
- createdByName: string | null
- quotationCode?: string | null
- rfqCode?: string | null
- quotationAttachments?: Array<{
- id: number
- revisionId: number
- fileName: string
- fileSize: number
- filePath: string
- description?: string | null
- }>
-}
-
-// 견적서 정보 타입 (Sheet용)
-export interface QuotationInfo {
- id: number
- quotationCode: string | null
- vendorName?: string
- rfqCode?: string
-}
-
-interface GetColumnsProps<TData> {
- setRowAction: React.Dispatch<
- React.SetStateAction<DataTableRowAction<TData> | null>
- >;
- unreadMessages?: Record<number, number>; // 읽지 않은 메시지 개수
- onQuotationClick?: (quotationId: number) => void; // 견적 클릭 핸들러
- openQuotationAttachmentsSheet?: (quotationId: number, quotationInfo: QuotationInfo) => void; // 견적서 첨부파일 sheet 열기
-}
-
-export function getRfqDetailColumns({
- setRowAction,
- unreadMessages = {},
- onQuotationClick,
- openQuotationAttachmentsSheet
-}: GetColumnsProps<RfqDetailView>): ColumnDef<RfqDetailView>[] {
- return [
- {
- id: "select",
- header: ({ table }) => (
- <Checkbox
- checked={
- table.getIsAllPageRowsSelected() ||
- (table.getIsSomePageRowsSelected() && "indeterminate")
- }
- onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
- aria-label="모두 선택"
- />
- ),
- cell: ({ row }) => {
- const status = row.original.status;
- const isSelectable = status ? !["Accepted", "Rejected"].includes(status) : true;
-
- return (
- <Checkbox
- checked={row.getIsSelected()}
- onCheckedChange={(value) => row.toggleSelected(!!value)}
- disabled={!isSelectable}
- aria-label="행 선택"
- className={!isSelectable ? "opacity-50 cursor-not-allowed" : ""}
- />
- );
- },
- enableSorting: false,
- enableHiding: false,
- size: 40,
- },
- {
- accessorKey: "status",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="견적 상태" />
- ),
- cell: ({ row }) => {
- const status = row.getValue("status") as string;
- // 상태에 따른 배지 색상 설정
- let variant: "default" | "secondary" | "outline" | "destructive" = "outline";
-
- if (status === "Submitted") {
- variant = "default"; // 제출됨 - 기본 색상
- } else if (status === "Accepted") {
- variant = "secondary"; // 승인됨 - 보조 색상
- } else if (status === "Rejected") {
- variant = "destructive"; // 거부됨 - 위험 색상
- }
-
- return (
- <Badge variant={variant}>{status || "Draft"}</Badge>
- );
- },
- meta: {
- excelHeader: "견적 상태"
- },
- enableResizing: true,
- size: 120,
- },
- {
- accessorKey: "vendorCode",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="벤더 코드" />
- ),
- cell: ({ row }) => <div>{row.getValue("vendorCode")}</div>,
- meta: {
- excelHeader: "벤더 코드"
- },
- enableResizing: true,
- size: 120,
- },
- {
- accessorKey: "vendorName",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="벤더명" />
- ),
- cell: ({ row }) => {
- const vendorName = row.getValue("vendorName") as string | null;
- const vendorId = row.original.vendorId;
-
- if (!vendorName) return <div>-</div>;
-
- if (vendorId) {
- return (
- <Button
- variant="link"
- className="p-0 h-auto font-normal text-left justify-start hover:underline"
- onClick={() => {
- window.open(`/ko/evcp/tech-vendors/${vendorId}/info`, '_blank');
- }}
- >
- {vendorName}
- </Button>
- );
- }
-
- return <div>{vendorName}</div>;
- },
- meta: {
- excelHeader: "벤더명"
- },
- enableResizing: true,
- size: 160,
- },
- {
- accessorKey: "totalPrice",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="견적 금액" />
- ),
- cell: ({ row }) => {
- const value = row.getValue("totalPrice") as string | number | null;
- const currency = row.getValue("currency") as string | null;
- const quotationId = row.original.id;
-
- if (value === null || value === undefined) return "-";
-
- // 숫자로 변환 시도
- const numValue = typeof value === 'string' ? parseFloat(value) : value;
- const displayValue = isNaN(numValue) ? value : numValue.toLocaleString();
-
- // 견적값이 있고 클릭 핸들러가 있는 경우 클릭 가능한 버튼으로 표시
- if (onQuotationClick && quotationId) {
- return (
- <Button
- variant="link"
- className="p-0 h-auto font-medium text-left justify-start hover:underline"
- onClick={() => onQuotationClick(quotationId)}
- title="견적 히스토리 보기"
- >
- {displayValue} {currency}
- </Button>
- );
- }
-
- return (
- <div className="font-medium">
- {displayValue} {currency}
- </div>
- );
- },
- meta: {
- excelHeader: "견적 금액"
- },
- enableResizing: true,
- size: 140,
- },
- {
- accessorKey: "quotationAttachments",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="첨부파일" />
- ),
- cell: ({ row }) => {
- const attachments = row.original.quotationAttachments || [];
- const attachmentCount = attachments.length;
-
- if (attachmentCount === 0) {
- return <div className="text-muted-foreground">-</div>;
- }
-
- return (
- <Button
- variant="ghost"
- size="sm"
- className="relative h-8 w-8 p-0 group"
- onClick={() => {
- // 견적서 첨부파일 sheet 열기
- if (openQuotationAttachmentsSheet) {
- const quotation = row.original;
- openQuotationAttachmentsSheet(quotation.id, {
- id: quotation.id,
- quotationCode: quotation.quotationCode || null,
- vendorName: quotation.vendorName || undefined,
- rfqCode: quotation.rfqCode || undefined,
- });
- }
- }}
- title={
- attachmentCount === 1
- ? `${attachments[0].fileName} (${(attachments[0].fileSize / 1024 / 1024).toFixed(2)} MB)`
- : `${attachmentCount}개의 첨부파일:\n${attachments.map(att => att.fileName).join('\n')}`
- }
- >
- <Paperclip className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
- {attachmentCount > 0 && (
- <span className="pointer-events-none absolute -top-1 -right-1 inline-flex h-4 min-w-[1rem] items-center justify-center rounded-full bg-primary px-1 text-[0.625rem] font-medium leading-none text-primary-foreground">
- {attachmentCount}
- </span>
- )}
- </Button>
- );
- },
- meta: {
- excelHeader: "첨부파일"
- },
- enableResizing: false,
- size: 80,
- },
- {
- accessorKey: "currency",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="통화" />
- ),
- cell: ({ row }) => <div>{row.getValue("currency")}</div>,
- meta: {
- excelHeader: "통화"
- },
- enableResizing: true,
- size: 80,
- },
- {
- accessorKey: "validUntil",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="유효기간" />
- ),
- cell: ({ cell }) => {
- const value = cell.getValue() as Date | null;
- return value ? formatDate(value, "KR") : "-";
- },
- meta: {
- excelHeader: "유효기간"
- },
- enableResizing: true,
- size: 120,
- },
- {
- accessorKey: "submittedAt",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="제출일" />
- ),
- cell: ({ cell }) => {
- const value = cell.getValue() as Date | null;
- return value ? formatDate(value, "KR") : "-";
- },
- meta: {
- excelHeader: "제출일"
- },
- enableResizing: true,
- size: 120,
- },
- {
- accessorKey: "createdByName",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="등록자" />
- ),
- cell: ({ row }) => <div>{row.getValue("createdByName")}</div>,
- meta: {
- excelHeader: "등록자"
- },
- enableResizing: true,
- size: 120,
- },
- {
- accessorKey: "remark",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="비고" />
- ),
- cell: ({ row }) => <div>{row.getValue("remark") || "-"}</div>,
- meta: {
- excelHeader: "비고"
- },
- enableResizing: true,
- size: 200,
- },
- {
- id: "actions",
- header: () => <div className="text-right">동작</div>,
- cell: function Cell({ row }) {
- const vendorId = row.original.vendorId;
- const unreadCount = vendorId ? unreadMessages[vendorId] || 0 : 0;
- const status = row.original.status;
- const isDraft = status === "Draft";
-
- return (
- <div className="text-right flex items-center justify-end gap-1">
- {/* 커뮤니케이션 버튼 */}
- <div className="relative">
- <Button
- variant="ghost"
- size="sm"
- className="h-8 w-8 p-0"
- onClick={() => setRowAction({ row, type: "communicate" })}
- title="벤더와 커뮤니케이션"
- >
- <MessageCircle className="h-4 w-4" />
- </Button>
- {unreadCount > 0 && (
- <Badge
- variant="destructive"
- className="absolute -top-1 -right-1 h-4 w-4 p-0 text-xs flex items-center justify-center"
- >
- {unreadCount > 9 ? '9+' : unreadCount}
- </Badge>
- )}
- </div>
-
- {/* 컨텍스트 메뉴 */}
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- variant="ghost"
- size="sm"
- className="h-8 w-8 p-0"
- title="더 많은 작업"
- >
- <MoreHorizontal className="h-4 w-4" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end">
- <DropdownMenuItem
- onClick={() => setRowAction({ row, type: "delete" })}
- disabled={!isDraft}
- className={!isDraft ? "opacity-50 cursor-not-allowed" : "text-destructive focus:text-destructive"}
- >
- <Trash2 className="mr-2 h-4 w-4" />
- 벤더 삭제
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
- </div>
- );
- },
- enableResizing: false,
- size: 120,
- },
- ];
+"use client"
+
+import * as React from "react"
+import type { ColumnDef, Row } from "@tanstack/react-table";
+import { formatDate } from "@/lib/utils"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { Checkbox } from "@/components/ui/checkbox";
+import { MessageCircle, MoreHorizontal, Trash2, Paperclip, Users } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+
+export interface DataTableRowAction<TData> {
+ row: Row<TData>;
+ type: "communicate" | "delete";
+}
+
+// 벤더 견적 데이터 타입 정의
+export interface RfqDetailView {
+ id: number
+ rfqId: number
+ vendorId?: number | null
+ vendorName: string | null
+ vendorCode: string | null
+ totalPrice: string | number | null
+ currency: string | null
+ validUntil: Date | null
+ status: string | null
+ remark: string | null
+ submittedAt: Date | null
+ acceptedAt: Date | null
+ rejectionReason: string | null
+ createdAt: Date | null
+ updatedAt: Date | null
+ createdByName: string | null
+ quotationCode?: string | null
+ rfqCode?: string | null
+ quotationAttachments?: Array<{
+ id: number
+ revisionId: number
+ fileName: string
+ fileSize: number
+ filePath: string
+ description?: string | null
+ }>
+}
+
+// 견적서 정보 타입 (Sheet용)
+export interface QuotationInfo {
+ id: number
+ quotationCode: string | null
+ vendorName?: string
+ rfqCode?: string
+}
+
+interface GetColumnsProps<TData> {
+ setRowAction: React.Dispatch<
+ React.SetStateAction<DataTableRowAction<TData> | null>
+ >;
+ unreadMessages?: Record<number, number>; // 읽지 않은 메시지 개수
+ onQuotationClick?: (quotationId: number) => void; // 견적 클릭 핸들러
+ openQuotationAttachmentsSheet?: (quotationId: number, quotationInfo: QuotationInfo) => void; // 견적서 첨부파일 sheet 열기
+ openContactsDialog?: (quotationId: number, vendorName?: string) => void; // 담당자 조회 다이얼로그 열기
+}
+
+export function getRfqDetailColumns({
+ setRowAction,
+ unreadMessages = {},
+ onQuotationClick,
+ openQuotationAttachmentsSheet,
+ openContactsDialog
+}: GetColumnsProps<RfqDetailView>): ColumnDef<RfqDetailView>[] {
+ return [
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="모두 선택"
+ />
+ ),
+ cell: ({ row }) => {
+ const status = row.original.status;
+ const isSelectable = status ? !["Accepted", "Rejected"].includes(status) : true;
+
+ return (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ disabled={!isSelectable}
+ aria-label="행 선택"
+ className={!isSelectable ? "opacity-50 cursor-not-allowed" : ""}
+ />
+ );
+ },
+ enableSorting: false,
+ enableHiding: false,
+ size: 40,
+ },
+ {
+ accessorKey: "status",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="견적 상태" />
+ ),
+ cell: ({ row }) => {
+ const status = row.getValue("status") as string;
+ // 상태에 따른 배지 색상 설정
+ let variant: "default" | "secondary" | "outline" | "destructive" = "outline";
+
+ if (status === "Submitted") {
+ variant = "default"; // 제출됨 - 기본 색상
+ } else if (status === "Accepted") {
+ variant = "secondary"; // 승인됨 - 보조 색상
+ } else if (status === "Rejected") {
+ variant = "destructive"; // 거부됨 - 위험 색상
+ }
+
+ return (
+ <Badge variant={variant}>{status || "Draft"}</Badge>
+ );
+ },
+ meta: {
+ excelHeader: "견적 상태"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "vendorCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="벤더 코드" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("vendorCode")}</div>,
+ meta: {
+ excelHeader: "벤더 코드"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "vendorName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="벤더명" />
+ ),
+ cell: ({ row }) => {
+ const vendorName = row.getValue("vendorName") as string | null;
+ const vendorId = row.original.vendorId;
+
+ if (!vendorName) return <div>-</div>;
+
+ if (vendorId) {
+ return (
+ <Button
+ variant="link"
+ className="p-0 h-auto font-normal text-left justify-start hover:underline"
+ onClick={() => {
+ window.open(`/ko/evcp/tech-vendors/${vendorId}/info`, '_blank');
+ }}
+ >
+ {vendorName}
+ </Button>
+ );
+ }
+
+ return <div>{vendorName}</div>;
+ },
+ meta: {
+ excelHeader: "벤더명"
+ },
+ enableResizing: true,
+ size: 160,
+ },
+ {
+ accessorKey: "totalPrice",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="견적 금액" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("totalPrice") as string | number | null;
+ const currency = row.getValue("currency") as string | null;
+ const quotationId = row.original.id;
+
+ if (value === null || value === undefined) return "-";
+
+ // 숫자로 변환 시도
+ const numValue = typeof value === 'string' ? parseFloat(value) : value;
+ const displayValue = isNaN(numValue) ? value : numValue.toLocaleString();
+
+ // 견적값이 있고 클릭 핸들러가 있는 경우 클릭 가능한 버튼으로 표시
+ if (onQuotationClick && quotationId) {
+ return (
+ <Button
+ variant="link"
+ className="p-0 h-auto font-medium text-left justify-start hover:underline"
+ onClick={() => onQuotationClick(quotationId)}
+ title="견적 히스토리 보기"
+ >
+ {displayValue} {currency}
+ </Button>
+ );
+ }
+
+ return (
+ <div className="font-medium">
+ {displayValue} {currency}
+ </div>
+ );
+ },
+ meta: {
+ excelHeader: "견적 금액"
+ },
+ enableResizing: true,
+ size: 140,
+ },
+ {
+ accessorKey: "quotationAttachments",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="첨부파일" />
+ ),
+ cell: ({ row }) => {
+ const attachments = row.original.quotationAttachments || [];
+ const attachmentCount = attachments.length;
+
+ if (attachmentCount === 0) {
+ return <div className="text-muted-foreground">-</div>;
+ }
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={() => {
+ // 견적서 첨부파일 sheet 열기
+ if (openQuotationAttachmentsSheet) {
+ const quotation = row.original;
+ openQuotationAttachmentsSheet(quotation.id, {
+ id: quotation.id,
+ quotationCode: quotation.quotationCode || null,
+ vendorName: quotation.vendorName || undefined,
+ rfqCode: quotation.rfqCode || undefined,
+ });
+ }
+ }}
+ title={
+ attachmentCount === 1
+ ? `${attachments[0].fileName} (${(attachments[0].fileSize / 1024 / 1024).toFixed(2)} MB)`
+ : `${attachmentCount}개의 첨부파일:\n${attachments.map(att => att.fileName).join('\n')}`
+ }
+ >
+ <Paperclip className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ {attachmentCount > 0 && (
+ <span className="pointer-events-none absolute -top-1 -right-1 inline-flex h-4 min-w-[1rem] items-center justify-center rounded-full bg-primary px-1 text-[0.625rem] font-medium leading-none text-primary-foreground">
+ {attachmentCount}
+ </span>
+ )}
+ </Button>
+ );
+ },
+ meta: {
+ excelHeader: "첨부파일"
+ },
+ enableResizing: false,
+ size: 80,
+ },
+ {
+ id: "contacts",
+ header: "담당자",
+ cell: ({ row }) => {
+ const quotation = row.original;
+
+ const handleClick = () => {
+ if (openContactsDialog) {
+ openContactsDialog(quotation.id, quotation.vendorName || undefined);
+ }
+ };
+
+ return (
+ <div className="w-20">
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label="담당자 정보 보기"
+ >
+ <Users className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ <span className="sr-only">담당자 정보 보기</span>
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>RFQ 발송 담당자 보기</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ );
+ },
+ meta: {
+ excelHeader: "담당자"
+ },
+ enableResizing: false,
+ size: 80,
+ },
+ {
+ accessorKey: "currency",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="통화" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("currency")}</div>,
+ meta: {
+ excelHeader: "통화"
+ },
+ enableResizing: true,
+ size: 80,
+ },
+ {
+ accessorKey: "validUntil",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="유효기간" />
+ ),
+ cell: ({ cell }) => {
+ const value = cell.getValue() as Date | null;
+ return value ? formatDate(value, "KR") : "-";
+ },
+ meta: {
+ excelHeader: "유효기간"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "submittedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="제출일" />
+ ),
+ cell: ({ cell }) => {
+ const value = cell.getValue() as Date | null;
+ return value ? formatDate(value, "KR") : "-";
+ },
+ meta: {
+ excelHeader: "제출일"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "createdByName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="등록자" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("createdByName")}</div>,
+ meta: {
+ excelHeader: "등록자"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "remark",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="비고" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("remark") || "-"}</div>,
+ meta: {
+ excelHeader: "비고"
+ },
+ enableResizing: true,
+ size: 200,
+ },
+ {
+ id: "actions",
+ header: () => <div className="text-right">동작</div>,
+ cell: function Cell({ row }) {
+ const vendorId = row.original.vendorId;
+ const unreadCount = vendorId ? unreadMessages[vendorId] || 0 : 0;
+ const status = row.original.status;
+ const isDraft = status === "Draft";
+
+ return (
+ <div className="text-right flex items-center justify-end gap-1">
+ {/* 커뮤니케이션 버튼 */}
+ <div className="relative">
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-8 w-8 p-0"
+ onClick={() => setRowAction({ row, type: "communicate" })}
+ title="벤더와 커뮤니케이션"
+ >
+ <MessageCircle className="h-4 w-4" />
+ </Button>
+ {unreadCount > 0 && (
+ <Badge
+ variant="destructive"
+ className="absolute -top-1 -right-1 h-4 w-4 p-0 text-xs flex items-center justify-center"
+ >
+ {unreadCount > 9 ? '9+' : unreadCount}
+ </Badge>
+ )}
+ </div>
+
+ {/* 컨텍스트 메뉴 */}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-8 w-8 p-0"
+ title="더 많은 작업"
+ >
+ <MoreHorizontal className="h-4 w-4" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuItem
+ onClick={() => setRowAction({ row, type: "delete" })}
+ disabled={!isDraft}
+ className={!isDraft ? "opacity-50 cursor-not-allowed" : "text-destructive focus:text-destructive"}
+ >
+ <Trash2 className="mr-2 h-4 w-4" />
+ 벤더 삭제
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+ );
+ },
+ enableResizing: false,
+ size: 120,
+ },
+ ];
} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx
index 1d701bd5..41572a93 100644
--- a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx
+++ b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx
@@ -1,710 +1,775 @@
-"use client"
-
-import * as React from "react"
-import { useEffect, useState, useCallback, useMemo } from "react"
-import {
- DataTableRowAction,
- getRfqDetailColumns,
- RfqDetailView
-} from "./rfq-detail-column"
-import { toast } from "sonner"
-
-import { Skeleton } from "@/components/ui/skeleton"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import { Loader2, UserPlus, Send, Trash2, CheckCircle } from "lucide-react"
-import { ClientDataTable } from "@/components/client-data-table/data-table"
-import { AddVendorDialog } from "./add-vendor-dialog"
-import { VendorCommunicationDrawer } from "./vendor-communication-drawer"
-import { DeleteVendorsDialog } from "../delete-vendors-dialog"
-import { QuotationHistoryDialog } from "@/lib/techsales-rfq/table/detail-table/quotation-history-dialog"
-import { TechSalesQuotationAttachmentsSheet, type QuotationAttachment } from "../tech-sales-quotation-attachments-sheet"
-import type { QuotationInfo } from "./rfq-detail-column"
-
-// 기본적인 RFQ 타입 정의
-interface TechSalesRfq {
- id: number
- rfqCode: string | null
- status: string
- materialCode?: string | null
- itemName?: string | null
- remark?: string | null
- rfqSendDate?: Date | null
- dueDate?: Date | null
- createdByName?: string | null
- rfqType: "SHIP" | "TOP" | "HULL" | null
- ptypeNm?: string | null
-}
-
-// 프로퍼티 정의
-interface RfqDetailTablesProps {
- selectedRfq: TechSalesRfq | null
- maxHeight?: string | number
-}
-
-
-export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps) {
- // console.log("selectedRfq", selectedRfq)
-
- // 상태 관리
- const [isLoading, setIsLoading] = useState(false)
- const [details, setDetails] = useState<RfqDetailView[]>([])
- const [vendorDialogOpen, setVendorDialogOpen] = React.useState(false)
-
- const [isAdddialogLoading, setIsAdddialogLoading] = useState(false)
-
- const [rowAction, setRowAction] = React.useState<DataTableRowAction<RfqDetailView> | null>(null)
-
- // 벤더 커뮤니케이션 상태 관리
- const [communicationDrawerOpen, setCommunicationDrawerOpen] = useState(false)
- const [selectedVendor, setSelectedVendor] = useState<RfqDetailView | null>(null)
-
- // 읽지 않은 메시지 개수
- const [unreadMessages, setUnreadMessages] = useState<Record<number, number>>({})
-
- // 테이블 선택 상태 관리
- const [selectedRows, setSelectedRows] = useState<RfqDetailView[]>([])
- const [isSendingRfq, setIsSendingRfq] = useState(false)
- const [isDeletingVendors, setIsDeletingVendors] = useState(false)
-
- // 벤더 삭제 확인 다이얼로그 상태 추가
- const [deleteConfirmDialogOpen, setDeleteConfirmDialogOpen] = useState(false)
-
- // 견적 히스토리 다이얼로그 상태 관리
- const [historyDialogOpen, setHistoryDialogOpen] = useState(false)
- const [selectedQuotationId, setSelectedQuotationId] = useState<number | null>(null)
-
- // 견적서 첨부파일 sheet 상태 관리
- const [quotationAttachmentsSheetOpen, setQuotationAttachmentsSheetOpen] = useState(false)
- const [selectedQuotationInfo, setSelectedQuotationInfo] = useState<QuotationInfo | null>(null)
- const [quotationAttachments, setQuotationAttachments] = useState<QuotationAttachment[]>([])
- const [isLoadingAttachments, setIsLoadingAttachments] = useState(false)
-
- // selectedRfq ID 메모이제이션 (객체 참조 변경 방지)
- const selectedRfqId = useMemo(() => selectedRfq?.id, [selectedRfq?.id])
-
- // existingVendorIds 메모이제이션
- const existingVendorIds = useMemo(() => {
- return details.map(detail => Number(detail.vendorId)).filter(Boolean);
- }, [details]);
-
- // 읽지 않은 메시지 로드 함수 메모이제이션
- const loadUnreadMessages = useCallback(async () => {
- if (!selectedRfqId) return;
-
- try {
- // 기술영업용 읽지 않은 메시지 수 가져오기 함수 구현
- const { getTechSalesUnreadMessageCounts } = await import("@/lib/techsales-rfq/service");
- const unreadData = await getTechSalesUnreadMessageCounts(selectedRfqId);
- setUnreadMessages(unreadData);
- } catch (error) {
- console.error("읽지 않은 메시지 로드 오류:", error);
- setUnreadMessages({});
- }
- }, [selectedRfqId]);
-
- // 데이터 새로고침 함수 메모이제이션
- const handleRefreshData = useCallback(async () => {
- if (!selectedRfqId) return
-
- try {
- // 실제 벤더 견적 데이터 다시 로딩
- const { getTechSalesRfqTechVendors } = await import("@/lib/techsales-rfq/service")
-
- const result = await getTechSalesRfqTechVendors(selectedRfqId)
-
- // 데이터 변환
- const transformedData = result.data?.map((item: any) => ({
- ...item,
- detailId: item.id,
- rfqId: selectedRfqId,
- rfqCode: selectedRfq?.rfqCode || null,
- rfqType: selectedRfq?.rfqType || null,
- ptypeNm: selectedRfq?.ptypeNm || null,
- vendorId: item.vendorId ? Number(item.vendorId) : undefined,
- })) || []
-
- setDetails(transformedData)
-
- // 읽지 않은 메시지 개수 업데이트
- await loadUnreadMessages();
-
- toast.success("데이터를 성공적으로 새로고침했습니다")
- } catch (error) {
- console.error("데이터 새로고침 오류:", error)
- toast.error("데이터를 새로고침하는 중 오류가 발생했습니다")
- }
- }, [selectedRfqId, selectedRfq?.rfqCode, selectedRfq?.rfqType, selectedRfq?.ptypeNm, loadUnreadMessages])
-
- // 벤더 추가 핸들러 메모이제이션
- const handleAddVendor = useCallback(async () => {
- try {
- setIsAdddialogLoading(true)
- setVendorDialogOpen(true)
- } catch (error) {
- console.error("데이터 로드 오류:", error)
- toast.error("벤더 정보를 불러오는 중 오류가 발생했습니다")
- } finally {
- setIsAdddialogLoading(false)
- }
- }, [])
-
- // RFQ 발송 핸들러 메모이제이션
- const handleSendRfq = useCallback(async () => {
- if (selectedRows.length === 0) {
- toast.warning("발송할 벤더를 선택해주세요.");
- return;
- }
-
- if (!selectedRfqId) {
- toast.error("선택된 RFQ가 없습니다.");
- return;
- }
-
- try {
- setIsSendingRfq(true);
-
- // 기술영업 RFQ 발송 서비스 함수 호출
- const vendorIds = selectedRows.map(row => row.vendorId).filter(Boolean);
- const { sendTechSalesRfqToVendors } = await import("@/lib/techsales-rfq/service");
-
- const result = await sendTechSalesRfqToVendors({
- rfqId: selectedRfqId,
- vendorIds: vendorIds as number[]
- });
-
- if (result.success) {
- toast.success(result.message || `${selectedRows.length}개 벤더에게 RFQ가 발송되었습니다.`);
- } else {
- toast.error(result.message || "RFQ 발송 중 오류가 발생했습니다.");
- }
-
- // 선택 해제
- setSelectedRows([]);
-
- // 데이터 새로고침
- await handleRefreshData();
-
- } catch (error) {
- console.error("RFQ 발송 오류:", error);
- toast.error("RFQ 발송 중 오류가 발생했습니다.");
- } finally {
- setIsSendingRfq(false);
- }
- }, [selectedRows, selectedRfqId, handleRefreshData]);
-
- // 벤더 선택 핸들러 추가
- const [isAcceptingVendors, setIsAcceptingVendors] = useState(false);
-
- const handleAcceptVendors = useCallback(async () => {
- if (selectedRows.length === 0) {
- toast.warning("선택할 벤더를 선택해주세요.");
- return;
- }
-
- if (selectedRows.length > 1) {
- toast.warning("하나의 벤더만 선택할 수 있습니다.");
- return;
- }
-
- const selectedQuotation = selectedRows[0];
- if (selectedQuotation.status !== "Submitted") {
- toast.warning("제출된 견적서만 선택할 수 있습니다.");
- return;
- }
-
- try {
- setIsAcceptingVendors(true);
-
- // 벤더 견적 승인 서비스 함수 호출
- const { acceptTechSalesVendorQuotationAction } = await import("@/lib/techsales-rfq/actions");
-
- const result = await acceptTechSalesVendorQuotationAction(selectedQuotation.id);
-
- if (result.success) {
- toast.success(result.message || "벤더가 성공적으로 선택되었습니다.");
- } else {
- toast.error(result.error || "벤더 선택 중 오류가 발생했습니다.");
- }
-
- // 선택 해제
- setSelectedRows([]);
-
- // 데이터 새로고침
- await handleRefreshData();
-
- } catch (error) {
- console.error("벤더 선택 오류:", error);
- toast.error("벤더 선택 중 오류가 발생했습니다.");
- } finally {
- setIsAcceptingVendors(false);
- }
- }, [selectedRows, handleRefreshData]);
-
- // 벤더 삭제 핸들러 메모이제이션
- const handleDeleteVendors = useCallback(async () => {
- if (selectedRows.length === 0) {
- toast.warning("삭제할 벤더를 선택해주세요.");
- return;
- }
-
- if (!selectedRfqId) {
- toast.error("선택된 RFQ가 없습니다.");
- return;
- }
-
- try {
- setIsDeletingVendors(true);
-
- const vendorIds = selectedRows.map(row => row.vendorId).filter(Boolean) as number[];
-
- if (vendorIds.length === 0) {
- toast.error("유효한 벤더 ID가 없습니다.");
- return;
- }
-
- // 서비스 함수 호출
- const { removeTechVendorsFromTechSalesRfq } = await import("@/lib/techsales-rfq/service");
-
- const result = await removeTechVendorsFromTechSalesRfq({
- rfqId: selectedRfqId,
- vendorIds: vendorIds
- });
-
- if (result.error) {
- toast.error(result.error);
- } else {
- const successCount = result.data?.length || 0
- toast.success(`${successCount}개의 벤더가 성공적으로 삭제되었습니다`);
- }
-
- // 선택 해제
- setSelectedRows([]);
-
- // 데이터 새로고침
- await handleRefreshData();
-
- } catch (error) {
- console.error("벤더 삭제 오류:", error);
- toast.error("벤더 삭제 중 오류가 발생했습니다.");
- } finally {
- setIsDeletingVendors(false);
- }
- }, [selectedRows, selectedRfqId, handleRefreshData]);
-
- // 벤더 삭제 확인 핸들러
- const handleDeleteVendorsConfirm = useCallback(() => {
- if (selectedRows.length === 0) {
- toast.warning("삭제할 벤더를 선택해주세요.");
- return;
- }
- setDeleteConfirmDialogOpen(true);
- }, [selectedRows]);
-
- // 벤더 삭제 확정 실행
- const executeDeleteVendors = useCallback(async () => {
- setDeleteConfirmDialogOpen(false);
- await handleDeleteVendors();
- }, [handleDeleteVendors]);
-
-
- // 견적 히스토리 다이얼로그 열기 핸들러 메모이제이션
- const handleOpenHistoryDialog = useCallback((quotationId: number) => {
- setSelectedQuotationId(quotationId);
- setHistoryDialogOpen(true);
- }, [])
-
- // 견적서 첨부파일 sheet 열기 핸들러 메모이제이션
- const handleOpenQuotationAttachmentsSheet = useCallback(async (quotationId: number, quotationInfo: QuotationInfo) => {
- try {
- setIsLoadingAttachments(true);
- setSelectedQuotationInfo(quotationInfo);
- setQuotationAttachmentsSheetOpen(true);
-
- // 견적서 첨부파일 조회
- const { getTechSalesVendorQuotationAttachments } = await import("@/lib/techsales-rfq/service");
- const result = await getTechSalesVendorQuotationAttachments(quotationId);
-
- if (result.error) {
- toast.error(result.error);
- setQuotationAttachments([]);
- } else {
- setQuotationAttachments(result.data || []);
- }
- } catch (error) {
- console.error("견적서 첨부파일 조회 오류:", error);
- toast.error("견적서 첨부파일을 불러오는 중 오류가 발생했습니다.");
- setQuotationAttachments([]);
- } finally {
- setIsLoadingAttachments(false);
- }
- }, [])
-
- // 칼럼 정의 - unreadMessages 상태 전달 (메모이제이션)
- const columns = useMemo(() =>
- getRfqDetailColumns({
- setRowAction,
- unreadMessages,
- onQuotationClick: handleOpenHistoryDialog,
- openQuotationAttachmentsSheet: handleOpenQuotationAttachmentsSheet
- }), [unreadMessages, handleOpenHistoryDialog, handleOpenQuotationAttachmentsSheet])
-
- // 필터 필드 정의 (메모이제이션)
- const advancedFilterFields = useMemo(
- () => [
- {
- id: "vendorName",
- label: "벤더명",
- type: "text",
- },
- {
- id: "vendorCode",
- label: "벤더 코드",
- type: "text",
- },
- {
- id: "currency",
- label: "통화",
- type: "text",
- },
- ],
- []
- )
-
- // 계산된 값들 메모이제이션
- const vendorsWithQuotations = useMemo(() =>
- details.filter(detail => detail.status === "Submitted").length,
- [details]
- );
-
- // RFQ ID가 변경될 때 데이터 로드
- useEffect(() => {
- async function loadRfqDetails() {
- if (!selectedRfqId) {
- setDetails([])
- return
- }
-
- try {
- setIsLoading(true)
-
- // 실제 벤더 견적 데이터 로딩
- const { getTechSalesVendorQuotationsWithJoin } = await import("@/lib/techsales-rfq/service")
-
- const result = await getTechSalesVendorQuotationsWithJoin({
- rfqId: selectedRfqId,
- page: 1,
- perPage: 1000, // 모든 데이터 가져오기
- })
-
- // 데이터 변환 (procurement 패턴에 맞게)
- const transformedData = result.data?.map(item => ({
- ...item,
- detailId: item.id,
- rfqId: selectedRfqId,
- rfqCode: selectedRfq?.rfqCode || null,
- vendorId: item.vendorId ? Number(item.vendorId) : undefined,
- // 기타 필요한 필드 변환
- })) || []
-
- setDetails(transformedData)
-
- // 읽지 않은 메시지 개수 로드
- await loadUnreadMessages();
-
- } catch (error) {
- console.error("RFQ 디테일 로드 오류:", error)
- setDetails([])
- toast.error("RFQ 세부정보를 불러오는 중 오류가 발생했습니다")
- } finally {
- setIsLoading(false)
- }
- }
-
- loadRfqDetails()
- }, [selectedRfqId, selectedRfq?.rfqCode, loadUnreadMessages])
-
- // 주기적으로 읽지 않은 메시지 갱신 (60초마다) - 메모이제이션된 함수 사용
- useEffect(() => {
- if (!selectedRfqId) return;
-
- const intervalId = setInterval(() => {
- loadUnreadMessages();
- }, 60000); // 60초마다 갱신
-
- return () => clearInterval(intervalId);
- }, [selectedRfqId, loadUnreadMessages]);
-
- // rowAction 처리 - procurement 패턴 적용 (메모이제이션)
- useEffect(() => {
- if (!rowAction) return
-
- const handleRowAction = async () => {
- try {
- // 통신 액션인 경우 드로어 열기
- if (rowAction.type === "communicate") {
- setSelectedVendor(rowAction.row.original);
- setCommunicationDrawerOpen(true);
-
- // rowAction 초기화
- setRowAction(null);
- return;
- }
-
- // 삭제 액션인 경우 개별 벤더 삭제
- if (rowAction.type === "delete") {
- const vendor = rowAction.row.original;
-
- if (!vendor.vendorId || !selectedRfqId) {
- toast.error("벤더 정보가 없습니다.");
- setRowAction(null);
- return;
- }
-
- // Draft 상태 체크
- if (vendor.status !== "Draft") {
- toast.error("Draft 상태의 벤더만 삭제할 수 있습니다.");
- setRowAction(null);
- return;
- }
-
- // 개별 벤더 삭제
- const { removeTechVendorFromTechSalesRfq } = await import("@/lib/techsales-rfq/service");
-
- const result = await removeTechVendorFromTechSalesRfq({
- rfqId: selectedRfqId,
- vendorId: vendor.vendorId
- });
-
- if (result.error) {
- toast.error(result.error);
- } else {
- toast.success(`${vendor.vendorName || '벤더'}가 성공적으로 삭제되었습니다.`);
- // 데이터 새로고침
- await handleRefreshData();
- }
-
- // rowAction 초기화
- setRowAction(null);
- return;
- }
- } catch (error) {
- console.error("액션 처리 오류:", error);
- toast.error("작업을 처리하는 중 오류가 발생했습니다");
- }
- };
-
- handleRowAction();
- }, [rowAction, selectedRfqId, handleRefreshData])
-
- // 선택된 행 변경 핸들러 메모이제이션
- const handleSelectedRowsChange = useCallback((selectedRowsData: RfqDetailView[]) => {
- setSelectedRows(selectedRowsData);
- }, []);
-
- // 커뮤니케이션 드로어 변경 핸들러 메모이제이션
- const handleCommunicationDrawerChange = useCallback((open: boolean) => {
- setCommunicationDrawerOpen(open);
- // 드로어가 닫힐 때 해당 벤더의 메시지를 읽음 처리하고 읽지 않은 메시지 개수 갱신
- if (!open && selectedVendor?.vendorId && selectedRfqId) {
- // 메시지를 읽음으로 처리
- import("@/lib/techsales-rfq/service").then(({ markTechSalesMessagesAsRead }) => {
- markTechSalesMessagesAsRead(selectedRfqId, selectedVendor.vendorId || undefined).catch(error => {
- console.error("메시지 읽음 처리 오류:", error);
- });
- });
-
- // 해당 벤더의 읽지 않은 메시지를 0으로 즉시 업데이트
- setUnreadMessages(prev => ({
- ...prev,
- [selectedVendor.vendorId!]: 0
- }));
-
- // 전체 읽지 않은 메시지 개수 갱신
- loadUnreadMessages();
- }
- }, [selectedVendor, selectedRfqId, loadUnreadMessages]);
-
- if (!selectedRfq) {
- return (
- <div className="flex items-center justify-center h-full text-muted-foreground">
- RFQ를 선택하세요
- </div>
- )
- }
-
- // 로딩 중인 경우
- if (isLoading) {
- return (
- <div className="p-4 space-y-4">
- <Skeleton className="h-8 w-1/2" />
- <Skeleton className="h-24 w-full" />
- <Skeleton className="h-48 w-full" />
- </div>
- )
- }
-
- return (
- <div className="h-full overflow-hidden pt-4">
- {/* 테이블 또는 빈 상태 표시 */}
- {details.length > 0 ? (
- <ClientDataTable
- columns={columns}
- data={details}
- advancedFilterFields={advancedFilterFields}
- maxHeight={maxHeight}
- onSelectedRowsChange={handleSelectedRowsChange}
- >
- <div className="flex justify-between items-center">
- <div className="flex items-center gap-2 mr-2">
- {selectedRows.length > 0 && (
- <Badge variant="default" className="h-6">
- {selectedRows.length}개 선택됨
- </Badge>
- )}
- {/* {totalUnreadMessages > 0 && (
- <Badge variant="destructive" className="h-6">
- 읽지 않은 메시지: {totalUnreadMessages}건
- </Badge>
- )} */}
- {vendorsWithQuotations > 0 && (
- <Badge variant="outline" className="h-6">
- 견적 제출: {vendorsWithQuotations}개 벤더
- </Badge>
- )}
- </div>
- <div className="flex gap-2">
- {/* 벤더 선택 버튼 */}
- <Button
- variant="default"
- size="sm"
- onClick={handleAcceptVendors}
- disabled={selectedRows.length === 0 || isAcceptingVendors}
- className="gap-2"
- >
- {isAcceptingVendors ? (
- <Loader2 className="size-4 animate-spin" aria-hidden="true" />
- ) : (
- <CheckCircle className="size-4" aria-hidden="true" />
- )}
- <span>벤더 선택</span>
- </Button>
-
- {/* RFQ 발송 버튼 */}
- <Button
- variant="outline"
- size="sm"
- onClick={handleSendRfq}
- disabled={selectedRows.length === 0 || isSendingRfq}
- className="gap-2"
- >
- {isSendingRfq ? (
- <Loader2 className="size-4 animate-spin" aria-hidden="true" />
- ) : (
- <Send className="size-4" aria-hidden="true" />
- )}
- <span>RFQ 발송</span>
- </Button>
-
- {/* 벤더 삭제 버튼 */}
- <Button
- variant="outline"
- size="sm"
- onClick={handleDeleteVendorsConfirm}
- disabled={selectedRows.length === 0 || isDeletingVendors}
- className="gap-2"
- >
- {isDeletingVendors ? (
- <Loader2 className="size-4 animate-spin" aria-hidden="true" />
- ) : (
- <Trash2 className="size-4" aria-hidden="true" />
- )}
- <span>벤더 삭제</span>
- </Button>
-
- {/* 벤더 추가 버튼 */}
- <Button
- variant="outline"
- size="sm"
- onClick={handleAddVendor}
- disabled={isAdddialogLoading}
- className="gap-2"
- >
- {isAdddialogLoading ? (
- <Loader2 className="size-4 animate-spin" aria-hidden="true" />
- ) : (
- <UserPlus className="size-4" aria-hidden="true" />
- )}
- <span>벤더 추가</span>
- </Button>
- </div>
- </div>
- </ClientDataTable>
- ) : (
- <div className="flex h-full items-center justify-center text-muted-foreground">
- <div className="text-center">
- <p className="text-lg font-medium">벤더가 없습니다</p>
- <p className="text-sm">벤더를 추가하여 RFQ를 시작하세요</p>
- <Button
- variant="outline"
- size="sm"
- onClick={handleAddVendor}
- disabled={isAdddialogLoading}
- className="mt-4 gap-2"
- >
- {isAdddialogLoading ? (
- <Loader2 className="size-4 animate-spin" aria-hidden="true" />
- ) : (
- <UserPlus className="size-4" aria-hidden="true" />
- )}
- <span>벤더 추가</span>
- </Button>
- </div>
- </div>
- )}
-
- {/* 다이얼로그들 */}
- <AddVendorDialog
- open={vendorDialogOpen}
- onOpenChange={setVendorDialogOpen}
- selectedRfq={selectedRfq as unknown as TechSalesRfq}
- existingVendorIds={existingVendorIds}
- onSuccess={handleRefreshData}
- />
-
- {/* 벤더 커뮤니케이션 드로어 */}
- <VendorCommunicationDrawer
- open={communicationDrawerOpen}
- onOpenChange={handleCommunicationDrawerChange}
- selectedRfq={selectedRfq}
- selectedVendor={selectedVendor}
- onSuccess={handleRefreshData}
- />
-
- {/* 다중 벤더 삭제 확인 다이얼로그 */}
- <DeleteVendorsDialog
- open={deleteConfirmDialogOpen}
- onOpenChange={setDeleteConfirmDialogOpen}
- vendors={selectedRows}
- onConfirm={executeDeleteVendors}
- isLoading={isDeletingVendors}
- />
-
- {/* 견적 히스토리 다이얼로그 */}
- <QuotationHistoryDialog
- open={historyDialogOpen}
- onOpenChange={setHistoryDialogOpen}
- quotationId={selectedQuotationId}
- />
-
- {/* 견적서 첨부파일 Sheet */}
- <TechSalesQuotationAttachmentsSheet
- open={quotationAttachmentsSheetOpen}
- onOpenChange={setQuotationAttachmentsSheetOpen}
- quotation={selectedQuotationInfo}
- attachments={quotationAttachments}
- isLoading={isLoadingAttachments}
- />
- </div>
- )
+"use client"
+
+import * as React from "react"
+import { useEffect, useState, useCallback, useMemo } from "react"
+import {
+ DataTableRowAction,
+ getRfqDetailColumns,
+ RfqDetailView
+} from "./rfq-detail-column"
+import { toast } from "sonner"
+
+import { Skeleton } from "@/components/ui/skeleton"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Loader2, UserPlus, Send, Trash2, CheckCircle } from "lucide-react"
+import { ClientDataTable } from "@/components/client-data-table/data-table"
+import { AddVendorDialog } from "./add-vendor-dialog"
+import { VendorCommunicationDrawer } from "./vendor-communication-drawer"
+import { DeleteVendorDialog } from "./delete-vendors-dialog"
+import { QuotationHistoryDialog } from "@/lib/techsales-rfq/table/detail-table/quotation-history-dialog"
+import { TechSalesQuotationAttachmentsSheet, type QuotationAttachment } from "../tech-sales-quotation-attachments-sheet"
+import type { QuotationInfo } from "./rfq-detail-column"
+import { VendorContactSelectionDialog } from "./vendor-contact-selection-dialog"
+import { QuotationContactsViewDialog } from "./quotation-contacts-view-dialog"
+
+// 기본적인 RFQ 타입 정의
+interface TechSalesRfq {
+ id: number
+ rfqCode: string | null
+ status: string
+ materialCode?: string | null
+ itemName?: string | null
+ remark?: string | null
+ rfqSendDate?: Date | null
+ dueDate?: Date | null
+ createdByName?: string | null
+ rfqType: "SHIP" | "TOP" | "HULL" | null
+ ptypeNm?: string | null
+}
+
+// 프로퍼티 정의
+interface RfqDetailTablesProps {
+ selectedRfq: TechSalesRfq | null
+ maxHeight?: string | number
+}
+
+
+export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps) {
+ // console.log("selectedRfq", selectedRfq)
+
+ // 상태 관리
+ const [isLoading, setIsLoading] = useState(false)
+ const [details, setDetails] = useState<RfqDetailView[]>([])
+ const [vendorDialogOpen, setVendorDialogOpen] = React.useState(false)
+
+ const [isAdddialogLoading, setIsAdddialogLoading] = useState(false)
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<RfqDetailView> | null>(null)
+
+ // 벤더 커뮤니케이션 상태 관리
+ const [communicationDrawerOpen, setCommunicationDrawerOpen] = useState(false)
+ const [selectedVendor, setSelectedVendor] = useState<RfqDetailView | null>(null)
+
+ // 읽지 않은 메시지 개수
+ const [unreadMessages, setUnreadMessages] = useState<Record<number, number>>({})
+
+ // 테이블 선택 상태 관리
+ const [selectedRows, setSelectedRows] = useState<RfqDetailView[]>([])
+ const [isSendingRfq, setIsSendingRfq] = useState(false)
+ const [isDeletingVendors, setIsDeletingVendors] = useState(false)
+
+ // 벤더 삭제 확인 다이얼로그 상태 추가
+ const [deleteConfirmDialogOpen, setDeleteConfirmDialogOpen] = useState(false)
+
+ // 견적 히스토리 다이얼로그 상태 관리
+ const [historyDialogOpen, setHistoryDialogOpen] = useState(false)
+ const [selectedQuotationId, setSelectedQuotationId] = useState<number | null>(null)
+
+ // 견적서 첨부파일 sheet 상태 관리
+ const [quotationAttachmentsSheetOpen, setQuotationAttachmentsSheetOpen] = useState(false)
+ const [selectedQuotationInfo, setSelectedQuotationInfo] = useState<QuotationInfo | null>(null)
+ const [quotationAttachments, setQuotationAttachments] = useState<QuotationAttachment[]>([])
+ const [isLoadingAttachments, setIsLoadingAttachments] = useState(false)
+
+ // 벤더 contact 선택 다이얼로그 상태 관리
+ const [contactSelectionDialogOpen, setContactSelectionDialogOpen] = useState(false)
+
+ // 담당자 조회 다이얼로그 상태 관리
+ const [contactsDialogOpen, setContactsDialogOpen] = useState(false)
+ const [selectedQuotationForContacts, setSelectedQuotationForContacts] = useState<{ id: number; vendorName?: string } | null>(null)
+
+ // selectedRfq ID 메모이제이션 (객체 참조 변경 방지)
+ const selectedRfqId = useMemo(() => selectedRfq?.id, [selectedRfq?.id])
+
+ // existingVendorIds 메모이제이션
+ const existingVendorIds = useMemo(() => {
+ return details.map(detail => Number(detail.vendorId)).filter(Boolean);
+ }, [details]);
+
+ // 읽지 않은 메시지 로드 함수 메모이제이션
+ const loadUnreadMessages = useCallback(async () => {
+ if (!selectedRfqId) return;
+
+ try {
+ // 기술영업용 읽지 않은 메시지 수 가져오기 함수 구현
+ const { getTechSalesUnreadMessageCounts } = await import("@/lib/techsales-rfq/service");
+ const unreadData = await getTechSalesUnreadMessageCounts(selectedRfqId);
+ setUnreadMessages(unreadData);
+ } catch (error) {
+ console.error("읽지 않은 메시지 로드 오류:", error);
+ setUnreadMessages({});
+ }
+ }, [selectedRfqId]);
+
+ // 데이터 새로고침 함수 메모이제이션
+ const handleRefreshData = useCallback(async () => {
+ if (!selectedRfqId) return
+
+ try {
+ // 실제 벤더 견적 데이터 다시 로딩
+ const { getTechSalesRfqTechVendors } = await import("@/lib/techsales-rfq/service")
+
+ const result = await getTechSalesRfqTechVendors(selectedRfqId)
+
+ // 데이터 변환
+ const transformedData = result.data?.map((item: any) => ({
+ ...item,
+ detailId: item.id,
+ rfqId: selectedRfqId,
+ rfqCode: selectedRfq?.rfqCode || null,
+ rfqType: selectedRfq?.rfqType || null,
+ ptypeNm: selectedRfq?.ptypeNm || null,
+ vendorId: item.vendorId ? Number(item.vendorId) : undefined,
+ })) || []
+
+ setDetails(transformedData)
+
+ // 읽지 않은 메시지 개수 업데이트
+ await loadUnreadMessages();
+
+ toast.success("데이터를 성공적으로 새로고침했습니다")
+ } catch (error) {
+ console.error("데이터 새로고침 오류:", error)
+ toast.error("데이터를 새로고침하는 중 오류가 발생했습니다")
+ }
+ }, [selectedRfqId, selectedRfq?.rfqCode, selectedRfq?.rfqType, selectedRfq?.ptypeNm, loadUnreadMessages])
+
+ // 벤더 추가 핸들러 메모이제이션
+ const handleAddVendor = useCallback(async () => {
+ try {
+ setIsAdddialogLoading(true)
+ setVendorDialogOpen(true)
+ } catch (error) {
+ console.error("데이터 로드 오류:", error)
+ toast.error("벤더 정보를 불러오는 중 오류가 발생했습니다")
+ } finally {
+ setIsAdddialogLoading(false)
+ }
+ }, [])
+
+ // RFQ 발송 핸들러 메모이제이션 - contact selection dialog 사용
+ const handleSendRfq = useCallback(async () => {
+ if (selectedRows.length === 0) {
+ toast.warning("발송할 벤더를 선택해주세요.");
+ return;
+ }
+
+ if (!selectedRfqId) {
+ toast.error("선택된 RFQ가 없습니다.");
+ return;
+ }
+
+ // 선택된 벤더들의 status가 모두 'Assigned'인지 확인
+ const nonAssignedVendors = selectedRows.filter(row => row.status !== "Assigned");
+ if (nonAssignedVendors.length > 0) {
+ toast.warning("Assigned 상태의 벤더만 RFQ를 발송할 수 있습니다.");
+ return;
+ }
+
+ // contact selection dialog 열기
+ setContactSelectionDialogOpen(true);
+ }, [selectedRows, selectedRfqId]);
+
+ // contact 기반 RFQ 발송 핸들러
+ const handleSendRfqWithContacts = useCallback(async (selectedContacts: Array<{
+ vendorId: number;
+ contactId: number;
+ contactEmail: string;
+ contactName: string;
+ }>) => {
+ if (!selectedRfqId) {
+ toast.error("선택된 RFQ가 없습니다.");
+ return;
+ }
+
+ try {
+ setIsSendingRfq(true);
+
+ // 기술영업 RFQ 발송 서비스 함수 호출 (contact 정보 포함)
+ const vendorIds = selectedRows.map(row => row.vendorId).filter(Boolean);
+ const { sendTechSalesRfqToVendors } = await import("@/lib/techsales-rfq/service");
+
+ const result = await sendTechSalesRfqToVendors({
+ rfqId: selectedRfqId,
+ vendorIds: vendorIds as number[],
+ selectedContacts: selectedContacts
+ });
+
+ if (result.success) {
+ toast.success(result.message || `${selectedContacts.length}명의 연락처에게 RFQ가 발송되었습니다.`);
+ } else {
+ toast.error(result.message || "RFQ 발송 중 오류가 발생했습니다.");
+ }
+
+ // 선택 해제
+ setSelectedRows([]);
+
+ // 데이터 새로고침
+ await handleRefreshData();
+
+ } catch (error) {
+ console.error("RFQ 발송 오류:", error);
+ toast.error("RFQ 발송 중 오류가 발생했습니다.");
+ } finally {
+ setIsSendingRfq(false);
+ }
+ }, [selectedRfqId, selectedRows, handleRefreshData]);
+
+ // 벤더 선택 핸들러 추가
+ const [isAcceptingVendors, setIsAcceptingVendors] = useState(false);
+
+ const handleAcceptVendors = useCallback(async () => {
+ if (selectedRows.length === 0) {
+ toast.warning("선택할 벤더를 선택해주세요.");
+ return;
+ }
+
+ if (selectedRows.length > 1) {
+ toast.warning("하나의 벤더만 선택할 수 있습니다.");
+ return;
+ }
+
+ const selectedQuotation = selectedRows[0];
+ if (selectedQuotation.status !== "Submitted") {
+ toast.warning("제출된 견적서만 선택할 수 있습니다.");
+ return;
+ }
+
+ try {
+ setIsAcceptingVendors(true);
+
+ // 벤더 견적 승인 서비스 함수 호출
+ const { acceptTechSalesVendorQuotationAction } = await import("@/lib/techsales-rfq/actions");
+
+ const result = await acceptTechSalesVendorQuotationAction(selectedQuotation.id);
+
+ if (result.success) {
+ toast.success(result.message || "벤더가 성공적으로 선택되었습니다.");
+ } else {
+ toast.error(result.error || "벤더 선택 중 오류가 발생했습니다.");
+ }
+
+ // 선택 해제
+ setSelectedRows([]);
+
+ // 데이터 새로고침
+ await handleRefreshData();
+
+ } catch (error) {
+ console.error("벤더 선택 오류:", error);
+ toast.error("벤더 선택 중 오류가 발생했습니다.");
+ } finally {
+ setIsAcceptingVendors(false);
+ }
+ }, [selectedRows, handleRefreshData]);
+
+ // 벤더 삭제 핸들러 메모이제이션
+ const handleDeleteVendors = useCallback(async () => {
+ if (selectedRows.length === 0) {
+ toast.warning("삭제할 벤더를 선택해주세요.");
+ return;
+ }
+
+ if (!selectedRfqId) {
+ toast.error("선택된 RFQ가 없습니다.");
+ return;
+ }
+
+ try {
+ setIsDeletingVendors(true);
+
+ const vendorIds = selectedRows.map(row => row.vendorId).filter(Boolean) as number[];
+
+ if (vendorIds.length === 0) {
+ toast.error("유효한 벤더 ID가 없습니다.");
+ return;
+ }
+
+ // 서비스 함수 호출
+ const { removeTechVendorsFromTechSalesRfq } = await import("@/lib/techsales-rfq/service");
+
+ const result = await removeTechVendorsFromTechSalesRfq({
+ rfqId: selectedRfqId,
+ vendorIds: vendorIds
+ });
+
+ if (result.error) {
+ toast.error(result.error);
+ } else {
+ const successCount = result.data?.length || 0
+ toast.success(`${successCount}개의 벤더가 성공적으로 삭제되었습니다`);
+ }
+
+ // 선택 해제
+ setSelectedRows([]);
+
+ // 데이터 새로고침
+ await handleRefreshData();
+
+ } catch (error) {
+ console.error("벤더 삭제 오류:", error);
+ toast.error("벤더 삭제 중 오류가 발생했습니다.");
+ } finally {
+ setIsDeletingVendors(false);
+ }
+ }, [selectedRows, selectedRfqId, handleRefreshData]);
+
+ // 벤더 삭제 확인 핸들러
+ const handleDeleteVendorsConfirm = useCallback(() => {
+ if (selectedRows.length === 0) {
+ toast.warning("삭제할 벤더를 선택해주세요.");
+ return;
+ }
+ setDeleteConfirmDialogOpen(true);
+ }, [selectedRows]);
+
+ // 벤더 삭제 확정 실행
+ const executeDeleteVendors = useCallback(async () => {
+ setDeleteConfirmDialogOpen(false);
+ await handleDeleteVendors();
+ }, [handleDeleteVendors]);
+
+
+ // 견적 히스토리 다이얼로그 열기 핸들러 메모이제이션
+ const handleOpenHistoryDialog = useCallback((quotationId: number) => {
+ setSelectedQuotationId(quotationId);
+ setHistoryDialogOpen(true);
+ }, [])
+
+ // 견적서 첨부파일 sheet 열기 핸들러 메모이제이션
+ const handleOpenQuotationAttachmentsSheet = useCallback(async (quotationId: number, quotationInfo: QuotationInfo) => {
+ try {
+ setIsLoadingAttachments(true);
+ setSelectedQuotationInfo(quotationInfo);
+ setQuotationAttachmentsSheetOpen(true);
+
+ // 견적서 첨부파일 조회
+ const { getTechSalesVendorQuotationAttachments } = await import("@/lib/techsales-rfq/service");
+ const result = await getTechSalesVendorQuotationAttachments(quotationId);
+
+ if (result.error) {
+ toast.error(result.error);
+ setQuotationAttachments([]);
+ } else {
+ setQuotationAttachments(result.data || []);
+ }
+ } catch (error) {
+ console.error("견적서 첨부파일 조회 오류:", error);
+ toast.error("견적서 첨부파일을 불러오는 중 오류가 발생했습니다.");
+ setQuotationAttachments([]);
+ } finally {
+ setIsLoadingAttachments(false);
+ }
+ }, [])
+
+ // 담당자 조회 다이얼로그 열기 함수
+ const handleOpenContactsDialog = useCallback((quotationId: number, vendorName?: string) => {
+ setSelectedQuotationForContacts({ id: quotationId, vendorName })
+ setContactsDialogOpen(true)
+ }, [])
+
+ // 칼럼 정의 - unreadMessages 상태 전달 (메모이제이션)
+ const columns = useMemo(() =>
+ getRfqDetailColumns({
+ setRowAction,
+ unreadMessages,
+ onQuotationClick: handleOpenHistoryDialog,
+ openQuotationAttachmentsSheet: handleOpenQuotationAttachmentsSheet,
+ openContactsDialog: handleOpenContactsDialog
+ }), [unreadMessages, handleOpenHistoryDialog, handleOpenQuotationAttachmentsSheet, handleOpenContactsDialog])
+
+ // 필터 필드 정의 (메모이제이션)
+ const advancedFilterFields = useMemo(
+ () => [
+ {
+ id: "vendorName",
+ label: "벤더명",
+ type: "text",
+ },
+ {
+ id: "vendorCode",
+ label: "벤더 코드",
+ type: "text",
+ },
+ {
+ id: "currency",
+ label: "통화",
+ type: "text",
+ },
+ ],
+ []
+ )
+
+ // 계산된 값들 메모이제이션
+ const vendorsWithQuotations = useMemo(() =>
+ details.filter(detail => detail.status === "Submitted").length,
+ [details]
+ );
+
+ // RFQ ID가 변경될 때 데이터 로드
+ useEffect(() => {
+ async function loadRfqDetails() {
+ if (!selectedRfqId) {
+ setDetails([])
+ return
+ }
+
+ try {
+ setIsLoading(true)
+
+ // 실제 벤더 견적 데이터 로딩
+ const { getTechSalesVendorQuotationsWithJoin } = await import("@/lib/techsales-rfq/service")
+
+ const result = await getTechSalesVendorQuotationsWithJoin({
+ rfqId: selectedRfqId,
+ page: 1,
+ perPage: 1000, // 모든 데이터 가져오기
+ })
+
+ // 데이터 변환 (procurement 패턴에 맞게)
+ const transformedData = result.data?.map(item => ({
+ ...item,
+ detailId: item.id,
+ rfqId: selectedRfqId,
+ rfqCode: selectedRfq?.rfqCode || null,
+ vendorId: item.vendorId ? Number(item.vendorId) : undefined,
+ // 기타 필요한 필드 변환
+ })) || []
+
+ setDetails(transformedData)
+
+ // 읽지 않은 메시지 개수 로드
+ await loadUnreadMessages();
+
+ } catch (error) {
+ console.error("RFQ 디테일 로드 오류:", error)
+ setDetails([])
+ toast.error("RFQ 세부정보를 불러오는 중 오류가 발생했습니다")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ loadRfqDetails()
+ }, [selectedRfqId, selectedRfq?.rfqCode, loadUnreadMessages])
+
+ // 주기적으로 읽지 않은 메시지 갱신 (60초마다) - 메모이제이션된 함수 사용
+ useEffect(() => {
+ if (!selectedRfqId) return;
+
+ const intervalId = setInterval(() => {
+ loadUnreadMessages();
+ }, 60000); // 60초마다 갱신
+
+ return () => clearInterval(intervalId);
+ }, [selectedRfqId, loadUnreadMessages]);
+
+ // rowAction 처리 - procurement 패턴 적용 (메모이제이션)
+ useEffect(() => {
+ if (!rowAction) return
+
+ const handleRowAction = async () => {
+ try {
+ // 통신 액션인 경우 드로어 열기
+ if (rowAction.type === "communicate") {
+ setSelectedVendor(rowAction.row.original);
+ setCommunicationDrawerOpen(true);
+
+ // rowAction 초기화
+ setRowAction(null);
+ return;
+ }
+
+ // 삭제 액션인 경우 개별 벤더 삭제
+ if (rowAction.type === "delete") {
+ const vendor = rowAction.row.original;
+
+ if (!vendor.vendorId || !selectedRfqId) {
+ toast.error("벤더 정보가 없습니다.");
+ setRowAction(null);
+ return;
+ }
+
+ // Draft 상태 체크
+ if (vendor.status !== "Draft") {
+ toast.error("Draft 상태의 벤더만 삭제할 수 있습니다.");
+ setRowAction(null);
+ return;
+ }
+
+ // 개별 벤더 삭제
+ const { removeTechVendorFromTechSalesRfq } = await import("@/lib/techsales-rfq/service");
+
+ const result = await removeTechVendorFromTechSalesRfq({
+ rfqId: selectedRfqId,
+ vendorId: vendor.vendorId
+ });
+
+ if (result.error) {
+ toast.error(result.error);
+ } else {
+ toast.success(`${vendor.vendorName || '벤더'}가 성공적으로 삭제되었습니다.`);
+ // 데이터 새로고침
+ await handleRefreshData();
+ }
+
+ // rowAction 초기화
+ setRowAction(null);
+ return;
+ }
+ } catch (error) {
+ console.error("액션 처리 오류:", error);
+ toast.error("작업을 처리하는 중 오류가 발생했습니다");
+ }
+ };
+
+ handleRowAction();
+ }, [rowAction, selectedRfqId, handleRefreshData])
+
+ // 선택된 행 변경 핸들러 메모이제이션
+ const handleSelectedRowsChange = useCallback((selectedRowsData: RfqDetailView[]) => {
+ setSelectedRows(selectedRowsData);
+ }, []);
+
+ // 커뮤니케이션 드로어 변경 핸들러 메모이제이션
+ const handleCommunicationDrawerChange = useCallback((open: boolean) => {
+ setCommunicationDrawerOpen(open);
+ // 드로어가 닫힐 때 해당 벤더의 메시지를 읽음 처리하고 읽지 않은 메시지 개수 갱신
+ if (!open && selectedVendor?.vendorId && selectedRfqId) {
+ // 메시지를 읽음으로 처리
+ import("@/lib/techsales-rfq/service").then(({ markTechSalesMessagesAsRead }) => {
+ markTechSalesMessagesAsRead(selectedRfqId, selectedVendor.vendorId || undefined).catch(error => {
+ console.error("메시지 읽음 처리 오류:", error);
+ });
+ });
+
+ // 해당 벤더의 읽지 않은 메시지를 0으로 즉시 업데이트
+ setUnreadMessages(prev => ({
+ ...prev,
+ [selectedVendor.vendorId!]: 0
+ }));
+
+ // 전체 읽지 않은 메시지 개수 갱신
+ loadUnreadMessages();
+ }
+ }, [selectedVendor, selectedRfqId, loadUnreadMessages]);
+
+ if (!selectedRfq) {
+ return (
+ <div className="flex items-center justify-center h-full text-muted-foreground">
+ RFQ를 선택하세요
+ </div>
+ )
+ }
+
+ // 로딩 중인 경우
+ if (isLoading) {
+ return (
+ <div className="p-4 space-y-4">
+ <Skeleton className="h-8 w-1/2" />
+ <Skeleton className="h-24 w-full" />
+ <Skeleton className="h-48 w-full" />
+ </div>
+ )
+ }
+
+ return (
+ <div className="h-full overflow-hidden pt-4">
+ {/* 테이블 또는 빈 상태 표시 */}
+ {details.length > 0 ? (
+ <ClientDataTable
+ columns={columns}
+ data={details}
+ advancedFilterFields={advancedFilterFields}
+ maxHeight={maxHeight}
+ onSelectedRowsChange={handleSelectedRowsChange}
+ >
+ <div className="flex justify-between items-center">
+ <div className="flex items-center gap-2 mr-2">
+ {selectedRows.length > 0 && (
+ <Badge variant="default" className="h-6">
+ {selectedRows.length}개 선택됨
+ </Badge>
+ )}
+ {/* {totalUnreadMessages > 0 && (
+ <Badge variant="destructive" className="h-6">
+ 읽지 않은 메시지: {totalUnreadMessages}건
+ </Badge>
+ )} */}
+ {vendorsWithQuotations > 0 && (
+ <Badge variant="outline" className="h-6">
+ 견적 제출: {vendorsWithQuotations}개 벤더
+ </Badge>
+ )}
+ </div>
+ <div className="flex gap-2">
+ {/* 벤더 선택 버튼 */}
+ <Button
+ variant="default"
+ size="sm"
+ onClick={handleAcceptVendors}
+ disabled={
+ selectedRows.length === 0 ||
+ isAcceptingVendors ||
+ selectedRows.length > 1 ||
+ selectedRows.some(row => row.status !== "Submitted")
+ }
+ className="gap-2"
+ >
+ {isAcceptingVendors ? (
+ <Loader2 className="size-4 animate-spin" aria-hidden="true" />
+ ) : (
+ <CheckCircle className="size-4" aria-hidden="true" />
+ )}
+ <span>벤더 선택</span>
+ </Button>
+
+ {/* RFQ 발송 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleSendRfq}
+ disabled={
+ selectedRows.length === 0 ||
+ isSendingRfq ||
+ selectedRows.some(row => row.status !== "Assigned")
+ }
+ className="gap-2"
+ >
+ {isSendingRfq ? (
+ <Loader2 className="size-4 animate-spin" aria-hidden="true" />
+ ) : (
+ <Send className="size-4" aria-hidden="true" />
+ )}
+ <span>RFQ 발송</span>
+ </Button>
+
+ {/* 벤더 삭제 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleDeleteVendorsConfirm}
+ disabled={selectedRows.length === 0 || isDeletingVendors}
+ className="gap-2"
+ >
+ {isDeletingVendors ? (
+ <Loader2 className="size-4 animate-spin" aria-hidden="true" />
+ ) : (
+ <Trash2 className="size-4" aria-hidden="true" />
+ )}
+ <span>벤더 삭제</span>
+ </Button>
+
+ {/* 벤더 추가 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleAddVendor}
+ disabled={isAdddialogLoading}
+ className="gap-2"
+ >
+ {isAdddialogLoading ? (
+ <Loader2 className="size-4 animate-spin" aria-hidden="true" />
+ ) : (
+ <UserPlus className="size-4" aria-hidden="true" />
+ )}
+ <span>벤더 추가</span>
+ </Button>
+ </div>
+ </div>
+ </ClientDataTable>
+ ) : (
+ <div className="flex h-full items-center justify-center text-muted-foreground">
+ <div className="text-center">
+ <p className="text-lg font-medium">벤더가 없습니다</p>
+ <p className="text-sm">벤더를 추가하여 RFQ를 시작하세요</p>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleAddVendor}
+ disabled={isAdddialogLoading}
+ className="mt-4 gap-2"
+ >
+ {isAdddialogLoading ? (
+ <Loader2 className="size-4 animate-spin" aria-hidden="true" />
+ ) : (
+ <UserPlus className="size-4" aria-hidden="true" />
+ )}
+ <span>벤더 추가</span>
+ </Button>
+ </div>
+ </div>
+ )}
+
+ {/* 다이얼로그들 */}
+ <AddVendorDialog
+ open={vendorDialogOpen}
+ onOpenChange={setVendorDialogOpen}
+ selectedRfq={selectedRfq as unknown as TechSalesRfq}
+ existingVendorIds={existingVendorIds}
+ onSuccess={handleRefreshData}
+ />
+
+ {/* 벤더 커뮤니케이션 드로어 */}
+ <VendorCommunicationDrawer
+ open={communicationDrawerOpen}
+ onOpenChange={handleCommunicationDrawerChange}
+ selectedRfq={selectedRfq}
+ selectedVendor={selectedVendor}
+ onSuccess={handleRefreshData}
+ />
+
+ {/* 다중 벤더 삭제 확인 다이얼로그 */}
+ <DeleteVendorDialog
+ open={deleteConfirmDialogOpen}
+ onOpenChange={setDeleteConfirmDialogOpen}
+ vendors={selectedRows}
+ onConfirm={executeDeleteVendors}
+ isLoading={isDeletingVendors}
+ />
+
+ {/* 견적 히스토리 다이얼로그 */}
+ <QuotationHistoryDialog
+ open={historyDialogOpen}
+ onOpenChange={setHistoryDialogOpen}
+ quotationId={selectedQuotationId}
+ />
+
+ {/* 견적서 첨부파일 Sheet */}
+ <TechSalesQuotationAttachmentsSheet
+ open={quotationAttachmentsSheetOpen}
+ onOpenChange={setQuotationAttachmentsSheetOpen}
+ quotation={selectedQuotationInfo}
+ attachments={quotationAttachments}
+ isLoading={isLoadingAttachments}
+ />
+
+ {/* 벤더 contact 선택 다이얼로그 */}
+ <VendorContactSelectionDialog
+ open={contactSelectionDialogOpen}
+ onOpenChange={setContactSelectionDialogOpen}
+ vendorIds={selectedRows.map(row => row.vendorId).filter(Boolean) as number[]}
+ onSendRfq={handleSendRfqWithContacts}
+ />
+
+ {/* 담당자 조회 다이얼로그 */}
+ <QuotationContactsViewDialog
+ open={contactsDialogOpen}
+ onOpenChange={setContactsDialogOpen}
+ quotationId={selectedQuotationForContacts?.id || null}
+ vendorName={selectedQuotationForContacts?.vendorName}
+ />
+ </div>
+ )
} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx b/lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx
index 0312451d..5b60ef0f 100644
--- a/lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx
+++ b/lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx
@@ -1,619 +1,621 @@
-"use client"
-
-import * as React from "react"
-import { useState, useEffect, useRef } from "react"
-import { RfqDetailView } from "./rfq-detail-column"
-import { Button } from "@/components/ui/button"
-import { Textarea } from "@/components/ui/textarea"
-import { Avatar, AvatarFallback } from "@/components/ui/avatar"
-import {
- Drawer,
- DrawerClose,
- DrawerContent,
- DrawerDescription,
- DrawerFooter,
- DrawerHeader,
- DrawerTitle,
-} from "@/components/ui/drawer"
-import { Badge } from "@/components/ui/badge"
-import { toast } from "sonner"
-import {
- Send,
- Paperclip,
- DownloadCloud,
- File,
- FileText,
- Image as ImageIcon,
- AlertCircle,
- X
-} from "lucide-react"
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
-import { formatDateTime } from "@/lib/utils"
-import { formatFileSize } from "@/lib/utils" // formatFileSize 유틸리티 임포트
-import { fetchTechSalesVendorComments, markTechSalesMessagesAsRead } from "@/lib/techsales-rfq/service"
-
-// 타입 정의
-interface Comment {
- id: number;
- rfqId: number;
- vendorId: number | null // null 허용으로 변경
- userId?: number | null // null 허용으로 변경
- content: string;
- isVendorComment: boolean | null; // null 허용으로 변경
- createdAt: Date;
- updatedAt: Date;
- userName?: string | null // null 허용으로 변경
- vendorName?: string | null // null 허용으로 변경
- attachments: Attachment[];
- isRead: boolean | null // null 허용으로 변경
-}
-
-interface Attachment {
- id: number;
- fileName: string;
- fileSize: number;
- fileType: string | null;
- filePath: string;
- uploadedAt: Date;
-}
-
-// 프롭스 정의
-interface VendorCommunicationDrawerProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- selectedRfq: {
- id: number;
- rfqCode: string | null;
- status: string;
- [key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any
- } | null;
- selectedVendor: RfqDetailView | null;
- onSuccess?: () => void;
-}
-
-async function sendComment(params: {
- rfqId: number;
- vendorId: number;
- content: string;
- attachments?: File[];
-}): Promise<Comment> {
- try {
- // 폼 데이터 생성 (파일 첨부를 위해)
- const formData = new FormData();
- formData.append('rfqId', params.rfqId.toString());
- formData.append('vendorId', params.vendorId.toString());
- formData.append('content', params.content);
- formData.append('isVendorComment', 'false');
-
- // 첨부파일 추가
- if (params.attachments && params.attachments.length > 0) {
- params.attachments.forEach((file) => {
- formData.append(`attachments`, file);
- });
- }
-
- // API 엔드포인트 구성 - techSales용으로 변경
- const url = `/api/tech-sales-rfqs/${params.rfqId}/vendors/${params.vendorId}/comments`;
-
- // API 호출
- const response = await fetch(url, {
- method: 'POST',
- body: formData, // multipart/form-data 형식 사용
- });
-
- if (!response.ok) {
- const errorText = await response.text();
- throw new Error(`API 요청 실패: ${response.status} ${errorText}`);
- }
-
- // 응답 데이터 파싱
- const result = await response.json();
-
- if (!result.success || !result.data) {
- throw new Error(result.message || '코멘트 전송 중 오류가 발생했습니다');
- }
-
- return result.data.comment;
- } catch (error) {
- console.error('코멘트 전송 오류:', error);
- throw error;
- }
-}
-
-export function VendorCommunicationDrawer({
- open,
- onOpenChange,
- selectedRfq,
- selectedVendor,
- onSuccess
-}: VendorCommunicationDrawerProps) {
- // 상태 관리
- const [comments, setComments] = useState<Comment[]>([]);
- const [newComment, setNewComment] = useState("");
- const [attachments, setAttachments] = useState<File[]>([]);
- const [isLoading, setIsLoading] = useState(false);
- const [isSubmitting, setIsSubmitting] = useState(false);
- const fileInputRef = useRef<HTMLInputElement>(null);
- const messagesEndRef = useRef<HTMLDivElement>(null);
-
- // 자동 새로고침 관련 상태
- const [autoRefresh, setAutoRefresh] = useState(true);
- const [lastMessageCount, setLastMessageCount] = useState(0);
- const intervalRef = useRef<NodeJS.Timeout | null>(null);
-
- // 첨부파일 관련 상태
- const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
- const [selectedAttachment, setSelectedAttachment] = useState<Attachment | null>(null);
-
- // 드로어가 열릴 때 데이터 로드
- useEffect(() => {
- if (open && selectedRfq && selectedVendor) {
- loadComments();
- // 자동 새로고침 시작
- if (autoRefresh) {
- startAutoRefresh();
- }
- } else {
- // 드로어가 닫히면 자동 새로고침 중지
- stopAutoRefresh();
- }
-
- // 컴포넌트 언마운트 시 정리
- return () => {
- stopAutoRefresh();
- };
- }, [open, selectedRfq, selectedVendor, autoRefresh]);
-
- // 스크롤 최하단으로 이동
- useEffect(() => {
- if (messagesEndRef.current) {
- messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
- }
- }, [comments]);
-
- // 자동 새로고침 시작
- const startAutoRefresh = () => {
- stopAutoRefresh(); // 기존 interval 정리
- intervalRef.current = setInterval(() => {
- if (open && selectedRfq && selectedVendor && !isSubmitting) {
- loadComments(true); // 자동 새로고침임을 표시
- }
- }, 60000); // 60초마다 새로고침
- };
-
- // 자동 새로고침 중지
- const stopAutoRefresh = () => {
- if (intervalRef.current) {
- clearInterval(intervalRef.current);
- intervalRef.current = null;
- }
- };
-
- // 자동 새로고침 토글
- const toggleAutoRefresh = () => {
- setAutoRefresh(prev => {
- const newValue = !prev;
- if (newValue && open) {
- startAutoRefresh();
- } else {
- stopAutoRefresh();
- }
- return newValue;
- });
- };
-
- // 코멘트 로드 함수 (자동 새로고침 여부 파라미터 추가)
- const loadComments = async (isAutoRefresh = false) => {
- if (!selectedRfq || !selectedVendor) return;
-
- try {
- // 자동 새로고침일 때는 로딩 표시하지 않음
- if (!isAutoRefresh) {
- setIsLoading(true);
- }
-
- // Server Action을 사용하여 코멘트 데이터 가져오기
- const commentsData = await fetchTechSalesVendorComments(selectedRfq.id, selectedVendor.vendorId || 0);
-
- // 새 메시지가 있는지 확인 (자동 새로고침일 때만)
- if (isAutoRefresh) {
- const newMessageCount = commentsData.length;
- if (newMessageCount > lastMessageCount && lastMessageCount > 0) {
- // 새 메시지 알림 (선택사항)
- toast.success(`새 메시지 ${newMessageCount - lastMessageCount}개가 도착했습니다`);
- }
- setLastMessageCount(newMessageCount);
- } else {
- setLastMessageCount(commentsData.length);
- }
-
- setComments(commentsData as Comment[]); // 구체적인 타입으로 캐스팅
-
- // Server Action을 사용하여 읽지 않은 메시지를 읽음 상태로 변경
- await markTechSalesMessagesAsRead(selectedRfq.id, selectedVendor.vendorId || 0);
- } catch (error) {
- console.error("코멘트 로드 오류:", error);
- if (!isAutoRefresh) { // 자동 새로고침일 때는 에러 토스트 표시하지 않음
- toast.error("메시지를 불러오는 중 오류가 발생했습니다");
- }
- } finally {
- // 항상 로딩 상태를 해제하되, 최소 200ms는 유지하여 깜빡거림 방지
- if (!isAutoRefresh) {
- setTimeout(() => {
- setIsLoading(false);
- }, 200);
- }
- }
- };
-
- // 파일 선택 핸들러
- const handleFileSelect = () => {
- fileInputRef.current?.click();
- };
-
- // 파일 변경 핸들러
- const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
- if (e.target.files && e.target.files.length > 0) {
- const newFiles = Array.from(e.target.files);
- setAttachments(prev => [...prev, ...newFiles]);
- }
- };
-
- // 파일 제거 핸들러
- const handleRemoveFile = (index: number) => {
- setAttachments(prev => prev.filter((_, i) => i !== index));
- };
-
- console.log(newComment)
-
- // 코멘트 전송 핸들러
- const handleSubmitComment = async () => {
- console.log("버튼 클릭1", selectedRfq,selectedVendor, selectedVendor?.vendorId )
- console.log(!newComment.trim() && attachments.length === 0)
-
- if (!newComment.trim() && attachments.length === 0) return;
- if (!selectedRfq || !selectedVendor || !selectedVendor.vendorId) return;
-
- console.log("버튼 클릭")
-
- try {
- setIsSubmitting(true);
-
- // API를 사용하여 새 코멘트 전송 (파일 업로드 때문에 FormData 사용)
- const newCommentObj = await sendComment({
- rfqId: selectedRfq.id,
- vendorId: selectedVendor.vendorId,
- content: newComment,
- attachments: attachments
- });
-
- // 상태 업데이트
- setComments(prev => [...prev, newCommentObj]);
- setNewComment("");
- setAttachments([]);
-
- toast.success("메시지가 전송되었습니다");
-
- // 데이터 새로고침
- if (onSuccess) {
- onSuccess();
- }
- } catch (error) {
- console.error("코멘트 전송 오류:", error);
- toast.error("메시지 전송 중 오류가 발생했습니다");
- } finally {
- setIsSubmitting(false);
- }
- };
-
- // 첨부파일 미리보기
- const handleAttachmentPreview = (attachment: Attachment) => {
- setSelectedAttachment(attachment);
- setPreviewDialogOpen(true);
- };
-
- // 첨부파일 다운로드
- const handleAttachmentDownload = (attachment: Attachment) => {
- // TODO: 실제 다운로드 구현
- window.open(attachment.filePath, '_blank');
- };
-
- // 파일 아이콘 선택
- const getFileIcon = (fileType: string) => {
- if (fileType.startsWith("image/")) return <ImageIcon className="h-5 w-5 text-blue-500" />;
- if (fileType.includes("pdf")) return <FileText className="h-5 w-5 text-red-500" />;
- if (fileType.includes("spreadsheet") || fileType.includes("excel"))
- return <FileText className="h-5 w-5 text-green-500" />;
- if (fileType.includes("document") || fileType.includes("word"))
- return <FileText className="h-5 w-5 text-blue-500" />;
- return <File className="h-5 w-5 text-gray-500" />;
- };
-
- // 첨부파일 미리보기 다이얼로그
- const renderAttachmentPreviewDialog = () => {
- if (!selectedAttachment) return null;
-
- const isImage = selectedAttachment.fileType?.startsWith("image/");
- const isPdf = selectedAttachment.fileType?.includes("pdf");
-
- return (
- <Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}>
- <DialogContent className="max-w-3xl">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- {getFileIcon(selectedAttachment.fileType || '')}
- {selectedAttachment.fileName}
- </DialogTitle>
- <DialogDescription>
- {formatFileSize(selectedAttachment.fileSize)} • {formatDateTime(selectedAttachment.uploadedAt, "KR")}
- </DialogDescription>
- </DialogHeader>
-
- <div className="min-h-[300px] flex items-center justify-center p-4">
- {isImage ? (
- <img
- src={selectedAttachment.filePath}
- alt={selectedAttachment.fileName}
- className="max-h-[500px] max-w-full object-contain"
- />
- ) : isPdf ? (
- <iframe
- src={`${selectedAttachment.filePath}#toolbar=0`}
- className="w-full h-[500px]"
- title={selectedAttachment.fileName}
- />
- ) : (
- <div className="flex flex-col items-center gap-4 p-8">
- {getFileIcon(selectedAttachment.fileType || '')}
- <p className="text-muted-foreground text-sm">미리보기를 지원하지 않는 파일 형식입니다.</p>
- <Button
- variant="outline"
- onClick={() => handleAttachmentDownload(selectedAttachment)}
- >
- <DownloadCloud className="h-4 w-4 mr-2" />
- 다운로드
- </Button>
- </div>
- )}
- </div>
- </DialogContent>
- </Dialog>
- );
- };
-
- if (!selectedRfq || !selectedVendor) {
- return null;
- }
-
- return (
- <Drawer open={open} onOpenChange={onOpenChange}>
- <DrawerContent className="max-h-[80vh] flex flex-col">
- <DrawerHeader className="border-b flex-shrink-0">
- <DrawerTitle className="flex items-center gap-2">
- <Avatar className="h-8 w-8">
- <AvatarFallback className="bg-primary/10">
- {selectedVendor.vendorName?.[0] || 'V'}
- </AvatarFallback>
- </Avatar>
- <div>
- <span>{selectedVendor.vendorName}</span>
- <Badge variant="outline" className="ml-2">{selectedVendor.vendorCode}</Badge>
- </div>
- </DrawerTitle>
- <DrawerDescription>
- RFQ: {selectedRfq.rfqCode} • 프로젝트: {selectedRfq.projectName}
- </DrawerDescription>
- </DrawerHeader>
-
- <div className="flex flex-col flex-1 min-h-0">
- {/* 메시지 목록 */}
- <div className="flex-1 p-4 overflow-y-auto min-h-[300px]">
- {isLoading && comments.length === 0 ? (
- <div className="flex h-full items-center justify-center">
- <p className="text-muted-foreground">메시지 로딩 중...</p>
- </div>
- ) : comments.length === 0 ? (
- <div className="flex h-full items-center justify-center">
- <div className="flex flex-col items-center gap-2">
- <AlertCircle className="h-6 w-6 text-muted-foreground" />
- <p className="text-muted-foreground">아직 메시지가 없습니다</p>
- </div>
- </div>
- ) : (
- <div className="space-y-4 relative">
- {isLoading && (
- <div className="absolute top-0 right-0 z-10 bg-background/80 backdrop-blur-sm rounded-md px-2 py-1">
- <div className="flex items-center gap-2">
- <div className="w-2 h-2 bg-primary rounded-full animate-pulse" />
- <span className="text-xs text-muted-foreground">새로고침 중...</span>
- </div>
- </div>
- )}
- {comments.map(comment => (
- <div
- key={comment.id}
- className={`flex gap-3 ${comment.isVendorComment ? 'justify-start' : 'justify-end'}`}
- >
- {comment.isVendorComment && (
- <Avatar className="h-8 w-8 mt-1">
- <AvatarFallback className="bg-primary/10">
- {comment.vendorName?.[0] || 'V'}
- </AvatarFallback>
- </Avatar>
- )}
-
- <div className={`rounded-lg p-3 max-w-[80%] ${
- comment.isVendorComment
- ? 'bg-muted'
- : 'bg-primary text-primary-foreground'
- }`}>
- <div className="text-sm font-medium mb-1">
- {comment.isVendorComment ? comment.vendorName : comment.userName}
- </div>
-
- {comment.content && (
- <div className="text-sm whitespace-pre-wrap break-words">
- {comment.content}
- </div>
- )}
-
- {/* 첨부파일 표시 */}
- {comment.attachments.length > 0 && (
- <div className={`mt-2 pt-2 ${
- comment.isVendorComment
- ? 'border-t border-t-border/30'
- : 'border-t border-t-primary-foreground/20'
- }`}>
- {comment.attachments.map(attachment => (
- <div
- key={attachment.id}
- className="flex items-center text-xs gap-2 mb-1 p-1 rounded hover:bg-black/5 cursor-pointer"
- onClick={() => handleAttachmentPreview(attachment)}
- >
- {getFileIcon(attachment.fileType || '')}
- <span className="flex-1 truncate">{attachment.fileName}</span>
- <span className="text-xs opacity-70">
- {formatFileSize(attachment.fileSize)}
- </span>
- <Button
- variant="ghost"
- size="icon"
- className="h-6 w-6 rounded-full"
- onClick={(e) => {
- e.stopPropagation();
- handleAttachmentDownload(attachment);
- }}
- >
- <DownloadCloud className="h-3 w-3" />
- </Button>
- </div>
- ))}
- </div>
- )}
-
- <div className="text-xs mt-1 opacity-70 flex items-center gap-1 justify-end">
- {formatDateTime(comment.createdAt)}
- </div>
- </div>
-
- {!comment.isVendorComment && (
- <Avatar className="h-8 w-8 mt-1">
- <AvatarFallback className="bg-primary/20">
- {comment.userName?.[0] || 'U'}
- </AvatarFallback>
- </Avatar>
- )}
- </div>
- ))}
- <div ref={messagesEndRef} />
- </div>
- )}
- </div>
-
- {/* 선택된 첨부파일 표시 */}
- {attachments.length > 0 && (
- <div className="p-2 bg-muted mx-4 rounded-md mb-2 flex-shrink-0">
- <div className="text-xs font-medium mb-1">첨부파일</div>
- <div className="flex flex-wrap gap-2">
- {attachments.map((file, index) => (
- <div key={index} className="flex items-center bg-background rounded-md p-1 pr-2 text-xs">
- {file.type.startsWith("image/") ? (
- <ImageIcon className="h-4 w-4 mr-1 text-blue-500" />
- ) : (
- <File className="h-4 w-4 mr-1 text-gray-500" />
- )}
- <span className="truncate max-w-[100px]">{file.name}</span>
- <Button
- variant="ghost"
- size="icon"
- className="h-4 w-4 ml-1 p-0"
- onClick={() => handleRemoveFile(index)}
- >
- <X className="h-3 w-3" />
- </Button>
- </div>
- ))}
- </div>
- </div>
- )}
-
- {/* 메시지 입력 영역 */}
- <div className="p-4 border-t flex-shrink-0">
- <div className="flex gap-2 items-end">
- <div className="flex-1">
- <Textarea
- placeholder="메시지를 입력하세요..."
- className="min-h-[80px] resize-none"
- value={newComment}
- onChange={(e) => setNewComment(e.target.value)}
- />
- </div>
- <div className="flex flex-col gap-2">
- <input
- type="file"
- ref={fileInputRef}
- className="hidden"
- multiple
- onChange={handleFileChange}
- />
- <Button
- variant="outline"
- size="icon"
- onClick={handleFileSelect}
- title="파일 첨부"
- >
- <Paperclip className="h-4 w-4" />
- </Button>
- <Button
- onClick={handleSubmitComment}
- disabled={(!newComment.trim() && attachments.length === 0) || isSubmitting}
- >
- <Send className="h-4 w-4" />
- </Button>
- </div>
- </div>
- </div>
- </div>
-
- <DrawerFooter className="border-t flex-shrink-0">
- <div className="flex justify-between items-center">
- <div className="flex items-center gap-2">
- <Button variant="outline" onClick={() => loadComments()}>
- 새로고침
- </Button>
- <Button
- variant={autoRefresh ? "default" : "outline"}
- size="sm"
- onClick={toggleAutoRefresh}
- className="gap-2"
- >
- {autoRefresh ? (
- <>
- <div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
- 자동 새로고침 ON
- </>
- ) : (
- <>
- <div className="w-2 h-2 bg-gray-400 rounded-full" />
- 자동 새로고침 OFF
- </>
- )}
- </Button>
- </div>
- <DrawerClose asChild>
- <Button variant="outline">닫기</Button>
- </DrawerClose>
- </div>
- </DrawerFooter>
- </DrawerContent>
-
- {renderAttachmentPreviewDialog()}
- </Drawer>
- );
+"use client"
+
+import * as React from "react"
+import { useState, useEffect, useRef } from "react"
+import { RfqDetailView } from "./rfq-detail-column"
+import { Button } from "@/components/ui/button"
+import { Textarea } from "@/components/ui/textarea"
+import { Avatar, AvatarFallback } from "@/components/ui/avatar"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+} from "@/components/ui/drawer"
+import { Badge } from "@/components/ui/badge"
+import { toast } from "sonner"
+import {
+ Send,
+ Paperclip,
+ DownloadCloud,
+ File,
+ FileText,
+ Image as ImageIcon,
+ AlertCircle,
+ X
+} from "lucide-react"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { formatDateTime } from "@/lib/utils"
+import { formatFileSize } from "@/lib/utils" // formatFileSize 유틸리티 임포트
+import { fetchTechSalesVendorComments, markTechSalesMessagesAsRead } from "@/lib/techsales-rfq/service"
+
+// 타입 정의
+interface Comment {
+ id: number;
+ rfqId: number;
+ vendorId: number | null // null 허용으로 변경
+ userId?: number | null // null 허용으로 변경
+ content: string;
+ isVendorComment: boolean | null; // null 허용으로 변경
+ createdAt: Date;
+ updatedAt: Date;
+ userName?: string | null // null 허용으로 변경
+ vendorName?: string | null // null 허용으로 변경
+ attachments: Attachment[];
+ isRead: boolean | null // null 허용으로 변경
+}
+
+interface Attachment {
+ id: number;
+ fileName: string;
+ originalFileName: string;
+ fileSize: number;
+ fileType: string | null;
+ filePath: string;
+ uploadedAt: Date;
+}
+
+// 프롭스 정의
+interface VendorCommunicationDrawerProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ selectedRfq: {
+ id: number;
+ rfqCode: string | null;
+ status: string;
+ [key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any
+ } | null;
+ selectedVendor: RfqDetailView | null;
+ onSuccess?: () => void;
+}
+
+async function sendComment(params: {
+ rfqId: number;
+ vendorId: number;
+ content: string;
+ attachments?: File[];
+}): Promise<Comment> {
+ try {
+ // 폼 데이터 생성 (파일 첨부를 위해)
+ const formData = new FormData();
+ formData.append('rfqId', params.rfqId.toString());
+ formData.append('vendorId', params.vendorId.toString());
+ formData.append('content', params.content);
+ formData.append('isVendorComment', 'false');
+
+ // 첨부파일 추가
+ if (params.attachments && params.attachments.length > 0) {
+ params.attachments.forEach((file) => {
+ formData.append(`attachments`, file);
+ });
+ }
+
+ // API 엔드포인트 구성 - techSales용으로 변경
+ const url = `/api/tech-sales-rfqs/${params.rfqId}/vendors/${params.vendorId}/comments`;
+
+ // API 호출
+ const response = await fetch(url, {
+ method: 'POST',
+ body: formData, // multipart/form-data 형식 사용
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(`API 요청 실패: ${response.status} ${errorText}`);
+ }
+
+ // 응답 데이터 파싱
+ const result = await response.json();
+
+ if (!result.success || !result.data) {
+ throw new Error(result.message || '코멘트 전송 중 오류가 발생했습니다');
+ }
+
+ return result.data.comment;
+ } catch (error) {
+ console.error('코멘트 전송 오류:', error);
+ throw error;
+ }
+}
+
+export function VendorCommunicationDrawer({
+ open,
+ onOpenChange,
+ selectedRfq,
+ selectedVendor,
+ onSuccess
+}: VendorCommunicationDrawerProps) {
+ // 상태 관리
+ const [comments, setComments] = useState<Comment[]>([]);
+ const [newComment, setNewComment] = useState("");
+ const [attachments, setAttachments] = useState<File[]>([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const fileInputRef = useRef<HTMLInputElement>(null);
+ const messagesEndRef = useRef<HTMLDivElement>(null);
+
+ // 자동 새로고침 관련 상태
+ const [autoRefresh, setAutoRefresh] = useState(true);
+ const [lastMessageCount, setLastMessageCount] = useState(0);
+ const intervalRef = useRef<NodeJS.Timeout | null>(null);
+
+ // 첨부파일 관련 상태
+ const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
+ const [selectedAttachment, setSelectedAttachment] = useState<Attachment | null>(null);
+
+ // 드로어가 열릴 때 데이터 로드
+ useEffect(() => {
+ if (open && selectedRfq && selectedVendor) {
+ loadComments();
+ // 자동 새로고침 시작
+ if (autoRefresh) {
+ startAutoRefresh();
+ }
+ } else {
+ // 드로어가 닫히면 자동 새로고침 중지
+ stopAutoRefresh();
+ }
+
+ // 컴포넌트 언마운트 시 정리
+ return () => {
+ stopAutoRefresh();
+ };
+ }, [open, selectedRfq, selectedVendor, autoRefresh]);
+
+ // 스크롤 최하단으로 이동
+ useEffect(() => {
+ if (messagesEndRef.current) {
+ messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
+ }
+ }, [comments]);
+
+ // 자동 새로고침 시작
+ const startAutoRefresh = () => {
+ stopAutoRefresh(); // 기존 interval 정리
+ intervalRef.current = setInterval(() => {
+ if (open && selectedRfq && selectedVendor && !isSubmitting) {
+ loadComments(true); // 자동 새로고침임을 표시
+ }
+ }, 60000); // 60초마다 새로고침
+ };
+
+ // 자동 새로고침 중지
+ const stopAutoRefresh = () => {
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current);
+ intervalRef.current = null;
+ }
+ };
+
+ // 자동 새로고침 토글
+ const toggleAutoRefresh = () => {
+ setAutoRefresh(prev => {
+ const newValue = !prev;
+ if (newValue && open) {
+ startAutoRefresh();
+ } else {
+ stopAutoRefresh();
+ }
+ return newValue;
+ });
+ };
+
+ // 코멘트 로드 함수 (자동 새로고침 여부 파라미터 추가)
+ const loadComments = async (isAutoRefresh = false) => {
+ if (!selectedRfq || !selectedVendor) return;
+
+ try {
+ // 자동 새로고침일 때는 로딩 표시하지 않음
+ if (!isAutoRefresh) {
+ setIsLoading(true);
+ }
+
+ // Server Action을 사용하여 코멘트 데이터 가져오기
+ const commentsData = await fetchTechSalesVendorComments(selectedRfq.id, selectedVendor.vendorId || 0);
+
+ // 새 메시지가 있는지 확인 (자동 새로고침일 때만)
+ if (isAutoRefresh) {
+ const newMessageCount = commentsData.length;
+ if (newMessageCount > lastMessageCount && lastMessageCount > 0) {
+ // 새 메시지 알림 (선택사항)
+ toast.success(`새 메시지 ${newMessageCount - lastMessageCount}개가 도착했습니다`);
+ }
+ setLastMessageCount(newMessageCount);
+ } else {
+ setLastMessageCount(commentsData.length);
+ }
+
+ setComments(commentsData as Comment[]); // 구체적인 타입으로 캐스팅
+
+ // Server Action을 사용하여 읽지 않은 메시지를 읽음 상태로 변경
+ await markTechSalesMessagesAsRead(selectedRfq.id, selectedVendor.vendorId || 0);
+ } catch (error) {
+ console.error("코멘트 로드 오류:", error);
+ if (!isAutoRefresh) { // 자동 새로고침일 때는 에러 토스트 표시하지 않음
+ toast.error("메시지를 불러오는 중 오류가 발생했습니다");
+ }
+ } finally {
+ // 항상 로딩 상태를 해제하되, 최소 200ms는 유지하여 깜빡거림 방지
+ if (!isAutoRefresh) {
+ setTimeout(() => {
+ setIsLoading(false);
+ }, 200);
+ }
+ }
+ };
+
+ // 파일 선택 핸들러
+ const handleFileSelect = () => {
+ fileInputRef.current?.click();
+ };
+
+ // 파일 변경 핸들러
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ if (e.target.files && e.target.files.length > 0) {
+ const newFiles = Array.from(e.target.files);
+ setAttachments(prev => [...prev, ...newFiles]);
+ }
+ };
+
+ // 파일 제거 핸들러
+ const handleRemoveFile = (index: number) => {
+ setAttachments(prev => prev.filter((_, i) => i !== index));
+ };
+
+ console.log(newComment)
+
+ // 코멘트 전송 핸들러
+ const handleSubmitComment = async () => {
+ console.log("버튼 클릭1", selectedRfq,selectedVendor, selectedVendor?.vendorId )
+ console.log(!newComment.trim() && attachments.length === 0)
+
+ if (!newComment.trim() && attachments.length === 0) return;
+ if (!selectedRfq || !selectedVendor || !selectedVendor.vendorId) return;
+
+ console.log("버튼 클릭")
+
+ try {
+ setIsSubmitting(true);
+
+ // API를 사용하여 새 코멘트 전송 (파일 업로드 때문에 FormData 사용)
+ const newCommentObj = await sendComment({
+ rfqId: selectedRfq.id,
+ vendorId: selectedVendor.vendorId,
+ content: newComment,
+ attachments: attachments
+ });
+
+ // 상태 업데이트
+ setComments(prev => [...prev, newCommentObj]);
+ setNewComment("");
+ setAttachments([]);
+
+ toast.success("메시지가 전송되었습니다");
+
+ // 데이터 새로고침
+ if (onSuccess) {
+ onSuccess();
+ }
+ } catch (error) {
+ console.error("코멘트 전송 오류:", error);
+ toast.error("메시지 전송 중 오류가 발생했습니다");
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ // 첨부파일 미리보기
+ const handleAttachmentPreview = (attachment: Attachment) => {
+ setSelectedAttachment(attachment);
+ setPreviewDialogOpen(true);
+ };
+
+ // 첨부파일 다운로드
+ const handleAttachmentDownload = (attachment: Attachment) => {
+ // TODO: 실제 다운로드 구현
+ window.open(attachment.filePath, '_blank');
+ };
+
+ // 파일 아이콘 선택
+ const getFileIcon = (fileType: string) => {
+ if (fileType.startsWith("image/")) return <ImageIcon className="h-5 w-5 text-blue-500" />;
+ if (fileType.includes("pdf")) return <FileText className="h-5 w-5 text-red-500" />;
+ if (fileType.includes("spreadsheet") || fileType.includes("excel"))
+ return <FileText className="h-5 w-5 text-green-500" />;
+ if (fileType.includes("document") || fileType.includes("word"))
+ return <FileText className="h-5 w-5 text-blue-500" />;
+ return <File className="h-5 w-5 text-gray-500" />;
+ };
+
+ // 첨부파일 미리보기 다이얼로그
+ const renderAttachmentPreviewDialog = () => {
+ if (!selectedAttachment) return null;
+
+ const isImage = selectedAttachment.fileType?.startsWith("image/");
+ const isPdf = selectedAttachment.fileType?.includes("pdf");
+
+ return (
+ <Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}>
+ <DialogContent className="max-w-3xl">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ {getFileIcon(selectedAttachment.fileType || '')}
+ {selectedAttachment.originalFileName || selectedAttachment.fileName}
+ </DialogTitle>
+ <DialogDescription>
+ {formatFileSize(selectedAttachment.fileSize)} • {formatDateTime(selectedAttachment.uploadedAt, "KR")}
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="min-h-[300px] flex items-center justify-center p-4">
+ {isImage ? (
+ // eslint-disable-next-line @next/next/no-img-element
+ <img
+ src={selectedAttachment.filePath}
+ alt={selectedAttachment.originalFileName || selectedAttachment.fileName}
+ className="max-h-[500px] max-w-full object-contain"
+ />
+ ) : isPdf ? (
+ <iframe
+ src={`${selectedAttachment.filePath}#toolbar=0`}
+ className="w-full h-[500px]"
+ title={selectedAttachment.originalFileName || selectedAttachment.fileName}
+ />
+ ) : (
+ <div className="flex flex-col items-center gap-4 p-8">
+ {getFileIcon(selectedAttachment.fileType || '')}
+ <p className="text-muted-foreground text-sm">미리보기를 지원하지 않는 파일 형식입니다.</p>
+ <Button
+ variant="outline"
+ onClick={() => handleAttachmentDownload(selectedAttachment)}
+ >
+ <DownloadCloud className="h-4 w-4 mr-2" />
+ 다운로드
+ </Button>
+ </div>
+ )}
+ </div>
+ </DialogContent>
+ </Dialog>
+ );
+ };
+
+ if (!selectedRfq || !selectedVendor) {
+ return null;
+ }
+
+ return (
+ <Drawer open={open} onOpenChange={onOpenChange}>
+ <DrawerContent className="max-h-[80vh] flex flex-col">
+ <DrawerHeader className="border-b flex-shrink-0">
+ <DrawerTitle className="flex items-center gap-2">
+ <Avatar className="h-8 w-8">
+ <AvatarFallback className="bg-primary/10">
+ {selectedVendor.vendorName?.[0] || 'V'}
+ </AvatarFallback>
+ </Avatar>
+ <div>
+ <span>{selectedVendor.vendorName}</span>
+ <Badge variant="outline" className="ml-2">{selectedVendor.vendorCode}</Badge>
+ </div>
+ </DrawerTitle>
+ <DrawerDescription>
+ RFQ: {selectedRfq.rfqCode} • 프로젝트: {selectedRfq.projectName}
+ </DrawerDescription>
+ </DrawerHeader>
+
+ <div className="flex flex-col flex-1 min-h-0">
+ {/* 메시지 목록 */}
+ <div className="flex-1 p-4 overflow-y-auto min-h-[300px]">
+ {isLoading && comments.length === 0 ? (
+ <div className="flex h-full items-center justify-center">
+ <p className="text-muted-foreground">메시지 로딩 중...</p>
+ </div>
+ ) : comments.length === 0 ? (
+ <div className="flex h-full items-center justify-center">
+ <div className="flex flex-col items-center gap-2">
+ <AlertCircle className="h-6 w-6 text-muted-foreground" />
+ <p className="text-muted-foreground">아직 메시지가 없습니다</p>
+ </div>
+ </div>
+ ) : (
+ <div className="space-y-4 relative">
+ {isLoading && (
+ <div className="absolute top-0 right-0 z-10 bg-background/80 backdrop-blur-sm rounded-md px-2 py-1">
+ <div className="flex items-center gap-2">
+ <div className="w-2 h-2 bg-primary rounded-full animate-pulse" />
+ <span className="text-xs text-muted-foreground">새로고침 중...</span>
+ </div>
+ </div>
+ )}
+ {comments.map(comment => (
+ <div
+ key={comment.id}
+ className={`flex gap-3 ${comment.isVendorComment ? 'justify-start' : 'justify-end'}`}
+ >
+ {comment.isVendorComment && (
+ <Avatar className="h-8 w-8 mt-1">
+ <AvatarFallback className="bg-primary/10">
+ {comment.vendorName?.[0] || 'V'}
+ </AvatarFallback>
+ </Avatar>
+ )}
+
+ <div className={`rounded-lg p-3 max-w-[80%] ${
+ comment.isVendorComment
+ ? 'bg-muted'
+ : 'bg-primary text-primary-foreground'
+ }`}>
+ <div className="text-sm font-medium mb-1">
+ {comment.isVendorComment ? comment.vendorName : comment.userName}
+ </div>
+
+ {comment.content && (
+ <div className="text-sm whitespace-pre-wrap break-words">
+ {comment.content}
+ </div>
+ )}
+
+ {/* 첨부파일 표시 */}
+ {comment.attachments.length > 0 && (
+ <div className={`mt-2 pt-2 ${
+ comment.isVendorComment
+ ? 'border-t border-t-border/30'
+ : 'border-t border-t-primary-foreground/20'
+ }`}>
+ {comment.attachments.map(attachment => (
+ <div
+ key={attachment.id}
+ className="flex items-center text-xs gap-2 mb-1 p-1 rounded hover:bg-black/5 cursor-pointer"
+ onClick={() => handleAttachmentPreview(attachment)}
+ >
+ {getFileIcon(attachment.fileType || '')}
+ <span className="flex-1 truncate">{attachment.originalFileName || attachment.fileName}</span>
+ <span className="text-xs opacity-70">
+ {formatFileSize(attachment.fileSize)}
+ </span>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-6 w-6 rounded-full"
+ onClick={(e) => {
+ e.stopPropagation();
+ handleAttachmentDownload(attachment);
+ }}
+ >
+ <DownloadCloud className="h-3 w-3" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ )}
+
+ <div className="text-xs mt-1 opacity-70 flex items-center gap-1 justify-end">
+ {formatDateTime(comment.createdAt)}
+ </div>
+ </div>
+
+ {!comment.isVendorComment && (
+ <Avatar className="h-8 w-8 mt-1">
+ <AvatarFallback className="bg-primary/20">
+ {comment.userName?.[0] || 'U'}
+ </AvatarFallback>
+ </Avatar>
+ )}
+ </div>
+ ))}
+ <div ref={messagesEndRef} />
+ </div>
+ )}
+ </div>
+
+ {/* 선택된 첨부파일 표시 */}
+ {attachments.length > 0 && (
+ <div className="p-2 bg-muted mx-4 rounded-md mb-2 flex-shrink-0">
+ <div className="text-xs font-medium mb-1">첨부파일</div>
+ <div className="flex flex-wrap gap-2">
+ {attachments.map((file, index) => (
+ <div key={index} className="flex items-center bg-background rounded-md p-1 pr-2 text-xs">
+ {file.type.startsWith("image/") ? (
+ <ImageIcon className="h-4 w-4 mr-1 text-blue-500" />
+ ) : (
+ <File className="h-4 w-4 mr-1 text-gray-500" />
+ )}
+ <span className="truncate max-w-[100px]">{file.name}</span>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-4 w-4 ml-1 p-0"
+ onClick={() => handleRemoveFile(index)}
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* 메시지 입력 영역 */}
+ <div className="p-4 border-t flex-shrink-0">
+ <div className="flex gap-2 items-end">
+ <div className="flex-1">
+ <Textarea
+ placeholder="메시지를 입력하세요..."
+ className="min-h-[80px] resize-none"
+ value={newComment}
+ onChange={(e) => setNewComment(e.target.value)}
+ />
+ </div>
+ <div className="flex flex-col gap-2">
+ <input
+ type="file"
+ ref={fileInputRef}
+ className="hidden"
+ multiple
+ onChange={handleFileChange}
+ />
+ <Button
+ variant="outline"
+ size="icon"
+ onClick={handleFileSelect}
+ title="파일 첨부"
+ >
+ <Paperclip className="h-4 w-4" />
+ </Button>
+ <Button
+ onClick={handleSubmitComment}
+ disabled={(!newComment.trim() && attachments.length === 0) || isSubmitting}
+ >
+ <Send className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <DrawerFooter className="border-t flex-shrink-0">
+ <div className="flex justify-between items-center">
+ <div className="flex items-center gap-2">
+ <Button variant="outline" onClick={() => loadComments()}>
+ 새로고침
+ </Button>
+ <Button
+ variant={autoRefresh ? "default" : "outline"}
+ size="sm"
+ onClick={toggleAutoRefresh}
+ className="gap-2"
+ >
+ {autoRefresh ? (
+ <>
+ <div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
+ 자동 새로고침 ON
+ </>
+ ) : (
+ <>
+ <div className="w-2 h-2 bg-gray-400 rounded-full" />
+ 자동 새로고침 OFF
+ </>
+ )}
+ </Button>
+ </div>
+ <DrawerClose asChild>
+ <Button variant="outline">닫기</Button>
+ </DrawerClose>
+ </div>
+ </DrawerFooter>
+ </DrawerContent>
+
+ {renderAttachmentPreviewDialog()}
+ </Drawer>
+ );
} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/detail-table/vendor-contact-selection-dialog.tsx b/lib/techsales-rfq/table/detail-table/vendor-contact-selection-dialog.tsx
new file mode 100644
index 00000000..aa6f6c2f
--- /dev/null
+++ b/lib/techsales-rfq/table/detail-table/vendor-contact-selection-dialog.tsx
@@ -0,0 +1,343 @@
+"use client"
+
+import * as React from "react"
+import { useState, useEffect, useCallback } from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import { Badge } from "@/components/ui/badge"
+import { Skeleton } from "@/components/ui/skeleton"
+import { Mail, Phone, User, Send, Loader2 } from "lucide-react"
+import { toast } from "sonner"
+
+interface VendorContact {
+ id: number
+ contactName: string
+ contactPosition: string | null
+ contactEmail: string
+ contactPhone: string | null
+ isPrimary: boolean
+}
+
+interface VendorWithContacts {
+ vendor: {
+ id: number
+ vendorName: string
+ vendorCode: string | null
+ }
+ contacts: VendorContact[]
+}
+
+interface SelectedContact {
+ vendorId: number
+ contactId: number
+ contactEmail: string
+ contactName: string
+}
+
+interface VendorContactSelectionDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ vendorIds: number[]
+ onSendRfq: (selectedContacts: SelectedContact[]) => Promise<void>
+}
+
+export function VendorContactSelectionDialog({
+ open,
+ onOpenChange,
+ vendorIds,
+ onSendRfq
+}: VendorContactSelectionDialogProps) {
+ const [vendorsWithContacts, setVendorsWithContacts] = useState<Record<number, VendorWithContacts>>({})
+ const [selectedContacts, setSelectedContacts] = useState<SelectedContact[]>([])
+ const [isLoading, setIsLoading] = useState(false)
+ const [isSending, setIsSending] = useState(false)
+
+ // 벤더 contact 정보 조회
+ useEffect(() => {
+ if (open && vendorIds.length > 0) {
+ loadVendorsContacts()
+ }
+ }, [open, vendorIds])
+
+ // 다이얼로그 닫힐 때 상태 초기화
+ useEffect(() => {
+ if (!open) {
+ setVendorsWithContacts({})
+ setSelectedContacts([])
+ setIsLoading(false)
+ }
+ }, [open])
+
+ const loadVendorsContacts = useCallback(async () => {
+ try {
+ setIsLoading(true)
+ const { getTechVendorsContacts } = await import("@/lib/techsales-rfq/service")
+
+ const result = await getTechVendorsContacts(vendorIds)
+
+ if (result.error) {
+ toast.error(result.error)
+ return
+ }
+
+ setVendorsWithContacts(result.data)
+
+ // 기본 선택: 모든 contact 선택
+ const defaultSelected: SelectedContact[] = []
+ Object.values(result.data).forEach(vendorData => {
+ vendorData.contacts.forEach(contact => {
+ defaultSelected.push({
+ vendorId: vendorData.vendor.id,
+ contactId: contact.id,
+ contactEmail: contact.contactEmail,
+ contactName: contact.contactName
+ })
+ })
+ })
+ setSelectedContacts(defaultSelected)
+
+ } catch (error) {
+ console.error("벤더 contact 조회 오류:", error)
+ toast.error("벤더 연락처를 불러오는 중 오류가 발생했습니다")
+ } finally {
+ setIsLoading(false)
+ }
+ }, [vendorIds])
+
+ // contact 선택/해제 핸들러
+ const handleContactToggle = (vendorId: number, contact: VendorContact) => {
+ const isSelected = selectedContacts.some(
+ sc => sc.vendorId === vendorId && sc.contactId === contact.id
+ )
+
+ if (isSelected) {
+ // 선택 해제
+ setSelectedContacts(prev =>
+ prev.filter(sc => !(sc.vendorId === vendorId && sc.contactId === contact.id))
+ )
+ } else {
+ // 선택 추가
+ setSelectedContacts(prev => [
+ ...prev,
+ {
+ vendorId,
+ contactId: contact.id,
+ contactEmail: contact.contactEmail,
+ contactName: contact.contactName
+ }
+ ])
+ }
+ }
+
+ // 벤더별 전체 선택/해제
+ const handleVendorToggle = (vendorId: number, vendorData: VendorWithContacts) => {
+ const vendorContacts = vendorData.contacts
+ const selectedVendorContacts = selectedContacts.filter(sc => sc.vendorId === vendorId)
+
+ if (selectedVendorContacts.length === vendorContacts.length) {
+ // 전체 해제
+ setSelectedContacts(prev => prev.filter(sc => sc.vendorId !== vendorId))
+ } else {
+ // 전체 선택
+ const newSelected = vendorContacts.map(contact => ({
+ vendorId,
+ contactId: contact.id,
+ contactEmail: contact.contactEmail,
+ contactName: contact.contactName
+ }))
+
+ setSelectedContacts(prev => [
+ ...prev.filter(sc => sc.vendorId !== vendorId),
+ ...newSelected
+ ])
+ }
+ }
+
+ // RFQ 발송 핸들러
+ const handleSendRfq = async () => {
+ if (selectedContacts.length === 0) {
+ toast.warning("발송할 연락처를 선택해주세요.")
+ return
+ }
+
+ try {
+ setIsSending(true)
+ await onSendRfq(selectedContacts)
+ onOpenChange(false)
+ } catch (error) {
+ console.error("RFQ 발송 오류:", error)
+ } finally {
+ setIsSending(false)
+ }
+ }
+
+ // 선택된 contact가 있는지 확인
+ const isContactSelected = (vendorId: number, contactId: number) => {
+ return selectedContacts.some(sc => sc.vendorId === vendorId && sc.contactId === contactId)
+ }
+
+ // 벤더별 선택 상태 확인
+ const getVendorSelectionState = (vendorId: number, vendorData: VendorWithContacts) => {
+ const selectedVendorContacts = selectedContacts.filter(sc => sc.vendorId === vendorId)
+ const totalContacts = vendorData.contacts.length
+
+ if (selectedVendorContacts.length === 0) return "none"
+ if (selectedVendorContacts.length === totalContacts) return "all"
+ return "partial"
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col">
+ <DialogHeader>
+ <DialogTitle>RFQ 발송 대상 선택</DialogTitle>
+ <DialogDescription>
+ 각 벤더의 연락처를 선택하여 RFQ를 발송하세요. 기본적으로 모든 연락처가 선택되어 있습니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="flex-1 overflow-y-auto space-y-4">
+ {isLoading ? (
+ <div className="space-y-4">
+ {[1, 2, 3].map((i) => (
+ <div key={i} className="space-y-3">
+ <Skeleton className="h-6 w-40" />
+ <div className="space-y-2 pl-4">
+ <Skeleton className="h-16 w-full" />
+ <Skeleton className="h-16 w-full" />
+ </div>
+ </div>
+ ))}
+ </div>
+ ) : Object.keys(vendorsWithContacts).length === 0 ? (
+ <div className="text-center py-8 text-muted-foreground">
+ <Mail className="size-12 mx-auto mb-2 opacity-50" />
+ <p>연락처 정보가 없습니다.</p>
+ <p className="text-sm">벤더의 연락처를 먼저 등록해주세요.</p>
+ </div>
+ ) : (
+ Object.entries(vendorsWithContacts).map(([vendorId, vendorData]) => {
+ const selectionState = getVendorSelectionState(Number(vendorId), vendorData)
+
+ return (
+ <div key={vendorId} className="border rounded-lg p-4">
+ <div className="flex items-center justify-between mb-3">
+ <div className="flex items-center gap-3">
+ <Checkbox
+ checked={selectionState === "all"}
+ ref={(el) => {
+ if (el) {
+ const input = el.querySelector('input[type="checkbox"]') as HTMLInputElement
+ if (input) {
+ input.indeterminate = selectionState === "partial"
+ }
+ }
+ }}
+ onCheckedChange={() => handleVendorToggle(Number(vendorId), vendorData)}
+ />
+ <div>
+ <h3 className="font-medium">{vendorData.vendor.vendorName}</h3>
+ {vendorData.vendor.vendorCode && (
+ <p className="text-sm text-muted-foreground">
+ 코드: {vendorData.vendor.vendorCode}
+ </p>
+ )}
+ </div>
+ </div>
+ <Badge variant="outline">
+ {selectedContacts.filter(sc => sc.vendorId === Number(vendorId)).length} / {vendorData.contacts.length} 선택됨
+ </Badge>
+ </div>
+
+ <div className="space-y-2 pl-6">
+ {vendorData.contacts.map((contact) => (
+ <div
+ key={contact.id}
+ className={`flex items-center justify-between p-3 rounded border ${
+ isContactSelected(Number(vendorId), contact.id)
+ ? "bg-blue-50 border-blue-200"
+ : "bg-gray-50 border-gray-200"
+ }`}
+ >
+ <div className="flex items-center gap-3">
+ <Checkbox
+ checked={isContactSelected(Number(vendorId), contact.id)}
+ onCheckedChange={() => handleContactToggle(Number(vendorId), contact)}
+ />
+ <div className="flex items-center gap-2">
+ <User className="size-4 text-muted-foreground" />
+ <div>
+ <div className="flex items-center gap-2">
+ <span className="font-medium">{contact.contactName}</span>
+ </div>
+ {contact.contactPosition && (
+ <p className="text-sm text-muted-foreground">
+ {contact.contactPosition}
+ </p>
+ )}
+ </div>
+ </div>
+ </div>
+
+ <div className="flex items-center gap-4 text-sm">
+ <div className="flex items-center gap-1">
+ <Mail className="size-4 text-muted-foreground" />
+ <span>{contact.contactEmail}</span>
+ </div>
+ {contact.contactPhone && (
+ <div className="flex items-center gap-1">
+ <Phone className="size-4 text-muted-foreground" />
+ <span>{contact.contactPhone}</span>
+ </div>
+ )}
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ )
+ })
+ )}
+ </div>
+
+ <DialogFooter>
+ <div className="flex items-center justify-between w-full">
+ <div className="text-sm text-muted-foreground">
+ 총 {selectedContacts.length}명의 연락처가 선택됨
+ </div>
+ <div className="flex gap-2">
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ 취소
+ </Button>
+ <Button
+ onClick={handleSendRfq}
+ disabled={selectedContacts.length === 0 || isSending}
+ className="flex items-center gap-2"
+ >
+ {isSending ? (
+ <>
+ <Loader2 className="size-4 animate-spin" />
+ 발송 중...
+ </>
+ ) : (
+ <>
+ <Send className="size-4" />
+ RFQ 발송 ({selectedContacts.length}명)
+ </>
+ )}
+ </Button>
+ </div>
+ </div>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/project-detail-dialog.tsx b/lib/techsales-rfq/table/project-detail-dialog.tsx
index 68f13960..00202501 100644
--- a/lib/techsales-rfq/table/project-detail-dialog.tsx
+++ b/lib/techsales-rfq/table/project-detail-dialog.tsx
@@ -1,120 +1,120 @@
-"use client"
-
-import * as React from "react"
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-
-// 기본적인 RFQ 타입 정의 (rfq-table.tsx와 일치)
-interface TechSalesRfq {
- id: number
- rfqCode: string | null
- itemId: number
- itemName: string | null
- materialCode: string | null
- dueDate: Date
- rfqSendDate: Date | null
- status: "RFQ Created" | "RFQ Vendor Assignned" | "RFQ Sent" | "Quotation Analysis" | "Closed"
- picCode: string | null
- remark: string | null
- cancelReason: string | null
- createdAt: Date
- updatedAt: Date
- createdBy: number | null
- createdByName: string
- updatedBy: number | null
- updatedByName: string
- sentBy: number | null
- sentByName: string | null
- pspid: string
- projNm: string
- sector: string
- projMsrm: number
- ptypeNm: string
- attachmentCount: number
- quotationCount: number
-}
-
-interface ProjectDetailDialogProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- selectedRfq: TechSalesRfq | null
-}
-
-export function ProjectDetailDialog({
- open,
- onOpenChange,
- selectedRfq,
-}: ProjectDetailDialogProps) {
- if (!selectedRfq) {
- return null
- }
-
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-4xl w-[80vw] max-h-[80vh] overflow-hidden flex flex-col">
- <DialogHeader className="border-b pb-4">
- <DialogTitle className="flex items-center gap-2">
- 프로젝트 상세정보
- <Badge variant="outline">{selectedRfq.pspid}</Badge>
- </DialogTitle>
- <DialogDescription className="space-y-1">
- <div className="flex items-center gap-2 text-base font-medium">
- <span>RFQ:</span>
- <Badge variant="secondary">{selectedRfq.rfqCode || "미할당"}</Badge>
- <span>|</span>
- <span>자재:</span>
- <span className="text-foreground">{selectedRfq.materialCode || "N/A"}</span>
- </div>
- <div className="text-sm text-muted-foreground">
- {selectedRfq.projNm} - {selectedRfq.ptypeNm} ({selectedRfq.itemName || "자재명 없음"})
- </div>
- </DialogDescription>
- </DialogHeader>
- <div className="space-y-6 p-1 overflow-y-auto">
- {/* 기본 프로젝트 정보 */}
- <div className="space-y-4">
- <h3 className="text-lg font-semibold">기본 정보</h3>
- <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">프로젝트 ID</div>
- <div className="text-sm">{selectedRfq.pspid}</div>
- </div>
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">프로젝트명</div>
- <div className="text-sm">{selectedRfq.projNm}</div>
- </div>
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">선종</div>
- <div className="text-sm">{selectedRfq.ptypeNm}</div>
- </div>
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">척수</div>
- <div className="text-sm">{selectedRfq.projMsrm}</div>
- </div>
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">섹터</div>
- <div className="text-sm">{selectedRfq.sector}</div>
- </div>
- </div>
- </div>
- </div>
-
- {/* 닫기 버튼 */}
- <div className="sticky bottom-0 left-0 z-20 bg-background border-t pt-4 mt-4">
- <div className="flex justify-end">
- <Button variant="outline" onClick={() => onOpenChange(false)}>
- 닫기
- </Button>
- </div>
- </div>
- </DialogContent>
- </Dialog>
- )
+"use client"
+
+import * as React from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+
+// 기본적인 RFQ 타입 정의 (rfq-table.tsx와 일치)
+interface TechSalesRfq {
+ id: number
+ rfqCode: string | null
+ itemId: number
+ itemName: string | null
+ materialCode: string | null
+ dueDate: Date
+ rfqSendDate: Date | null
+ status: "RFQ Created" | "RFQ Vendor Assignned" | "RFQ Sent" | "Quotation Analysis" | "Closed"
+ picCode: string | null
+ remark: string | null
+ cancelReason: string | null
+ createdAt: Date
+ updatedAt: Date
+ createdBy: number | null
+ createdByName: string
+ updatedBy: number | null
+ updatedByName: string
+ sentBy: number | null
+ sentByName: string | null
+ pspid: string
+ projNm: string
+ sector: string
+ projMsrm: number
+ ptypeNm: string
+ attachmentCount: number
+ quotationCount: number
+}
+
+interface ProjectDetailDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ selectedRfq: TechSalesRfq | null
+}
+
+export function ProjectDetailDialog({
+ open,
+ onOpenChange,
+ selectedRfq,
+}: ProjectDetailDialogProps) {
+ if (!selectedRfq) {
+ return null
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl w-[80vw] max-h-[80vh] overflow-hidden flex flex-col">
+ <DialogHeader className="border-b pb-4">
+ <DialogTitle className="flex items-center gap-2">
+ 프로젝트 상세정보
+ <Badge variant="outline">{selectedRfq.pspid}</Badge>
+ </DialogTitle>
+ <DialogDescription className="space-y-1">
+ <div className="flex items-center gap-2 text-base font-medium">
+ <span>RFQ:</span>
+ <Badge variant="secondary">{selectedRfq.rfqCode || "미할당"}</Badge>
+ <span>|</span>
+ <span>자재:</span>
+ <span className="text-foreground">{selectedRfq.materialCode || "N/A"}</span>
+ </div>
+ <div className="text-sm text-muted-foreground">
+ {selectedRfq.projNm} - {selectedRfq.ptypeNm} ({selectedRfq.itemName || "자재명 없음"})
+ </div>
+ </DialogDescription>
+ </DialogHeader>
+ <div className="space-y-6 p-1 overflow-y-auto">
+ {/* 기본 프로젝트 정보 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-semibold">기본 정보</h3>
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">프로젝트 ID</div>
+ <div className="text-sm">{selectedRfq.pspid}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">프로젝트명</div>
+ <div className="text-sm">{selectedRfq.projNm}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">선종</div>
+ <div className="text-sm">{selectedRfq.ptypeNm}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">척수</div>
+ <div className="text-sm">{selectedRfq.projMsrm}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">섹터</div>
+ <div className="text-sm">{selectedRfq.sector}</div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ {/* 닫기 버튼 */}
+ <div className="sticky bottom-0 left-0 z-20 bg-background border-t pt-4 mt-4">
+ <div className="flex justify-end">
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ 닫기
+ </Button>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/rfq-filter-sheet.tsx b/lib/techsales-rfq/table/rfq-filter-sheet.tsx
index 9b6acfb2..a03e6167 100644
--- a/lib/techsales-rfq/table/rfq-filter-sheet.tsx
+++ b/lib/techsales-rfq/table/rfq-filter-sheet.tsx
@@ -1,759 +1,759 @@
-"use client"
-
-import { useEffect, useTransition, useState, useRef } from "react"
-import { useRouter, useParams } from "next/navigation"
-import { z } from "zod"
-import { useForm } from "react-hook-form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { Search, X } from "lucide-react"
-import { customAlphabet } from "nanoid"
-import { parseAsStringEnum, useQueryState } from "nuqs"
-
-import { Button } from "@/components/ui/button"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import { Input } from "@/components/ui/input"
-import { DateRangePicker } from "@/components/date-range-picker"
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
-import { cn } from "@/lib/utils"
-import { useTranslation } from '@/i18n/client'
-import { getFiltersStateParser } from "@/lib/parsers"
-
-// nanoid 생성기
-const generateId = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 6)
-
-// 필터 스키마 정의 (TechSales RFQ에 맞게 수정)
-const filterSchema = z.object({
- rfqCode: z.string().optional(),
- materialCode: z.string().optional(),
- itemName: z.string().optional(),
- pspid: z.string().optional(),
- projNm: z.string().optional(),
- ptypeNm: z.string().optional(),
- createdByName: z.string().optional(),
- status: z.string().optional(),
- dateRange: z.object({
- from: z.date().optional(),
- to: z.date().optional(),
- }).optional(),
-})
-
-// 상태 옵션 정의 (TechSales RFQ 상태에 맞게 수정)
-const statusOptions = [
- { value: "RFQ Created", label: "RFQ Created" },
- { value: "RFQ Vendor Assignned", label: "RFQ Vendor Assignned" },
- { value: "RFQ Sent", label: "RFQ Sent" },
- { value: "Quotation Analysis", label: "Quotation Analysis" },
- { value: "Closed", label: "Closed" },
-]
-
-type FilterFormValues = z.infer<typeof filterSchema>
-
-interface RFQFilterSheetProps {
- isOpen: boolean;
- onClose: () => void;
- onSearch?: () => void;
- isLoading?: boolean;
-}
-
-// Updated component for inline use (not a sheet anymore)
-export function RFQFilterSheet({
- isOpen,
- onClose,
- onSearch,
- isLoading = false
-}: RFQFilterSheetProps) {
- const router = useRouter()
- const params = useParams();
- const lng = params ? (params.lng as string) : 'ko';
- const { t } = useTranslation(lng);
-
- const [isPending, startTransition] = useTransition()
-
- // 초기화 상태 추가 - 폼 초기화 중에는 상태 변경을 방지
- const [isInitializing, setIsInitializing] = useState(false)
- // 마지막으로 적용된 필터를 추적하기 위한 ref
- const lastAppliedFilters = useRef<string>("")
-
- // nuqs로 URL 상태 관리 - 파라미터명을 'basicFilters'로 변경
- const [filters, setFilters] = useQueryState(
- "basicFilters",
- getFiltersStateParser().withDefault([])
- )
-
- // joinOperator 설정
- const [joinOperator, setJoinOperator] = useQueryState(
- "basicJoinOperator",
- parseAsStringEnum(["and", "or"]).withDefault("and")
- )
-
- // 현재 URL의 페이지 파라미터도 가져옴
- const [page, setPage] = useQueryState("page", { defaultValue: "1" })
-
- // 폼 상태 초기화
- const form = useForm<FilterFormValues>({
- resolver: zodResolver(filterSchema),
- defaultValues: {
- rfqCode: "",
- materialCode: "",
- itemName: "",
- pspid: "",
- projNm: "",
- ptypeNm: "",
- createdByName: "",
- status: "",
- dateRange: {
- from: undefined,
- to: undefined,
- },
- },
- })
-
- // URL 필터에서 초기 폼 상태 설정 - 개선된 버전
- useEffect(() => {
- // 현재 필터를 문자열로 직렬화
- const currentFiltersString = JSON.stringify(filters);
-
- // 패널이 열렸고, 필터가 있고, 마지막에 적용된 필터와 다를 때만 업데이트
- if (isOpen && filters && filters.length > 0 && currentFiltersString !== lastAppliedFilters.current) {
- setIsInitializing(true);
-
- const formValues = { ...form.getValues() };
- let formUpdated = false;
-
- filters.forEach(filter => {
- if (filter.id === "rfqSendDate" && Array.isArray(filter.value) && filter.value.length > 0) {
- formValues.dateRange = {
- from: filter.value[0] ? new Date(filter.value[0]) : undefined,
- to: filter.value[1] ? new Date(filter.value[1]) : undefined,
- };
- formUpdated = true;
- } else if (filter.id in formValues) {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- (formValues as any)[filter.id] = filter.value;
- formUpdated = true;
- }
- });
-
- // 폼 값이 변경된 경우에만 reset으로 한 번에 업데이트
- if (formUpdated) {
- form.reset(formValues);
- lastAppliedFilters.current = currentFiltersString;
- }
-
- setIsInitializing(false);
- }
- }, [filters, isOpen, form]) // form 의존성 추가
-
- // 현재 적용된 필터 카운트
- const getActiveFilterCount = () => {
- return filters?.length || 0
- }
-
- // 조회 버튼 클릭 핸들러
- const handleSearch = () => {
- // 필터 패널 닫기 로직이 있다면 여기에 추가
- if (onSearch) {
- onSearch();
- }
- }
-
- // 폼 제출 핸들러 - 개선된 버전
- async function onSubmit(data: FilterFormValues) {
- // 초기화 중이면 제출 방지
- if (isInitializing) return;
-
- startTransition(async () => {
- try {
- // 필터 배열 생성
- const newFilters = []
-
- if (data.rfqCode?.trim()) {
- newFilters.push({
- id: "rfqCode",
- value: data.rfqCode.trim(),
- type: "text" as const,
- operator: "iLike" as const,
- rowId: generateId()
- })
- }
-
- if (data.materialCode?.trim()) {
- newFilters.push({
- id: "materialCode",
- value: data.materialCode.trim(),
- type: "text" as const,
- operator: "iLike" as const,
- rowId: generateId()
- })
- }
-
- if (data.itemName?.trim()) {
- newFilters.push({
- id: "itemName",
- value: data.itemName.trim(),
- type: "text" as const,
- operator: "iLike" as const,
- rowId: generateId()
- })
- }
-
- if (data.pspid?.trim()) {
- newFilters.push({
- id: "pspid",
- value: data.pspid.trim(),
- type: "text" as const,
- operator: "iLike" as const,
- rowId: generateId()
- })
- }
-
- if (data.projNm?.trim()) {
- newFilters.push({
- id: "projNm",
- value: data.projNm.trim(),
- type: "text" as const,
- operator: "iLike" as const,
- rowId: generateId()
- })
- }
-
- if (data.ptypeNm?.trim()) {
- newFilters.push({
- id: "ptypeNm",
- value: data.ptypeNm.trim(),
- type: "text" as const,
- operator: "iLike" as const,
- rowId: generateId()
- })
- }
-
- if (data.createdByName?.trim()) {
- newFilters.push({
- id: "createdByName",
- value: data.createdByName.trim(),
- type: "text" as const,
- operator: "iLike" as const,
- rowId: generateId()
- })
- }
-
- if (data.status?.trim()) {
- newFilters.push({
- id: "status",
- value: data.status.trim(),
- type: "select" as const,
- operator: "eq" as const,
- rowId: generateId()
- })
- }
-
- // Add date range to params if it exists
- if (data.dateRange?.from) {
- newFilters.push({
- id: "rfqSendDate",
- value: [
- data.dateRange.from.toISOString().split('T')[0],
- data.dateRange.to ? data.dateRange.to.toISOString().split('T')[0] : undefined
- ].filter(Boolean) as string[],
- type: "date" as const,
- operator: "isBetween" as const,
- rowId: generateId()
- })
- }
-
- console.log("기본 필터 적용:", newFilters);
-
- // 마지막 적용된 필터 업데이트
- lastAppliedFilters.current = JSON.stringify(newFilters);
-
- // 먼저 필터를 설정
- await setFilters(newFilters.length > 0 ? newFilters : null);
-
- // 그 다음 페이지를 1로 설정
- await setPage("1");
-
- // 필터 업데이트 후 조회 핸들러 호출 (제공된 경우)
- handleSearch();
-
- // 페이지 새로고침으로 서버 데이터 다시 가져오기
- setTimeout(() => {
- window.location.reload();
- }, 100);
- } catch (error) {
- console.error("필터 적용 오류:", error);
- }
- })
- }
-
- // 필터 초기화 핸들러 - 개선된 버전
- async function handleReset() {
- try {
- setIsInitializing(true);
-
- form.reset({
- rfqCode: "",
- materialCode: "",
- itemName: "",
- pspid: "",
- projNm: "",
- ptypeNm: "",
- createdByName: "",
- status: "",
- dateRange: { from: undefined, to: undefined },
- });
-
- // 필터와 조인 연산자를 초기화
- await setFilters(null);
- await setJoinOperator("and");
- await setPage("1");
-
- // 마지막 적용된 필터 초기화
- lastAppliedFilters.current = "";
-
- console.log("필터 초기화 완료");
- setIsInitializing(false);
-
- // 페이지 새로고침으로 서버 데이터 다시 가져오기
- setTimeout(() => {
- window.location.reload();
- }, 100);
- } catch (error) {
- console.error("필터 초기화 오류:", error);
- setIsInitializing(false);
- }
- }
-
- // Don't render if not open (for side panel use)
- if (!isOpen) {
- return null;
- }
-
- return (
- <div className="flex flex-col h-full max-h-full p-4">
- {/* Filter Panel Header - 보더 제거, 배경 색상 적용 */}
- <div className="flex items-center justify-between px-6 min-h-[60px] shrink-0">
- <h3 className="text-lg font-semibold whitespace-nowrap">검색 필터</h3>
- </div>
-
- {/* Join Operator Selection - 보더 제거, 배경 색상 적용 */}
- <div className="px-6 shrink-0">
- <label className="text-sm font-medium">조건 결합 방식</label>
- <Select
- value={joinOperator}
- onValueChange={(value: "and" | "or") => setJoinOperator(value)}
- disabled={isInitializing}
- >
- <SelectTrigger className="h-8 w-[180px] mt-2 bg-white">
- <SelectValue placeholder="조건 결합 방식" />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="and">모든 조건 충족 (AND)</SelectItem>
- <SelectItem value="or">하나라도 충족 (OR)</SelectItem>
- </SelectContent>
- </Select>
- </div>
-
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col h-full min-h-0">
- {/* Scrollable content area - 헤더와 버튼 사이에서 스크롤 */}
- <div className="flex-1 min-h-0 overflow-y-auto px-6 pb-4">
- <div className="space-y-6 pt-4">
- {/* RFQ NO. */}
- <FormField
- control={form.control}
- name="rfqCode"
- render={({ field }) => (
- <FormItem>
- <FormLabel>{t("RFQ NO.")}</FormLabel>
- <FormControl>
- <div className="relative">
- <Input
- placeholder={t("RFQ 번호 입력")}
- {...field}
- className={cn(field.value && "pr-8", "bg-white")}
- disabled={isInitializing}
- />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="absolute right-0 top-0 h-full px-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("rfqCode", "");
- }}
- disabled={isInitializing}
- >
- <X className="size-3.5" />
- <span className="sr-only">Clear</span>
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 자재그룹 */}
- <FormField
- control={form.control}
- name="materialCode"
- render={({ field }) => (
- <FormItem>
- <FormLabel>{t("자재그룹")}</FormLabel>
- <FormControl>
- <div className="relative">
- <Input
- placeholder={t("자재그룹 입력")}
- {...field}
- className={cn(field.value && "pr-8", "bg-white")}
- disabled={isInitializing}
- />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="absolute right-0 top-0 h-full px-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("materialCode", "");
- }}
- disabled={isInitializing}
- >
- <X className="size-3.5" />
- <span className="sr-only">Clear</span>
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 자재명 */}
- <FormField
- control={form.control}
- name="itemName"
- render={({ field }) => (
- <FormItem>
- <FormLabel>{t("자재명")}</FormLabel>
- <FormControl>
- <div className="relative">
- <Input
- placeholder={t("자재명 입력")}
- {...field}
- className={cn(field.value && "pr-8", "bg-white")}
- disabled={isInitializing}
- />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="absolute right-0 top-0 h-full px-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("itemName", "");
- }}
- disabled={isInitializing}
- >
- <X className="size-3.5" />
- <span className="sr-only">Clear</span>
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 프로젝트 ID */}
- <FormField
- control={form.control}
- name="pspid"
- render={({ field }) => (
- <FormItem>
- <FormLabel>{t("프로젝트 ID")}</FormLabel>
- <FormControl>
- <div className="relative">
- <Input
- placeholder={t("프로젝트 ID 입력")}
- {...field}
- className={cn(field.value && "pr-8", "bg-white")}
- disabled={isInitializing}
- />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="absolute right-0 top-0 h-full px-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("pspid", "");
- }}
- disabled={isInitializing}
- >
- <X className="size-3.5" />
- <span className="sr-only">Clear</span>
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 프로젝트명 */}
- <FormField
- control={form.control}
- name="projNm"
- render={({ field }) => (
- <FormItem>
- <FormLabel>{t("프로젝트명")}</FormLabel>
- <FormControl>
- <div className="relative">
- <Input
- placeholder={t("프로젝트명 입력")}
- {...field}
- className={cn(field.value && "pr-8", "bg-white")}
- disabled={isInitializing}
- />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="absolute right-0 top-0 h-full px-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("projNm", "");
- }}
- disabled={isInitializing}
- >
- <X className="size-3.5" />
- <span className="sr-only">Clear</span>
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 선종명 */}
- <FormField
- control={form.control}
- name="ptypeNm"
- render={({ field }) => (
- <FormItem>
- <FormLabel>{t("선종명")}</FormLabel>
- <FormControl>
- <div className="relative">
- <Input
- placeholder={t("선종명 입력")}
- {...field}
- className={cn(field.value && "pr-8", "bg-white")}
- disabled={isInitializing}
- />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="absolute right-0 top-0 h-full px-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("ptypeNm", "");
- }}
- disabled={isInitializing}
- >
- <X className="size-3.5" />
- <span className="sr-only">Clear</span>
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 요청자 */}
- <FormField
- control={form.control}
- name="createdByName"
- render={({ field }) => (
- <FormItem>
- <FormLabel>{t("요청자")}</FormLabel>
- <FormControl>
- <div className="relative">
- <Input
- placeholder={t("요청자 입력")}
- {...field}
- className={cn(field.value && "pr-8", "bg-white")}
- disabled={isInitializing}
- />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="absolute right-0 top-0 h-full px-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("createdByName", "");
- }}
- disabled={isInitializing}
- >
- <X className="size-3.5" />
- <span className="sr-only">Clear</span>
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* Status */}
- <FormField
- control={form.control}
- name="status"
- render={({ field }) => (
- <FormItem>
- <FormLabel>{t("Status")}</FormLabel>
- <Select
- value={field.value}
- onValueChange={field.onChange}
- disabled={isInitializing}
- >
- <FormControl>
- <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}>
- <div className="flex justify-between w-full">
- <SelectValue placeholder={t("Select status")} />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="h-4 w-4 -mr-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("status", "");
- }}
- disabled={isInitializing}
- >
- <X className="size-3" />
- <span className="sr-only">Clear</span>
- </Button>
- )}
- </div>
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {statusOptions.map(option => (
- <SelectItem key={option.value} value={option.value}>
- {option.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* RFQ 전송일 */}
- <FormField
- control={form.control}
- name="dateRange"
- render={({ field }) => (
- <FormItem>
- <FormLabel>{t("RFQ 전송일")}</FormLabel>
- <FormControl>
- <div className="relative">
- <DateRangePicker
- triggerSize="default"
- triggerClassName="w-full bg-white"
- align="start"
- showClearButton={true}
- placeholder={t("RFQ 전송일 범위를 고르세요")}
- date={field.value || undefined}
- onDateChange={field.onChange}
- disabled={isInitializing}
- />
- {(field.value?.from || field.value?.to) && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="absolute right-10 top-0 h-full px-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("dateRange", { from: undefined, to: undefined });
- }}
- disabled={isInitializing}
- >
- <X className="size-3.5" />
- <span className="sr-only">Clear</span>
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
- </div>
-
- {/* Fixed buttons at bottom - 보더 제거, 배경 색상 적용 */}
- <div className="p-4 shrink-0">
- <div className="flex gap-2 justify-end">
- <Button
- type="button"
- variant="outline"
- onClick={handleReset}
- disabled={isPending || getActiveFilterCount() === 0 || isInitializing}
- className="px-4"
- >
- {t("초기화")}
- </Button>
- <Button
- type="submit"
- variant="samsung"
- disabled={isPending || isLoading || isInitializing}
- className="px-4"
- >
- <Search className="size-4 mr-2" />
- {isPending || isLoading ? t("조회 중...") : t("조회")}
- </Button>
- </div>
- </div>
- </form>
- </Form>
- </div>
- )
+"use client"
+
+import { useEffect, useTransition, useState, useRef } from "react"
+import { useRouter, useParams } from "next/navigation"
+import { z } from "zod"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Search, X } from "lucide-react"
+import { customAlphabet } from "nanoid"
+import { parseAsStringEnum, useQueryState } from "nuqs"
+
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import { DateRangePicker } from "@/components/date-range-picker"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { cn } from "@/lib/utils"
+import { useTranslation } from '@/i18n/client'
+import { getFiltersStateParser } from "@/lib/parsers"
+
+// nanoid 생성기
+const generateId = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 6)
+
+// 필터 스키마 정의 (TechSales RFQ에 맞게 수정)
+const filterSchema = z.object({
+ rfqCode: z.string().optional(),
+ materialCode: z.string().optional(),
+ itemName: z.string().optional(),
+ pspid: z.string().optional(),
+ projNm: z.string().optional(),
+ ptypeNm: z.string().optional(),
+ createdByName: z.string().optional(),
+ status: z.string().optional(),
+ dateRange: z.object({
+ from: z.date().optional(),
+ to: z.date().optional(),
+ }).optional(),
+})
+
+// 상태 옵션 정의 (TechSales RFQ 상태에 맞게 수정)
+const statusOptions = [
+ { value: "RFQ Created", label: "RFQ Created" },
+ { value: "RFQ Vendor Assignned", label: "RFQ Vendor Assignned" },
+ { value: "RFQ Sent", label: "RFQ Sent" },
+ { value: "Quotation Analysis", label: "Quotation Analysis" },
+ { value: "Closed", label: "Closed" },
+]
+
+type FilterFormValues = z.infer<typeof filterSchema>
+
+interface RFQFilterSheetProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSearch?: () => void;
+ isLoading?: boolean;
+}
+
+// Updated component for inline use (not a sheet anymore)
+export function RFQFilterSheet({
+ isOpen,
+ onClose,
+ onSearch,
+ isLoading = false
+}: RFQFilterSheetProps) {
+ const router = useRouter()
+ const params = useParams();
+ const lng = params ? (params.lng as string) : 'ko';
+ const { t } = useTranslation(lng);
+
+ const [isPending, startTransition] = useTransition()
+
+ // 초기화 상태 추가 - 폼 초기화 중에는 상태 변경을 방지
+ const [isInitializing, setIsInitializing] = useState(false)
+ // 마지막으로 적용된 필터를 추적하기 위한 ref
+ const lastAppliedFilters = useRef<string>("")
+
+ // nuqs로 URL 상태 관리 - 파라미터명을 'basicFilters'로 변경
+ const [filters, setFilters] = useQueryState(
+ "basicFilters",
+ getFiltersStateParser().withDefault([])
+ )
+
+ // joinOperator 설정
+ const [joinOperator, setJoinOperator] = useQueryState(
+ "basicJoinOperator",
+ parseAsStringEnum(["and", "or"]).withDefault("and")
+ )
+
+ // 현재 URL의 페이지 파라미터도 가져옴
+ const [page, setPage] = useQueryState("page", { defaultValue: "1" })
+
+ // 폼 상태 초기화
+ const form = useForm<FilterFormValues>({
+ resolver: zodResolver(filterSchema),
+ defaultValues: {
+ rfqCode: "",
+ materialCode: "",
+ itemName: "",
+ pspid: "",
+ projNm: "",
+ ptypeNm: "",
+ createdByName: "",
+ status: "",
+ dateRange: {
+ from: undefined,
+ to: undefined,
+ },
+ },
+ })
+
+ // URL 필터에서 초기 폼 상태 설정 - 개선된 버전
+ useEffect(() => {
+ // 현재 필터를 문자열로 직렬화
+ const currentFiltersString = JSON.stringify(filters);
+
+ // 패널이 열렸고, 필터가 있고, 마지막에 적용된 필터와 다를 때만 업데이트
+ if (isOpen && filters && filters.length > 0 && currentFiltersString !== lastAppliedFilters.current) {
+ setIsInitializing(true);
+
+ const formValues = { ...form.getValues() };
+ let formUpdated = false;
+
+ filters.forEach(filter => {
+ if (filter.id === "rfqSendDate" && Array.isArray(filter.value) && filter.value.length > 0) {
+ formValues.dateRange = {
+ from: filter.value[0] ? new Date(filter.value[0]) : undefined,
+ to: filter.value[1] ? new Date(filter.value[1]) : undefined,
+ };
+ formUpdated = true;
+ } else if (filter.id in formValues) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (formValues as any)[filter.id] = filter.value;
+ formUpdated = true;
+ }
+ });
+
+ // 폼 값이 변경된 경우에만 reset으로 한 번에 업데이트
+ if (formUpdated) {
+ form.reset(formValues);
+ lastAppliedFilters.current = currentFiltersString;
+ }
+
+ setIsInitializing(false);
+ }
+ }, [filters, isOpen, form]) // form 의존성 추가
+
+ // 현재 적용된 필터 카운트
+ const getActiveFilterCount = () => {
+ return filters?.length || 0
+ }
+
+ // 조회 버튼 클릭 핸들러
+ const handleSearch = () => {
+ // 필터 패널 닫기 로직이 있다면 여기에 추가
+ if (onSearch) {
+ onSearch();
+ }
+ }
+
+ // 폼 제출 핸들러 - 개선된 버전
+ async function onSubmit(data: FilterFormValues) {
+ // 초기화 중이면 제출 방지
+ if (isInitializing) return;
+
+ startTransition(async () => {
+ try {
+ // 필터 배열 생성
+ const newFilters = []
+
+ if (data.rfqCode?.trim()) {
+ newFilters.push({
+ id: "rfqCode",
+ value: data.rfqCode.trim(),
+ type: "text" as const,
+ operator: "iLike" as const,
+ rowId: generateId()
+ })
+ }
+
+ if (data.materialCode?.trim()) {
+ newFilters.push({
+ id: "materialCode",
+ value: data.materialCode.trim(),
+ type: "text" as const,
+ operator: "iLike" as const,
+ rowId: generateId()
+ })
+ }
+
+ if (data.itemName?.trim()) {
+ newFilters.push({
+ id: "itemName",
+ value: data.itemName.trim(),
+ type: "text" as const,
+ operator: "iLike" as const,
+ rowId: generateId()
+ })
+ }
+
+ if (data.pspid?.trim()) {
+ newFilters.push({
+ id: "pspid",
+ value: data.pspid.trim(),
+ type: "text" as const,
+ operator: "iLike" as const,
+ rowId: generateId()
+ })
+ }
+
+ if (data.projNm?.trim()) {
+ newFilters.push({
+ id: "projNm",
+ value: data.projNm.trim(),
+ type: "text" as const,
+ operator: "iLike" as const,
+ rowId: generateId()
+ })
+ }
+
+ if (data.ptypeNm?.trim()) {
+ newFilters.push({
+ id: "ptypeNm",
+ value: data.ptypeNm.trim(),
+ type: "text" as const,
+ operator: "iLike" as const,
+ rowId: generateId()
+ })
+ }
+
+ if (data.createdByName?.trim()) {
+ newFilters.push({
+ id: "createdByName",
+ value: data.createdByName.trim(),
+ type: "text" as const,
+ operator: "iLike" as const,
+ rowId: generateId()
+ })
+ }
+
+ if (data.status?.trim()) {
+ newFilters.push({
+ id: "status",
+ value: data.status.trim(),
+ type: "select" as const,
+ operator: "eq" as const,
+ rowId: generateId()
+ })
+ }
+
+ // Add date range to params if it exists
+ if (data.dateRange?.from) {
+ newFilters.push({
+ id: "rfqSendDate",
+ value: [
+ data.dateRange.from.toISOString().split('T')[0],
+ data.dateRange.to ? data.dateRange.to.toISOString().split('T')[0] : undefined
+ ].filter(Boolean) as string[],
+ type: "date" as const,
+ operator: "isBetween" as const,
+ rowId: generateId()
+ })
+ }
+
+ console.log("기본 필터 적용:", newFilters);
+
+ // 마지막 적용된 필터 업데이트
+ lastAppliedFilters.current = JSON.stringify(newFilters);
+
+ // 먼저 필터를 설정
+ await setFilters(newFilters.length > 0 ? newFilters : null);
+
+ // 그 다음 페이지를 1로 설정
+ await setPage("1");
+
+ // 필터 업데이트 후 조회 핸들러 호출 (제공된 경우)
+ handleSearch();
+
+ // 페이지 새로고침으로 서버 데이터 다시 가져오기
+ setTimeout(() => {
+ window.location.reload();
+ }, 100);
+ } catch (error) {
+ console.error("필터 적용 오류:", error);
+ }
+ })
+ }
+
+ // 필터 초기화 핸들러 - 개선된 버전
+ async function handleReset() {
+ try {
+ setIsInitializing(true);
+
+ form.reset({
+ rfqCode: "",
+ materialCode: "",
+ itemName: "",
+ pspid: "",
+ projNm: "",
+ ptypeNm: "",
+ createdByName: "",
+ status: "",
+ dateRange: { from: undefined, to: undefined },
+ });
+
+ // 필터와 조인 연산자를 초기화
+ await setFilters(null);
+ await setJoinOperator("and");
+ await setPage("1");
+
+ // 마지막 적용된 필터 초기화
+ lastAppliedFilters.current = "";
+
+ console.log("필터 초기화 완료");
+ setIsInitializing(false);
+
+ // 페이지 새로고침으로 서버 데이터 다시 가져오기
+ setTimeout(() => {
+ window.location.reload();
+ }, 100);
+ } catch (error) {
+ console.error("필터 초기화 오류:", error);
+ setIsInitializing(false);
+ }
+ }
+
+ // Don't render if not open (for side panel use)
+ if (!isOpen) {
+ return null;
+ }
+
+ return (
+ <div className="flex flex-col h-full max-h-full p-4">
+ {/* Filter Panel Header - 보더 제거, 배경 색상 적용 */}
+ <div className="flex items-center justify-between px-6 min-h-[60px] shrink-0">
+ <h3 className="text-lg font-semibold whitespace-nowrap">검색 필터</h3>
+ </div>
+
+ {/* Join Operator Selection - 보더 제거, 배경 색상 적용 */}
+ <div className="px-6 shrink-0">
+ <label className="text-sm font-medium">조건 결합 방식</label>
+ <Select
+ value={joinOperator}
+ onValueChange={(value: "and" | "or") => setJoinOperator(value)}
+ disabled={isInitializing}
+ >
+ <SelectTrigger className="h-8 w-[180px] mt-2 bg-white">
+ <SelectValue placeholder="조건 결합 방식" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="and">모든 조건 충족 (AND)</SelectItem>
+ <SelectItem value="or">하나라도 충족 (OR)</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col h-full min-h-0">
+ {/* Scrollable content area - 헤더와 버튼 사이에서 스크롤 */}
+ <div className="flex-1 min-h-0 overflow-y-auto px-6 pb-4">
+ <div className="space-y-6 pt-4">
+ {/* RFQ NO. */}
+ <FormField
+ control={form.control}
+ name="rfqCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("RFQ NO.")}</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder={t("RFQ 번호 입력")}
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("rfqCode", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 자재그룹 */}
+ <FormField
+ control={form.control}
+ name="materialCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("자재그룹")}</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder={t("자재그룹 입력")}
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("materialCode", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 자재명 */}
+ <FormField
+ control={form.control}
+ name="itemName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("자재명")}</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder={t("자재명 입력")}
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("itemName", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 프로젝트 ID */}
+ <FormField
+ control={form.control}
+ name="pspid"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("프로젝트 ID")}</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder={t("프로젝트 ID 입력")}
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("pspid", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 프로젝트명 */}
+ <FormField
+ control={form.control}
+ name="projNm"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("프로젝트명")}</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder={t("프로젝트명 입력")}
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("projNm", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 선종명 */}
+ <FormField
+ control={form.control}
+ name="ptypeNm"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("선종명")}</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder={t("선종명 입력")}
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("ptypeNm", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 요청자 */}
+ <FormField
+ control={form.control}
+ name="createdByName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("요청자")}</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder={t("요청자 입력")}
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("createdByName", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Status */}
+ <FormField
+ control={form.control}
+ name="status"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("Status")}</FormLabel>
+ <Select
+ value={field.value}
+ onValueChange={field.onChange}
+ disabled={isInitializing}
+ >
+ <FormControl>
+ <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}>
+ <div className="flex justify-between w-full">
+ <SelectValue placeholder={t("Select status")} />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="h-4 w-4 -mr-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("status", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {statusOptions.map(option => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* RFQ 전송일 */}
+ <FormField
+ control={form.control}
+ name="dateRange"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("RFQ 전송일")}</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <DateRangePicker
+ triggerSize="default"
+ triggerClassName="w-full bg-white"
+ align="start"
+ showClearButton={true}
+ placeholder={t("RFQ 전송일 범위를 고르세요")}
+ date={field.value || undefined}
+ onDateChange={field.onChange}
+ disabled={isInitializing}
+ />
+ {(field.value?.from || field.value?.to) && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-10 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("dateRange", { from: undefined, to: undefined });
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </div>
+
+ {/* Fixed buttons at bottom - 보더 제거, 배경 색상 적용 */}
+ <div className="p-4 shrink-0">
+ <div className="flex gap-2 justify-end">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={handleReset}
+ disabled={isPending || getActiveFilterCount() === 0 || isInitializing}
+ className="px-4"
+ >
+ {t("초기화")}
+ </Button>
+ <Button
+ type="submit"
+ variant="samsung"
+ disabled={isPending || isLoading || isInitializing}
+ className="px-4"
+ >
+ <Search className="size-4 mr-2" />
+ {isPending || isLoading ? t("조회 중...") : t("조회")}
+ </Button>
+ </div>
+ </div>
+ </form>
+ </Form>
+ </div>
+ )
} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/rfq-items-view-dialog.tsx b/lib/techsales-rfq/table/rfq-items-view-dialog.tsx
index 289ad312..c0aaf477 100644
--- a/lib/techsales-rfq/table/rfq-items-view-dialog.tsx
+++ b/lib/techsales-rfq/table/rfq-items-view-dialog.tsx
@@ -21,8 +21,8 @@ interface RfqItem {
itemCode: string;
itemList: string;
workType: string;
- shipType?: string; // 조선용
- subItemName?: string; // 해양용
+ shipTypes?: string; // 조선용
+ subItemList?: string; // 해양용
}
interface RfqItemsViewDialogProps {
@@ -167,7 +167,7 @@ export function RfqItemsViewDialog({
{item.itemList}
</div>
<div className="w-[150px] pl-2 text-sm">
- {item.itemType === 'SHIP' ? item.shipType : item.subItemName}
+ {item.itemType === 'SHIP' ? item.shipTypes : item.subItemList}
</div>
</div>
))}
diff --git a/lib/techsales-rfq/table/rfq-table-column.tsx b/lib/techsales-rfq/table/rfq-table-column.tsx
index 89054d0e..f41857cd 100644
--- a/lib/techsales-rfq/table/rfq-table-column.tsx
+++ b/lib/techsales-rfq/table/rfq-table-column.tsx
@@ -1,420 +1,413 @@
-"use client"
-
-import * as React from "react"
-import { ColumnDef } from "@tanstack/react-table"
-import { formatDate, formatDateTime } from "@/lib/utils"
-import { Checkbox } from "@/components/ui/checkbox"
-import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
-import { DataTableRowAction } from "@/types/table"
-import { Paperclip, Package, FileText, BarChart3 } from "lucide-react"
-import { Button } from "@/components/ui/button"
-
-// 기본적인 RFQ 타입 정의 (rfq-table.tsx 파일과 일치해야 함)
-type TechSalesRfq = {
- id: number
- rfqCode: string | null
- description: string | null
- dueDate: Date
- rfqSendDate: Date | null
- status: "RFQ Created" | "RFQ Vendor Assignned" | "RFQ Sent" | "Quotation Analysis" | "Closed"
- picCode: string | null
- remark: string | null
- cancelReason: string | null
- createdAt: Date
- updatedAt: Date
- createdBy: number | null
- createdByName: string
- updatedBy: number | null
- updatedByName: string
- sentBy: number | null
- sentByName: string | null
- pspid: string
- projNm: string
- sector: string
- projMsrm: number
- ptypeNm: string
- attachmentCount: number
- hasTbeAttachments: boolean
- hasCbeAttachments: boolean
- quotationCount: number
- itemCount: number
- // 나머지 필드는 사용할 때마다 추가
- [key: string]: unknown
-}
-
-interface GetColumnsProps {
- setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<TechSalesRfq> | null>>;
- openAttachmentsSheet: (rfqId: number, attachmentType?: 'RFQ_COMMON' | 'TBE_RESULT' | 'CBE_RESULT') => void;
- openItemsDialog: (rfq: TechSalesRfq) => void;
-}
-
-export function getColumns({
- setRowAction,
- openAttachmentsSheet,
- openItemsDialog,
-}: GetColumnsProps): ColumnDef<TechSalesRfq>[] {
- return [
- {
- id: "select",
- // Remove the "Select all" checkbox in header since we're doing single-select
- header: () => <span className="sr-only">Select</span>,
- cell: ({ row, table }) => (
- <Checkbox
- checked={row.getIsSelected()}
- onCheckedChange={(value) => {
- // If selecting this row
- if (value) {
- // First deselect all rows (to ensure single selection)
- table.toggleAllRowsSelected(false)
- // Then select just this row
- row.toggleSelected(true)
- // Trigger the same action that was in the "Select" button
- setRowAction({ row, type: "select" as const })
- } else {
- // Just deselect this row
- row.toggleSelected(false)
- }
- }}
- aria-label="Select row"
- className="translate-y-0.5"
- />
- ),
- enableSorting: false,
- enableHiding: false,
- enableResizing: false,
- size: 40,
- minSize: 40,
- maxSize: 40,
- },
-
- {
- accessorKey: "status",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="진행상태" />
- ),
- cell: ({ row }) => <div>{row.getValue("status")}</div>,
- meta: {
- excelHeader: "진행상태"
- },
- enableResizing: true,
- minSize: 80,
- size: 100,
- },
- {
- accessorKey: "rfqCode",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="RFQ No." />
- ),
- cell: ({ row }) => <div>{row.getValue("rfqCode")}</div>,
- meta: {
- excelHeader: "RFQ No."
- },
- enableResizing: true,
- size: 120,
- },
- {
- accessorKey: "description",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="RFQ Title" />
- ),
- cell: ({ row }) => <div>{row.getValue("description")}</div>,
- meta: {
- excelHeader: "RFQ Title"
- },
- enableResizing: true,
- size: 200,
- },
- {
- accessorKey: "projNm",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="프로젝트명" />
- ),
- cell: ({ row }) => {
- const projNm = row.getValue("projNm") as string;
- return (
- <Button
- variant="link"
- className="p-0 h-auto font-normal text-left justify-start hover:underline"
- onClick={() => setRowAction({ row, type: "view" as const })}
- >
- {projNm}
- </Button>
- );
- },
- meta: {
- excelHeader: "프로젝트명"
- },
- enableResizing: true,
- size: 160,
- },
- // {
- // accessorKey: "projMsrm",
- // header: ({ column }) => (
- // <DataTableColumnHeaderSimple column={column} title="척수" />
- // ),
- // cell: ({ row }) => <div>{row.getValue("projMsrm")}</div>,
- // meta: {
- // excelHeader: "척수"
- // },
- // enableResizing: true,
- // minSize: 60,
- // size: 80,
- // },
- // {
- // accessorKey: "ptypeNm",
- // header: ({ column }) => (
- // <DataTableColumnHeaderSimple column={column} title="선종" />
- // ),
- // cell: ({ row }) => <div>{row.getValue("ptypeNm")}</div>,
- // meta: {
- // excelHeader: "선종"
- // },
- // enableResizing: true,
- // size: 120,
- // },
- // {
- // accessorKey: "quotationCount",
- // header: ({ column }) => (
- // <DataTableColumnHeaderSimple column={column} title="견적수" />
- // ),
- // cell: ({ row }) => <div>{row.getValue("quotationCount")}</div>,
- // meta: {
- // excelHeader: "견적수"
- // },
- // enableResizing: true,
- // size: 80,
- // },
- {
- accessorKey: "rfqSendDate",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="최초 전송일" />
- ),
- cell: ({ cell }) => {
- const value = cell.getValue();
- return value ? formatDate(value as Date, "KR") : "";
- },
- meta: {
- excelHeader: "최초 전송일"
- },
- enableResizing: true,
- size: 120,
- },
- {
- accessorKey: "dueDate",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="RFQ 마감일" />
- ),
- cell: ({ cell }) => {
- const value = cell.getValue();
- return value ? formatDate(value as Date, "KR") : "";
- },
- meta: {
- excelHeader: "RFQ 마감일"
- },
- enableResizing: true,
- minSize: 80,
- size: 120,
- },
- {
- accessorKey: "createdByName",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="요청자" />
- ),
- cell: ({ row }) => <div>{row.getValue("createdByName")}</div>,
- meta: {
- excelHeader: "요청자"
- },
- enableResizing: true,
- size: 120,
- },
- {
- accessorKey: "createdAt",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="등록일" />
- ),
- cell: ({ cell }) => {
- const value = cell.getValue();
- return value ? formatDateTime(value as Date, "KR") : "";
- },
- meta: {
- excelHeader: "등록일"
- },
- enableResizing: true,
- size: 160,
- },
- {
- accessorKey: "updatedAt",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="수정일" />
- ),
- cell: ({ cell }) => {
- const value = cell.getValue();
- return value ? formatDateTime(value as Date, "KR") : "";
- },
- meta: {
- excelHeader: "수정일"
- },
- enableResizing: true,
- size: 160,
- },
- // 우측 고정 컬럼들
- {
- id: "items",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="아이템" />
- ),
- cell: ({ row }) => {
- const rfq = row.original
- const itemCount = rfq.itemCount || 0
-
- const handleClick = () => {
- openItemsDialog(rfq)
- }
-
- return (
- <Button
- variant="ghost"
- size="sm"
- className="relative h-8 w-8 p-0 group"
- onClick={handleClick}
- aria-label={`View ${itemCount} items`}
- >
- <Package className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
- {itemCount > 0 && (
- <span className="pointer-events-none absolute -top-1 -right-1 inline-flex h-4 min-w-[1rem] items-center justify-center rounded-full bg-primary px-1 text-[0.625rem] font-medium leading-none text-primary-foreground">
- {itemCount}
- </span>
- )}
- <span className="sr-only">
- {itemCount > 0 ? `${itemCount} 아이템` : "아이템 없음"}
- </span>
- </Button>
- )
- },
- enableSorting: false,
- enableResizing: false,
- size: 80,
- meta: {
- excelHeader: "아이템"
- },
- },
- {
- id: "attachments",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="RFQ 첨부파일" />
- ),
- cell: ({ row }) => {
- const rfq = row.original
- const attachmentCount = rfq.attachmentCount || 0
-
- const handleClick = () => {
- openAttachmentsSheet(rfq.id, 'RFQ_COMMON')
- }
-
- return (
- <Button
- variant="ghost"
- size="sm"
- className="relative h-8 w-8 p-0 group"
- onClick={handleClick}
- aria-label={
- attachmentCount > 0 ? `View ${attachmentCount} attachments` : "Add attachments"
- }
- >
- <Paperclip className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
- {attachmentCount > 0 && (
- <span className="pointer-events-none absolute -top-1 -right-1 inline-flex h-4 min-w-[1rem] items-center justify-center rounded-full bg-primary px-1 text-[0.625rem] font-medium leading-none text-primary-foreground">
- {attachmentCount}
- </span>
- )}
- <span className="sr-only">
- {attachmentCount > 0 ? `${attachmentCount} 첨부파일` : "첨부파일 추가"}
- </span>
- </Button>
- )
- },
- enableSorting: false,
- enableResizing: false,
- size: 80,
- meta: {
- excelHeader: "첨부파일"
- },
- },
- {
- id: "tbe-attachments",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="TBE 결과" />
- ),
- cell: ({ row }) => {
- const rfq = row.original
- const hasTbeAttachments = rfq.hasTbeAttachments
-
- const handleClick = () => {
- openAttachmentsSheet(rfq.id, 'TBE_RESULT')
- }
-
- return (
- <Button
- variant="ghost"
- size="sm"
- className="relative h-8 w-8 p-0 group"
- onClick={handleClick}
- aria-label={hasTbeAttachments ? "TBE 첨부파일 있음" : "TBE 첨부파일 추가"}
- >
- <FileText className="h-4 w-4 text-muted-foreground group-hover:text-green-600 transition-colors" />
- {hasTbeAttachments && (
- <span className="pointer-events-none absolute -top-1 -right-1 inline-flex h-3 w-3 rounded-full bg-red-500"></span>
- )}
- <span className="sr-only">
- {hasTbeAttachments ? "TBE 첨부파일 있음" : "TBE 첨부파일 추가"}
- </span>
- </Button>
- )
- },
- enableSorting: false,
- enableResizing: false,
- size: 80,
- meta: {
- excelHeader: "TBE 결과"
- },
- },
- {
- id: "cbe-attachments",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="CBE 결과" />
- ),
- cell: ({ row }) => {
- const rfq = row.original
- const hasCbeAttachments = rfq.hasCbeAttachments
-
- const handleClick = () => {
- openAttachmentsSheet(rfq.id, 'CBE_RESULT')
- }
-
- return (
- <Button
- variant="ghost"
- size="sm"
- className="relative h-8 w-8 p-0 group"
- onClick={handleClick}
- aria-label={hasCbeAttachments ? "CBE 첨부파일 있음" : "CBE 첨부파일 추가"}
- >
- <BarChart3 className="h-4 w-4 text-muted-foreground group-hover:text-blue-600 transition-colors" />
- {hasCbeAttachments && (
- <span className="pointer-events-none absolute -top-1 -right-1 inline-flex h-3 w-3 rounded-full bg-red-500"></span>
- )}
- <span className="sr-only">
- {hasCbeAttachments ? "CBE 첨부파일 있음" : "CBE 첨부파일 추가"}
- </span>
- </Button>
- )
- },
- enableSorting: false,
- enableResizing: false,
- size: 80,
- meta: {
- excelHeader: "CBE 결과"
- },
- },
- ]
+"use client"
+
+import * as React from "react"
+import { ColumnDef } from "@tanstack/react-table"
+import { formatDate, formatDateTime } from "@/lib/utils"
+import { Checkbox } from "@/components/ui/checkbox"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { DataTableRowAction } from "@/types/table"
+import { Paperclip, Package, FileText, BarChart3 } from "lucide-react"
+import { Button } from "@/components/ui/button"
+import { TechSalesRfq } from "./rfq-table"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<TechSalesRfq> | null>>;
+ openAttachmentsSheet: (rfqId: number, attachmentType?: 'RFQ_COMMON' | 'TBE_RESULT' | 'CBE_RESULT') => void;
+ openItemsDialog: (rfq: TechSalesRfq) => void;
+}
+
+export function getColumns({
+ setRowAction,
+ openAttachmentsSheet,
+ openItemsDialog,
+}: GetColumnsProps): ColumnDef<TechSalesRfq>[] {
+ return [
+ {
+ id: "select",
+ // Remove the "Select all" checkbox in header since we're doing single-select
+ header: () => <span className="sr-only">Select</span>,
+ cell: ({ row, table }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => {
+ // If selecting this row
+ if (value) {
+ // First deselect all rows (to ensure single selection)
+ table.toggleAllRowsSelected(false)
+ // Then select just this row
+ row.toggleSelected(true)
+ // Trigger the same action that was in the "Select" button
+ setRowAction({ row, type: "select" as const })
+ } else {
+ // Just deselect this row
+ row.toggleSelected(false)
+ }
+ }}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ enableResizing: false,
+ size: 40,
+ minSize: 40,
+ maxSize: 40,
+ },
+
+ {
+ accessorKey: "status",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="진행상태" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("status")}</div>,
+ meta: {
+ excelHeader: "진행상태"
+ },
+ enableResizing: true,
+ minSize: 80,
+ size: 100,
+ },
+ {
+ accessorKey: "rfqCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ No." />
+ ),
+ cell: ({ row }) => <div>{row.getValue("rfqCode")}</div>,
+ meta: {
+ excelHeader: "RFQ No."
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "description",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ Title" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("description")}</div>,
+ meta: {
+ excelHeader: "RFQ Title"
+ },
+ enableResizing: true,
+ size: 200,
+ },
+ {
+ accessorKey: "projNm",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="프로젝트명" />
+ ),
+ cell: ({ row }) => {
+ const projNm = row.getValue("projNm") as string;
+ return (
+ <Button
+ variant="link"
+ className="p-0 h-auto font-normal text-left justify-start hover:underline"
+ onClick={() => setRowAction({ row, type: "view" as const })}
+ >
+ {projNm}
+ </Button>
+ );
+ },
+ meta: {
+ excelHeader: "프로젝트명"
+ },
+ enableResizing: true,
+ size: 160,
+ },
+ {
+ accessorKey: "workTypes",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="공종" />
+ ),
+ cell: ({ row }) => {
+ const workTypes = row.getValue("workTypes") as string | null;
+ return (
+ <div className="max-w-[150px]">
+ {workTypes ? (
+ <span className="text-sm truncate block" title={workTypes}>
+ {workTypes}
+ </span>
+ ) : (
+ <span className="text-muted-foreground text-sm">-</span>
+ )}
+ </div>
+ );
+ },
+ meta: {
+ excelHeader: "공종"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ // {
+ // accessorKey: "projMsrm",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="척수" />
+ // ),
+ // cell: ({ row }) => <div>{row.getValue("projMsrm")}</div>,
+ // meta: {
+ // excelHeader: "척수"
+ // },
+ // enableResizing: true,
+ // minSize: 60,
+ // size: 80,
+ // },
+ // {
+ // accessorKey: "ptypeNm",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="선종" />
+ // ),
+ // cell: ({ row }) => <div>{row.getValue("ptypeNm")}</div>,
+ // meta: {
+ // excelHeader: "선종"
+ // },
+ // enableResizing: true,
+ // size: 120,
+ // },
+ // {
+ // accessorKey: "quotationCount",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="견적수" />
+ // ),
+ // cell: ({ row }) => <div>{row.getValue("quotationCount")}</div>,
+ // meta: {
+ // excelHeader: "견적수"
+ // },
+ // enableResizing: true,
+ // size: 80,
+ // },
+ {
+ accessorKey: "rfqSendDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="최초 전송일" />
+ ),
+ cell: ({ cell }) => {
+ const value = cell.getValue();
+ return value ? formatDate(value as Date, "KR") : "";
+ },
+ meta: {
+ excelHeader: "최초 전송일"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "dueDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ 마감일" />
+ ),
+ cell: ({ cell }) => {
+ const value = cell.getValue();
+ return value ? formatDate(value as Date, "KR") : "";
+ },
+ meta: {
+ excelHeader: "RFQ 마감일"
+ },
+ enableResizing: true,
+ minSize: 80,
+ size: 120,
+ },
+ {
+ accessorKey: "createdByName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="요청자" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("createdByName")}</div>,
+ meta: {
+ excelHeader: "요청자"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="등록일" />
+ ),
+ cell: ({ cell }) => {
+ const value = cell.getValue();
+ return value ? formatDateTime(value as Date, "KR") : "";
+ },
+ meta: {
+ excelHeader: "등록일"
+ },
+ enableResizing: true,
+ size: 160,
+ },
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="수정일" />
+ ),
+ cell: ({ cell }) => {
+ const value = cell.getValue();
+ return value ? formatDateTime(value as Date, "KR") : "";
+ },
+ meta: {
+ excelHeader: "수정일"
+ },
+ enableResizing: true,
+ size: 160,
+ },
+ // 우측 고정 컬럼들
+ {
+ id: "items",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="아이템" />
+ ),
+ cell: ({ row }) => {
+ const rfq = row.original
+ const itemCount = rfq.itemCount || 0
+
+ const handleClick = () => {
+ openItemsDialog(rfq)
+ }
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label={`View ${itemCount} items`}
+ >
+ <Package className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ {itemCount > 0 && (
+ <span className="pointer-events-none absolute -top-1 -right-1 inline-flex h-4 min-w-[1rem] items-center justify-center rounded-full bg-primary px-1 text-[0.625rem] font-medium leading-none text-primary-foreground">
+ {itemCount}
+ </span>
+ )}
+ <span className="sr-only">
+ {itemCount > 0 ? `${itemCount} 아이템` : "아이템 없음"}
+ </span>
+ </Button>
+ )
+ },
+ enableSorting: false,
+ enableResizing: false,
+ size: 80,
+ meta: {
+ excelHeader: "아이템"
+ },
+ },
+ {
+ id: "attachments",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ 첨부파일" />
+ ),
+ cell: ({ row }) => {
+ const rfq = row.original
+ const attachmentCount = rfq.attachmentCount || 0
+
+ const handleClick = () => {
+ openAttachmentsSheet(rfq.id, 'RFQ_COMMON')
+ }
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label={
+ attachmentCount > 0 ? `View ${attachmentCount} attachments` : "Add attachments"
+ }
+ >
+ <Paperclip className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ {attachmentCount > 0 && (
+ <span className="pointer-events-none absolute -top-1 -right-1 inline-flex h-4 min-w-[1rem] items-center justify-center rounded-full bg-primary px-1 text-[0.625rem] font-medium leading-none text-primary-foreground">
+ {attachmentCount}
+ </span>
+ )}
+ <span className="sr-only">
+ {attachmentCount > 0 ? `${attachmentCount} 첨부파일` : "첨부파일 추가"}
+ </span>
+ </Button>
+ )
+ },
+ enableSorting: false,
+ enableResizing: false,
+ size: 80,
+ meta: {
+ excelHeader: "첨부파일"
+ },
+ },
+ {
+ id: "tbe-attachments",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="TBE 결과" />
+ ),
+ cell: ({ row }) => {
+ const rfq = row.original
+ const hasTbeAttachments = rfq.hasTbeAttachments
+
+ const handleClick = () => {
+ openAttachmentsSheet(rfq.id, 'TBE_RESULT')
+ }
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label={hasTbeAttachments ? "TBE 첨부파일 있음" : "TBE 첨부파일 추가"}
+ >
+ <FileText className="h-4 w-4 text-muted-foreground group-hover:text-green-600 transition-colors" />
+ {hasTbeAttachments && (
+ <span className="pointer-events-none absolute -top-1 -right-1 inline-flex h-3 w-3 rounded-full bg-red-500"></span>
+ )}
+ <span className="sr-only">
+ {hasTbeAttachments ? "TBE 첨부파일 있음" : "TBE 첨부파일 추가"}
+ </span>
+ </Button>
+ )
+ },
+ enableSorting: false,
+ enableResizing: false,
+ size: 80,
+ meta: {
+ excelHeader: "TBE 결과"
+ },
+ },
+ {
+ id: "cbe-attachments",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="CBE 결과" />
+ ),
+ cell: ({ row }) => {
+ const rfq = row.original
+ const hasCbeAttachments = rfq.hasCbeAttachments
+
+ const handleClick = () => {
+ openAttachmentsSheet(rfq.id, 'CBE_RESULT')
+ }
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label={hasCbeAttachments ? "CBE 첨부파일 있음" : "CBE 첨부파일 추가"}
+ >
+ <BarChart3 className="h-4 w-4 text-muted-foreground group-hover:text-blue-600 transition-colors" />
+ {hasCbeAttachments && (
+ <span className="pointer-events-none absolute -top-1 -right-1 inline-flex h-3 w-3 rounded-full bg-red-500"></span>
+ )}
+ <span className="sr-only">
+ {hasCbeAttachments ? "CBE 첨부파일 있음" : "CBE 첨부파일 추가"}
+ </span>
+ </Button>
+ )
+ },
+ enableSorting: false,
+ enableResizing: false,
+ size: 80,
+ meta: {
+ excelHeader: "CBE 결과"
+ },
+ },
+ ]
} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/rfq-table-toolbar-actions.tsx b/lib/techsales-rfq/table/rfq-table-toolbar-actions.tsx
index a8c2d08c..3ccca4eb 100644
--- a/lib/techsales-rfq/table/rfq-table-toolbar-actions.tsx
+++ b/lib/techsales-rfq/table/rfq-table-toolbar-actions.tsx
@@ -1,80 +1,80 @@
-"use client"
-
-import * as React from "react"
-import { Download, RefreshCw } from "lucide-react"
-import { toast } from "sonner"
-
-import { exportTableToExcel } from "@/lib/export"
-import { Button } from "@/components/ui/button"
-import { type Table } from "@tanstack/react-table"
-import { CreateShipRfqDialog } from "./create-rfq-ship-dialog"
-import { CreateTopRfqDialog } from "./create-rfq-top-dialog"
-import { CreateHullRfqDialog } from "./create-rfq-hull-dialog"
-
-interface RFQTableToolbarActionsProps<TData> {
- selection: Table<TData>;
- onRefresh?: () => void;
- rfqType?: "SHIP" | "TOP" | "HULL";
-}
-
-export function RFQTableToolbarActions<TData>({
- selection,
- onRefresh,
- rfqType = "SHIP"
-}: RFQTableToolbarActionsProps<TData>) {
-
- // 데이터 새로고침
- const handleRefresh = () => {
- if (onRefresh) {
- onRefresh();
- toast.success("데이터를 새로고침했습니다");
- }
- }
-
- // RFQ 타입에 따른 다이얼로그 렌더링
- const renderRfqDialog = () => {
- switch (rfqType) {
- case "TOP":
- return <CreateTopRfqDialog onCreated={onRefresh} />;
- case "HULL":
- return <CreateHullRfqDialog onCreated={onRefresh} />;
- case "SHIP":
- default:
- return <CreateShipRfqDialog onCreated={onRefresh} />;
- }
- }
-
- return (
- <div className="flex items-center gap-2">
- {/* RFQ 생성 다이얼로그 */}
- {renderRfqDialog()}
-
- {/* 새로고침 버튼 */}
- <Button
- variant="outline"
- size="sm"
- onClick={handleRefresh}
- className="gap-2"
- >
- <RefreshCw className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">새로고침</span>
- </Button>
-
- {/* 내보내기 버튼 */}
- <Button
- variant="outline"
- size="sm"
- onClick={() =>
- exportTableToExcel(selection, {
- filename: "tech_sales_rfq",
- excludeColumns: ["select", "actions"],
- })
- }
- className="gap-2"
- >
- <Download className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">내보내기</span>
- </Button>
- </div>
- )
+"use client"
+
+import * as React from "react"
+import { Download, RefreshCw } from "lucide-react"
+import { toast } from "sonner"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import { type Table } from "@tanstack/react-table"
+import { CreateShipRfqDialog } from "./create-rfq-ship-dialog"
+import { CreateTopRfqDialog } from "./create-rfq-top-dialog"
+import { CreateHullRfqDialog } from "./create-rfq-hull-dialog"
+
+interface RFQTableToolbarActionsProps<TData> {
+ selection: Table<TData>;
+ onRefresh?: () => void;
+ rfqType?: "SHIP" | "TOP" | "HULL";
+}
+
+export function RFQTableToolbarActions<TData>({
+ selection,
+ onRefresh,
+ rfqType = "SHIP"
+}: RFQTableToolbarActionsProps<TData>) {
+
+ // 데이터 새로고침
+ const handleRefresh = () => {
+ if (onRefresh) {
+ onRefresh();
+ toast.success("데이터를 새로고침했습니다");
+ }
+ }
+
+ // RFQ 타입에 따른 다이얼로그 렌더링
+ const renderRfqDialog = () => {
+ switch (rfqType) {
+ case "TOP":
+ return <CreateTopRfqDialog onCreated={onRefresh} />;
+ case "HULL":
+ return <CreateHullRfqDialog onCreated={onRefresh} />;
+ case "SHIP":
+ default:
+ return <CreateShipRfqDialog onCreated={onRefresh} />;
+ }
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+ {/* RFQ 생성 다이얼로그 */}
+ {renderRfqDialog()}
+
+ {/* 새로고침 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleRefresh}
+ className="gap-2"
+ >
+ <RefreshCw className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">새로고침</span>
+ </Button>
+
+ {/* 내보내기 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ exportTableToExcel(selection, {
+ filename: "tech_sales_rfq",
+ excludeColumns: ["select", "actions"],
+ })
+ }
+ className="gap-2"
+ >
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">내보내기</span>
+ </Button>
+ </div>
+ )
} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/rfq-table.tsx b/lib/techsales-rfq/table/rfq-table.tsx
index 615753cd..e3551625 100644
--- a/lib/techsales-rfq/table/rfq-table.tsx
+++ b/lib/techsales-rfq/table/rfq-table.tsx
@@ -1,589 +1,636 @@
-"use client"
-
-import * as React from "react"
-import { useSearchParams } from "next/navigation"
-import { Button } from "@/components/ui/button"
-import { PanelLeftClose, PanelLeftOpen } from "lucide-react"
-import type {
- DataTableAdvancedFilterField,
- DataTableRowAction,
-} from "@/types/table"
-import {
- ResizablePanelGroup,
- ResizablePanel,
- ResizableHandle,
-} from "@/components/ui/resizable"
-
-import { useDataTable } from "@/hooks/use-data-table"
-import { DataTable } from "@/components/data-table/data-table"
-import { getColumns } from "./rfq-table-column"
-import { useEffect, useMemo } from "react"
-import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
-import { RFQTableToolbarActions } from "./rfq-table-toolbar-actions"
-import { getTechSalesRfqsWithJoin, getTechSalesRfqAttachments } from "@/lib/techsales-rfq/service"
-import { toast } from "sonner"
-import { useTablePresets } from "@/components/data-table/use-table-presets"
-import { RfqDetailTables } from "./detail-table/rfq-detail-table"
-import { cn } from "@/lib/utils"
-import { ProjectDetailDialog } from "./project-detail-dialog"
-import { RFQFilterSheet } from "./rfq-filter-sheet"
-import { TechSalesRfqAttachmentsSheet, ExistingTechSalesAttachment } from "./tech-sales-rfq-attachments-sheet"
-import { RfqItemsViewDialog } from "./rfq-items-view-dialog"
-// 기본적인 RFQ 타입 정의 (repository selectTechSalesRfqsWithJoin 반환 타입에 맞춤)
-interface TechSalesRfq {
- id: number
- rfqCode: string | null
- biddingProjectId: number | null
- materialCode: string | null
- dueDate: Date
- rfqSendDate: Date | null
- status: "RFQ Created" | "RFQ Vendor Assignned" | "RFQ Sent" | "Quotation Analysis" | "Closed"
- description: string | null
- remark: string | null
- cancelReason: string | null
- createdAt: Date
- updatedAt: Date
- createdBy: number | null
- createdByName: string
- updatedBy: number | null
- updatedByName: string
- sentBy: number | null
- sentByName: string | null
- // 조인된 프로젝트 정보
- pspid: string
- projNm: string
- sector: string
- projMsrm: number
- ptypeNm: string
- attachmentCount: number
- quotationCount: number
- rfqType: "SHIP" | "TOP" | "HULL" | null
- // 필요에 따라 다른 필드들 추가
- [key: string]: unknown
-}
-
-interface RFQListTableProps {
- promises: Promise<[Awaited<ReturnType<typeof getTechSalesRfqsWithJoin>>]>
- className?: string;
- calculatedHeight?: string; // 계산된 높이 추가
- rfqType: "SHIP" | "TOP" | "HULL";
-}
-
-export function RFQListTable({
- promises,
- className,
- calculatedHeight,
- rfqType
-}: RFQListTableProps) {
- const searchParams = useSearchParams()
-
- // 필터 패널 상태
- const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false)
-
- // 선택된 RFQ 상태
- const [selectedRfq, setSelectedRfq] = React.useState<TechSalesRfq | null>(null)
-
- // 프로젝트 상세정보 다이얼로그 상태
- const [isProjectDetailOpen, setIsProjectDetailOpen] = React.useState(false)
- const [projectDetailRfq, setProjectDetailRfq] = React.useState<TechSalesRfq | null>(null)
-
- // 첨부파일 시트 상태
- const [attachmentsOpen, setAttachmentsOpen] = React.useState(false)
- const [selectedRfqForAttachments, setSelectedRfqForAttachments] = React.useState<TechSalesRfq | null>(null)
- const [attachmentsDefault, setAttachmentsDefault] = React.useState<ExistingTechSalesAttachment[]>([])
-
- // 아이템 다이얼로그 상태
- const [itemsDialogOpen, setItemsDialogOpen] = React.useState(false)
- const [selectedRfqForItems, setSelectedRfqForItems] = React.useState<TechSalesRfq | null>(null)
-
- // 패널 collapse 상태
- const [panelHeight, setPanelHeight] = React.useState<number>(55)
-
- // 고정 높이 설정을 위한 상수 (실제 측정값으로 조정 필요)
- const LAYOUT_HEADER_HEIGHT = 64 // Layout Header 높이
- const LAYOUT_FOOTER_HEIGHT = 60 // Layout Footer 높이 (있다면 실제 값)
- const LOCAL_HEADER_HEIGHT = 72 // 로컬 헤더 바 높이 (p-4 + border)
- const FILTER_PANEL_WIDTH = 400 // 필터 패널 너비
-
- // 높이 계산
- // 필터 패널 높이 - Layout Header와 Footer 사이
- const FIXED_FILTER_HEIGHT = `calc(100vh - ${LAYOUT_HEADER_HEIGHT*2}px)`
-
- console.log(calculatedHeight)
-
- // 테이블 컨텐츠 높이 - 전달받은 높이에서 로컬 헤더 제외
- const FIXED_TABLE_HEIGHT = calculatedHeight
- ? `calc(${calculatedHeight} - ${LOCAL_HEADER_HEIGHT}px)`
- : `calc(100vh - ${LAYOUT_HEADER_HEIGHT + LAYOUT_FOOTER_HEIGHT + LOCAL_HEADER_HEIGHT+76}px)` // fallback
-
- // Suspense 방식으로 데이터 처리
- const [promiseData] = React.use(promises)
- const tableData = promiseData
-
- const [rowAction, setRowAction] = React.useState<DataTableRowAction<TechSalesRfq> | null>(null)
-
- // 초기 설정 정의
- const initialSettings = React.useMemo(() => ({
- page: parseInt(searchParams?.get('page') || '1'),
- perPage: parseInt(searchParams?.get('perPage') || '10'),
- sort: searchParams?.get('sort') ? JSON.parse(searchParams.get('sort')!) : [{ id: "updatedAt", desc: true }],
- filters: searchParams?.get('filters') ? JSON.parse(searchParams.get('filters')!) : [],
- joinOperator: (searchParams?.get('joinOperator') as "and" | "or") || "and",
- basicFilters: searchParams?.get('basicFilters') ? JSON.parse(searchParams.get('basicFilters')!) : [],
- basicJoinOperator: (searchParams?.get('basicJoinOperator') as "and" | "or") || "and",
- search: searchParams?.get('search') || '',
- from: searchParams?.get('from') || undefined,
- to: searchParams?.get('to') || undefined,
- columnVisibility: {},
- columnOrder: [],
- pinnedColumns: { left: [], right: ["items", "attachments", "tbe-attachments", "cbe-attachments"] },
- groupBy: [],
- expandedRows: []
- }), [searchParams])
-
- // DB 기반 프리셋 훅 사용
- const {
- // presets,
- // activePresetId,
- // hasUnsavedChanges,
- // isLoading: presetsLoading,
- // createPreset,
- // applyPreset,
- // updatePreset,
- // deletePreset,
- // setDefaultPreset,
- // renamePreset,
- getCurrentSettings,
- } = useTablePresets<TechSalesRfq>('rfq-list-table', initialSettings)
-
- // 조회 버튼 클릭 핸들러
- const handleSearch = () => {
- setIsFilterPanelOpen(false)
- }
-
- // 행 액션 처리
- useEffect(() => {
- if (rowAction) {
- switch (rowAction.type) {
- case "select":
- // 객체 참조 안정화를 위해 필요한 필드만 추출
- const rfqData = rowAction.row.original;
- setSelectedRfq({
- id: rfqData.id,
- rfqCode: rfqData.rfqCode,
- rfqType: rfqData.rfqType, // 빠뜨린 rfqType 필드 추가
- biddingProjectId: rfqData.biddingProjectId,
- materialCode: rfqData.materialCode,
- dueDate: rfqData.dueDate,
- rfqSendDate: rfqData.rfqSendDate,
- status: rfqData.status,
- description: rfqData.description,
- remark: rfqData.remark,
- cancelReason: rfqData.cancelReason,
- createdAt: rfqData.createdAt,
- updatedAt: rfqData.updatedAt,
- createdBy: rfqData.createdBy,
- createdByName: rfqData.createdByName,
- updatedBy: rfqData.updatedBy,
- updatedByName: rfqData.updatedByName,
- sentBy: rfqData.sentBy,
- sentByName: rfqData.sentByName,
- pspid: rfqData.pspid,
- projNm: rfqData.projNm,
- sector: rfqData.sector,
- projMsrm: rfqData.projMsrm,
- ptypeNm: rfqData.ptypeNm,
- attachmentCount: rfqData.attachmentCount,
- quotationCount: rfqData.quotationCount,
- });
- break;
- case "view":
- // 프로젝트 상세정보 다이얼로그 열기
- const projectRfqData = rowAction.row.original;
- setProjectDetailRfq({
- id: projectRfqData.id,
- rfqCode: projectRfqData.rfqCode,
- rfqType: projectRfqData.rfqType, // 빠뜨린 rfqType 필드 추가
- biddingProjectId: projectRfqData.biddingProjectId,
- materialCode: projectRfqData.materialCode,
- dueDate: projectRfqData.dueDate,
- rfqSendDate: projectRfqData.rfqSendDate,
- status: projectRfqData.status,
- description: projectRfqData.description,
- remark: projectRfqData.remark,
- cancelReason: projectRfqData.cancelReason,
- createdAt: projectRfqData.createdAt,
- updatedAt: projectRfqData.updatedAt,
- createdBy: projectRfqData.createdBy,
- createdByName: projectRfqData.createdByName,
- updatedBy: projectRfqData.updatedBy,
- updatedByName: projectRfqData.updatedByName,
- sentBy: projectRfqData.sentBy,
- sentByName: projectRfqData.sentByName,
- pspid: projectRfqData.pspid,
- projNm: projectRfqData.projNm,
- sector: projectRfqData.sector,
- projMsrm: projectRfqData.projMsrm,
- ptypeNm: projectRfqData.ptypeNm,
- attachmentCount: projectRfqData.attachmentCount,
- quotationCount: projectRfqData.quotationCount,
- });
- setIsProjectDetailOpen(true);
- break;
- case "update":
- console.log("Update rfq:", rowAction.row.original)
- break;
- case "delete":
- console.log("Delete rfq:", rowAction.row.original)
- break;
- }
- setRowAction(null)
- }
- }, [rowAction])
-
- // 첨부파일 시트 상태에 타입 추가
- const [attachmentType, setAttachmentType] = React.useState<"RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT">("RFQ_COMMON")
-
- // 첨부파일 시트 열기 함수
- const openAttachmentsSheet = React.useCallback(async (rfqId: number, attachmentType: 'RFQ_COMMON' | 'TBE_RESULT' | 'CBE_RESULT' = 'RFQ_COMMON') => {
- try {
- // 선택된 RFQ 찾기
- const rfq = tableData?.data?.find(r => r.id === rfqId)
- if (!rfq) {
- toast.error("RFQ를 찾을 수 없습니다.")
- return
- }
-
- // attachmentType을 RFQ_COMMON, TBE_RESULT, CBE_RESULT 중 하나로 변환
- const validAttachmentType=attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT"
-
- // 실제 첨부파일 목록 조회 API 호출
- const result = await getTechSalesRfqAttachments(rfqId)
-
- if (result.error) {
- toast.error(result.error)
- return
- }
-
- // 해당 타입의 첨부파일만 필터링
- const filteredAttachments = result.data.filter(att => att.attachmentType === validAttachmentType)
-
- // API 응답을 ExistingTechSalesAttachment 형식으로 변환
- const attachments: ExistingTechSalesAttachment[] = filteredAttachments.map(att => ({
- id: att.id,
- techSalesRfqId: att.techSalesRfqId || rfqId, // null인 경우 rfqId 사용
- fileName: att.fileName,
- originalFileName: att.originalFileName,
- filePath: att.filePath,
- fileSize: att.fileSize || undefined,
- fileType: att.fileType || undefined,
- attachmentType: att.attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT",
- description: att.description || undefined,
- createdBy: att.createdBy,
- createdAt: att.createdAt,
- }))
-
- setAttachmentType(validAttachmentType)
- setAttachmentsDefault(attachments)
- setSelectedRfqForAttachments(rfq as unknown as TechSalesRfq)
- setAttachmentsOpen(true)
- } catch (error) {
- console.error("첨부파일 조회 오류:", error)
- toast.error("첨부파일 조회 중 오류가 발생했습니다.")
- }
- }, [tableData?.data])
-
- // 첨부파일 업데이트 콜백
- const handleAttachmentsUpdated = React.useCallback((rfqId: number, newAttachmentCount: number) => {
- // Service에서 이미 revalidateTag와 revalidatePath로 캐시 무효화 처리됨
- console.log(`RFQ ${rfqId}의 첨부파일 개수가 ${newAttachmentCount}개로 업데이트됨`)
-
- // 성공 피드백
- setTimeout(() => {
- toast.success(`첨부파일 개수가 업데이트되었습니다. (${newAttachmentCount}개)`, {
- duration: 3000
- })
- }, 500)
- }, [])
-
- // 아이템 다이얼로그 열기 함수
- const openItemsDialog = React.useCallback((rfq: TechSalesRfq) => {
- console.log("Opening items dialog for RFQ:", rfq.id, rfq)
- setSelectedRfqForItems(rfq as unknown as TechSalesRfq)
- setItemsDialogOpen(true)
- }, [])
-
- const columns = React.useMemo(
- () => getColumns({
- setRowAction,
- openAttachmentsSheet,
- openItemsDialog
- }),
- [openAttachmentsSheet, openItemsDialog]
- )
-
- // 고급 필터 필드 정의
- const advancedFilterFields: DataTableAdvancedFilterField<TechSalesRfq>[] = [
- {
- id: "rfqCode",
- label: "RFQ No.",
- type: "text",
- },
- {
- id: "description",
- label: "설명",
- type: "text",
- },
- {
- id: "projNm",
- label: "프로젝트명",
- type: "text",
- },
- {
- id: "rfqSendDate",
- label: "RFQ 전송일",
- type: "date",
- },
- {
- id: "dueDate",
- label: "RFQ 마감일",
- type: "date",
- },
- {
- id: "createdByName",
- label: "요청자",
- type: "text",
- },
- {
- id: "status",
- label: "상태",
- type: "text",
- },
- ]
-
- // 현재 설정 가져오기
- const currentSettings = useMemo(() => {
- return getCurrentSettings()
- }, [getCurrentSettings])
-
- // useDataTable 초기 상태 설정
- const initialState = useMemo(() => {
- return {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- sorting: initialSettings.sort.filter((sortItem: any) => {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const columnExists = columns.some((col: any) => col.accessorKey === sortItem.id)
- return columnExists
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- }) as any,
- columnVisibility: currentSettings.columnVisibility,
- columnPinning: currentSettings.pinnedColumns,
- }
- }, [currentSettings, initialSettings.sort, columns])
-
- // useDataTable 훅 설정
- const { table } = useDataTable({
- data: tableData?.data || [],
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- columns: columns as any,
- pageCount: tableData?.pageCount || 0,
- rowCount: tableData?.total || 0,
- filterFields: [],
- enablePinning: true,
- enableAdvancedFilter: true,
- initialState,
- getRowId: (originalRow) => String(originalRow.id),
- shallow: false,
- clearOnDefault: true,
- columnResizeMode: "onEnd",
- })
-
- // Get active basic filter count
- const getActiveBasicFilterCount = () => {
- try {
- const basicFilters = searchParams?.get('basicFilters')
- return basicFilters ? JSON.parse(basicFilters).length : 0
- } catch {
- return 0
- }
- }
-
- console.log(panelHeight)
-
- return (
- <div
- className={cn("flex flex-col relative", className)}
- style={{ height: calculatedHeight }}
- >
- {/* Filter Panel - 계산된 높이 적용 */}
- <div
- className={cn(
- "fixed left-0 bg-background border-r z-30 flex flex-col transition-all duration-300 ease-in-out overflow-hidden",
- isFilterPanelOpen ? "border-r shadow-lg" : "border-r-0"
- )}
- style={{
- width: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px',
- top: `${LAYOUT_HEADER_HEIGHT*2}px`,
- height: FIXED_FILTER_HEIGHT
- }}
- >
- {/* Filter Content */}
- <div className="h-full">
- <RFQFilterSheet
- isOpen={isFilterPanelOpen}
- onClose={() => setIsFilterPanelOpen(false)}
- onSearch={handleSearch}
- isLoading={false}
- />
- </div>
- </div>
-
- {/* Main Content */}
- <div
- className="flex flex-col transition-all duration-300 ease-in-out"
- style={{
- width: isFilterPanelOpen ? `calc(100% - ${FILTER_PANEL_WIDTH}px)` : '100%',
- marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px',
- height: '100%'
- }}
- >
- {/* Header Bar - 고정 높이 */}
- <div
- className="flex items-center justify-between p-4 bg-background border-b"
- style={{
- height: `${LOCAL_HEADER_HEIGHT}px`,
- flexShrink: 0
- }}
- >
- <div className="flex items-center gap-3">
- <Button
- variant="outline"
- size="sm"
- type='button'
- onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)}
- className="flex items-center shadow-sm"
- >
- {isFilterPanelOpen ? <PanelLeftClose className="size-4"/> : <PanelLeftOpen className="size-4"/>}
- {getActiveBasicFilterCount() > 0 && (
- <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs">
- {getActiveBasicFilterCount()}
- </span>
- )}
- </Button>
- </div>
-
- {/* Right side info */}
- <div className="text-sm text-muted-foreground">
- {tableData && (
- <span>총 {tableData.total || 0}건</span>
- )}
- </div>
- </div>
-
- {/* Table Content Area - 계산된 높이 사용 */}
- <div
- className="relative bg-background"
- style={{
- height: FIXED_TABLE_HEIGHT,
- display: 'grid',
- gridTemplateRows: '1fr',
- gridTemplateColumns: '1fr'
- }}
- >
- <ResizablePanelGroup
- direction="vertical"
- className="w-full h-full"
- >
- <ResizablePanel
- defaultSize={60}
- minSize={25}
- maxSize={75}
- collapsible={false}
- onResize={(size) => {
- setPanelHeight(size)
- }}
- className="flex flex-col overflow-hidden"
- >
- {/* 상단 테이블 영역 */}
- <div className="flex-1 min-h-0 overflow-hidden">
- <DataTable
- table={table}
- maxHeight={`${panelHeight*0.5}vh`}
- >
- <DataTableAdvancedToolbar
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- table={table as any}
- filterFields={advancedFilterFields}
- shallow={false}
- >
- <div className="flex items-center gap-2">
- {/* 아직 개발/테스트 전이라서 주석처리함. 지우지 말 것! 미사용 린터 에러는 무시. */}
- {/* <TablePresetManager<TechSalesRfq>
- presets={presets}
- activePresetId={activePresetId}
- currentSettings={currentSettings}
- hasUnsavedChanges={hasUnsavedChanges}
- isLoading={presetsLoading}
- onCreatePreset={createPreset}
- onUpdatePreset={updatePreset}
- onDeletePreset={deletePreset}
- onApplyPreset={applyPreset}
- onSetDefaultPreset={setDefaultPreset}
- onRenamePreset={renamePreset}
- /> */}
-
- <RFQTableToolbarActions
- selection={table}
- onRefresh={() => {}}
- rfqType={rfqType}
- />
- </div>
- </DataTableAdvancedToolbar>
- </DataTable>
- </div>
- </ResizablePanel>
-
- <ResizableHandle withHandle />
-
- <ResizablePanel
- minSize={25}
- defaultSize={40}
- collapsible={false}
- className="flex flex-col overflow-hidden"
- >
- {/* 하단 상세 테이블 영역 */}
- <div className="flex-1 min-h-0 overflow-hidden bg-background">
- <RfqDetailTables selectedRfq={selectedRfq} maxHeight={`${(100-panelHeight)*0.4}vh`}/>
- </div>
- </ResizablePanel>
- </ResizablePanelGroup>
- </div>
- </div>
-
- {/* 프로젝트 상세정보 다이얼로그 */}
- <ProjectDetailDialog
- open={isProjectDetailOpen}
- onOpenChange={setIsProjectDetailOpen}
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- selectedRfq={projectDetailRfq as any}
- />
-
- {/* 첨부파일 관리 시트 */}
- <TechSalesRfqAttachmentsSheet
- open={attachmentsOpen}
- onOpenChange={setAttachmentsOpen}
- defaultAttachments={attachmentsDefault}
- rfq={selectedRfqForAttachments}
- attachmentType={attachmentType}
- onAttachmentsUpdated={handleAttachmentsUpdated}
- />
-
- {/* 아이템 보기 다이얼로그 */}
- <RfqItemsViewDialog
- open={itemsDialogOpen}
- onOpenChange={setItemsDialogOpen}
- rfq={selectedRfqForItems}
- />
- </div>
- )
+"use client"
+
+import * as React from "react"
+import { useSearchParams } from "next/navigation"
+import { Button } from "@/components/ui/button"
+import { PanelLeftClose, PanelLeftOpen } from "lucide-react"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+import {
+ ResizablePanelGroup,
+ ResizablePanel,
+ ResizableHandle,
+} from "@/components/ui/resizable"
+
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { getColumns } from "./rfq-table-column"
+import { useEffect, useMemo } from "react"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import { RFQTableToolbarActions } from "./rfq-table-toolbar-actions"
+import { getTechSalesRfqsWithJoin, getTechSalesRfqAttachments } from "@/lib/techsales-rfq/service"
+import { toast } from "sonner"
+import { useTablePresets } from "@/components/data-table/use-table-presets"
+import { RfqDetailTables } from "./detail-table/rfq-detail-table"
+import { cn } from "@/lib/utils"
+import { ProjectDetailDialog } from "./project-detail-dialog"
+import { RFQFilterSheet } from "./rfq-filter-sheet"
+import { TechSalesRfqAttachmentsSheet, ExistingTechSalesAttachment } from "./tech-sales-rfq-attachments-sheet"
+import { RfqItemsViewDialog } from "./rfq-items-view-dialog"
+// 기본적인 RFQ 타입 정의 (repository selectTechSalesRfqsWithJoin 반환 타입에 맞춤)
+export interface TechSalesRfq {
+ id: number
+ rfqCode: string | null
+ biddingProjectId: number | null
+ materialCode: string | null
+ dueDate: Date
+ rfqSendDate: Date | null
+ status: "RFQ Created" | "RFQ Vendor Assignned" | "RFQ Sent" | "Quotation Analysis" | "Closed"
+ description: string | null
+ picCode: string | null
+ remark: string | null
+ cancelReason: string | null
+ createdAt: Date
+ updatedAt: Date
+ createdBy: number | null
+ createdByName: string
+ updatedBy: number | null
+ updatedByName: string
+ sentBy: number | null
+ sentByName: string | null
+ // 조인된 프로젝트 정보
+ pspid: string
+ projNm: string
+ sector: string
+ projMsrm: number
+ ptypeNm: string
+ attachmentCount: number
+ hasTbeAttachments: boolean
+ hasCbeAttachments: boolean
+ quotationCount: number
+ rfqType: "SHIP" | "TOP" | "HULL" | null
+ itemCount: number
+ workTypes: string | null
+ // 필요에 따라 다른 필드들 추가
+ [key: string]: unknown
+}
+
+interface RFQListTableProps {
+ promises: Promise<[Awaited<ReturnType<typeof getTechSalesRfqsWithJoin>>]>
+ className?: string;
+ calculatedHeight?: string; // 계산된 높이 추가
+ rfqType: "SHIP" | "TOP" | "HULL";
+}
+
+export function RFQListTable({
+ promises,
+ className,
+ calculatedHeight,
+ rfqType
+}: RFQListTableProps) {
+ const searchParams = useSearchParams()
+
+ // 필터 패널 상태
+ const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false)
+
+ // 선택된 RFQ 상태
+ const [selectedRfq, setSelectedRfq] = React.useState<TechSalesRfq | null>(null)
+
+ // 프로젝트 상세정보 다이얼로그 상태
+ const [isProjectDetailOpen, setIsProjectDetailOpen] = React.useState(false)
+ const [projectDetailRfq, setProjectDetailRfq] = React.useState<TechSalesRfq | null>(null)
+
+ // 첨부파일 시트 상태
+ const [attachmentsOpen, setAttachmentsOpen] = React.useState(false)
+ const [selectedRfqForAttachments, setSelectedRfqForAttachments] = React.useState<TechSalesRfq | null>(null)
+ const [attachmentsDefault, setAttachmentsDefault] = React.useState<ExistingTechSalesAttachment[]>([])
+
+ // 아이템 다이얼로그 상태
+ const [itemsDialogOpen, setItemsDialogOpen] = React.useState(false)
+ const [selectedRfqForItems, setSelectedRfqForItems] = React.useState<TechSalesRfq | null>(null)
+
+ // 패널 collapse 상태
+ const [panelHeight, setPanelHeight] = React.useState<number>(55)
+
+ // 고정 높이 설정을 위한 상수 (실제 측정값으로 조정 필요)
+ const LAYOUT_HEADER_HEIGHT = 64 // Layout Header 높이
+ const LAYOUT_FOOTER_HEIGHT = 60 // Layout Footer 높이 (있다면 실제 값)
+ const LOCAL_HEADER_HEIGHT = 72 // 로컬 헤더 바 높이 (p-4 + border)
+ const FILTER_PANEL_WIDTH = 400 // 필터 패널 너비
+
+ // 높이 계산
+ // 필터 패널 높이 - Layout Header와 Footer 사이
+ const FIXED_FILTER_HEIGHT = `calc(100vh - ${LAYOUT_HEADER_HEIGHT*2}px)`
+
+ console.log(calculatedHeight)
+
+ // 테이블 컨텐츠 높이 - 전달받은 높이에서 로컬 헤더 제외
+ const FIXED_TABLE_HEIGHT = calculatedHeight
+ ? `calc(${calculatedHeight} - ${LOCAL_HEADER_HEIGHT}px)`
+ : `calc(100vh - ${LAYOUT_HEADER_HEIGHT + LAYOUT_FOOTER_HEIGHT + LOCAL_HEADER_HEIGHT+76}px)` // fallback
+
+ // Suspense 방식으로 데이터 처리
+ const [promiseData] = React.use(promises)
+ const tableData = promiseData
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<TechSalesRfq> | null>(null)
+
+ // 초기 설정 정의
+ const initialSettings = React.useMemo(() => ({
+ page: parseInt(searchParams?.get('page') || '1'),
+ perPage: parseInt(searchParams?.get('perPage') || '10'),
+ sort: searchParams?.get('sort') ? JSON.parse(searchParams.get('sort')!) : [{ id: "updatedAt", desc: true }],
+ filters: searchParams?.get('filters') ? JSON.parse(searchParams.get('filters')!) : [],
+ joinOperator: (searchParams?.get('joinOperator') as "and" | "or") || "and",
+ basicFilters: searchParams?.get('basicFilters') ? JSON.parse(searchParams.get('basicFilters')!) : [],
+ basicJoinOperator: (searchParams?.get('basicJoinOperator') as "and" | "or") || "and",
+ search: searchParams?.get('search') || '',
+ from: searchParams?.get('from') || undefined,
+ to: searchParams?.get('to') || undefined,
+ columnVisibility: {},
+ columnOrder: [],
+ pinnedColumns: { left: [], right: ["items", "attachments", "tbe-attachments", "cbe-attachments"] },
+ groupBy: [],
+ expandedRows: []
+ }), [searchParams])
+
+ // DB 기반 프리셋 훅 사용
+ const {
+ // presets,
+ // activePresetId,
+ // hasUnsavedChanges,
+ // isLoading: presetsLoading,
+ // createPreset,
+ // applyPreset,
+ // updatePreset,
+ // deletePreset,
+ // setDefaultPreset,
+ // renamePreset,
+ getCurrentSettings,
+ } = useTablePresets<TechSalesRfq>('rfq-list-table', initialSettings)
+
+ // 조회 버튼 클릭 핸들러
+ const handleSearch = () => {
+ setIsFilterPanelOpen(false)
+ }
+
+ // 행 액션 처리
+ useEffect(() => {
+ if (rowAction) {
+ switch (rowAction.type) {
+ case "select":
+ // 객체 참조 안정화를 위해 필요한 필드만 추출
+ const rfqData = rowAction.row.original;
+ setSelectedRfq({
+ id: rfqData.id,
+ rfqCode: rfqData.rfqCode,
+ rfqType: rfqData.rfqType, // 빠뜨린 rfqType 필드 추가
+ biddingProjectId: rfqData.biddingProjectId,
+ hasTbeAttachments: rfqData.hasTbeAttachments,
+ hasCbeAttachments: rfqData.hasCbeAttachments,
+ materialCode: rfqData.materialCode,
+ dueDate: rfqData.dueDate,
+ rfqSendDate: rfqData.rfqSendDate,
+ status: rfqData.status,
+ description: rfqData.description,
+ picCode: rfqData.picCode,
+ remark: rfqData.remark,
+ cancelReason: rfqData.cancelReason,
+ createdAt: rfqData.createdAt,
+ updatedAt: rfqData.updatedAt,
+ createdBy: rfqData.createdBy,
+ createdByName: rfqData.createdByName,
+ updatedBy: rfqData.updatedBy,
+ updatedByName: rfqData.updatedByName,
+ sentBy: rfqData.sentBy,
+ sentByName: rfqData.sentByName,
+ pspid: rfqData.pspid,
+ projNm: rfqData.projNm,
+ sector: rfqData.sector,
+ projMsrm: rfqData.projMsrm,
+ ptypeNm: rfqData.ptypeNm,
+ attachmentCount: rfqData.attachmentCount,
+ quotationCount: rfqData.quotationCount,
+ itemCount: rfqData.itemCount,
+ workTypes: rfqData.workTypes,
+ });
+ break;
+ case "view":
+ // 프로젝트 상세정보 다이얼로그 열기
+ const projectRfqData = rowAction.row.original;
+ setProjectDetailRfq({
+ id: projectRfqData.id,
+ rfqCode: projectRfqData.rfqCode,
+ rfqType: projectRfqData.rfqType, // 빠뜨린 rfqType 필드 추가
+ biddingProjectId: projectRfqData.biddingProjectId,
+ hasTbeAttachments: projectRfqData.hasTbeAttachments,
+ hasCbeAttachments: projectRfqData.hasCbeAttachments,
+ materialCode: projectRfqData.materialCode,
+ dueDate: projectRfqData.dueDate,
+ rfqSendDate: projectRfqData.rfqSendDate,
+ status: projectRfqData.status,
+ description: projectRfqData.description,
+ picCode: projectRfqData.picCode,
+ remark: projectRfqData.remark,
+ cancelReason: projectRfqData.cancelReason,
+ createdAt: projectRfqData.createdAt,
+ updatedAt: projectRfqData.updatedAt,
+ createdBy: projectRfqData.createdBy,
+ createdByName: projectRfqData.createdByName,
+ updatedBy: projectRfqData.updatedBy,
+ updatedByName: projectRfqData.updatedByName,
+ sentBy: projectRfqData.sentBy,
+ sentByName: projectRfqData.sentByName,
+ pspid: projectRfqData.pspid,
+ projNm: projectRfqData.projNm,
+ sector: projectRfqData.sector,
+ projMsrm: projectRfqData.projMsrm,
+ ptypeNm: projectRfqData.ptypeNm,
+ attachmentCount: projectRfqData.attachmentCount,
+ quotationCount: projectRfqData.quotationCount,
+ itemCount: projectRfqData.itemCount,
+ workTypes: projectRfqData.workTypes,
+ });
+ setIsProjectDetailOpen(true);
+ break;
+ case "update":
+ console.log("Update rfq:", rowAction.row.original)
+ break;
+ case "delete":
+ console.log("Delete rfq:", rowAction.row.original)
+ break;
+ }
+ setRowAction(null)
+ }
+ }, [rowAction])
+
+ // 첨부파일 시트 상태에 타입 추가
+ const [attachmentType, setAttachmentType] = React.useState<"RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT">("RFQ_COMMON")
+
+ // 첨부파일 시트 열기 함수
+ const openAttachmentsSheet = React.useCallback(async (rfqId: number, attachmentType: 'RFQ_COMMON' | 'TBE_RESULT' | 'CBE_RESULT' = 'RFQ_COMMON') => {
+ try {
+ // 선택된 RFQ 찾기
+ const rfq = tableData?.data?.find(r => r.id === rfqId)
+ if (!rfq) {
+ toast.error("RFQ를 찾을 수 없습니다.")
+ return
+ }
+
+ // attachmentType을 RFQ_COMMON, TBE_RESULT, CBE_RESULT 중 하나로 변환
+ const validAttachmentType=attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT"
+
+ // 실제 첨부파일 목록 조회 API 호출
+ const result = await getTechSalesRfqAttachments(rfqId)
+
+ if (result.error) {
+ toast.error(result.error)
+ return
+ }
+
+ // 해당 타입의 첨부파일만 필터링
+ const filteredAttachments = result.data.filter(att => att.attachmentType === validAttachmentType)
+
+ // API 응답을 ExistingTechSalesAttachment 형식으로 변환
+ const attachments: ExistingTechSalesAttachment[] = filteredAttachments.map(att => ({
+ id: att.id,
+ techSalesRfqId: att.techSalesRfqId || rfqId, // null인 경우 rfqId 사용
+ fileName: att.fileName,
+ originalFileName: att.originalFileName,
+ filePath: att.filePath,
+ fileSize: att.fileSize || undefined,
+ fileType: att.fileType || undefined,
+ attachmentType: att.attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT",
+ description: att.description || undefined,
+ createdBy: att.createdBy,
+ createdAt: att.createdAt,
+ }))
+
+ setAttachmentType(validAttachmentType)
+ setAttachmentsDefault(attachments)
+ setSelectedRfqForAttachments(rfq as unknown as TechSalesRfq)
+ setAttachmentsOpen(true)
+ } catch (error) {
+ console.error("첨부파일 조회 오류:", error)
+ toast.error("첨부파일 조회 중 오류가 발생했습니다.")
+ }
+ }, [tableData?.data])
+
+ // // 첨부파일 업데이트 콜백
+ // const handleAttachmentsUpdated = React.useCallback((rfqId: number, newAttachmentCount: number) => {
+ // // Service에서 이미 revalidateTag와 revalidatePath로 캐시 무효화 처리됨
+ // console.log(`RFQ ${rfqId}의 첨부파일 개수가 ${newAttachmentCount}개로 업데이트됨`)
+
+ // // 성공 피드백
+ // setTimeout(() => {
+ // toast.success(`첨부파일 개수가 업데이트되었습니다. (${newAttachmentCount}개)`, {
+ // duration: 3000
+ // })
+ // }, 500)
+ // }, [])
+
+ // 아이템 다이얼로그 열기 함수
+ const openItemsDialog = React.useCallback((rfq: TechSalesRfq) => {
+ console.log("Opening items dialog for RFQ:", rfq.id, rfq)
+ setSelectedRfqForItems(rfq)
+ setItemsDialogOpen(true)
+ }, [])
+
+ const columns = React.useMemo(
+ () => getColumns({
+ setRowAction,
+ openAttachmentsSheet,
+ openItemsDialog
+ }),
+ [openAttachmentsSheet, openItemsDialog, setRowAction]
+ )
+
+ // 고급 필터 필드 정의
+ const advancedFilterFields: DataTableAdvancedFilterField<TechSalesRfq>[] = [
+ {
+ id: "rfqCode",
+ label: "RFQ No.",
+ type: "text",
+ },
+ {
+ id: "description",
+ label: "설명",
+ type: "text",
+ },
+ {
+ id: "projNm",
+ label: "프로젝트명",
+ type: "text",
+ },
+ {
+ id: "rfqSendDate",
+ label: "RFQ 전송일",
+ type: "date",
+ },
+ {
+ id: "dueDate",
+ label: "RFQ 마감일",
+ type: "date",
+ },
+ {
+ id: "createdByName",
+ label: "요청자",
+ type: "text",
+ },
+ {
+ id: "status",
+ label: "상태",
+ type: "text",
+ },
+ {
+ id: "workTypes",
+ label: "Work Type",
+ type: "multi-select",
+ options: [
+ // 조선 workTypes
+ { label: "기장", value: "기장" },
+ { label: "전장", value: "전장" },
+ { label: "선실", value: "선실" },
+ { label: "배관", value: "배관" },
+ { label: "철의", value: "철의" },
+ { label: "선체", value: "선체" },
+ // 해양TOP workTypes
+ { label: "TM", value: "TM" },
+ { label: "TS", value: "TS" },
+ { label: "TE", value: "TE" },
+ { label: "TP", value: "TP" },
+ // 해양HULL workTypes
+ { label: "HA", value: "HA" },
+ { label: "HE", value: "HE" },
+ { label: "HH", value: "HH" },
+ { label: "HM", value: "HM" },
+ { label: "NC", value: "NC" },
+ { label: "HO", value: "HO" },
+ { label: "HP", value: "HP" },
+ ],
+ },
+ ]
+
+ // 현재 설정 가져오기
+ const currentSettings = useMemo(() => {
+ return getCurrentSettings()
+ }, [getCurrentSettings])
+
+ // useDataTable 초기 상태 설정
+ const initialState = useMemo(() => {
+ return {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ sorting: initialSettings.sort.filter((sortItem: any) => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const columnExists = columns.some((col: any) => col.accessorKey === sortItem.id)
+ return columnExists
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ }) as any,
+ columnVisibility: currentSettings.columnVisibility,
+ columnPinning: currentSettings.pinnedColumns,
+ }
+ }, [currentSettings, initialSettings.sort, columns])
+
+ // useDataTable 훅 설정
+ const { table } = useDataTable({
+ data: tableData?.data || [],
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ columns: columns as any,
+ pageCount: tableData?.pageCount || 0,
+ rowCount: tableData?.total || 0,
+ filterFields: [],
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState,
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ columnResizeMode: "onEnd",
+ })
+
+ // Get active basic filter count
+ const getActiveBasicFilterCount = () => {
+ try {
+ const basicFilters = searchParams?.get('basicFilters')
+ return basicFilters ? JSON.parse(basicFilters).length : 0
+ } catch {
+ return 0
+ }
+ }
+
+ console.log(panelHeight)
+
+ return (
+ <div
+ className={cn("flex flex-col relative", className)}
+ style={{ height: calculatedHeight }}
+ >
+ {/* Filter Panel - 계산된 높이 적용 */}
+ <div
+ className={cn(
+ "fixed left-0 bg-background border-r z-30 flex flex-col transition-all duration-300 ease-in-out overflow-hidden",
+ isFilterPanelOpen ? "border-r shadow-lg" : "border-r-0"
+ )}
+ style={{
+ width: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px',
+ top: `${LAYOUT_HEADER_HEIGHT*2}px`,
+ height: FIXED_FILTER_HEIGHT
+ }}
+ >
+ {/* Filter Content */}
+ <div className="h-full">
+ <RFQFilterSheet
+ isOpen={isFilterPanelOpen}
+ onClose={() => setIsFilterPanelOpen(false)}
+ onSearch={handleSearch}
+ isLoading={false}
+ />
+ </div>
+ </div>
+
+ {/* Main Content */}
+ <div
+ className="flex flex-col transition-all duration-300 ease-in-out"
+ style={{
+ width: isFilterPanelOpen ? `calc(100% - ${FILTER_PANEL_WIDTH}px)` : '100%',
+ marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px',
+ height: '100%'
+ }}
+ >
+ {/* Header Bar - 고정 높이 */}
+ <div
+ className="flex items-center justify-between p-4 bg-background border-b"
+ style={{
+ height: `${LOCAL_HEADER_HEIGHT}px`,
+ flexShrink: 0
+ }}
+ >
+ <div className="flex items-center gap-3">
+ <Button
+ variant="outline"
+ size="sm"
+ type='button'
+ onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)}
+ className="flex items-center shadow-sm"
+ >
+ {isFilterPanelOpen ? <PanelLeftClose className="size-4"/> : <PanelLeftOpen className="size-4"/>}
+ {getActiveBasicFilterCount() > 0 && (
+ <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs">
+ {getActiveBasicFilterCount()}
+ </span>
+ )}
+ </Button>
+ </div>
+
+ {/* Right side info */}
+ <div className="text-sm text-muted-foreground">
+ {tableData && (
+ <span>총 {tableData.total || 0}건</span>
+ )}
+ </div>
+ </div>
+
+ {/* Table Content Area - 계산된 높이 사용 */}
+ <div
+ className="relative bg-background"
+ style={{
+ height: FIXED_TABLE_HEIGHT,
+ display: 'grid',
+ gridTemplateRows: '1fr',
+ gridTemplateColumns: '1fr'
+ }}
+ >
+ <ResizablePanelGroup
+ direction="vertical"
+ className="w-full h-full"
+ >
+ <ResizablePanel
+ defaultSize={60}
+ minSize={25}
+ maxSize={75}
+ collapsible={false}
+ onResize={(size) => {
+ setPanelHeight(size)
+ }}
+ className="flex flex-col overflow-hidden"
+ >
+ {/* 상단 테이블 영역 */}
+ <div className="flex-1 min-h-0 overflow-hidden">
+ <DataTable
+ table={table}
+ maxHeight={`${panelHeight*0.5}vh`}
+ >
+ <DataTableAdvancedToolbar
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ table={table as any}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <div className="flex items-center gap-2">
+ {/* 아직 개발/테스트 전이라서 주석처리함. 지우지 말 것! 미사용 린터 에러는 무시. */}
+ {/* <TablePresetManager<TechSalesRfq>
+ presets={presets}
+ activePresetId={activePresetId}
+ currentSettings={currentSettings}
+ hasUnsavedChanges={hasUnsavedChanges}
+ isLoading={presetsLoading}
+ onCreatePreset={createPreset}
+ onUpdatePreset={updatePreset}
+ onDeletePreset={deletePreset}
+ onApplyPreset={applyPreset}
+ onSetDefaultPreset={setDefaultPreset}
+ onRenamePreset={renamePreset}
+ /> */}
+
+ <RFQTableToolbarActions
+ selection={table}
+ onRefresh={() => {}}
+ rfqType={rfqType}
+ />
+ </div>
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ </div>
+ </ResizablePanel>
+
+ <ResizableHandle withHandle />
+
+ <ResizablePanel
+ minSize={25}
+ defaultSize={40}
+ collapsible={false}
+ className="flex flex-col overflow-hidden"
+ >
+ {/* 하단 상세 테이블 영역 */}
+ <div className="flex-1 min-h-0 overflow-hidden bg-background">
+ <RfqDetailTables selectedRfq={selectedRfq} maxHeight={`${(100-panelHeight)*0.4}vh`}/>
+ </div>
+ </ResizablePanel>
+ </ResizablePanelGroup>
+ </div>
+ </div>
+
+ {/* 프로젝트 상세정보 다이얼로그 */}
+ <ProjectDetailDialog
+ open={isProjectDetailOpen}
+ onOpenChange={setIsProjectDetailOpen}
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ selectedRfq={projectDetailRfq as any}
+ />
+
+ {/* 첨부파일 관리 시트 */}
+ <TechSalesRfqAttachmentsSheet
+ open={attachmentsOpen}
+ onOpenChange={setAttachmentsOpen}
+ defaultAttachments={attachmentsDefault}
+ rfq={selectedRfqForAttachments}
+ attachmentType={attachmentType}
+ />
+
+ {/* 아이템 보기 다이얼로그 */}
+ <RfqItemsViewDialog
+ open={itemsDialogOpen}
+ onOpenChange={setItemsDialogOpen}
+ rfq={selectedRfqForItems ? {
+ id: selectedRfqForItems.id,
+ rfqCode: selectedRfqForItems.rfqCode,
+ status: selectedRfqForItems.status,
+ description: selectedRfqForItems.description || undefined,
+ rfqType: selectedRfqForItems.rfqType
+ } : null}
+ />
+ </div>
+ )
} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/tech-sales-quotation-attachments-sheet.tsx b/lib/techsales-rfq/table/tech-sales-quotation-attachments-sheet.tsx
index 08363535..a03839c1 100644
--- a/lib/techsales-rfq/table/tech-sales-quotation-attachments-sheet.tsx
+++ b/lib/techsales-rfq/table/tech-sales-quotation-attachments-sheet.tsx
@@ -14,7 +14,6 @@ import { Badge } from "@/components/ui/badge"
import { Separator } from "@/components/ui/separator"
import { formatDate } from "@/lib/utils"
import prettyBytes from "pretty-bytes"
-import { downloadFile } from "@/lib/file-download"
// 견적서 첨부파일 타입 정의
export interface QuotationAttachment {
@@ -80,20 +79,26 @@ export function TechSalesQuotationAttachmentsSheet({
// 기본 파일
return <File className="h-5 w-5 text-gray-500" />;
};
-
- // 파일 다운로드 처리
- const handleDownload = (attachment: QuotationAttachment) => {
- downloadFile(attachment.filePath, attachment.originalFileName || attachment.fileName)
- /*
- const link = document.createElement('a');
- link.href = attachment.filePath;
- link.download = attachment.originalFileName || attachment.fileName;
- link.target = '_blank';
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
- */
- };
+
+ // 파일 다운로드 핸들러
+ const handleDownloadClick = React.useCallback(async (attachment: QuotationAttachment) => {
+ try {
+ const { downloadFile } = await import('@/lib/file-download');
+ await downloadFile(attachment.filePath, attachment.originalFileName || attachment.fileName, {
+ showToast: true,
+ onError: (error) => {
+ console.error('다운로드 오류:', error);
+ // TODO: toast 에러 메시지 추가 (sonner import 필요)
+ },
+ onSuccess: (fileName, fileSize) => {
+ console.log(`다운로드 성공: ${fileName} (${fileSize} bytes)`);
+ }
+ });
+ } catch (error) {
+ console.error('다운로드 오류:', error);
+ // TODO: toast 에러 메시지 추가 (sonner import 필요)
+ }
+ }, []);
// 리비전별로 첨부파일 그룹핑
const groupedAttachments = React.useMemo(() => {
@@ -176,7 +181,7 @@ export function TechSalesQuotationAttachmentsSheet({
className="flex items-start gap-3 p-3 border rounded-lg hover:bg-muted/50 transition-colors ml-4"
>
<div className="mt-1">
- {getFileIcon(attachment.fileName)}
+ {getFileIcon(attachment.originalFileName || attachment.fileName)}
</div>
<div className="flex-1 min-w-0">
@@ -211,7 +216,7 @@ export function TechSalesQuotationAttachmentsSheet({
variant="ghost"
size="icon"
className="h-8 w-8"
- onClick={() => handleDownload(attachment)}
+ onClick={() => handleDownloadClick(attachment)}
title="다운로드"
>
<Download className="h-4 w-4" />
diff --git a/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx b/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx
index fccedf0a..f2ae1084 100644
--- a/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx
+++ b/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx
@@ -1,550 +1,570 @@
-"use client"
-
-import * as React from "react"
-import { z } from "zod"
-import { useForm, useFieldArray } from "react-hook-form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { cn } from "@/lib/utils"
-import {
- Sheet,
- SheetContent,
- SheetHeader,
- SheetTitle,
- SheetDescription,
- SheetFooter,
- SheetClose,
-} from "@/components/ui/sheet"
-import { Button } from "@/components/ui/button"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
- FormDescription
-} from "@/components/ui/form"
-import { Loader, Download, X, Eye, AlertCircle } from "lucide-react"
-import { toast } from "sonner"
-import { Badge } from "@/components/ui/badge"
-
-import {
- Dropzone,
- DropzoneDescription,
- DropzoneInput,
- DropzoneTitle,
- DropzoneUploadIcon,
- DropzoneZone,
-} from "@/components/ui/dropzone"
-import {
- FileList,
- FileListAction,
- FileListDescription,
- FileListHeader,
- FileListIcon,
- FileListInfo,
- FileListItem,
- FileListName,
-} from "@/components/ui/file-list"
-
-import prettyBytes from "pretty-bytes"
-import { formatDate } from "@/lib/utils"
-import { processTechSalesRfqAttachments } from "@/lib/techsales-rfq/service"
-import { useSession } from "next-auth/react"
-import { downloadFile } from "@/lib/file-download"
-
-const MAX_FILE_SIZE = 6e8 // 600MB
-
-/** 기존 첨부 파일 정보 (techSalesAttachments 테이블 구조) */
-export interface ExistingTechSalesAttachment {
- id: number
- techSalesRfqId: number
- fileName: string
- originalFileName: string
- filePath: string
- fileSize?: number
- fileType?: string
- attachmentType: "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT"
- description?: string
- createdBy: number
- createdAt: Date
-}
-
-/** 새로 업로드할 파일 */
-const newUploadSchema = z.object({
- fileObj: z.any().optional(), // 실제 File
- attachmentType: z.enum(["RFQ_COMMON", "TBE_RESULT", "CBE_RESULT"]).default("RFQ_COMMON"),
- description: z.string().optional(),
-})
-
-/** 기존 첨부 (react-hook-form에서 관리) */
-const existingAttachSchema = z.object({
- id: z.number(),
- techSalesRfqId: z.number(),
- fileName: z.string(),
- originalFileName: z.string(),
- filePath: z.string(),
- fileSize: z.number().optional(),
- fileType: z.string().optional(),
- attachmentType: z.enum(["RFQ_COMMON", "TBE_RESULT", "CBE_RESULT"]),
- description: z.string().optional(),
- createdBy: z.number(),
- createdAt: z.custom<Date>(),
-})
-
-/** RHF 폼 전체 스키마 */
-const attachmentsFormSchema = z.object({
- techSalesRfqId: z.number().int(),
- existing: z.array(existingAttachSchema),
- newUploads: z.array(newUploadSchema),
-})
-
-type AttachmentsFormValues = z.infer<typeof attachmentsFormSchema>
-
-// TechSalesRfq 타입 (간단 버전)
-interface TechSalesRfq {
- id: number
- rfqCode: string | null
- status: string
- // 필요한 다른 필드들...
-}
-
-interface TechSalesRfqAttachmentsSheetProps
- extends React.ComponentPropsWithRef<typeof Sheet> {
- defaultAttachments?: ExistingTechSalesAttachment[]
- rfq: TechSalesRfq | null
- /** 첨부파일 타입 */
- attachmentType?: "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT"
- /** 읽기 전용 모드 (벤더용) */
- readOnly?: boolean
- /** 업로드/삭제 후 상위 테이블에 attachmentCount 등을 업데이트하기 위한 콜백 */
- // onAttachmentsUpdated?: (rfqId: number, newAttachmentCount: number) => void
-
-}
-
-export function TechSalesRfqAttachmentsSheet({
- defaultAttachments = [],
- // onAttachmentsUpdated,
- rfq,
- attachmentType = "RFQ_COMMON",
- readOnly = false,
- ...props
-}: TechSalesRfqAttachmentsSheetProps) {
- const [isPending, setIsPending] = React.useState(false)
- const session = useSession()
-
- // 첨부파일 타입별 제목과 설명 설정
- const attachmentConfig = React.useMemo(() => {
- switch (attachmentType) {
- case "TBE_RESULT":
- return {
- title: "TBE 결과 첨부파일",
- description: "기술 평가(TBE) 결과 파일을 관리합니다.",
- fileTypeLabel: "TBE 결과",
- canEdit: !readOnly
- }
- case "CBE_RESULT":
- return {
- title: "CBE 결과 첨부파일",
- description: "상업성 평가(CBE) 결과 파일을 관리합니다.",
- fileTypeLabel: "CBE 결과",
- canEdit: !readOnly
- }
- default: // RFQ_COMMON
- return {
- title: "RFQ 첨부파일",
- description: readOnly ? "RFQ 공통 첨부파일을 조회합니다." : "RFQ 공통 첨부파일을 관리합니다.",
- fileTypeLabel: "공통",
- canEdit: !readOnly
- }
- }
- }, [attachmentType, readOnly])
-
- // // RFQ 상태에 따른 편집 가능 여부 결정 (readOnly prop이 true면 항상 false)
- // const isEditable = React.useMemo(() => {
- // if (!rfq) return false
- // return attachmentConfig.canEdit
- // }, [rfq, attachmentConfig.canEdit])
-
- const form = useForm<AttachmentsFormValues>({
- resolver: zodResolver(attachmentsFormSchema),
- defaultValues: {
- techSalesRfqId: rfq?.id || 0,
- existing: defaultAttachments.map(att => ({
- id: att.id,
- techSalesRfqId: att.techSalesRfqId,
- fileName: att.fileName,
- originalFileName: att.originalFileName,
- filePath: att.filePath,
- fileSize: att.fileSize || undefined,
- fileType: att.fileType || undefined,
- attachmentType: att.attachmentType,
- description: att.description || undefined,
- createdBy: att.createdBy,
- createdAt: att.createdAt,
- })),
- newUploads: [],
- },
- })
-
- // useFieldArray for existing and new uploads
- const {
- fields: existingFields,
- remove: removeExisting,
- } = useFieldArray({
- control: form.control,
- name: "existing",
- })
-
- const {
- fields: newUploadFields,
- append: appendNewUpload,
- remove: removeNewUpload,
- } = useFieldArray({
- control: form.control,
- name: "newUploads",
- })
-
- // Reset form when defaultAttachments changes
- React.useEffect(() => {
- if (defaultAttachments) {
- form.reset({
- techSalesRfqId: rfq?.id || 0,
- existing: defaultAttachments.map(att => ({
- id: att.id,
- techSalesRfqId: att.techSalesRfqId,
- fileName: att.fileName,
- originalFileName: att.originalFileName,
- filePath: att.filePath,
- fileSize: att.fileSize || undefined,
- fileType: att.fileType || undefined,
- attachmentType: att.attachmentType,
- description: att.description || undefined,
- createdBy: att.createdBy,
- createdAt: att.createdAt,
- })),
- newUploads: [],
- })
- }
- }, [defaultAttachments, rfq?.id, form])
-
- // Handle dropzone accept
- const handleDropAccepted = React.useCallback((acceptedFiles: File[]) => {
- acceptedFiles.forEach((file) => {
- appendNewUpload({
- fileObj: file,
- attachmentType: "RFQ_COMMON",
- description: "",
- })
- })
- }, [appendNewUpload])
-
- // Handle dropzone reject
- const handleDropRejected = React.useCallback(() => {
- toast.error("파일 크기가 너무 크거나 지원하지 않는 파일 형식입니다.")
- }, [])
-
- // Handle remove existing attachment
- const handleRemoveExisting = React.useCallback((index: number) => {
- removeExisting(index)
- }, [removeExisting])
-
- // Handle form submission
- const onSubmit = async (data: AttachmentsFormValues) => {
- if (!rfq) {
- toast.error("RFQ 정보를 찾을 수 없습니다.")
- return
- }
-
- setIsPending(true)
- try {
- // 삭제할 첨부파일 ID 수집
- const deleteAttachmentIds = defaultAttachments
- .filter((original) => !data.existing.find(existing => existing.id === original.id))
- .map(attachment => attachment.id)
-
- // 새 파일 정보 수집
- const newFiles = data.newUploads
- .filter(upload => upload.fileObj)
- .map(upload => ({
- file: upload.fileObj as File,
- attachmentType: attachmentType,
- description: upload.description,
- }))
-
- // 실제 API 호출
- const result = await processTechSalesRfqAttachments({
- techSalesRfqId: rfq.id,
- newFiles,
- deleteAttachmentIds,
- createdBy: parseInt(session.data?.user.id || "0"),
- })
-
- if (result.error) {
- toast.error(result.error)
- return
- }
-
- // 성공 메시지 표시 (업로드된 파일 수 포함)
- const uploadedCount = newFiles.length
- const deletedCount = deleteAttachmentIds.length
-
- let successMessage = "첨부파일이 저장되었습니다."
- if (uploadedCount > 0 && deletedCount > 0) {
- successMessage = `${uploadedCount}개 파일 업로드, ${deletedCount}개 파일 삭제 완료`
- } else if (uploadedCount > 0) {
- successMessage = `${uploadedCount}개 파일이 업로드되었습니다.`
- } else if (deletedCount > 0) {
- successMessage = `${deletedCount}개 파일이 삭제되었습니다.`
- }
-
- toast.success(successMessage)
-
- // // 즉시 첨부파일 목록 새로고침
- // const refreshResult = await getTechSalesRfqAttachments(rfq.id)
- // if (refreshResult.error) {
- // console.error("첨부파일 목록 새로고침 실패:", refreshResult.error)
- // toast.warning("첨부파일 목록 새로고침에 실패했습니다. 시트를 다시 열어주세요.")
- // } else {
- // // 새로운 첨부파일 목록으로 폼 업데이트
- // const refreshedAttachments = refreshResult.data.map(att => ({
- // id: att.id,
- // techSalesRfqId: att.techSalesRfqId || rfq.id,
- // fileName: att.fileName,
- // originalFileName: att.originalFileName,
- // filePath: att.filePath,
- // fileSize: att.fileSize,
- // fileType: att.fileType,
- // attachmentType: att.attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT",
- // description: att.description,
- // createdBy: att.createdBy,
- // createdAt: att.createdAt,
- // }))
-
- // // 폼을 새로운 데이터로 리셋 (새 업로드 목록은 비움)
- // form.reset({
- // techSalesRfqId: rfq.id,
- // existing: refreshedAttachments.map(att => ({
- // ...att,
- // fileSize: att.fileSize || undefined,
- // fileType: att.fileType || undefined,
- // description: att.description || undefined,
- // })),
- // newUploads: [],
- // })
-
- // // 즉시 UI 업데이트를 위한 추가 피드백
- // if (uploadedCount > 0) {
- // toast.success("첨부파일 목록이 업데이트되었습니다.", { duration: 2000 })
- // }
- // }
-
- // // 콜백으로 상위 컴포넌트에 변경사항 알림
- // const newAttachmentCount = refreshResult.error ?
- // (data.existing.length + newFiles.length - deleteAttachmentIds.length) :
- // refreshResult.data.length
- // onAttachmentsUpdated?.(rfq.id, newAttachmentCount)
-
- } catch (error) {
- console.error("첨부파일 저장 오류:", error)
- toast.error("첨부파일 저장 중 오류가 발생했습니다.")
- } finally {
- setIsPending(false)
- }
- }
-
- return (
- <Sheet {...props}>
- <SheetContent className="flex flex-col gap-6 sm:max-w-md">
- <SheetHeader className="text-left">
- <SheetTitle>{attachmentConfig.title}</SheetTitle>
- <SheetDescription>
- <div>RFQ: {rfq?.rfqCode || "N/A"}</div>
- <div className="mt-1">{attachmentConfig.description}</div>
- {!attachmentConfig.canEdit && (
- <div className="mt-2 flex items-center gap-2 text-amber-600">
- <AlertCircle className="h-4 w-4" />
- <span className="text-sm">현재 상태에서는 편집할 수 없습니다</span>
- </div>
- )}
- </SheetDescription>
- </SheetHeader>
-
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-1 flex-col gap-6">
- {/* 1) Existing attachments */}
- <div className="grid gap-4">
- <h6 className="font-semibold leading-none tracking-tight">
- 기존 첨부파일 ({existingFields.length}개)
- </h6>
- {existingFields.map((field, index) => {
- const typeLabel = attachmentConfig.fileTypeLabel
- const sizeText = field.fileSize ? prettyBytes(field.fileSize) : "알 수 없음"
- const dateText = field.createdAt ? formatDate(field.createdAt, "KR") : ""
-
- return (
- <div key={field.id} className="flex items-start justify-between p-3 border rounded-md gap-3">
- <div className="flex-1 min-w-0 overflow-hidden">
- <div className="flex items-center gap-2 mb-1 flex-wrap">
- <p className="text-sm font-medium break-words leading-tight">
- {field.originalFileName || field.fileName}
- </p>
- <Badge variant="outline" className="text-xs shrink-0">
- {typeLabel}
- </Badge>
- </div>
- <p className="text-xs text-muted-foreground">
- {sizeText} • {dateText}
- </p>
- {field.description && (
- <p className="text-xs text-muted-foreground mt-1 break-words">
- {field.description}
- </p>
- )}
- </div>
-
- <div className="flex items-center gap-1 shrink-0">
- {/* Download button */}
- {field.filePath && (
- <a
- // href={`/api/tech-sales-rfq-download?path=${encodeURIComponent(field.filePath)}`}
- // download={field.originalFileName || field.fileName}
- onClick={() => downloadFile(field.filePath, field.originalFileName || field.fileName)}
- className="inline-block"
- >
- <Button variant="ghost" size="icon" type="button" className="h-8 w-8">
- <Download className="h-4 w-4" />
- </Button>
- </a>
- )}
- {/* Remove button - 편집 가능할 때만 표시 */}
- {attachmentConfig.canEdit && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="h-8 w-8"
- onClick={() => handleRemoveExisting(index)}
- >
- <X className="h-4 w-4" />
- </Button>
- )}
- </div>
- </div>
- )
- })}
- </div>
-
- {/* 2) Dropzone for new uploads - 편집 가능할 때만 표시 */}
- {attachmentConfig.canEdit ? (
- <>
- <Dropzone
- maxSize={MAX_FILE_SIZE}
- onDropAccepted={handleDropAccepted}
- onDropRejected={handleDropRejected}
- >
- {({ maxSize }) => (
- <FormField
- control={form.control}
- name="newUploads"
- render={() => (
- <FormItem>
- <FormLabel>새 파일 업로드</FormLabel>
- <DropzoneZone className="flex justify-center">
- <FormControl>
- <DropzoneInput />
- </FormControl>
- <div className="flex items-center gap-6">
- <DropzoneUploadIcon />
- <div className="grid gap-0.5">
- <DropzoneTitle>파일을 드래그하거나 클릭하세요</DropzoneTitle>
- <DropzoneDescription>
- 최대 크기: {maxSize ? prettyBytes(maxSize) : "600MB"}
- </DropzoneDescription>
- </div>
- </div>
- </DropzoneZone>
- <FormDescription>파일을 여러 개 선택할 수 있습니다.</FormDescription>
- <FormMessage />
- </FormItem>
- )}
- />
- )}
- </Dropzone>
-
- {/* newUpload fields -> FileList */}
- {newUploadFields.length > 0 && (
- <div className="grid gap-4">
- <h6 className="font-semibold leading-none tracking-tight">
- 새 파일 ({newUploadFields.length}개)
- </h6>
- <FileList>
- {newUploadFields.map((field, idx) => {
- const fileObj = form.getValues(`newUploads.${idx}.fileObj`)
- if (!fileObj) return null
-
- const fileName = fileObj.name
- const fileSize = fileObj.size
- return (
- <FileListItem key={field.id}>
- <FileListHeader>
- <FileListIcon />
- <FileListInfo>
- <FileListName>{fileName}</FileListName>
- <FileListDescription>
- {prettyBytes(fileSize)}
- </FileListDescription>
- </FileListInfo>
- <FileListAction onClick={() => removeNewUpload(idx)}>
- <X />
- <span className="sr-only">제거</span>
- </FileListAction>
- </FileListHeader>
-
- </FileListItem>
- )
- })}
- </FileList>
- </div>
- )}
- </>
- ) : (
- <div className="p-3 bg-muted rounded-md flex items-center justify-center">
- <div className="text-center text-sm text-muted-foreground">
- <Eye className="h-4 w-4 mx-auto mb-2" />
- <p>보기 모드에서는 파일 첨부를 할 수 없습니다.</p>
- </div>
- </div>
- )}
-
- <SheetFooter className="gap-2 pt-2 sm:space-x-0">
- <SheetClose asChild>
- <Button type="button" variant="outline">
- {attachmentConfig.canEdit ? "취소" : "닫기"}
- </Button>
- </SheetClose>
- {attachmentConfig.canEdit && (
- <Button
- type="submit"
- disabled={
- isPending ||
- (
- form.getValues().newUploads.length === 0 &&
- form.getValues().existing.length === defaultAttachments.length &&
- form.getValues().existing.every(existing =>
- defaultAttachments.some(original => original.id === existing.id)
- )
- )
- }
- >
- {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />}
- {isPending ? "저장 중..." : "저장"}
- </Button>
- )}
- </SheetFooter>
- </form>
- </Form>
- </SheetContent>
- </Sheet>
- )
+"use client"
+
+import * as React from "react"
+import { z } from "zod"
+import { useForm, useFieldArray } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import {
+ Sheet,
+ SheetContent,
+ SheetHeader,
+ SheetTitle,
+ SheetDescription,
+ SheetFooter,
+ SheetClose,
+} from "@/components/ui/sheet"
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ FormDescription
+} from "@/components/ui/form"
+import { Loader, Download, X, Eye, AlertCircle } from "lucide-react"
+import { toast } from "sonner"
+import { Badge } from "@/components/ui/badge"
+
+import {
+ Dropzone,
+ DropzoneDescription,
+ DropzoneInput,
+ DropzoneTitle,
+ DropzoneUploadIcon,
+ DropzoneZone,
+} from "@/components/ui/dropzone"
+import {
+ FileList,
+ FileListAction,
+ FileListDescription,
+ FileListHeader,
+ FileListIcon,
+ FileListInfo,
+ FileListItem,
+ FileListName,
+} from "@/components/ui/file-list"
+
+import prettyBytes from "pretty-bytes"
+import { formatDate } from "@/lib/utils"
+import { processTechSalesRfqAttachments } from "@/lib/techsales-rfq/service"
+import { useSession } from "next-auth/react"
+
+const MAX_FILE_SIZE = 6e8 // 600MB
+
+/** 기존 첨부 파일 정보 (techSalesAttachments 테이블 구조) */
+export interface ExistingTechSalesAttachment {
+ id: number
+ techSalesRfqId: number
+ fileName: string
+ originalFileName: string
+ filePath: string
+ fileSize?: number
+ fileType?: string
+ attachmentType: "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT"
+ description?: string
+ createdBy: number
+ createdAt: Date
+}
+
+/** 새로 업로드할 파일 */
+const newUploadSchema = z.object({
+ fileObj: z.any().optional(), // 실제 File
+ attachmentType: z.enum(["RFQ_COMMON", "TBE_RESULT", "CBE_RESULT"]).default("RFQ_COMMON"),
+ description: z.string().optional(),
+})
+
+/** 기존 첨부 (react-hook-form에서 관리) */
+const existingAttachSchema = z.object({
+ id: z.number(),
+ techSalesRfqId: z.number(),
+ fileName: z.string(),
+ originalFileName: z.string(),
+ filePath: z.string(),
+ fileSize: z.number().optional(),
+ fileType: z.string().optional(),
+ attachmentType: z.enum(["RFQ_COMMON", "TBE_RESULT", "CBE_RESULT"]),
+ description: z.string().optional(),
+ createdBy: z.number(),
+ createdAt: z.custom<Date>(),
+})
+
+/** RHF 폼 전체 스키마 */
+const attachmentsFormSchema = z.object({
+ techSalesRfqId: z.number().int(),
+ existing: z.array(existingAttachSchema),
+ newUploads: z.array(newUploadSchema),
+})
+
+type AttachmentsFormValues = z.infer<typeof attachmentsFormSchema>
+
+// TechSalesRfq 타입 (간단 버전)
+interface TechSalesRfq {
+ id: number
+ rfqCode: string | null
+ status: string
+ // 필요한 다른 필드들...
+}
+
+interface TechSalesRfqAttachmentsSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ defaultAttachments?: ExistingTechSalesAttachment[]
+ rfq: TechSalesRfq | null
+ /** 첨부파일 타입 */
+ attachmentType?: "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT"
+ /** 읽기 전용 모드 (벤더용) */
+ readOnly?: boolean
+ /** 업로드/삭제 후 상위 테이블에 attachmentCount 등을 업데이트하기 위한 콜백 */
+ // onAttachmentsUpdated?: (rfqId: number, newAttachmentCount: number) => void
+
+}
+
+export function TechSalesRfqAttachmentsSheet({
+ defaultAttachments = [],
+ // onAttachmentsUpdated,
+ rfq,
+ attachmentType = "RFQ_COMMON",
+ readOnly = false,
+ ...props
+}: TechSalesRfqAttachmentsSheetProps) {
+ const [isPending, setIsPending] = React.useState(false)
+ const session = useSession()
+
+ // 파일 다운로드 핸들러
+ const handleDownloadClick = React.useCallback(async (filePath: string, fileName: string) => {
+ try {
+ const { downloadFile } = await import('@/lib/file-download')
+ await downloadFile(filePath, fileName, {
+ showToast: true,
+ onError: (error) => {
+ console.error('다운로드 오류:', error)
+ toast.error(error)
+ },
+ onSuccess: (fileName, fileSize) => {
+ console.log(`다운로드 성공: ${fileName} (${fileSize} bytes)`)
+ }
+ })
+ } catch (error) {
+ console.error('다운로드 오류:', error)
+ toast.error('파일 다운로드 중 오류가 발생했습니다.')
+ }
+ }, [])
+ // 첨부파일 타입별 제목과 설명 설정
+ const attachmentConfig = React.useMemo(() => {
+ switch (attachmentType) {
+ case "TBE_RESULT":
+ return {
+ title: "TBE 결과 첨부파일",
+ description: "기술 평가(TBE) 결과 파일을 관리합니다.",
+ fileTypeLabel: "TBE 결과",
+ canEdit: !readOnly
+ }
+ case "CBE_RESULT":
+ return {
+ title: "CBE 결과 첨부파일",
+ description: "상업성 평가(CBE) 결과 파일을 관리합니다.",
+ fileTypeLabel: "CBE 결과",
+ canEdit: !readOnly
+ }
+ default: // RFQ_COMMON
+ return {
+ title: "RFQ 첨부파일",
+ description: readOnly ? "RFQ 공통 첨부파일을 조회합니다." : "RFQ 공통 첨부파일을 관리합니다.",
+ fileTypeLabel: "공통",
+ canEdit: !readOnly
+ }
+ }
+ }, [attachmentType, readOnly])
+
+ // // RFQ 상태에 따른 편집 가능 여부 결정 (readOnly prop이 true면 항상 false)
+ // const isEditable = React.useMemo(() => {
+ // if (!rfq) return false
+ // return attachmentConfig.canEdit
+ // }, [rfq, attachmentConfig.canEdit])
+
+ const form = useForm<AttachmentsFormValues>({
+ resolver: zodResolver(attachmentsFormSchema),
+ defaultValues: {
+ techSalesRfqId: rfq?.id || 0,
+ existing: defaultAttachments.map(att => ({
+ id: att.id,
+ techSalesRfqId: att.techSalesRfqId,
+ fileName: att.fileName,
+ originalFileName: att.originalFileName,
+ filePath: att.filePath,
+ fileSize: att.fileSize || undefined,
+ fileType: att.fileType || undefined,
+ attachmentType: att.attachmentType,
+ description: att.description || undefined,
+ createdBy: att.createdBy,
+ createdAt: att.createdAt,
+ })),
+ newUploads: [],
+ },
+ })
+
+ // useFieldArray for existing and new uploads
+ const {
+ fields: existingFields,
+ remove: removeExisting,
+ } = useFieldArray({
+ control: form.control,
+ name: "existing",
+ })
+
+ const {
+ fields: newUploadFields,
+ append: appendNewUpload,
+ remove: removeNewUpload,
+ } = useFieldArray({
+ control: form.control,
+ name: "newUploads",
+ })
+
+ // Reset form when defaultAttachments changes
+ React.useEffect(() => {
+ if (defaultAttachments) {
+ form.reset({
+ techSalesRfqId: rfq?.id || 0,
+ existing: defaultAttachments.map(att => ({
+ id: att.id,
+ techSalesRfqId: att.techSalesRfqId,
+ fileName: att.fileName,
+ originalFileName: att.originalFileName,
+ filePath: att.filePath,
+ fileSize: att.fileSize || undefined,
+ fileType: att.fileType || undefined,
+ attachmentType: att.attachmentType,
+ description: att.description || undefined,
+ createdBy: att.createdBy,
+ createdAt: att.createdAt,
+ })),
+ newUploads: [],
+ })
+ }
+ }, [defaultAttachments, rfq?.id, form])
+
+ // Handle dropzone accept
+ const handleDropAccepted = React.useCallback((acceptedFiles: File[]) => {
+ acceptedFiles.forEach((file) => {
+ appendNewUpload({
+ fileObj: file,
+ attachmentType: "RFQ_COMMON",
+ description: "",
+ })
+ })
+ }, [appendNewUpload])
+
+ // Handle dropzone reject
+ const handleDropRejected = React.useCallback(() => {
+ toast.error("파일 크기가 너무 크거나 지원하지 않는 파일 형식입니다.")
+ }, [])
+
+ // Handle remove existing attachment
+ const handleRemoveExisting = React.useCallback((index: number) => {
+ removeExisting(index)
+ }, [removeExisting])
+
+ // Handle form submission
+ const onSubmit = async (data: AttachmentsFormValues) => {
+ if (!rfq) {
+ toast.error("RFQ 정보를 찾을 수 없습니다.")
+ return
+ }
+
+ setIsPending(true)
+ try {
+ // 삭제할 첨부파일 ID 수집
+ const deleteAttachmentIds = defaultAttachments
+ .filter((original) => !data.existing.find(existing => existing.id === original.id))
+ .map(attachment => attachment.id)
+
+ // 새 파일 정보 수집
+ const newFiles = data.newUploads
+ .filter(upload => upload.fileObj)
+ .map(upload => ({
+ file: upload.fileObj as File,
+ attachmentType: attachmentType,
+ description: upload.description,
+ }))
+
+ // 실제 API 호출
+ const result = await processTechSalesRfqAttachments({
+ techSalesRfqId: rfq.id,
+ newFiles,
+ deleteAttachmentIds,
+ createdBy: parseInt(session.data?.user.id || "0"),
+ })
+
+ if (result.error) {
+ toast.error(result.error)
+ return
+ }
+
+ // 성공 메시지 표시 (업로드된 파일 수 포함)
+ const uploadedCount = newFiles.length
+ const deletedCount = deleteAttachmentIds.length
+
+ let successMessage = "첨부파일이 저장되었습니다."
+ if (uploadedCount > 0 && deletedCount > 0) {
+ successMessage = `${uploadedCount}개 파일 업로드, ${deletedCount}개 파일 삭제 완료`
+ } else if (uploadedCount > 0) {
+ successMessage = `${uploadedCount}개 파일이 업로드되었습니다.`
+ } else if (deletedCount > 0) {
+ successMessage = `${deletedCount}개 파일이 삭제되었습니다.`
+ }
+
+ toast.success(successMessage)
+
+ // 다이얼로그 자동 닫기
+ props.onOpenChange?.(false)
+
+ // // 즉시 첨부파일 목록 새로고침
+ // const refreshResult = await getTechSalesRfqAttachments(rfq.id)
+ // if (refreshResult.error) {
+ // console.error("첨부파일 목록 새로고침 실패:", refreshResult.error)
+ // toast.warning("첨부파일 목록 새로고침에 실패했습니다. 시트를 다시 열어주세요.")
+ // } else {
+ // // 새로운 첨부파일 목록으로 폼 업데이트
+ // const refreshedAttachments = refreshResult.data.map(att => ({
+ // id: att.id,
+ // techSalesRfqId: att.techSalesRfqId || rfq.id,
+ // fileName: att.fileName,
+ // originalFileName: att.originalFileName,
+ // filePath: att.filePath,
+ // fileSize: att.fileSize,
+ // fileType: att.fileType,
+ // attachmentType: att.attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT",
+ // description: att.description,
+ // createdBy: att.createdBy,
+ // createdAt: att.createdAt,
+ // }))
+
+ // // 폼을 새로운 데이터로 리셋 (새 업로드 목록은 비움)
+ // form.reset({
+ // techSalesRfqId: rfq.id,
+ // existing: refreshedAttachments.map(att => ({
+ // ...att,
+ // fileSize: att.fileSize || undefined,
+ // fileType: att.fileType || undefined,
+ // description: att.description || undefined,
+ // })),
+ // newUploads: [],
+ // })
+
+ // // 즉시 UI 업데이트를 위한 추가 피드백
+ // if (uploadedCount > 0) {
+ // toast.success("첨부파일 목록이 업데이트되었습니다.", { duration: 2000 })
+ // }
+ // }
+
+ // // 콜백으로 상위 컴포넌트에 변경사항 알림
+ // const newAttachmentCount = refreshResult.error ?
+ // (data.existing.length + newFiles.length - deleteAttachmentIds.length) :
+ // refreshResult.data.length
+ // onAttachmentsUpdated?.(rfq.id, newAttachmentCount)
+
+ } catch (error) {
+ console.error("첨부파일 저장 오류:", error)
+ toast.error("첨부파일 저장 중 오류가 발생했습니다.")
+ } finally {
+ setIsPending(false)
+ }
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetHeader className="text-left">
+ <SheetTitle>{attachmentConfig.title}</SheetTitle>
+ <SheetDescription>
+ <div>RFQ: {rfq?.rfqCode || "N/A"}</div>
+ <div className="mt-1">{attachmentConfig.description}</div>
+ {!attachmentConfig.canEdit && (
+ <div className="mt-2 flex items-center gap-2 text-amber-600">
+ <AlertCircle className="h-4 w-4" />
+ <span className="text-sm">현재 상태에서는 편집할 수 없습니다</span>
+ </div>
+ )}
+ </SheetDescription>
+ </SheetHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-1 flex-col gap-6">
+ {/* 1) Existing attachments */}
+ <div className="grid gap-4">
+ <h6 className="font-semibold leading-none tracking-tight">
+ 기존 첨부파일 ({existingFields.length}개)
+ </h6>
+ {existingFields.map((field, index) => {
+ const typeLabel = attachmentConfig.fileTypeLabel
+ const sizeText = field.fileSize ? prettyBytes(field.fileSize) : "알 수 없음"
+ const dateText = field.createdAt ? formatDate(field.createdAt, "KR") : ""
+
+ return (
+ <div key={field.id} className="flex items-start justify-between p-3 border rounded-md gap-3">
+ <div className="flex-1 min-w-0 overflow-hidden">
+ <div className="flex items-center gap-2 mb-1 flex-wrap">
+ <p className="text-sm font-medium break-words leading-tight">
+ {field.originalFileName || field.fileName}
+ </p>
+ <Badge variant="outline" className="text-xs shrink-0">
+ {typeLabel}
+ </Badge>
+ </div>
+ <p className="text-xs text-muted-foreground">
+ {sizeText} • {dateText}
+ </p>
+ {field.description && (
+ <p className="text-xs text-muted-foreground mt-1 break-words">
+ {field.description}
+ </p>
+ )}
+ </div>
+
+ <div className="flex items-center gap-1 shrink-0">
+ {/* Download button */}
+ {field.filePath && (
+ <Button
+ variant="ghost"
+ size="icon"
+ type="button"
+ className="h-8 w-8"
+ onClick={() => handleDownloadClick(field.filePath, field.originalFileName || field.fileName)}
+ title="다운로드"
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ )}
+ {/* Remove button - 편집 가능할 때만 표시 */}
+ {attachmentConfig.canEdit && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="h-8 w-8"
+ onClick={() => handleRemoveExisting(index)}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ </div>
+ )
+ })}
+ </div>
+
+ {/* 2) Dropzone for new uploads - 편집 가능할 때만 표시 */}
+ {attachmentConfig.canEdit ? (
+ <>
+ <Dropzone
+ maxSize={MAX_FILE_SIZE}
+ onDropAccepted={handleDropAccepted}
+ onDropRejected={handleDropRejected}
+ >
+ {({ maxSize }) => (
+ <FormField
+ control={form.control}
+ name="newUploads"
+ render={() => (
+ <FormItem>
+ <FormLabel>새 파일 업로드</FormLabel>
+ <DropzoneZone className="flex justify-center">
+ <FormControl>
+ <DropzoneInput />
+ </FormControl>
+ <div className="flex items-center gap-6">
+ <DropzoneUploadIcon />
+ <div className="grid gap-0.5">
+ <DropzoneTitle>파일을 드래그하거나 클릭하세요</DropzoneTitle>
+ <DropzoneDescription>
+ 최대 크기: {maxSize ? prettyBytes(maxSize) : "600MB"}
+ </DropzoneDescription>
+ </div>
+ </div>
+ </DropzoneZone>
+ <FormDescription>파일을 여러 개 선택할 수 있습니다.</FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ )}
+ </Dropzone>
+
+ {/* newUpload fields -> FileList */}
+ {newUploadFields.length > 0 && (
+ <div className="grid gap-4">
+ <h6 className="font-semibold leading-none tracking-tight">
+ 새 파일 ({newUploadFields.length}개)
+ </h6>
+ <FileList>
+ {newUploadFields.map((field, idx) => {
+ const fileObj = form.getValues(`newUploads.${idx}.fileObj`)
+ if (!fileObj) return null
+
+ const fileName = fileObj.name
+ const fileSize = fileObj.size
+ return (
+ <FileListItem key={field.id}>
+ <FileListHeader>
+ <FileListIcon />
+ <FileListInfo>
+ <FileListName>{fileName}</FileListName>
+ <FileListDescription>
+ {prettyBytes(fileSize)}
+ </FileListDescription>
+ </FileListInfo>
+ <FileListAction onClick={() => removeNewUpload(idx)}>
+ <X />
+ <span className="sr-only">제거</span>
+ </FileListAction>
+ </FileListHeader>
+
+ </FileListItem>
+ )
+ })}
+ </FileList>
+ </div>
+ )}
+ </>
+ ) : (
+ <div className="p-3 bg-muted rounded-md flex items-center justify-center">
+ <div className="text-center text-sm text-muted-foreground">
+ <Eye className="h-4 w-4 mx-auto mb-2" />
+ <p>보기 모드에서는 파일 첨부를 할 수 없습니다.</p>
+ </div>
+ </div>
+ )}
+
+ <SheetFooter className="gap-2 pt-2 sm:space-x-0">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ {attachmentConfig.canEdit ? "취소" : "닫기"}
+ </Button>
+ </SheetClose>
+ {attachmentConfig.canEdit && (
+ <Button
+ type="submit"
+ disabled={
+ isPending ||
+ (
+ form.getValues().newUploads.length === 0 &&
+ form.getValues().existing.length === defaultAttachments.length &&
+ form.getValues().existing.every(existing =>
+ defaultAttachments.some(original => original.id === existing.id)
+ )
+ )
+ }
+ >
+ {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />}
+ {isPending ? "저장 중..." : "저장"}
+ </Button>
+ )}
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
} \ No newline at end of file
diff --git a/lib/techsales-rfq/validations.ts b/lib/techsales-rfq/validations.ts
index c373b576..ecd63b4f 100644
--- a/lib/techsales-rfq/validations.ts
+++ b/lib/techsales-rfq/validations.ts
@@ -1,192 +1,192 @@
-import {
- createSearchParamsCache,
- parseAsArrayOf,
- parseAsInteger,
- parseAsString,
- parseAsStringEnum,
-} from "nuqs/server";
-import * as z from "zod";
-
-import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers";
-import { techSalesRfqs, techSalesVendorQuotations } from "@/db/schema";
-
-
-// =======================
-// 1) SearchParams (목록 필터링/정렬)
-// =======================
-export const searchParamsCache = createSearchParamsCache({
- flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
- page: parseAsInteger.withDefault(1),
- perPage: parseAsInteger.withDefault(10),
- sort: getSortingStateParser<typeof techSalesRfqs>().withDefault([
- { id: "updatedAt", desc: true },
- ]),
-
- // 고급 필터
- filters: getFiltersStateParser().withDefault([]),
- joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- // 기본 필터 (RFQFilterBox) - 새로운 필드 추가
- basicFilters: getFiltersStateParser().withDefault([]),
- basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- search: parseAsString.withDefault(""),
- from: parseAsString.withDefault(""),
- to: parseAsString.withDefault(""),
-});
-
-export type GetTechSalesRfqsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>;
-
-
-export const searchParamsVendorQuotationsCache = createSearchParamsCache({
- flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
- page: parseAsInteger.withDefault(1),
- perPage: parseAsInteger.withDefault(10),
- sort: getSortingStateParser<typeof techSalesVendorQuotations>().withDefault([
- { id: "updatedAt", desc: true },
- ]),
-
- // 고급 필터
- filters: getFiltersStateParser().withDefault([]),
- joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- // 기본 필터 (RFQFilterBox) - 새로운 필드 추가
- basicFilters: getFiltersStateParser().withDefault([]),
- basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- search: parseAsString.withDefault(""),
- from: parseAsString.withDefault(""),
- to: parseAsString.withDefault(""),
-
-});
-
-export type GetTechSalesVendorQuotationsSchema = Awaited<ReturnType<typeof searchParamsVendorQuotationsCache.parse>>;
-
-export const searchParamsDashboardCache = createSearchParamsCache({
- flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
- page: parseAsInteger.withDefault(1),
- perPage: parseAsInteger.withDefault(10),
- sort: getSortingStateParser<typeof techSalesRfqs>().withDefault([
- { id: "updatedAt", desc: true },
- ]),
-
- // 고급 필터
- filters: getFiltersStateParser().withDefault([]),
- joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- search: parseAsString.withDefault(""),
-});
-
-export type GetTechSalesDashboardSchema = Awaited<ReturnType<typeof searchParamsDashboardCache.parse>>;
-
-// 조선 RFQ용 SearchParams
-export const searchParamsShipCache = createSearchParamsCache({
- flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
- page: parseAsInteger.withDefault(1),
- perPage: parseAsInteger.withDefault(10),
- sort: getSortingStateParser<typeof techSalesRfqs>().withDefault([
- { id: "updatedAt", desc: true },
- ]),
-
- // 고급 필터
- filters: getFiltersStateParser().withDefault([]),
- joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- // 기본 필터 (RFQFilterBox)
- basicFilters: getFiltersStateParser().withDefault([]),
- basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- search: parseAsString.withDefault(""),
- from: parseAsString.withDefault(""),
- to: parseAsString.withDefault(""),
-});
-
-export type GetTechSalesShipRfqsSchema = Awaited<ReturnType<typeof searchParamsShipCache.parse>>;
-
-// 해양 TOP RFQ용 SearchParams
-export const searchParamsTopCache = createSearchParamsCache({
- flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
- page: parseAsInteger.withDefault(1),
- perPage: parseAsInteger.withDefault(10),
- sort: getSortingStateParser<typeof techSalesRfqs>().withDefault([
- { id: "updatedAt", desc: true },
- ]),
-
- // 고급 필터
- filters: getFiltersStateParser().withDefault([]),
- joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- // 기본 필터 (RFQFilterBox)
- basicFilters: getFiltersStateParser().withDefault([]),
- basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- search: parseAsString.withDefault(""),
- from: parseAsString.withDefault(""),
- to: parseAsString.withDefault(""),
-});
-
-export type GetTechSalesTopRfqsSchema = Awaited<ReturnType<typeof searchParamsTopCache.parse>>;
-
-// 해양 HULL RFQ용 SearchParams
-export const searchParamsHullCache = createSearchParamsCache({
- flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
- page: parseAsInteger.withDefault(1),
- perPage: parseAsInteger.withDefault(10),
- sort: getSortingStateParser<typeof techSalesRfqs>().withDefault([
- { id: "updatedAt", desc: true },
- ]),
-
- // 고급 필터
- filters: getFiltersStateParser().withDefault([]),
- joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- // 기본 필터 (RFQFilterBox)
- basicFilters: getFiltersStateParser().withDefault([]),
- basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- search: parseAsString.withDefault(""),
- from: parseAsString.withDefault(""),
- to: parseAsString.withDefault(""),
-});
-
-export type GetTechSalesHullRfqsSchema = Awaited<ReturnType<typeof searchParamsHullCache.parse>>;
-
-// RFQ 생성 스키마
-export const createTechSalesRfqSchema = z.object({
- itemId: z.number(),
- biddingProjectId: z.number().optional(),
- materialCode: z.string().optional(),
- dueDate: z.date(),
- status: z.string().default("RFQ Created"),
- rfqSealedYn: z.boolean().default(false),
- picCode: z.string().optional(),
- remark: z.string().optional().nullable(),
- rfqType: z.enum(["SHIP", "TOP", "HULL"]).default("SHIP"),
-});
-
-export type CreateTechSalesRfqSchema = z.infer<typeof createTechSalesRfqSchema>;
-
-// 결합도 우려가 있지만
-// 벤더가 기술영업(조선) RFQ 조회할 때 쓸 밸리데이션
-export const searchParamsVendorRfqCache = createSearchParamsCache({
- flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
- page: parseAsInteger.withDefault(1),
- perPage: parseAsInteger.withDefault(10),
- sort: getSortingStateParser<typeof techSalesVendorQuotations>().withDefault([
- { id: "updatedAt", desc: true },
- ]),
-
- // 고급 필터
- filters: getFiltersStateParser().withDefault([]),
- joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- // 기본 필터 (RFQFilterBox) - 새로운 필드 추가
- basicFilters: getFiltersStateParser().withDefault([]),
- basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- search: parseAsString.withDefault(""),
- from: parseAsString.withDefault(""),
- to: parseAsString.withDefault(""),
-});
-
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server";
+import * as z from "zod";
+
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers";
+import { techSalesRfqs, techSalesVendorQuotations } from "@/db/schema";
+
+
+// =======================
+// 1) SearchParams (목록 필터링/정렬)
+// =======================
+export const searchParamsCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<typeof techSalesRfqs>().withDefault([
+ { id: "updatedAt", desc: true },
+ ]),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ // 기본 필터 (RFQFilterBox) - 새로운 필드 추가
+ basicFilters: getFiltersStateParser().withDefault([]),
+ basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ search: parseAsString.withDefault(""),
+ from: parseAsString.withDefault(""),
+ to: parseAsString.withDefault(""),
+});
+
+export type GetTechSalesRfqsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>;
+
+
+export const searchParamsVendorQuotationsCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<typeof techSalesVendorQuotations>().withDefault([
+ { id: "updatedAt", desc: true },
+ ]),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ // 기본 필터 (RFQFilterBox) - 새로운 필드 추가
+ basicFilters: getFiltersStateParser().withDefault([]),
+ basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ search: parseAsString.withDefault(""),
+ from: parseAsString.withDefault(""),
+ to: parseAsString.withDefault(""),
+
+});
+
+export type GetTechSalesVendorQuotationsSchema = Awaited<ReturnType<typeof searchParamsVendorQuotationsCache.parse>>;
+
+export const searchParamsDashboardCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<typeof techSalesRfqs>().withDefault([
+ { id: "updatedAt", desc: true },
+ ]),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ search: parseAsString.withDefault(""),
+});
+
+export type GetTechSalesDashboardSchema = Awaited<ReturnType<typeof searchParamsDashboardCache.parse>>;
+
+// 조선 RFQ용 SearchParams
+export const searchParamsShipCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<typeof techSalesRfqs>().withDefault([
+ { id: "updatedAt", desc: true },
+ ]),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ // 기본 필터 (RFQFilterBox)
+ basicFilters: getFiltersStateParser().withDefault([]),
+ basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ search: parseAsString.withDefault(""),
+ from: parseAsString.withDefault(""),
+ to: parseAsString.withDefault(""),
+});
+
+export type GetTechSalesShipRfqsSchema = Awaited<ReturnType<typeof searchParamsShipCache.parse>>;
+
+// 해양 TOP RFQ용 SearchParams
+export const searchParamsTopCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<typeof techSalesRfqs>().withDefault([
+ { id: "updatedAt", desc: true },
+ ]),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ // 기본 필터 (RFQFilterBox)
+ basicFilters: getFiltersStateParser().withDefault([]),
+ basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ search: parseAsString.withDefault(""),
+ from: parseAsString.withDefault(""),
+ to: parseAsString.withDefault(""),
+});
+
+export type GetTechSalesTopRfqsSchema = Awaited<ReturnType<typeof searchParamsTopCache.parse>>;
+
+// 해양 HULL RFQ용 SearchParams
+export const searchParamsHullCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<typeof techSalesRfqs>().withDefault([
+ { id: "updatedAt", desc: true },
+ ]),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ // 기본 필터 (RFQFilterBox)
+ basicFilters: getFiltersStateParser().withDefault([]),
+ basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ search: parseAsString.withDefault(""),
+ from: parseAsString.withDefault(""),
+ to: parseAsString.withDefault(""),
+});
+
+export type GetTechSalesHullRfqsSchema = Awaited<ReturnType<typeof searchParamsHullCache.parse>>;
+
+// RFQ 생성 스키마
+export const createTechSalesRfqSchema = z.object({
+ itemId: z.number(),
+ biddingProjectId: z.number().optional(),
+ materialCode: z.string().optional(),
+ dueDate: z.date(),
+ status: z.string().default("RFQ Created"),
+ rfqSealedYn: z.boolean().default(false),
+ picCode: z.string().optional(),
+ remark: z.string().optional().nullable(),
+ rfqType: z.enum(["SHIP", "TOP", "HULL"]).default("SHIP"),
+});
+
+export type CreateTechSalesRfqSchema = z.infer<typeof createTechSalesRfqSchema>;
+
+// 결합도 우려가 있지만
+// 벤더가 기술영업(조선) RFQ 조회할 때 쓸 밸리데이션
+export const searchParamsVendorRfqCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<typeof techSalesVendorQuotations>().withDefault([
+ { id: "updatedAt", desc: true },
+ ]),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ // 기본 필터 (RFQFilterBox) - 새로운 필드 추가
+ basicFilters: getFiltersStateParser().withDefault([]),
+ basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ search: parseAsString.withDefault(""),
+ from: parseAsString.withDefault(""),
+ to: parseAsString.withDefault(""),
+});
+
export type GetQuotationsSchema = Awaited<ReturnType<typeof searchParamsVendorRfqCache.parse>>; \ No newline at end of file
diff --git a/lib/techsales-rfq/vendor-response/buyer-communication-drawer.tsx b/lib/techsales-rfq/vendor-response/buyer-communication-drawer.tsx
index 4422a32c..c0f63ff7 100644
--- a/lib/techsales-rfq/vendor-response/buyer-communication-drawer.tsx
+++ b/lib/techsales-rfq/vendor-response/buyer-communication-drawer.tsx
@@ -1,711 +1,729 @@
-"use client"
-
-import * as React from "react"
-import { useState, useEffect, useRef } from "react"
-import { toast } from "sonner"
-import {
- Send,
- Paperclip,
- DownloadCloud,
- File,
- FileText,
- Image as ImageIcon,
- AlertCircle,
- X,
- User,
- Building
-} from "lucide-react"
-import {
- Drawer,
- DrawerClose,
- DrawerContent,
- DrawerDescription,
- DrawerFooter,
- DrawerHeader,
- DrawerTitle,
-} from "@/components/ui/drawer"
-import { Button } from "@/components/ui/button"
-import { Textarea } from "@/components/ui/textarea"
-import { Avatar, AvatarFallback } from "@/components/ui/avatar"
-import { Badge } from "@/components/ui/badge"
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
-import { formatDateTime, formatFileSize } from "@/lib/utils"
-import { useSession } from "next-auth/react"
-
-// 타입 정의
-export interface TechSalesAttachment {
- id: number
- fileName: string
- fileSize: number
- fileType: string | null
- filePath: string
- uploadedAt: Date
-}
-
-export interface TechSalesComment {
- id: number
- rfqId: number
- vendorId: number | null
- userId?: number | null
- content: string
- isVendorComment: boolean | null
- createdAt: Date
- updatedAt: Date
- userName?: string | null
- vendorName?: string | null
- attachments: TechSalesAttachment[]
- isRead: boolean | null
-}
-
-// 프롭스 정의
-interface BuyerCommunicationDrawerProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- quotation: {
- id: number;
- rfqId: number;
- vendorId: number;
- quotationCode: string | null;
- rfq?: {
- rfqCode: string | null;
- };
- } | null;
- onSuccess?: () => void;
-}
-
-// 클라이언트에서 API를 통해 코멘트를 가져오는 함수
-export async function fetchTechSalesVendorCommentsClient(rfqId: number, vendorId: number): Promise<TechSalesComment[]> {
- const response = await fetch(`/api/tech-sales-rfqs/${rfqId}/vendors/${vendorId}/comments`);
-
- if (!response.ok) {
- throw new Error(`API 요청 실패: ${response.status}`);
- }
-
- const result = await response.json();
-
- if (!result.success) {
- throw new Error(result.message || '코멘트 조회 중 오류가 발생했습니다');
- }
-
- // API 응답 타입 정의
- interface ApiComment {
- id: number;
- rfqId: number;
- vendorId: number | null;
- userId?: number | null;
- content: string;
- isVendorComment: boolean | null;
- createdAt: string;
- updatedAt: string;
- userName?: string | null;
- vendorName?: string | null;
- isRead: boolean | null;
- attachments: Array<{
- id: number;
- fileName: string;
- fileSize: number;
- fileType: string | null;
- filePath: string;
- uploadedAt: string;
- }>;
- }
-
- return result.data.map((comment: ApiComment) => ({
- ...comment,
- createdAt: new Date(comment.createdAt),
- updatedAt: new Date(comment.updatedAt),
- attachments: comment.attachments.map((att) => ({
- ...att,
- uploadedAt: new Date(att.uploadedAt)
- }))
- }));
-}
-
-// 벤더 코멘트 전송 함수
-export function sendVendorCommentClient(params: {
- rfqId: number;
- vendorId: number;
- content: string;
- attachments?: File[];
-}): Promise<TechSalesComment> {
- // 폼 데이터 생성 (파일 첨부를 위해)
- const formData = new FormData();
- formData.append('rfqId', params.rfqId.toString());
- formData.append('vendorId', params.vendorId.toString());
- formData.append('content', params.content);
- formData.append('isVendorComment', 'true'); // 벤더가 보내는 메시지이므로 true
-
- // 첨부파일 추가
- if (params.attachments && params.attachments.length > 0) {
- params.attachments.forEach((file) => {
- formData.append(`attachments`, file);
- });
- }
-
- // API 엔드포인트 구성 (techsales API 경로)
- const url = `/api/tech-sales-rfqs/${params.rfqId}/vendors/${params.vendorId}/comments`;
-
- console.log("API 요청 시작:", { url, params });
-
- // API 호출
- return fetch(url, {
- method: 'POST',
- body: formData, // multipart/form-data 형식 사용
- })
- .then(response => {
- console.log("API 응답 상태:", response.status);
-
- if (!response.ok) {
- return response.text().then(text => {
- console.error("API 에러 응답:", text);
- throw new Error(`API 요청 실패: ${response.status} ${text}`);
- });
- }
- return response.json();
- })
- .then(result => {
- console.log("API 응답 데이터:", result);
-
- if (!result.success || !result.data) {
- throw new Error(result.message || '코멘트 전송 중 오류가 발생했습니다');
- }
-
- // API 응답 타입 정의
- interface ApiAttachment {
- id: number;
- fileName: string;
- fileSize: number;
- fileType: string | null;
- filePath: string;
- uploadedAt: string;
- }
-
- interface ApiCommentResponse {
- id: number;
- rfqId: number;
- vendorId: number | null;
- userId?: number | null;
- content: string;
- isVendorComment: boolean | null;
- createdAt: string;
- updatedAt: string;
- userName?: string | null;
- isRead: boolean | null;
- attachments: ApiAttachment[];
- }
-
- const commentData = result.data.comment as ApiCommentResponse;
-
- return {
- ...commentData,
- createdAt: new Date(commentData.createdAt),
- updatedAt: new Date(commentData.updatedAt),
- attachments: commentData.attachments.map((att) => ({
- ...att,
- uploadedAt: new Date(att.uploadedAt)
- }))
- };
- })
- .catch(error => {
- console.error("클라이언트 API 호출 에러:", error);
- throw error;
- });
-}
-
-export function BuyerCommunicationDrawer({
- open,
- onOpenChange,
- quotation,
- onSuccess
-}: BuyerCommunicationDrawerProps) {
- // 세션 정보
- const { data: session } = useSession();
-
- // 상태 관리
- const [comments, setComments] = useState<TechSalesComment[]>([]);
- const [newComment, setNewComment] = useState("");
- const [attachments, setAttachments] = useState<File[]>([]);
- const [isLoading, setIsLoading] = useState(false);
- const [isSubmitting, setIsSubmitting] = useState(false);
- const fileInputRef = useRef<HTMLInputElement>(null);
- const messagesEndRef = useRef<HTMLDivElement>(null);
-
- // 자동 새로고침 관련 상태
- const [autoRefresh, setAutoRefresh] = useState(true);
- const [lastMessageCount, setLastMessageCount] = useState(0);
- const intervalRef = useRef<NodeJS.Timeout | null>(null);
-
- // 첨부파일 관련 상태
- const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
- const [selectedAttachment, setSelectedAttachment] = useState<TechSalesAttachment | null>(null);
-
- // 드로어가 열릴 때 데이터 로드
- useEffect(() => {
- if (open && quotation) {
- loadComments();
- // 자동 새로고침 시작
- if (autoRefresh) {
- startAutoRefresh();
- }
- } else {
- // 드로어가 닫히면 자동 새로고침 중지
- stopAutoRefresh();
- }
-
- // 컴포넌트 언마운트 시 정리
- return () => {
- stopAutoRefresh();
- };
- }, [open, quotation, autoRefresh]);
-
- // 스크롤 최하단으로 이동
- useEffect(() => {
- if (messagesEndRef.current) {
- messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
- }
- }, [comments]);
-
- // 자동 새로고침 시작
- const startAutoRefresh = () => {
- stopAutoRefresh(); // 기존 interval 정리
- intervalRef.current = setInterval(() => {
- if (open && quotation && !isSubmitting) {
- loadComments(true); // 자동 새로고침임을 표시
- }
- }, 60000); // 60초마다 새로고침
- };
-
- // 자동 새로고침 중지
- const stopAutoRefresh = () => {
- if (intervalRef.current) {
- clearInterval(intervalRef.current);
- intervalRef.current = null;
- }
- };
-
- // 자동 새로고침 토글
- const toggleAutoRefresh = () => {
- setAutoRefresh(prev => {
- const newValue = !prev;
- if (newValue && open) {
- startAutoRefresh();
- } else {
- stopAutoRefresh();
- }
- return newValue;
- });
- };
-
- // 코멘트 로드 함수 (자동 새로고침 여부 파라미터 추가)
- const loadComments = async (isAutoRefresh = false) => {
- if (!quotation) return;
-
- try {
- // 자동 새로고침일 때는 로딩 표시하지 않음
- if (!isAutoRefresh) {
- setIsLoading(true);
- }
-
- // API를 사용하여 코멘트 데이터 가져오기
- const commentsData = await fetchTechSalesVendorCommentsClient(quotation.rfqId, quotation.vendorId);
-
- // 새 메시지가 있는지 확인 (자동 새로고침일 때만)
- if (isAutoRefresh) {
- const newMessageCount = commentsData.length;
- if (newMessageCount > lastMessageCount && lastMessageCount > 0) {
- // 새 메시지 알림
- toast.success(`새 메시지 ${newMessageCount - lastMessageCount}개가 도착했습니다`);
- }
- setLastMessageCount(newMessageCount);
- } else {
- setLastMessageCount(commentsData.length);
- }
-
- setComments(commentsData);
-
- // 읽음 상태 처리는 API 측에서 처리되는 것으로 가정
- } catch (error) {
- console.error("코멘트 로드 오류:", error);
- if (!isAutoRefresh) { // 자동 새로고침일 때는 에러 토스트 표시하지 않음
- toast.error("메시지를 불러오는 중 오류가 발생했습니다");
- }
- } finally {
- // 항상 로딩 상태를 해제하되, 최소 200ms는 유지하여 깜빡거림 방지
- if (!isAutoRefresh) {
- setTimeout(() => {
- setIsLoading(false);
- }, 200);
- }
- }
- };
-
- // 파일 선택 핸들러
- const handleFileSelect = () => {
- fileInputRef.current?.click();
- };
-
- // 파일 변경 핸들러
- const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
- if (e.target.files && e.target.files.length > 0) {
- const newFiles = Array.from(e.target.files);
- setAttachments(prev => [...prev, ...newFiles]);
- }
- };
-
- // 파일 제거 핸들러
- const handleRemoveFile = (index: number) => {
- setAttachments(prev => prev.filter((_, i) => i !== index));
- };
-
- // 코멘트 전송 핸들러
- const handleSubmitComment = async () => {
- if (!newComment.trim() && attachments.length === 0) return;
- if (!quotation) return;
-
- try {
- setIsSubmitting(true);
-
- // API를 사용하여 새 코멘트 전송 (파일 업로드 때문에 FormData 사용)
- const newCommentObj = await sendVendorCommentClient({
- rfqId: quotation.rfqId,
- vendorId: quotation.vendorId,
- content: newComment,
- attachments: attachments
- });
-
- // 상태 업데이트
- setComments(prev => [...prev, newCommentObj]);
- setNewComment("");
- setAttachments([]);
-
- toast.success("메시지가 전송되었습니다");
-
- // 데이터 새로고침
- if (onSuccess) {
- onSuccess();
- }
- } catch (error) {
- console.error("코멘트 전송 오류:", error);
- toast.error("메시지 전송 중 오류가 발생했습니다");
- } finally {
- setIsSubmitting(false);
- }
- };
-
- // 첨부파일 미리보기
- const handleAttachmentPreview = (attachment: TechSalesAttachment) => {
- setSelectedAttachment(attachment);
- setPreviewDialogOpen(true);
- };
-
- // 첨부파일 다운로드
- const handleAttachmentDownload = (attachment: TechSalesAttachment) => {
- // 실제 다운로드 구현
- window.open(attachment.filePath, '_blank');
- };
-
- // 파일 아이콘 선택
- const getFileIcon = (fileType: string | null) => {
- if (!fileType) return <File className="h-5 w-5 text-gray-500" />;
- if (fileType.startsWith("image/")) return <ImageIcon className="h-5 w-5 text-blue-500" />;
- if (fileType.includes("pdf")) return <FileText className="h-5 w-5 text-red-500" />;
- if (fileType.includes("spreadsheet") || fileType.includes("excel"))
- return <FileText className="h-5 w-5 text-green-500" />;
- if (fileType.includes("document") || fileType.includes("word"))
- return <FileText className="h-5 w-5 text-blue-500" />;
- return <File className="h-5 w-5 text-gray-500" />;
- };
-
- // 첨부파일 미리보기 다이얼로그
- const renderAttachmentPreviewDialog = () => {
- if (!selectedAttachment) return null;
-
- const isImage = selectedAttachment.fileType?.startsWith("image/") || false;
- const isPdf = selectedAttachment.fileType?.includes("pdf") || false;
-
- return (
- <Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}>
- <DialogContent className="max-w-3xl">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- {getFileIcon(selectedAttachment.fileType)}
- {selectedAttachment.fileName}
- </DialogTitle>
- <DialogDescription>
- {formatFileSize(selectedAttachment.fileSize)} • {formatDateTime(selectedAttachment.uploadedAt)}
- </DialogDescription>
- </DialogHeader>
-
- <div className="min-h-[300px] flex items-center justify-center p-4">
- {isImage ? (
- <img
- src={selectedAttachment.filePath}
- alt={selectedAttachment.fileName}
- className="max-h-[500px] max-w-full object-contain"
- />
- ) : isPdf ? (
- <iframe
- src={`${selectedAttachment.filePath}#toolbar=0`}
- className="w-full h-[500px]"
- title={selectedAttachment.fileName}
- />
- ) : (
- <div className="flex flex-col items-center gap-4 p-8">
- {getFileIcon(selectedAttachment.fileType)}
- <p className="text-muted-foreground text-sm">미리보기를 지원하지 않는 파일 형식입니다.</p>
- <Button
- variant="outline"
- onClick={() => handleAttachmentDownload(selectedAttachment)}
- >
- <DownloadCloud className="h-4 w-4 mr-2" />
- 다운로드
- </Button>
- </div>
- )}
- </div>
- </DialogContent>
- </Dialog>
- );
- };
-
- if (!quotation) {
- return null;
- }
-
- // 구매자 정보 (실제로는 API에서 가져와야 함)
- const buyerName = "구매 담당자";
-
- return (
- <Drawer open={open} onOpenChange={onOpenChange}>
- <DrawerContent className="max-h-[80vh] flex flex-col">
- <DrawerHeader className="border-b flex-shrink-0">
- <DrawerTitle className="flex items-center gap-2">
- <Avatar className="h-8 w-8">
- <AvatarFallback className="bg-primary/10">
- <User className="h-4 w-4" />
- </AvatarFallback>
- </Avatar>
- <div>
- <span>{buyerName}</span>
- <Badge variant="outline" className="ml-2">구매자</Badge>
- </div>
- </DrawerTitle>
- <DrawerDescription>
- RFQ: {quotation.rfq?.rfqCode || "N/A"} • 견적서: {quotation.quotationCode}
- </DrawerDescription>
- </DrawerHeader>
-
- <div className="flex flex-col flex-1 min-h-0">
- {/* 메시지 목록 */}
- <div className="flex-1 p-4 overflow-y-auto min-h-[300px]">
- {isLoading && comments.length === 0 ? (
- <div className="flex h-full items-center justify-center">
- <p className="text-muted-foreground">메시지 로딩 중...</p>
- </div>
- ) : comments.length === 0 ? (
- <div className="flex h-full items-center justify-center">
- <div className="flex flex-col items-center gap-2">
- <AlertCircle className="h-6 w-6 text-muted-foreground" />
- <p className="text-muted-foreground">아직 메시지가 없습니다</p>
- </div>
- </div>
- ) : (
- <div className="space-y-4 relative">
- {isLoading && (
- <div className="absolute top-0 right-0 z-10 bg-background/80 backdrop-blur-sm rounded-md px-2 py-1">
- <div className="flex items-center gap-2">
- <div className="w-2 h-2 bg-primary rounded-full animate-pulse" />
- <span className="text-xs text-muted-foreground">새로고침 중...</span>
- </div>
- </div>
- )}
- {comments.map(comment => (
- <div
- key={comment.id}
- className={`flex gap-3 ${comment.isVendorComment ? 'justify-end' : 'justify-start'}`}
- >
- {!comment.isVendorComment && (
- <Avatar className="h-8 w-8 mt-1">
- <AvatarFallback className="bg-primary/10">
- <User className="h-4 w-4" />
- </AvatarFallback>
- </Avatar>
- )}
-
- <div className={`rounded-lg p-3 max-w-[80%] ${comment.isVendorComment
- ? 'bg-primary text-primary-foreground'
- : 'bg-muted'
- }`}>
- <div className="text-sm font-medium mb-1">
- {comment.isVendorComment ? (
- session?.user?.name || "벤더"
- ) : (
- comment.userName || buyerName
- )}
- </div>
-
- {comment.content && (
- <div className="text-sm whitespace-pre-wrap break-words">
- {comment.content}
- </div>
- )}
-
- {/* 첨부파일 표시 */}
- {comment.attachments.length > 0 && (
- <div className={`mt-2 pt-2 ${comment.isVendorComment
- ? 'border-t border-t-primary-foreground/20'
- : 'border-t border-t-border/30'
- }`}>
- {comment.attachments.map(attachment => (
- <div
- key={attachment.id}
- className="flex items-center text-xs gap-2 mb-1 p-1 rounded hover:bg-black/5 cursor-pointer"
- onClick={() => handleAttachmentPreview(attachment)}
- >
- {getFileIcon(attachment.fileType)}
- <span className="flex-1 truncate">{attachment.fileName}</span>
- <span className="text-xs opacity-70">
- {formatFileSize(attachment.fileSize)}
- </span>
- <Button
- variant="ghost"
- size="icon"
- className="h-6 w-6 rounded-full"
- onClick={(e) => {
- e.stopPropagation();
- handleAttachmentDownload(attachment);
- }}
- >
- <DownloadCloud className="h-3 w-3" />
- </Button>
- </div>
- ))}
- </div>
- )}
-
- <div className="text-xs mt-1 opacity-70 flex items-center gap-1 justify-end">
- {formatDateTime(comment.createdAt)}
- </div>
- </div>
-
- {comment.isVendorComment && (
- <Avatar className="h-8 w-8 mt-1">
- <AvatarFallback className="bg-primary/20">
- <Building className="h-4 w-4" />
- </AvatarFallback>
- </Avatar>
- )}
- </div>
- ))}
- <div ref={messagesEndRef} />
- </div>
- )}
- </div>
-
- {/* 선택된 첨부파일 표시 */}
- {attachments.length > 0 && (
- <div className="p-2 bg-muted mx-4 rounded-md mb-2 flex-shrink-0">
- <div className="text-xs font-medium mb-1">첨부파일</div>
- <div className="flex flex-wrap gap-2">
- {attachments.map((file, index) => (
- <div key={index} className="flex items-center bg-background rounded-md p-1 pr-2 text-xs">
- {file.type.startsWith("image/") ? (
- <ImageIcon className="h-4 w-4 mr-1 text-blue-500" />
- ) : (
- <File className="h-4 w-4 mr-1 text-gray-500" />
- )}
- <span className="truncate max-w-[100px]">{file.name}</span>
- <Button
- variant="ghost"
- size="icon"
- className="h-4 w-4 ml-1 p-0"
- onClick={() => handleRemoveFile(index)}
- >
- <X className="h-3 w-3" />
- </Button>
- </div>
- ))}
- </div>
- </div>
- )}
-
- {/* 메시지 입력 영역 */}
- <div className="p-4 border-t flex-shrink-0">
- <div className="flex gap-2 items-end">
- <div className="flex-1">
- <Textarea
- placeholder="메시지를 입력하세요..."
- className="min-h-[80px] resize-none"
- value={newComment}
- onChange={(e) => setNewComment(e.target.value)}
- />
- </div>
- <div className="flex flex-col gap-2">
- <input
- type="file"
- ref={fileInputRef}
- className="hidden"
- multiple
- onChange={handleFileChange}
- />
- <Button
- variant="outline"
- size="icon"
- onClick={handleFileSelect}
- title="파일 첨부"
- >
- <Paperclip className="h-4 w-4" />
- </Button>
- <Button
- onClick={handleSubmitComment}
- disabled={(!newComment.trim() && attachments.length === 0) || isSubmitting}
- >
- <Send className="h-4 w-4" />
- </Button>
- </div>
- </div>
- </div>
- </div>
-
- <DrawerFooter className="border-t flex-shrink-0">
- <div className="flex justify-between items-center">
- <div className="flex items-center gap-2">
- <Button variant="outline" onClick={() => loadComments()}>
- 새로고침
- </Button>
- <Button
- variant={autoRefresh ? "default" : "outline"}
- size="sm"
- onClick={toggleAutoRefresh}
- className="gap-2"
- >
- {autoRefresh ? (
- <>
- <div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
- 자동 새로고침 ON
- </>
- ) : (
- <>
- <div className="w-2 h-2 bg-gray-400 rounded-full" />
- 자동 새로고침 OFF
- </>
- )}
- </Button>
- </div>
- <DrawerClose asChild>
- <Button variant="outline">닫기</Button>
- </DrawerClose>
- </div>
- </DrawerFooter>
- </DrawerContent>
-
- {renderAttachmentPreviewDialog()}
- </Drawer>
- );
+"use client"
+
+import * as React from "react"
+import { useState, useEffect, useRef } from "react"
+import { toast } from "sonner"
+import {
+ Send,
+ Paperclip,
+ DownloadCloud,
+ File,
+ FileText,
+ Image as ImageIcon,
+ AlertCircle,
+ X,
+ User,
+ Building
+} from "lucide-react"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+} from "@/components/ui/drawer"
+import { Button } from "@/components/ui/button"
+import { Textarea } from "@/components/ui/textarea"
+import { Avatar, AvatarFallback } from "@/components/ui/avatar"
+import { Badge } from "@/components/ui/badge"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { formatDateTime, formatFileSize } from "@/lib/utils"
+import { useSession } from "next-auth/react"
+
+// 타입 정의
+export interface TechSalesAttachment {
+ id: number
+ fileName: string
+ originalFileName: string
+ fileSize: number
+ fileType: string | null
+ filePath: string
+ uploadedAt: Date
+}
+
+export interface TechSalesComment {
+ id: number
+ rfqId: number
+ vendorId: number | null
+ userId?: number | null
+ content: string
+ isVendorComment: boolean | null
+ createdAt: Date
+ updatedAt: Date
+ userName?: string | null
+ vendorName?: string | null
+ attachments: TechSalesAttachment[]
+ isRead: boolean | null
+}
+
+// 프롭스 정의
+interface BuyerCommunicationDrawerProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ quotation: {
+ id: number;
+ rfqId: number;
+ vendorId: number;
+ quotationCode: string | null;
+ rfq?: {
+ rfqCode: string | null;
+ };
+ } | null;
+ onSuccess?: () => void;
+}
+
+// 클라이언트에서 API를 통해 코멘트를 가져오는 함수
+export async function fetchTechSalesVendorCommentsClient(rfqId: number, vendorId: number): Promise<TechSalesComment[]> {
+ const response = await fetch(`/api/tech-sales-rfqs/${rfqId}/vendors/${vendorId}/comments`);
+
+ if (!response.ok) {
+ throw new Error(`API 요청 실패: ${response.status}`);
+ }
+
+ const result = await response.json();
+
+ if (!result.success) {
+ throw new Error(result.message || '코멘트 조회 중 오류가 발생했습니다');
+ }
+
+ // API 응답 타입 정의
+ interface ApiComment {
+ id: number;
+ rfqId: number;
+ vendorId: number | null;
+ userId?: number | null;
+ content: string;
+ isVendorComment: boolean | null;
+ createdAt: string;
+ updatedAt: string;
+ userName?: string | null;
+ vendorName?: string | null;
+ isRead: boolean | null;
+ attachments: Array<{
+ id: number;
+ fileName: string;
+ originalFileName: string;
+ fileSize: number;
+ fileType: string | null;
+ filePath: string;
+ uploadedAt: string;
+ }>;
+ }
+
+ return result.data.map((comment: ApiComment) => ({
+ ...comment,
+ createdAt: new Date(comment.createdAt),
+ updatedAt: new Date(comment.updatedAt),
+ attachments: comment.attachments.map((att) => ({
+ ...att,
+ uploadedAt: new Date(att.uploadedAt)
+ }))
+ }));
+}
+
+// 벤더 코멘트 전송 함수
+export function sendVendorCommentClient(params: {
+ rfqId: number;
+ vendorId: number;
+ content: string;
+ attachments?: File[];
+}): Promise<TechSalesComment> {
+ // 폼 데이터 생성 (파일 첨부를 위해)
+ const formData = new FormData();
+ formData.append('rfqId', params.rfqId.toString());
+ formData.append('vendorId', params.vendorId.toString());
+ formData.append('content', params.content);
+ formData.append('isVendorComment', 'true'); // 벤더가 보내는 메시지이므로 true
+
+ // 첨부파일 추가
+ if (params.attachments && params.attachments.length > 0) {
+ params.attachments.forEach((file) => {
+ formData.append(`attachments`, file);
+ });
+ }
+
+ // API 엔드포인트 구성 (techsales API 경로)
+ const url = `/api/tech-sales-rfqs/${params.rfqId}/vendors/${params.vendorId}/comments`;
+
+ console.log("API 요청 시작:", { url, params });
+
+ // API 호출
+ return fetch(url, {
+ method: 'POST',
+ body: formData, // multipart/form-data 형식 사용
+ })
+ .then(response => {
+ console.log("API 응답 상태:", response.status);
+
+ if (!response.ok) {
+ return response.text().then(text => {
+ console.error("API 에러 응답:", text);
+ throw new Error(`API 요청 실패: ${response.status} ${text}`);
+ });
+ }
+ return response.json();
+ })
+ .then(result => {
+ console.log("API 응답 데이터:", result);
+
+ if (!result.success || !result.data) {
+ throw new Error(result.message || '코멘트 전송 중 오류가 발생했습니다');
+ }
+
+ // API 응답 타입 정의
+ interface ApiAttachment {
+ id: number;
+ fileName: string;
+ originalFileName: string;
+ fileSize: number;
+ fileType: string | null;
+ filePath: string;
+ uploadedAt: string;
+ }
+
+ interface ApiCommentResponse {
+ id: number;
+ rfqId: number;
+ vendorId: number | null;
+ userId?: number | null;
+ content: string;
+ isVendorComment: boolean | null;
+ createdAt: string;
+ updatedAt: string;
+ userName?: string | null;
+ isRead: boolean | null;
+ attachments: ApiAttachment[];
+ }
+
+ const commentData = result.data.comment as ApiCommentResponse;
+
+ return {
+ ...commentData,
+ createdAt: new Date(commentData.createdAt),
+ updatedAt: new Date(commentData.updatedAt),
+ attachments: commentData.attachments.map((att) => ({
+ ...att,
+ uploadedAt: new Date(att.uploadedAt)
+ }))
+ };
+ })
+ .catch(error => {
+ console.error("클라이언트 API 호출 에러:", error);
+ throw error;
+ });
+}
+
+export function BuyerCommunicationDrawer({
+ open,
+ onOpenChange,
+ quotation,
+ onSuccess
+}: BuyerCommunicationDrawerProps) {
+ // 세션 정보
+ const { data: session } = useSession();
+
+ // 상태 관리
+ const [comments, setComments] = useState<TechSalesComment[]>([]);
+ const [newComment, setNewComment] = useState("");
+ const [attachments, setAttachments] = useState<File[]>([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const fileInputRef = useRef<HTMLInputElement>(null);
+ const messagesEndRef = useRef<HTMLDivElement>(null);
+
+ // 자동 새로고침 관련 상태
+ const [autoRefresh, setAutoRefresh] = useState(true);
+ const [lastMessageCount, setLastMessageCount] = useState(0);
+ const intervalRef = useRef<NodeJS.Timeout | null>(null);
+
+ // 첨부파일 관련 상태
+ const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
+ const [selectedAttachment, setSelectedAttachment] = useState<TechSalesAttachment | null>(null);
+
+ // 드로어가 열릴 때 데이터 로드
+ useEffect(() => {
+ if (open && quotation) {
+ loadComments();
+ // 자동 새로고침 시작
+ if (autoRefresh) {
+ startAutoRefresh();
+ }
+ } else {
+ // 드로어가 닫히면 자동 새로고침 중지
+ stopAutoRefresh();
+ }
+
+ // 컴포넌트 언마운트 시 정리
+ return () => {
+ stopAutoRefresh();
+ };
+ }, [open, quotation, autoRefresh]);
+
+ // 스크롤 최하단으로 이동
+ useEffect(() => {
+ if (messagesEndRef.current) {
+ messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
+ }
+ }, [comments]);
+
+ // 자동 새로고침 시작
+ const startAutoRefresh = () => {
+ stopAutoRefresh(); // 기존 interval 정리
+ intervalRef.current = setInterval(() => {
+ if (open && quotation && !isSubmitting) {
+ loadComments(true); // 자동 새로고침임을 표시
+ }
+ }, 60000); // 60초마다 새로고침
+ };
+
+ // 자동 새로고침 중지
+ const stopAutoRefresh = () => {
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current);
+ intervalRef.current = null;
+ }
+ };
+
+ // 자동 새로고침 토글
+ const toggleAutoRefresh = () => {
+ setAutoRefresh(prev => {
+ const newValue = !prev;
+ if (newValue && open) {
+ startAutoRefresh();
+ } else {
+ stopAutoRefresh();
+ }
+ return newValue;
+ });
+ };
+
+ // 코멘트 로드 함수 (자동 새로고침 여부 파라미터 추가)
+ const loadComments = async (isAutoRefresh = false) => {
+ if (!quotation) return;
+
+ try {
+ // 자동 새로고침일 때는 로딩 표시하지 않음
+ if (!isAutoRefresh) {
+ setIsLoading(true);
+ }
+
+ // API를 사용하여 코멘트 데이터 가져오기
+ const commentsData = await fetchTechSalesVendorCommentsClient(quotation.rfqId, quotation.vendorId);
+
+ // 새 메시지가 있는지 확인 (자동 새로고침일 때만)
+ if (isAutoRefresh) {
+ const newMessageCount = commentsData.length;
+ if (newMessageCount > lastMessageCount && lastMessageCount > 0) {
+ // 새 메시지 알림
+ toast.success(`새 메시지 ${newMessageCount - lastMessageCount}개가 도착했습니다`);
+ }
+ setLastMessageCount(newMessageCount);
+ } else {
+ setLastMessageCount(commentsData.length);
+ }
+
+ setComments(commentsData);
+
+ // 읽음 상태 처리는 API 측에서 처리되는 것으로 가정
+ } catch (error) {
+ console.error("코멘트 로드 오류:", error);
+ if (!isAutoRefresh) { // 자동 새로고침일 때는 에러 토스트 표시하지 않음
+ toast.error("메시지를 불러오는 중 오류가 발생했습니다");
+ }
+ } finally {
+ // 항상 로딩 상태를 해제하되, 최소 200ms는 유지하여 깜빡거림 방지
+ if (!isAutoRefresh) {
+ setTimeout(() => {
+ setIsLoading(false);
+ }, 200);
+ }
+ }
+ };
+
+ // 파일 선택 핸들러
+ const handleFileSelect = () => {
+ fileInputRef.current?.click();
+ };
+
+ // 파일 변경 핸들러
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ if (e.target.files && e.target.files.length > 0) {
+ const newFiles = Array.from(e.target.files);
+ setAttachments(prev => [...prev, ...newFiles]);
+ }
+ };
+
+ // 파일 제거 핸들러
+ const handleRemoveFile = (index: number) => {
+ setAttachments(prev => prev.filter((_, i) => i !== index));
+ };
+
+ // 코멘트 전송 핸들러
+ const handleSubmitComment = async () => {
+ if (!newComment.trim() && attachments.length === 0) return;
+ if (!quotation) return;
+
+ try {
+ setIsSubmitting(true);
+
+ // API를 사용하여 새 코멘트 전송 (파일 업로드 때문에 FormData 사용)
+ const newCommentObj = await sendVendorCommentClient({
+ rfqId: quotation.rfqId,
+ vendorId: quotation.vendorId,
+ content: newComment,
+ attachments: attachments
+ });
+
+ // 상태 업데이트
+ setComments(prev => [...prev, newCommentObj]);
+ setNewComment("");
+ setAttachments([]);
+
+ toast.success("메시지가 전송되었습니다");
+
+ // 데이터 새로고침
+ if (onSuccess) {
+ onSuccess();
+ }
+ } catch (error) {
+ console.error("코멘트 전송 오류:", error);
+ toast.error("메시지 전송 중 오류가 발생했습니다");
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ // 첨부파일 미리보기
+ const handleAttachmentPreview = (attachment: TechSalesAttachment) => {
+ setSelectedAttachment(attachment);
+ setPreviewDialogOpen(true);
+ };
+
+ // 첨부파일 다운로드 핸들러
+ const handleDownloadClick = React.useCallback(async (attachment: TechSalesAttachment) => {
+ try {
+ const { downloadFile } = await import('@/lib/file-download');
+ await downloadFile(attachment.filePath, attachment.originalFileName || attachment.fileName, {
+ showToast: true,
+ onError: (error) => {
+ console.error('다운로드 오류:', error);
+ toast.error(error);
+ },
+ onSuccess: (fileName, fileSize) => {
+ console.log(`다운로드 성공: ${fileName} (${fileSize} bytes)`);
+ }
+ });
+ } catch (error) {
+ console.error('다운로드 오류:', error);
+ toast.error('파일 다운로드 중 오류가 발생했습니다.');
+ }
+ }, []);
+
+ // 파일 아이콘 선택
+ const getFileIcon = (fileType: string | null) => {
+ if (!fileType) return <File className="h-5 w-5 text-gray-500" />;
+ if (fileType.startsWith("image/")) return <ImageIcon className="h-5 w-5 text-blue-500" />;
+ if (fileType.includes("pdf")) return <FileText className="h-5 w-5 text-red-500" />;
+ if (fileType.includes("spreadsheet") || fileType.includes("excel"))
+ return <FileText className="h-5 w-5 text-green-500" />;
+ if (fileType.includes("document") || fileType.includes("word"))
+ return <FileText className="h-5 w-5 text-blue-500" />;
+ return <File className="h-5 w-5 text-gray-500" />;
+ };
+
+ // 첨부파일 미리보기 다이얼로그
+ const renderAttachmentPreviewDialog = () => {
+ if (!selectedAttachment) return null;
+
+ const isImage = selectedAttachment.fileType?.startsWith("image/") || false;
+ const isPdf = selectedAttachment.fileType?.includes("pdf") || false;
+
+ return (
+ <Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}>
+ <DialogContent className="max-w-3xl">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ {getFileIcon(selectedAttachment.fileType)}
+ {selectedAttachment.originalFileName || selectedAttachment.fileName}
+ </DialogTitle>
+ <DialogDescription>
+ {formatFileSize(selectedAttachment.fileSize)} • {formatDateTime(selectedAttachment.uploadedAt)}
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="min-h-[300px] flex items-center justify-center p-4">
+ {isImage ? (
+ // eslint-disable-next-line @next/next/no-img-element
+ <img
+ src={selectedAttachment.filePath}
+ alt={selectedAttachment.originalFileName || selectedAttachment.fileName}
+ className="max-h-[500px] max-w-full object-contain"
+ />
+ ) : isPdf ? (
+ <iframe
+ src={`${selectedAttachment.filePath}#toolbar=0`}
+ className="w-full h-[500px]"
+ title={selectedAttachment.originalFileName || selectedAttachment.fileName}
+ />
+ ) : (
+ <div className="flex flex-col items-center gap-4 p-8">
+ {getFileIcon(selectedAttachment.fileType)}
+ <p className="text-muted-foreground text-sm">미리보기를 지원하지 않는 파일 형식입니다.</p>
+ <Button
+ variant="outline"
+ onClick={() => handleDownloadClick(selectedAttachment)}
+ >
+ <DownloadCloud className="h-4 w-4 mr-2" />
+ 다운로드
+ </Button>
+ </div>
+ )}
+ </div>
+ </DialogContent>
+ </Dialog>
+ );
+ };
+
+ if (!quotation) {
+ return null;
+ }
+
+ // 구매자 정보 (실제로는 API에서 가져와야 함)
+ const buyerName = "구매 담당자";
+
+ return (
+ <Drawer open={open} onOpenChange={onOpenChange}>
+ <DrawerContent className="max-h-[80vh] flex flex-col">
+ <DrawerHeader className="border-b flex-shrink-0">
+ <DrawerTitle className="flex items-center gap-2">
+ <Avatar className="h-8 w-8">
+ <AvatarFallback className="bg-primary/10">
+ <User className="h-4 w-4" />
+ </AvatarFallback>
+ </Avatar>
+ <div>
+ <span>{buyerName}</span>
+ <Badge variant="outline" className="ml-2">구매자</Badge>
+ </div>
+ </DrawerTitle>
+ <DrawerDescription>
+ RFQ: {quotation.rfq?.rfqCode || "N/A"} • 견적서: {quotation.quotationCode}
+ </DrawerDescription>
+ </DrawerHeader>
+
+ <div className="flex flex-col flex-1 min-h-0">
+ {/* 메시지 목록 */}
+ <div className="flex-1 p-4 overflow-y-auto min-h-[300px]">
+ {isLoading && comments.length === 0 ? (
+ <div className="flex h-full items-center justify-center">
+ <p className="text-muted-foreground">메시지 로딩 중...</p>
+ </div>
+ ) : comments.length === 0 ? (
+ <div className="flex h-full items-center justify-center">
+ <div className="flex flex-col items-center gap-2">
+ <AlertCircle className="h-6 w-6 text-muted-foreground" />
+ <p className="text-muted-foreground">아직 메시지가 없습니다</p>
+ </div>
+ </div>
+ ) : (
+ <div className="space-y-4 relative">
+ {isLoading && (
+ <div className="absolute top-0 right-0 z-10 bg-background/80 backdrop-blur-sm rounded-md px-2 py-1">
+ <div className="flex items-center gap-2">
+ <div className="w-2 h-2 bg-primary rounded-full animate-pulse" />
+ <span className="text-xs text-muted-foreground">새로고침 중...</span>
+ </div>
+ </div>
+ )}
+ {comments.map(comment => (
+ <div
+ key={comment.id}
+ className={`flex gap-3 ${comment.isVendorComment ? 'justify-end' : 'justify-start'}`}
+ >
+ {!comment.isVendorComment && (
+ <Avatar className="h-8 w-8 mt-1">
+ <AvatarFallback className="bg-primary/10">
+ <User className="h-4 w-4" />
+ </AvatarFallback>
+ </Avatar>
+ )}
+
+ <div className={`rounded-lg p-3 max-w-[80%] ${comment.isVendorComment
+ ? 'bg-primary text-primary-foreground'
+ : 'bg-muted'
+ }`}>
+ <div className="text-sm font-medium mb-1">
+ {comment.isVendorComment ? (
+ session?.user?.name || "벤더"
+ ) : (
+ comment.userName || buyerName
+ )}
+ </div>
+
+ {comment.content && (
+ <div className="text-sm whitespace-pre-wrap break-words">
+ {comment.content}
+ </div>
+ )}
+
+ {/* 첨부파일 표시 */}
+ {comment.attachments.length > 0 && (
+ <div className={`mt-2 pt-2 ${comment.isVendorComment
+ ? 'border-t border-t-primary-foreground/20'
+ : 'border-t border-t-border/30'
+ }`}>
+ {comment.attachments.map(attachment => (
+ <div
+ key={attachment.id}
+ className="flex items-center text-xs gap-2 mb-1 p-1 rounded hover:bg-black/5 cursor-pointer"
+ onClick={() => handleAttachmentPreview(attachment)}
+ >
+ {getFileIcon(attachment.fileType)}
+ <span className="flex-1 truncate">{attachment.originalFileName || attachment.fileName}</span>
+ <span className="text-xs opacity-70">
+ {formatFileSize(attachment.fileSize)}
+ </span>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-6 w-6 rounded-full"
+ onClick={(e) => {
+ e.stopPropagation();
+ handleDownloadClick(attachment);
+ }}
+ >
+ <DownloadCloud className="h-3 w-3" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ )}
+
+ <div className="text-xs mt-1 opacity-70 flex items-center gap-1 justify-end">
+ {formatDateTime(comment.createdAt)}
+ </div>
+ </div>
+
+ {comment.isVendorComment && (
+ <Avatar className="h-8 w-8 mt-1">
+ <AvatarFallback className="bg-primary/20">
+ <Building className="h-4 w-4" />
+ </AvatarFallback>
+ </Avatar>
+ )}
+ </div>
+ ))}
+ <div ref={messagesEndRef} />
+ </div>
+ )}
+ </div>
+
+ {/* 선택된 첨부파일 표시 */}
+ {attachments.length > 0 && (
+ <div className="p-2 bg-muted mx-4 rounded-md mb-2 flex-shrink-0">
+ <div className="text-xs font-medium mb-1">첨부파일</div>
+ <div className="flex flex-wrap gap-2">
+ {attachments.map((file, index) => (
+ <div key={index} className="flex items-center bg-background rounded-md p-1 pr-2 text-xs">
+ {file.type.startsWith("image/") ? (
+ <ImageIcon className="h-4 w-4 mr-1 text-blue-500" />
+ ) : (
+ <File className="h-4 w-4 mr-1 text-gray-500" />
+ )}
+ <span className="truncate max-w-[100px]">{file.name}</span>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-4 w-4 ml-1 p-0"
+ onClick={() => handleRemoveFile(index)}
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* 메시지 입력 영역 */}
+ <div className="p-4 border-t flex-shrink-0">
+ <div className="flex gap-2 items-end">
+ <div className="flex-1">
+ <Textarea
+ placeholder="메시지를 입력하세요..."
+ className="min-h-[80px] resize-none"
+ value={newComment}
+ onChange={(e) => setNewComment(e.target.value)}
+ />
+ </div>
+ <div className="flex flex-col gap-2">
+ <input
+ type="file"
+ ref={fileInputRef}
+ className="hidden"
+ multiple
+ onChange={handleFileChange}
+ />
+ <Button
+ variant="outline"
+ size="icon"
+ onClick={handleFileSelect}
+ title="파일 첨부"
+ >
+ <Paperclip className="h-4 w-4" />
+ </Button>
+ <Button
+ onClick={handleSubmitComment}
+ disabled={(!newComment.trim() && attachments.length === 0) || isSubmitting}
+ >
+ <Send className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <DrawerFooter className="border-t flex-shrink-0">
+ <div className="flex justify-between items-center">
+ <div className="flex items-center gap-2">
+ <Button variant="outline" onClick={() => loadComments()}>
+ 새로고침
+ </Button>
+ <Button
+ variant={autoRefresh ? "default" : "outline"}
+ size="sm"
+ onClick={toggleAutoRefresh}
+ className="gap-2"
+ >
+ {autoRefresh ? (
+ <>
+ <div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
+ 자동 새로고침 ON
+ </>
+ ) : (
+ <>
+ <div className="w-2 h-2 bg-gray-400 rounded-full" />
+ 자동 새로고침 OFF
+ </>
+ )}
+ </Button>
+ </div>
+ <DrawerClose asChild>
+ <Button variant="outline">닫기</Button>
+ </DrawerClose>
+ </div>
+ </DrawerFooter>
+ </DrawerContent>
+
+ {renderAttachmentPreviewDialog()}
+ </Drawer>
+ );
} \ No newline at end of file
diff --git a/lib/techsales-rfq/vendor-response/detail/communication-tab.tsx b/lib/techsales-rfq/vendor-response/detail/communication-tab.tsx
index 3f2a5280..5bed179e 100644
--- a/lib/techsales-rfq/vendor-response/detail/communication-tab.tsx
+++ b/lib/techsales-rfq/vendor-response/detail/communication-tab.tsx
@@ -1,209 +1,209 @@
-"use client"
-
-import * as React from "react"
-import { useState, useEffect } from "react"
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
-import { Button } from "@/components/ui/button"
-import { Badge } from "@/components/ui/badge"
-import { ScrollArea } from "@/components/ui/scroll-area"
-import { Skeleton } from "@/components/ui/skeleton"
-import { MessageSquare, Paperclip } from "lucide-react"
-import { fetchTechSalesVendorCommentsClient, TechSalesComment } from "../buyer-communication-drawer"
-import { BuyerCommunicationDrawer } from "../buyer-communication-drawer"
-
-interface CommunicationTabProps {
- quotation: {
- id: number
- rfqId: number
- vendorId: number
- quotationCode: string | null
- rfq: {
- id: number
- rfqCode: string | null
- createdByUser?: {
- id: number
- name: string | null
- email: string | null
- } | null
- } | null
- vendor: {
- vendorName: string
- } | null
- }
-}
-
-export function CommunicationTab({ quotation }: CommunicationTabProps) {
- const [comments, setComments] = useState<TechSalesComment[]>([]);
- const [unreadCount, setUnreadCount] = useState(0);
- const [loadingComments, setLoadingComments] = useState(false);
- const [communicationDrawerOpen, setCommunicationDrawerOpen] = useState(false);
-
- // 컴포넌트 마운트 시 메시지 미리 로드
- useEffect(() => {
- if (quotation) {
- loadCommunicationData();
- }
- }, [quotation]);
-
- // 메시지 데이터 로드 함수
- const loadCommunicationData = async () => {
- try {
- setLoadingComments(true);
- const commentsData = await fetchTechSalesVendorCommentsClient(quotation.rfqId, quotation.vendorId);
- setComments(commentsData);
-
- // 읽지 않은 메시지 수 계산 (구매자가 보낸 메시지 중 읽지 않은 것)
- const unread = commentsData.filter(
- comment => !comment.isVendorComment && !comment.isRead
- ).length;
- setUnreadCount(unread);
- } catch (error) {
- console.error("메시지 데이터 로드 오류:", error);
- } finally {
- setLoadingComments(false);
- }
- };
-
- // 커뮤니케이션 드로어가 닫힐 때 데이터 새로고침
- const handleCommunicationDrawerChange = (open: boolean) => {
- setCommunicationDrawerOpen(open);
- if (!open) {
- loadCommunicationData(); // 드로어가 닫힐 때 데이터 새로고침
- }
- };
-
- return (
- <div className="h-full flex flex-col">
- {/* 헤더 */}
- <Card className="mb-4">
- <CardHeader className="flex flex-row items-center justify-between">
- <div>
- <CardTitle className="flex items-center gap-2">
- <MessageSquare className="h-5 w-5" />
- 커뮤니케이션
- {unreadCount > 0 && (
- <Badge variant="destructive" className="ml-2">
- 새 메시지 {unreadCount}
- </Badge>
- )}
- </CardTitle>
- <CardDescription>
- RFQ {quotation.rfq?.rfqCode || "미할당"}에 대한 구매담당자와의 커뮤니케이션
- </CardDescription>
- </div>
- <Button
- onClick={() => setCommunicationDrawerOpen(true)}
- variant="outline"
- size="sm"
- >
- <MessageSquare className="h-4 w-4 mr-2" />
- {unreadCount > 0 ? "새 메시지 확인" : "메시지 보내기"}
- </Button>
- </CardHeader>
- <CardContent>
- <div className="flex items-center gap-4 text-sm text-muted-foreground">
- <span>구매담당자: {quotation.rfq?.createdByUser?.name || "N/A"}</span>
- <span>•</span>
- <span>벤더: {quotation.vendor?.vendorName}</span>
- </div>
- </CardContent>
- </Card>
-
- {/* 메시지 미리보기 */}
- <Card className="flex-1 flex flex-col min-h-0">
- <CardHeader>
- <CardTitle className="text-lg">메시지 ({comments.length})</CardTitle>
- </CardHeader>
- <CardContent>
- {loadingComments ? (
- <div className="flex items-center justify-center p-8">
- <div className="text-center">
- <Skeleton className="h-4 w-32 mx-auto mb-2" />
- <Skeleton className="h-4 w-48 mx-auto" />
- </div>
- </div>
- ) : comments.length === 0 ? (
- <div className="min-h-[200px] flex flex-col items-center justify-center text-center p-8">
- <div className="max-w-md">
- <div className="mx-auto bg-primary/10 rounded-full w-12 h-12 flex items-center justify-center mb-4">
- <MessageSquare className="h-6 w-6 text-primary" />
- </div>
- <h3 className="text-lg font-medium mb-2">아직 메시지가 없습니다</h3>
- <p className="text-muted-foreground mb-4">
- 견적서에 대한 질문이나 의견이 있으신가요? 구매자와 메시지를 주고받으세요.
- </p>
- <Button
- onClick={() => setCommunicationDrawerOpen(true)}
- className="mx-auto"
- >
- 메시지 보내기
- </Button>
- </div>
- </div>
- ) : (
- <div className="space-y-4">
- {/* 최근 메시지 3개 미리보기 */}
- <div className="space-y-2">
- <h3 className="text-sm font-medium">최근 메시지</h3>
- <ScrollArea className="h-[250px] rounded-md border p-4">
- {comments.slice(-3).map(comment => (
- <div
- key={comment.id}
- className={`p-3 mb-3 rounded-lg ${!comment.isVendorComment && !comment.isRead
- ? 'bg-primary/10 border-l-4 border-primary'
- : 'bg-muted/50'
- }`}
- >
- <div className="flex justify-between items-center mb-1">
- <span className="text-sm font-medium">
- {comment.isVendorComment
- ? '나'
- : comment.userName || '구매 담당자'}
- </span>
- <span className="text-xs text-muted-foreground">
- {new Date(comment.createdAt).toLocaleDateString()}
- </span>
- </div>
- <p className="text-sm line-clamp-2">{comment.content}</p>
- {comment.attachments.length > 0 && (
- <div className="mt-1 text-xs text-muted-foreground">
- <Paperclip className="h-3 w-3 inline mr-1" />
- 첨부파일 {comment.attachments.length}개
- </div>
- )}
- </div>
- ))}
- </ScrollArea>
- </div>
-
- <div className="flex justify-center">
- <Button
- onClick={() => setCommunicationDrawerOpen(true)}
- className="w-full"
- >
- 전체 메시지 보기 ({comments.length}개)
- </Button>
- </div>
- </div>
- )}
- </CardContent>
- </Card>
-
- {/* 커뮤니케이션 드로어 */}
- <BuyerCommunicationDrawer
- open={communicationDrawerOpen}
- onOpenChange={handleCommunicationDrawerChange}
- quotation={{
- id: quotation.id,
- rfqId: quotation.rfqId,
- vendorId: quotation.vendorId,
- quotationCode: quotation.quotationCode,
- rfq: quotation.rfq ? {
- rfqCode: quotation.rfq.rfqCode
- } : undefined
- }}
- onSuccess={loadCommunicationData}
- />
- </div>
- )
+"use client"
+
+import * as React from "react"
+import { useState, useEffect } from "react"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { Skeleton } from "@/components/ui/skeleton"
+import { MessageSquare, Paperclip } from "lucide-react"
+import { fetchTechSalesVendorCommentsClient, TechSalesComment } from "../buyer-communication-drawer"
+import { BuyerCommunicationDrawer } from "../buyer-communication-drawer"
+
+interface CommunicationTabProps {
+ quotation: {
+ id: number
+ rfqId: number
+ vendorId: number
+ quotationCode: string | null
+ rfq: {
+ id: number
+ rfqCode: string | null
+ createdByUser?: {
+ id: number
+ name: string | null
+ email: string | null
+ } | null
+ } | null
+ vendor: {
+ vendorName: string
+ } | null
+ }
+}
+
+export function CommunicationTab({ quotation }: CommunicationTabProps) {
+ const [comments, setComments] = useState<TechSalesComment[]>([]);
+ const [unreadCount, setUnreadCount] = useState(0);
+ const [loadingComments, setLoadingComments] = useState(false);
+ const [communicationDrawerOpen, setCommunicationDrawerOpen] = useState(false);
+
+ // 컴포넌트 마운트 시 메시지 미리 로드
+ useEffect(() => {
+ if (quotation) {
+ loadCommunicationData();
+ }
+ }, [quotation]);
+
+ // 메시지 데이터 로드 함수
+ const loadCommunicationData = async () => {
+ try {
+ setLoadingComments(true);
+ const commentsData = await fetchTechSalesVendorCommentsClient(quotation.rfqId, quotation.vendorId);
+ setComments(commentsData);
+
+ // 읽지 않은 메시지 수 계산 (구매자가 보낸 메시지 중 읽지 않은 것)
+ const unread = commentsData.filter(
+ comment => !comment.isVendorComment && !comment.isRead
+ ).length;
+ setUnreadCount(unread);
+ } catch (error) {
+ console.error("메시지 데이터 로드 오류:", error);
+ } finally {
+ setLoadingComments(false);
+ }
+ };
+
+ // 커뮤니케이션 드로어가 닫힐 때 데이터 새로고침
+ const handleCommunicationDrawerChange = (open: boolean) => {
+ setCommunicationDrawerOpen(open);
+ if (!open) {
+ loadCommunicationData(); // 드로어가 닫힐 때 데이터 새로고침
+ }
+ };
+
+ return (
+ <div className="h-full flex flex-col">
+ {/* 헤더 */}
+ <Card className="mb-4">
+ <CardHeader className="flex flex-row items-center justify-between">
+ <div>
+ <CardTitle className="flex items-center gap-2">
+ <MessageSquare className="h-5 w-5" />
+ 커뮤니케이션
+ {unreadCount > 0 && (
+ <Badge variant="destructive" className="ml-2">
+ 새 메시지 {unreadCount}
+ </Badge>
+ )}
+ </CardTitle>
+ <CardDescription>
+ RFQ {quotation.rfq?.rfqCode || "미할당"}에 대한 구매담당자와의 커뮤니케이션
+ </CardDescription>
+ </div>
+ <Button
+ onClick={() => setCommunicationDrawerOpen(true)}
+ variant="outline"
+ size="sm"
+ >
+ <MessageSquare className="h-4 w-4 mr-2" />
+ {unreadCount > 0 ? "새 메시지 확인" : "메시지 보내기"}
+ </Button>
+ </CardHeader>
+ <CardContent>
+ <div className="flex items-center gap-4 text-sm text-muted-foreground">
+ <span>구매담당자: {quotation.rfq?.createdByUser?.name || "N/A"}</span>
+ <span>•</span>
+ <span>벤더: {quotation.vendor?.vendorName}</span>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 메시지 미리보기 */}
+ <Card className="flex-1 flex flex-col min-h-0">
+ <CardHeader>
+ <CardTitle className="text-lg">메시지 ({comments.length})</CardTitle>
+ </CardHeader>
+ <CardContent>
+ {loadingComments ? (
+ <div className="flex items-center justify-center p-8">
+ <div className="text-center">
+ <Skeleton className="h-4 w-32 mx-auto mb-2" />
+ <Skeleton className="h-4 w-48 mx-auto" />
+ </div>
+ </div>
+ ) : comments.length === 0 ? (
+ <div className="min-h-[200px] flex flex-col items-center justify-center text-center p-8">
+ <div className="max-w-md">
+ <div className="mx-auto bg-primary/10 rounded-full w-12 h-12 flex items-center justify-center mb-4">
+ <MessageSquare className="h-6 w-6 text-primary" />
+ </div>
+ <h3 className="text-lg font-medium mb-2">아직 메시지가 없습니다</h3>
+ <p className="text-muted-foreground mb-4">
+ 견적서에 대한 질문이나 의견이 있으신가요? 구매자와 메시지를 주고받으세요.
+ </p>
+ <Button
+ onClick={() => setCommunicationDrawerOpen(true)}
+ className="mx-auto"
+ >
+ 메시지 보내기
+ </Button>
+ </div>
+ </div>
+ ) : (
+ <div className="space-y-4">
+ {/* 최근 메시지 3개 미리보기 */}
+ <div className="space-y-2">
+ <h3 className="text-sm font-medium">최근 메시지</h3>
+ <ScrollArea className="h-[250px] rounded-md border p-4">
+ {comments.slice(-3).map(comment => (
+ <div
+ key={comment.id}
+ className={`p-3 mb-3 rounded-lg ${!comment.isVendorComment && !comment.isRead
+ ? 'bg-primary/10 border-l-4 border-primary'
+ : 'bg-muted/50'
+ }`}
+ >
+ <div className="flex justify-between items-center mb-1">
+ <span className="text-sm font-medium">
+ {comment.isVendorComment
+ ? '나'
+ : comment.userName || '구매 담당자'}
+ </span>
+ <span className="text-xs text-muted-foreground">
+ {new Date(comment.createdAt).toLocaleDateString()}
+ </span>
+ </div>
+ <p className="text-sm line-clamp-2">{comment.content}</p>
+ {comment.attachments.length > 0 && (
+ <div className="mt-1 text-xs text-muted-foreground">
+ <Paperclip className="h-3 w-3 inline mr-1" />
+ 첨부파일 {comment.attachments.length}개
+ </div>
+ )}
+ </div>
+ ))}
+ </ScrollArea>
+ </div>
+
+ <div className="flex justify-center">
+ <Button
+ onClick={() => setCommunicationDrawerOpen(true)}
+ className="w-full"
+ >
+ 전체 메시지 보기 ({comments.length}개)
+ </Button>
+ </div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 커뮤니케이션 드로어 */}
+ <BuyerCommunicationDrawer
+ open={communicationDrawerOpen}
+ onOpenChange={handleCommunicationDrawerChange}
+ quotation={{
+ id: quotation.id,
+ rfqId: quotation.rfqId,
+ vendorId: quotation.vendorId,
+ quotationCode: quotation.quotationCode,
+ rfq: quotation.rfq ? {
+ rfqCode: quotation.rfq.rfqCode
+ } : undefined
+ }}
+ onSuccess={loadCommunicationData}
+ />
+ </div>
+ )
} \ No newline at end of file
diff --git a/lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx b/lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx
index a8f44474..771db896 100644
--- a/lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx
+++ b/lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx
@@ -1,149 +1,149 @@
-"use client"
-
-import * as React from "react"
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
-import { Badge } from "@/components/ui/badge"
-import { ScrollArea } from "@/components/ui/scroll-area"
-import { formatDate } from "@/lib/utils"
-
-interface ProjectInfoTabProps {
- quotation: {
- id: number
- rfq: {
- id: number
- rfqCode: string | null
- materialCode: string | null
- dueDate: Date | null
- status: string | null
- remark: string | null
- biddingProject?: {
- id: number
- pspid: string | null
- projNm: string | null
- sector: string | null
- projMsrm: string | null
- ptypeNm: string | null
- } | null
- createdByUser?: {
- id: number
- name: string | null
- email: string | null
- } | null
- } | null
- vendor: {
- id: number
- vendorName: string
- vendorCode: string | null
- } | null
- }
-}
-
-export function ProjectInfoTab({ quotation }: ProjectInfoTabProps) {
- const rfq = quotation.rfq
-
- console.log("rfq: ", rfq)
-
- if (!rfq) {
- return (
- <div className="flex items-center justify-center h-full">
- <div className="text-center">
- <h3 className="text-lg font-medium">RFQ 정보를 찾을 수 없습니다</h3>
- <p className="text-sm text-muted-foreground mt-1">
- 연결된 RFQ 정보가 없습니다.
- </p>
- </div>
- </div>
- )
- }
-
- return (
- <ScrollArea className="h-full">
- <div className="space-y-6 p-1">
- {/* RFQ 기본 정보 */}
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
- RFQ 기본 정보
- <Badge variant="outline">{rfq.rfqCode || "미할당"}</Badge>
- </CardTitle>
- <CardDescription>
- 요청서 기본 정보 및 자재 정보
- </CardDescription>
- </CardHeader>
- <CardContent className="space-y-4">
- <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">RFQ 번호</div>
- <div className="text-sm">{rfq.rfqCode || "미할당"}</div>
- </div>
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">자재 그룹</div>
- <div className="text-sm">{rfq.materialCode || "N/A"}</div>
- </div>
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">마감일</div>
- <div className="text-sm">
- {rfq.dueDate ? formatDate(rfq.dueDate) : "N/A"}
- </div>
- </div>
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">RFQ 상태</div>
- <div className="text-sm">{rfq.status || "N/A"}</div>
- </div>
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">담당자</div>
- <div className="text-sm">{rfq.createdByUser?.name || "N/A"}</div>
- </div>
- </div>
- {rfq.remark && (
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">비고</div>
- <div className="text-sm p-3 bg-muted rounded-md">{rfq.remark}</div>
- </div>
- )}
- </CardContent>
- </Card>
-
- {/* 프로젝트 기본 정보 */}
- {rfq.biddingProject && (
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
- 프로젝트 기본 정보
- <Badge variant="outline">{rfq.biddingProject.pspid || "N/A"}</Badge>
- </CardTitle>
- <CardDescription>
- 연결된 프로젝트의 기본 정보
- </CardDescription>
- </CardHeader>
- <CardContent className="space-y-4">
- <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">프로젝트 ID</div>
- <div className="text-sm">{rfq.biddingProject.pspid || "N/A"}</div>
- </div>
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">프로젝트명</div>
- <div className="text-sm">{rfq.biddingProject.projNm || "N/A"}</div>
- </div>
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">프로젝트 섹터</div>
- <div className="text-sm">{rfq.biddingProject.sector || "N/A"}</div>
- </div>
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">프로젝트 규모</div>
- <div className="text-sm">{rfq.biddingProject.projMsrm || "N/A"}</div>
- </div>
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">프로젝트 타입</div>
- <div className="text-sm">{rfq.biddingProject.ptypeNm || "N/A"}</div>
- </div>
- </div>
- </CardContent>
- </Card>
- )}
-
- </div>
- </ScrollArea>
- )
+"use client"
+
+import * as React from "react"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { Badge } from "@/components/ui/badge"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { formatDate } from "@/lib/utils"
+
+interface ProjectInfoTabProps {
+ quotation: {
+ id: number
+ rfq: {
+ id: number
+ rfqCode: string | null
+ materialCode: string | null
+ dueDate: Date | null
+ status: string | null
+ remark: string | null
+ biddingProject?: {
+ id: number
+ pspid: string | null
+ projNm: string | null
+ sector: string | null
+ projMsrm: string | null
+ ptypeNm: string | null
+ } | null
+ createdByUser?: {
+ id: number
+ name: string | null
+ email: string | null
+ } | null
+ } | null
+ vendor: {
+ id: number
+ vendorName: string
+ vendorCode: string | null
+ } | null
+ }
+}
+
+export function ProjectInfoTab({ quotation }: ProjectInfoTabProps) {
+ const rfq = quotation.rfq
+
+ console.log("rfq: ", rfq)
+
+ if (!rfq) {
+ return (
+ <div className="flex items-center justify-center h-full">
+ <div className="text-center">
+ <h3 className="text-lg font-medium">RFQ 정보를 찾을 수 없습니다</h3>
+ <p className="text-sm text-muted-foreground mt-1">
+ 연결된 RFQ 정보가 없습니다.
+ </p>
+ </div>
+ </div>
+ )
+ }
+
+ return (
+ <ScrollArea className="h-full">
+ <div className="space-y-6 p-1">
+ {/* RFQ 기본 정보 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ RFQ 기본 정보
+ <Badge variant="outline">{rfq.rfqCode || "미할당"}</Badge>
+ </CardTitle>
+ <CardDescription>
+ 요청서 기본 정보 및 자재 정보
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">RFQ 번호</div>
+ <div className="text-sm">{rfq.rfqCode || "미할당"}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">자재 그룹</div>
+ <div className="text-sm">{rfq.materialCode || "N/A"}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">마감일</div>
+ <div className="text-sm">
+ {rfq.dueDate ? formatDate(rfq.dueDate) : "N/A"}
+ </div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">RFQ 상태</div>
+ <div className="text-sm">{rfq.status || "N/A"}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">담당자</div>
+ <div className="text-sm">{rfq.createdByUser?.name || "N/A"}</div>
+ </div>
+ </div>
+ {rfq.remark && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">비고</div>
+ <div className="text-sm p-3 bg-muted rounded-md">{rfq.remark}</div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 프로젝트 기본 정보 */}
+ {rfq.biddingProject && (
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ 프로젝트 기본 정보
+ <Badge variant="outline">{rfq.biddingProject.pspid || "N/A"}</Badge>
+ </CardTitle>
+ <CardDescription>
+ 연결된 프로젝트의 기본 정보
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">프로젝트 ID</div>
+ <div className="text-sm">{rfq.biddingProject.pspid || "N/A"}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">프로젝트명</div>
+ <div className="text-sm">{rfq.biddingProject.projNm || "N/A"}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">프로젝트 섹터</div>
+ <div className="text-sm">{rfq.biddingProject.sector || "N/A"}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">프로젝트 규모</div>
+ <div className="text-sm">{rfq.biddingProject.projMsrm || "N/A"}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">프로젝트 타입</div>
+ <div className="text-sm">{rfq.biddingProject.ptypeNm || "N/A"}</div>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ )}
+
+ </div>
+ </ScrollArea>
+ )
} \ No newline at end of file
diff --git a/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx b/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx
index 0425ccc9..0a56b702 100644
--- a/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx
+++ b/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx
@@ -1,522 +1,523 @@
-"use client"
-
-import * as React from "react"
-import { useState, useEffect } from "react"
-import { useRouter } from "next/navigation"
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
-import { Button } from "@/components/ui/button"
-import { Input } from "@/components/ui/input"
-import { Label } from "@/components/ui/label"
-import { Textarea } from "@/components/ui/textarea"
-import { Badge } from "@/components/ui/badge"
-import { ScrollArea } from "@/components/ui/scroll-area"
-import { CalendarIcon, Send, AlertCircle, Upload, X, FileText, Download } from "lucide-react"
-import { Calendar } from "@/components/ui/calendar"
-import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
-import { Alert, AlertDescription } from "@/components/ui/alert"
-import { formatDate, cn } from "@/lib/utils"
-import { toast } from "sonner"
-import { useSession } from "next-auth/react"
-
-interface QuotationResponseTabProps {
- quotation: {
- id: number
- status: string
- totalPrice: string | null
- currency: string | null
- validUntil: Date | null
- remark: string | null
- quotationAttachments?: Array<{
- id: number
- fileName: string
- fileSize: number
- filePath: string
- description?: string | null
- }>
- rfq: {
- id: number
- rfqCode: string | null
- materialCode: string | null
- dueDate: Date | null
- status: string | null
- item?: {
- itemName: string | null
- } | null
- } | null
- vendor: {
- vendorName: string
- } | null
- }
-}
-
-const CURRENCIES = [
- { value: "KRW", label: "KRW (원)" },
- { value: "USD", label: "USD (달러)" },
- { value: "EUR", label: "EUR (유로)" },
- { value: "JPY", label: "JPY (엔)" },
- { value: "CNY", label: "CNY (위안)" },
-]
-
-export function QuotationResponseTab({ quotation }: QuotationResponseTabProps) {
- const [totalPrice, setTotalPrice] = useState(quotation.totalPrice?.toString() || "")
- const [currency, setCurrency] = useState(quotation.currency || "KRW")
- const [validUntil, setValidUntil] = useState<Date | undefined>(
- quotation.validUntil ? new Date(quotation.validUntil) : undefined
- )
- const [remark, setRemark] = useState(quotation.remark || "")
- const [isLoading, setIsLoading] = useState(false)
- const [attachments, setAttachments] = useState<Array<{
- id?: number
- fileName: string
- fileSize: number
- filePath: string
- isNew?: boolean
- file?: File
- }>>([])
- const [isUploadingFiles, setIsUploadingFiles] = useState(false)
- const router = useRouter()
- const session = useSession()
-
- // // 초기 첨부파일 데이터 로드
- // useEffect(() => {
- // if (quotation.quotationAttachments) {
- // setAttachments(quotation.quotationAttachments.map(att => ({
- // id: att.id,
- // fileName: att.fileName,
- // fileSize: att.fileSize,
- // filePath: att.filePath,
- // isNew: false
- // })))
- // }
- // }, [quotation.quotationAttachments])
-
- const rfq = quotation.rfq
- const isDueDatePassed = rfq?.dueDate ? new Date(rfq.dueDate) < new Date() : false
- const canSubmit = !["Accepted", "Rejected"].includes(quotation.status) && !isDueDatePassed
- const canEdit = !["Accepted", "Rejected"].includes(quotation.status) && !isDueDatePassed
-
- // 파일 업로드 핸들러
- const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
- const files = event.target.files
- if (!files) return
-
- Array.from(files).forEach(file => {
- setAttachments(prev => [
- ...prev,
- {
- fileName: file.name,
- fileSize: file.size,
- filePath: '',
- isNew: true,
- file
- }
- ])
- })
- }
-
- // 첨부파일 제거
- const removeAttachment = (index: number) => {
- setAttachments(prev => prev.filter((_, i) => i !== index))
- }
-
- // 파일 업로드 함수
- const uploadFiles = async () => {
- const newFiles = attachments.filter(att => att.isNew && att.file)
- if (newFiles.length === 0) return []
-
- setIsUploadingFiles(true)
- const uploadedFiles = []
-
- try {
- for (const attachment of newFiles) {
- const formData = new FormData()
- formData.append('file', attachment.file!)
-
- const response = await fetch('/api/upload', {
- method: 'POST',
- body: formData
- })
-
- if (!response.ok) throw new Error('파일 업로드 실패')
-
- const result = await response.json()
- uploadedFiles.push({
- fileName: result.fileName,
- filePath: result.url,
- fileSize: attachment.fileSize
- })
- }
- return uploadedFiles
- } catch (error) {
- console.error('파일 업로드 오류:', error)
- toast.error('파일 업로드 중 오류가 발생했습니다.')
- return []
- } finally {
- setIsUploadingFiles(false)
- }
- }
-
- const handleSubmit = async () => {
- if (!totalPrice || !currency || !validUntil) {
- toast.error("모든 필수 항목을 입력해주세요.")
- return
- }
-
- setIsLoading(true)
- try {
- // 파일 업로드 먼저 처리
- const uploadedFiles = await uploadFiles()
-
- const { submitTechSalesVendorQuotation } = await import("@/lib/techsales-rfq/service")
-
- const result = await submitTechSalesVendorQuotation({
- id: quotation.id,
- currency,
- totalPrice,
- validUntil: validUntil!,
- remark,
- attachments: uploadedFiles,
- updatedBy: parseInt(session.data?.user.id || "0")
- })
-
- if (result.error) {
- toast.error(result.error)
- } else {
- toast.success("견적서가 제출되었습니다.")
- // // 페이지 새로고침 대신 router.refresh() 사용
- // router.refresh()
- // 페이지 새로고침
- window.location.reload()
- }
- } catch {
- toast.error("제출 중 오류가 발생했습니다.")
- } finally {
- setIsLoading(false)
- }
- }
-
- const getStatusBadgeVariant = (status: string) => {
- switch (status) {
- case "Draft":
- return "secondary"
- case "Submitted":
- return "default"
- case "Revised":
- return "outline"
- case "Rejected":
- return "destructive"
- case "Accepted":
- return "success"
- default:
- return "secondary"
- }
- }
-
- const getStatusLabel = (status: string) => {
- switch (status) {
- case "Draft":
- return "초안"
- case "Submitted":
- return "제출됨"
- case "Revised":
- return "수정됨"
- case "Rejected":
- return "반려됨"
- case "Accepted":
- return "승인됨"
- default:
- return status
- }
- }
-
- return (
- <ScrollArea className="h-full">
- <div className="space-y-6 p-1">
- {/* 견적서 상태 정보 */}
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
- 견적서 상태
- <Badge variant={getStatusBadgeVariant(quotation.status)}>
- {getStatusLabel(quotation.status)}
- </Badge>
- </CardTitle>
- <CardDescription>
- 현재 견적서 상태 및 마감일 정보
- </CardDescription>
- </CardHeader>
- <CardContent className="space-y-4">
- <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">견적서 상태</div>
- <div className="text-sm">{getStatusLabel(quotation.status)}</div>
- </div>
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">RFQ 마감일</div>
- <div className="text-sm">
- {rfq?.dueDate ? formatDate(rfq.dueDate) : "N/A"}
- </div>
- </div>
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">남은 시간</div>
- <div className="text-sm">
- {isDueDatePassed ? (
- <span className="text-destructive">마감됨</span>
- ) : rfq?.dueDate ? (
- <span className="text-green-600">
- {Math.ceil((new Date(rfq.dueDate).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24))}일
- </span>
- ) : (
- "N/A"
- )}
- </div>
- </div>
- </div>
-
- {isDueDatePassed && (
- <Alert>
- <AlertCircle className="h-4 w-4" />
- <AlertDescription>
- RFQ 마감일이 지났습니다. 견적서를 수정하거나 제출할 수 없습니다.
- </AlertDescription>
- </Alert>
- )}
-
- {!canEdit && !isDueDatePassed && (
- <Alert>
- <AlertCircle className="h-4 w-4" />
- <AlertDescription>
- 현재 상태에서는 견적서를 수정할 수 없습니다.
- </AlertDescription>
- </Alert>
- )}
- </CardContent>
- </Card>
-
- {/* 견적 응답 폼 */}
- <Card>
- <CardHeader>
- <CardTitle>견적 응답</CardTitle>
- <CardDescription>
- 총 가격, 통화, 유효기간을 입력해주세요.
- </CardDescription>
- </CardHeader>
- <CardContent className="space-y-6">
- {/* 총 가격 */}
- <div className="space-y-2">
- <Label htmlFor="totalPrice">
- 총 가격 <span className="text-destructive">*</span>
- </Label>
- <Input
- id="totalPrice"
- type="number"
- placeholder="총 가격을 입력하세요"
- value={totalPrice}
- onChange={(e) => setTotalPrice(e.target.value)}
- disabled={!canEdit}
- className="text-right"
- />
- </div>
-
- {/* 통화 */}
- <div className="space-y-2">
- <Label htmlFor="currency">
- 통화 <span className="text-destructive">*</span>
- </Label>
- <Select value={currency} onValueChange={setCurrency} disabled={!canEdit}>
- <SelectTrigger>
- <SelectValue placeholder="통화를 선택하세요" />
- </SelectTrigger>
- <SelectContent>
- {CURRENCIES.map((curr) => (
- <SelectItem key={curr.value} value={curr.value}>
- {curr.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- </div>
-
- {/* 유효기간 */}
- <div className="space-y-2">
- <Label>
- 견적 유효기간 <span className="text-destructive">*</span>
- </Label>
- <Popover>
- <PopoverTrigger asChild>
- <Button
- variant="outline"
- className={cn(
- "w-full justify-start text-left font-normal",
- !validUntil && "text-muted-foreground"
- )}
- disabled={!canEdit}
- >
- <CalendarIcon className="mr-2 h-4 w-4" />
- {validUntil ? formatDate(validUntil) : "날짜를 선택하세요"}
- </Button>
- </PopoverTrigger>
- <PopoverContent className="w-auto p-0" align="start">
- <Calendar
- mode="single"
- selected={validUntil}
- onSelect={setValidUntil}
- disabled={(date) => date < new Date()}
- initialFocus
- />
- </PopoverContent>
- </Popover>
- </div>
-
- {/* 비고 */}
- <div className="space-y-2">
- <Label htmlFor="remark">비고</Label>
- <Textarea
- id="remark"
- placeholder="추가 설명이나 조건을 입력하세요"
- value={remark}
- onChange={(e) => setRemark(e.target.value)}
- disabled={!canEdit}
- rows={4}
- />
- </div>
-
- {/* 첨부파일 */}
- <div className="space-y-4">
- <Label>첨부파일</Label>
-
- {/* 파일 업로드 버튼 */}
- {canEdit && (
- <div className="flex items-center gap-2">
- <Button
- type="button"
- variant="outline"
- size="sm"
- disabled={isUploadingFiles}
- onClick={() => document.getElementById('file-input')?.click()}
- >
- <Upload className="h-4 w-4 mr-2" />
- 파일 선택
- </Button>
- <input
- id="file-input"
- type="file"
- multiple
- onChange={handleFileSelect}
- className="hidden"
- accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.jpg,.jpeg,.png,.zip"
- />
- <span className="text-sm text-muted-foreground">
- PDF, 문서파일, 이미지파일, 압축파일 등
- </span>
- </div>
- )}
-
- {/* 첨부파일 목록 */}
- {attachments.length > 0 && (
- <div className="space-y-2">
- {attachments.map((attachment, index) => (
- <div
- key={index}
- className="flex items-center justify-between p-3 border rounded-lg bg-muted/50"
- >
- <div className="flex items-center gap-2">
- <FileText className="h-4 w-4 text-muted-foreground" />
- <div>
- <div className="text-sm font-medium">{attachment.fileName}</div>
- <div className="text-xs text-muted-foreground">
- {(attachment.fileSize / 1024 / 1024).toFixed(2)} MB
- {attachment.isNew && (
- <Badge variant="secondary" className="ml-2">
- 새 파일
- </Badge>
- )}
- </div>
- </div>
- </div>
- <div className="flex items-center gap-2">
- {!attachment.isNew && (
- <Button
- type="button"
- variant="ghost"
- size="sm"
- onClick={() => window.open(attachment.filePath, '_blank')}
- >
- <Download className="h-4 w-4" />
- </Button>
- )}
- {canEdit && (
- <Button
- type="button"
- variant="ghost"
- size="sm"
- onClick={() => removeAttachment(index)}
- >
- <X className="h-4 w-4" />
- </Button>
- )}
- </div>
- </div>
- ))}
- </div>
- )}
- </div>
-
- {/* 액션 버튼 */}
- {canEdit && canSubmit && (
- <div className="flex justify-center pt-4">
- <Button
- onClick={handleSubmit}
- disabled={isLoading || !totalPrice || !currency || !validUntil}
- className="w-full "
- >
- <Send className="mr-2 h-4 w-4" />
- 견적서 제출
- </Button>
- </div>
- )}
- </CardContent>
- </Card>
-
- {/* 현재 견적 정보 (읽기 전용) */}
- {quotation.totalPrice && (
- <Card>
- <CardHeader>
- <CardTitle>현재 견적 정보</CardTitle>
- <CardDescription>
- 저장된 견적 정보
- </CardDescription>
- </CardHeader>
- <CardContent className="space-y-4">
- <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">총 가격</div>
- <div className="text-lg font-semibold">
- {parseFloat(quotation.totalPrice).toLocaleString()} {quotation.currency}
- </div>
- </div>
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">통화</div>
- <div className="text-sm">{quotation.currency}</div>
- </div>
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">유효기간</div>
- <div className="text-sm">
- {quotation.validUntil ? formatDate(quotation.validUntil) : "N/A"}
- </div>
- </div>
- </div>
- {quotation.remark && (
- <div className="space-y-2">
- <div className="text-sm font-medium text-muted-foreground">비고</div>
- <div className="text-sm p-3 bg-muted rounded-md">{quotation.remark}</div>
- </div>
- )}
- </CardContent>
- </Card>
- )}
- </div>
- </ScrollArea>
- )
+"use client"
+
+import * as React from "react"
+import { useState } from "react"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Textarea } from "@/components/ui/textarea"
+import { Badge } from "@/components/ui/badge"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { CalendarIcon, Send, AlertCircle, X, FileText, Download, History, FileIcon } from "lucide-react"
+import { Calendar } from "@/components/ui/calendar"
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
+import { Alert, AlertDescription } from "@/components/ui/alert"
+import { formatDate, cn } from "@/lib/utils"
+import { toast } from "sonner"
+import { useSession } from "next-auth/react"
+import { QuotationHistoryDialog } from "@/lib/techsales-rfq/table/detail-table/quotation-history-dialog"
+
+interface QuotationResponseTabProps {
+ quotation: {
+ id: number
+ status: string
+ totalPrice: string | null
+ currency: string | null
+ validUntil: Date | null
+ remark: string | null
+ quotationAttachments?: Array<{
+ id: number
+ fileName: string
+ fileSize: number
+ filePath: string
+ description?: string | null
+ }>
+ rfq: {
+ id: number
+ rfqCode: string | null
+ materialCode: string | null
+ dueDate: Date | null
+ status: string | null
+ item?: {
+ itemName: string | null
+ } | null
+ } | null
+ vendor: {
+ vendorName: string
+ } | null
+ }
+}
+
+const CURRENCIES = [
+ { value: "KRW", label: "KRW (원)" },
+ { value: "USD", label: "USD (달러)" },
+ { value: "EUR", label: "EUR (유로)" },
+ { value: "JPY", label: "JPY (엔)" },
+ { value: "CNY", label: "CNY (위안)" },
+]
+
+export function QuotationResponseTab({ quotation }: QuotationResponseTabProps) {
+ const [totalPrice, setTotalPrice] = useState(quotation.totalPrice?.toString() || "")
+ const [currency, setCurrency] = useState(quotation.currency || "KRW")
+ const [validUntil, setValidUntil] = useState<Date | undefined>(
+ quotation.validUntil ? new Date(quotation.validUntil) : undefined
+ )
+ const [remark, setRemark] = useState(quotation.remark || "")
+ const [isLoading, setIsLoading] = useState(false)
+ const [attachments, setAttachments] = useState<Array<{
+ id?: number
+ fileName: string
+ fileSize: number
+ filePath: string
+ isNew?: boolean
+ file?: File
+ }>>([])
+ const [isUploadingFiles, setIsUploadingFiles] = useState(false)
+
+ // 견적 히스토리 다이얼로그 상태 관리
+ const [historyDialogOpen, setHistoryDialogOpen] = useState(false)
+
+ const session = useSession()
+
+ // // 초기 첨부파일 데이터 로드
+ // useEffect(() => {
+ // if (quotation.quotationAttachments) {
+ // setAttachments(quotation.quotationAttachments.map(att => ({
+ // id: att.id,
+ // fileName: att.fileName,
+ // fileSize: att.fileSize,
+ // filePath: att.filePath,
+ // isNew: false
+ // })))
+ // }
+ // }, [quotation.quotationAttachments])
+
+ const rfq = quotation.rfq
+ const isDueDatePassed = rfq?.dueDate ? new Date(rfq.dueDate) < new Date() : false
+ const canSubmit = !["Accepted", "Rejected"].includes(quotation.status) && !isDueDatePassed
+ const canEdit = !["Accepted", "Rejected"].includes(quotation.status) && !isDueDatePassed
+
+ // 파일 업로드 핸들러
+ const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
+ const files = event.target.files
+ if (!files) return
+
+ Array.from(files).forEach(file => {
+ setAttachments(prev => [
+ ...prev,
+ {
+ fileName: file.name,
+ fileSize: file.size,
+ filePath: '',
+ isNew: true,
+ file
+ }
+ ])
+ })
+ }
+
+ // 첨부파일 제거
+ const removeAttachment = (index: number) => {
+ setAttachments(prev => prev.filter((_, i) => i !== index))
+ }
+
+ // 파일 업로드 함수
+ const uploadFiles = async () => {
+ const newFiles = attachments.filter(att => att.isNew && att.file)
+ if (newFiles.length === 0) return []
+
+ setIsUploadingFiles(true)
+
+ try {
+ // 서비스 함수를 사용하여 파일 업로드
+ const { uploadQuotationAttachments } = await import('@/lib/techsales-rfq/service')
+
+ const files = newFiles.map(att => att.file!).filter(Boolean)
+ const userId = parseInt(session.data?.user.id || "0")
+
+ const result = await uploadQuotationAttachments(quotation.id, files, userId)
+
+ if (result.success && result.attachments) {
+ return result.attachments
+ } else {
+ throw new Error(result.error || '파일 저장에 실패했습니다.')
+ }
+ } catch (error) {
+ console.error('파일 업로드 오류:', error)
+ toast.error('파일 업로드 중 오류가 발생했습니다.')
+ return []
+ } finally {
+ setIsUploadingFiles(false)
+ }
+ }
+
+ const handleSubmit = async () => {
+ if (!totalPrice || !currency || !validUntil) {
+ toast.error("모든 필수 항목을 입력해주세요.")
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ // 파일 업로드 먼저 처리
+ const uploadedFiles = await uploadFiles()
+
+ const { submitTechSalesVendorQuotation } = await import("@/lib/techsales-rfq/service")
+
+ const result = await submitTechSalesVendorQuotation({
+ id: quotation.id,
+ currency,
+ totalPrice,
+ validUntil: validUntil!,
+ remark,
+ attachments: uploadedFiles,
+ updatedBy: parseInt(session.data?.user.id || "0")
+ })
+
+ if (result.error) {
+ toast.error(result.error)
+ } else {
+ toast.success("견적서가 제출되었습니다.")
+ // // 페이지 새로고침 대신 router.refresh() 사용
+ // router.refresh()
+ // 페이지 새로고침
+ window.location.reload()
+ }
+ } catch {
+ toast.error("제출 중 오류가 발생했습니다.")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const getStatusBadgeVariant = (status: string) => {
+ switch (status) {
+ case "Draft":
+ return "secondary"
+ case "Submitted":
+ return "default"
+ case "Revised":
+ return "outline"
+ case "Rejected":
+ return "destructive"
+ case "Accepted":
+ return "success"
+ default:
+ return "secondary"
+ }
+ }
+
+ const getStatusLabel = (status: string) => {
+ switch (status) {
+ case "Draft":
+ return "초안"
+ case "Submitted":
+ return "제출됨"
+ case "Revised":
+ return "수정됨"
+ case "Rejected":
+ return "반려됨"
+ case "Accepted":
+ return "승인됨"
+ default:
+ return status
+ }
+ }
+
+ return (
+ <ScrollArea className="h-full">
+ <div className="space-y-6 p-1">
+ {/* 견적서 상태 정보 */}
+ <Card>
+ <CardHeader>
+ <div className="flex items-center justify-between">
+ <div>
+ <CardTitle className="flex items-center gap-2">
+ 견적서 상태
+ <Badge variant={getStatusBadgeVariant(quotation.status)}>
+ {getStatusLabel(quotation.status)}
+ </Badge>
+ </CardTitle>
+ <CardDescription>
+ 현재 견적서 상태 및 마감일 정보
+ </CardDescription>
+ </div>
+
+ {/* 견적 히스토리 보기 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setHistoryDialogOpen(true)}
+ className="flex items-center gap-2"
+ >
+ <History className="h-4 w-4" />
+ 이전 견적 히스토리 보기
+ </Button>
+ </div>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">견적서 상태</div>
+ <div className="text-sm">{getStatusLabel(quotation.status)}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">RFQ 마감일</div>
+ <div className="text-sm">
+ {rfq?.dueDate ? formatDate(rfq.dueDate) : "N/A"}
+ </div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">남은 시간</div>
+ <div className="text-sm">
+ {isDueDatePassed ? (
+ <span className="text-destructive">마감됨</span>
+ ) : rfq?.dueDate ? (
+ <span className="text-green-600">
+ {Math.ceil((new Date(rfq.dueDate).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24))}일
+ </span>
+ ) : (
+ "N/A"
+ )}
+ </div>
+ </div>
+ </div>
+
+ {isDueDatePassed && (
+ <Alert>
+ <AlertCircle className="h-4 w-4" />
+ <AlertDescription>
+ RFQ 마감일이 지났습니다. 견적서를 수정하거나 제출할 수 없습니다.
+ </AlertDescription>
+ </Alert>
+ )}
+
+ {!canEdit && !isDueDatePassed && (
+ <Alert>
+ <AlertCircle className="h-4 w-4" />
+ <AlertDescription>
+ 현재 상태에서는 견적서를 수정할 수 없습니다.
+ </AlertDescription>
+ </Alert>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 견적 응답 폼 */}
+ <Card>
+ <CardHeader>
+ <CardTitle>견적 응답</CardTitle>
+ <CardDescription>
+ 견적 정보를 입력하고 제출하세요
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="totalPrice">견적 금액 *</Label>
+ <Input
+ id="totalPrice"
+ type="number"
+ value={totalPrice}
+ onChange={(e) => setTotalPrice(e.target.value)}
+ placeholder="견적 금액을 입력하세요"
+ disabled={!canEdit}
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="currency">통화 *</Label>
+ <Select value={currency} onValueChange={setCurrency} disabled={!canEdit}>
+ <SelectTrigger>
+ <SelectValue placeholder="통화를 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ {CURRENCIES.map((curr) => (
+ <SelectItem key={curr.value} value={curr.value}>
+ {curr.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+
+ <div className="space-y-2">
+ <Label>견적 유효기한 *</Label>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ className={cn(
+ "w-full justify-start text-left font-normal",
+ !validUntil && "text-muted-foreground"
+ )}
+ disabled={!canEdit}
+ >
+ <CalendarIcon className="mr-2 h-4 w-4" />
+ {validUntil ? formatDate(validUntil) : "유효기한을 선택하세요"}
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0">
+ <Calendar
+ mode="single"
+ selected={validUntil}
+ onSelect={setValidUntil}
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="remark">비고</Label>
+ <Textarea
+ id="remark"
+ value={remark}
+ onChange={(e) => setRemark(e.target.value)}
+ placeholder="추가 설명이나 특이사항을 입력하세요"
+ rows={3}
+ disabled={!canEdit}
+ />
+ </div>
+
+ {/* 첨부파일 섹션 */}
+ <div className="space-y-2">
+ <Label>첨부파일</Label>
+ <div className="border-2 border-dashed border-gray-300 rounded-lg p-4">
+ <input
+ type="file"
+ id="file-upload"
+ multiple
+ onChange={handleFileSelect}
+ className="hidden"
+ disabled={!canEdit}
+ />
+ <div className="text-center">
+ <FileText className="mx-auto h-12 w-12 text-gray-400" />
+ <div className="mt-2">
+ <Label htmlFor="file-upload" className="cursor-pointer">
+ <span className="text-sm font-medium text-blue-600 hover:text-blue-500">
+ 파일을 선택하세요
+ </span>
+ </Label>
+ </div>
+ <p className="text-xs text-gray-500 mt-1">
+ PDF, DOC, DOCX, XLS, XLSX, PNG, JPG 등
+ </p>
+ </div>
+ </div>
+
+ {/* 첨부파일 목록 */}
+ {attachments.length > 0 && (
+ <div className="space-y-2">
+ <Label>첨부된 파일</Label>
+ <div className="space-y-2">
+ {attachments.map((attachment, index) => (
+ <div key={index} className="flex items-center justify-between p-2 bg-gray-50 rounded">
+ <div className="flex items-center gap-2">
+ <FileIcon className="h-4 w-4 text-gray-500" />
+ <span className="text-sm">{attachment.fileName}</span>
+ <span className="text-xs text-gray-500">
+ ({(attachment.fileSize / 1024).toFixed(1)} KB)
+ </span>
+ </div>
+ <div className="flex items-center gap-2">
+ {!attachment.isNew && (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => window.open(attachment.filePath, '_blank')}
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ )}
+ {canEdit && (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => removeAttachment(index)}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+ </div>
+
+ {/* 제출 버튼 */}
+ <div className="flex justify-end">
+ <Button
+ onClick={handleSubmit}
+ disabled={!canSubmit || isLoading || isUploadingFiles}
+ className="flex items-center gap-2"
+ >
+ {isLoading ? (
+ <>
+ <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
+ 제출 중...
+ </>
+ ) : (
+ <>
+ <Send className="h-4 w-4" />
+ 견적서 제출
+ </>
+ )}
+ </Button>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 현재 견적 정보 (읽기 전용) */}
+ {quotation.totalPrice && (
+ <Card>
+ <CardHeader>
+ <CardTitle>현재 견적 정보</CardTitle>
+ <CardDescription>
+ 저장된 견적 정보
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">총 가격</div>
+ <div className="text-lg font-semibold">
+ {parseFloat(quotation.totalPrice).toLocaleString()} {quotation.currency}
+ </div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">통화</div>
+ <div className="text-sm">{quotation.currency}</div>
+ </div>
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">유효기간</div>
+ <div className="text-sm">
+ {quotation.validUntil ? formatDate(quotation.validUntil) : "N/A"}
+ </div>
+ </div>
+ </div>
+ {quotation.remark && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium text-muted-foreground">비고</div>
+ <div className="text-sm p-3 bg-muted rounded-md">{quotation.remark}</div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+ )}
+ </div>
+
+ {/* 견적 히스토리 다이얼로그 */}
+ <QuotationHistoryDialog
+ open={historyDialogOpen}
+ onOpenChange={setHistoryDialogOpen}
+ quotationId={quotation.id}
+ />
+ </ScrollArea>
+ )
} \ No newline at end of file
diff --git a/lib/techsales-rfq/vendor-response/detail/quotation-tabs.tsx b/lib/techsales-rfq/vendor-response/detail/quotation-tabs.tsx
index 2e2f5d70..7af50b24 100644
--- a/lib/techsales-rfq/vendor-response/detail/quotation-tabs.tsx
+++ b/lib/techsales-rfq/vendor-response/detail/quotation-tabs.tsx
@@ -1,84 +1,84 @@
-"use client"
-
-import * as React from "react"
-import { useRouter, useSearchParams } from "next/navigation"
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
-import { ProjectInfoTab } from "./project-info-tab"
-import { QuotationResponseTab } from "./quotation-response-tab"
-import { CommunicationTab } from "./communication-tab"
-
-interface QuotationData {
- id: number
- status: string
- totalPrice: string | null
- currency: string | null
- validUntil: Date | null
- remark: string | null
- rfq: {
- id: number
- rfqCode: string | null
- materialCode: string | null
- dueDate: Date | null
- status: string | null
- remark: string | null
- biddingProject?: {
- id: number
- pspid: string | null
- projNm: string | null
- sector: string | null
- projMsrm: string | null
- ptypeNm: string | null
- } | null
- createdByUser?: {
- id: number
- name: string | null
- email: string | null
- } | null
- } | null
- vendor: {
- id: number
- vendorName: string
- vendorCode: string | null
- } | null
-}
-
-interface TechSalesQuotationTabsProps {
- quotation: QuotationData
- defaultTab?: string
-}
-
-export function TechSalesQuotationTabs({ quotation, defaultTab = "project" }: TechSalesQuotationTabsProps) {
- const router = useRouter()
- const searchParams = useSearchParams()
- const currentTab = searchParams?.get("tab") || defaultTab
-
- const handleTabChange = (value: string) => {
- const params = new URLSearchParams(searchParams?.toString() || "")
- params.set("tab", value)
- router.push(`?${params.toString()}`, { scroll: false })
- }
-
- return (
- <Tabs value={currentTab} onValueChange={handleTabChange} className="h-full flex flex-col">
- <TabsList className="grid w-full grid-cols-3">
- <TabsTrigger value="project">프로젝트 및 RFQ 정보</TabsTrigger>
- <TabsTrigger value="quotation">견적 응답</TabsTrigger>
- <TabsTrigger value="communication">커뮤니케이션</TabsTrigger>
- </TabsList>
-
- <div className="flex-1 mt-4 overflow-hidden">
- <TabsContent value="project" className="h-full m-0">
- <ProjectInfoTab quotation={quotation} />
- </TabsContent>
-
- <TabsContent value="quotation" className="h-full m-0">
- <QuotationResponseTab quotation={quotation} />
- </TabsContent>
-
- <TabsContent value="communication" className="h-full m-0">
- <CommunicationTab quotation={quotation} />
- </TabsContent>
- </div>
- </Tabs>
- )
+"use client"
+
+import * as React from "react"
+import { useRouter, useSearchParams } from "next/navigation"
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import { ProjectInfoTab } from "./project-info-tab"
+import { QuotationResponseTab } from "./quotation-response-tab"
+import { CommunicationTab } from "./communication-tab"
+
+interface QuotationData {
+ id: number
+ status: string
+ totalPrice: string | null
+ currency: string | null
+ validUntil: Date | null
+ remark: string | null
+ rfq: {
+ id: number
+ rfqCode: string | null
+ materialCode: string | null
+ dueDate: Date | null
+ status: string | null
+ remark: string | null
+ biddingProject?: {
+ id: number
+ pspid: string | null
+ projNm: string | null
+ sector: string | null
+ projMsrm: string | null
+ ptypeNm: string | null
+ } | null
+ createdByUser?: {
+ id: number
+ name: string | null
+ email: string | null
+ } | null
+ } | null
+ vendor: {
+ id: number
+ vendorName: string
+ vendorCode: string | null
+ } | null
+}
+
+interface TechSalesQuotationTabsProps {
+ quotation: QuotationData
+ defaultTab?: string
+}
+
+export function TechSalesQuotationTabs({ quotation, defaultTab = "project" }: TechSalesQuotationTabsProps) {
+ const router = useRouter()
+ const searchParams = useSearchParams()
+ const currentTab = searchParams?.get("tab") || defaultTab
+
+ const handleTabChange = (value: string) => {
+ const params = new URLSearchParams(searchParams?.toString() || "")
+ params.set("tab", value)
+ router.push(`?${params.toString()}`, { scroll: false })
+ }
+
+ return (
+ <Tabs value={currentTab} onValueChange={handleTabChange} className="h-full flex flex-col">
+ <TabsList className="grid w-full grid-cols-3">
+ <TabsTrigger value="project">프로젝트 및 RFQ 정보</TabsTrigger>
+ <TabsTrigger value="quotation">견적 응답</TabsTrigger>
+ <TabsTrigger value="communication">커뮤니케이션</TabsTrigger>
+ </TabsList>
+
+ <div className="flex-1 mt-4 overflow-hidden">
+ <TabsContent value="project" className="h-full m-0">
+ <ProjectInfoTab quotation={quotation} />
+ </TabsContent>
+
+ <TabsContent value="quotation" className="h-full m-0">
+ <QuotationResponseTab quotation={quotation} />
+ </TabsContent>
+
+ <TabsContent value="communication" className="h-full m-0">
+ <CommunicationTab quotation={quotation} />
+ </TabsContent>
+ </div>
+ </Tabs>
+ )
} \ No newline at end of file
diff --git a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx
index 39de94ed..328def80 100644
--- a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx
+++ b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx
@@ -1,672 +1,710 @@
-"use client"
-
-import * as React from "react"
-import { type ColumnDef } from "@tanstack/react-table"
-import { Edit, Paperclip, Package } from "lucide-react"
-import { formatCurrency, formatDate, formatDateTime } from "@/lib/utils"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import { Checkbox } from "@/components/ui/checkbox"
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "@/components/ui/tooltip"
-import {
- TechSalesVendorQuotations,
- TECH_SALES_QUOTATION_STATUS_CONFIG,
- TECH_SALES_QUOTATION_STATUSES
-} from "@/db/schema"
-import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime"
-import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
-
-interface QuotationWithRfqCode extends TechSalesVendorQuotations {
- // RFQ 관련 정보
- rfqCode?: string;
- materialCode?: string;
- dueDate?: Date;
- rfqStatus?: string;
-
- // 아이템 정보
- itemName?: string;
- itemCount?: number;
-
- // 프로젝트 정보
- projNm?: string;
- pspid?: string;
- sector?: string;
-
- // RFQ 정보
- description?: string;
-
- // 벤더 정보
- vendorName?: string;
- vendorCode?: string;
-
- // 사용자 정보
- createdByName?: string | null;
- updatedByName?: string | null;
-
- // 첨부파일 개수
- attachmentCount?: number;
-}
-
-interface GetColumnsProps {
- router: AppRouterInstance;
- openAttachmentsSheet: (rfqId: number) => void;
- openItemsDialog: (rfq: { id: number; rfqCode?: string; status?: string; rfqType?: "SHIP" | "TOP" | "HULL"; }) => void;
-}
-
-export function getColumns({ router, openAttachmentsSheet, openItemsDialog }: GetColumnsProps): ColumnDef<QuotationWithRfqCode>[] {
- return [
- {
- id: "select",
- header: ({ table }) => (
- <Checkbox
- checked={
- table.getIsAllPageRowsSelected() ||
- (table.getIsSomePageRowsSelected() && "indeterminate")
- }
- onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
- aria-label="모두 선택"
- className="translate-y-0.5"
- />
- ),
- cell: ({ row }) => {
- const isRejected = row.original.status === TECH_SALES_QUOTATION_STATUSES.REJECTED;
- const isAccepted = row.original.status === TECH_SALES_QUOTATION_STATUSES.ACCEPTED;
- const isDisabled = isRejected || isAccepted;
-
- return (
- <Checkbox
- checked={row.getIsSelected()}
- onCheckedChange={(value) => row.toggleSelected(!!value)}
- aria-label="행 선택"
- className="translate-y-0.5"
- disabled={isDisabled}
- />
- );
- },
- enableSorting: false,
- enableHiding: false,
- },
- // {
- // accessorKey: "id",
- // header: ({ column }) => (
- // <DataTableColumnHeaderSimple column={column} title="ID" />
- // ),
- // cell: ({ row }) => (
- // <div className="w-20">
- // <span className="font-mono text-xs">{row.getValue("id")}</span>
- // </div>
- // ),
- // enableSorting: true,
- // enableHiding: true,
- // },
- {
- accessorKey: "rfqCode",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="RFQ 번호" />
- ),
- cell: ({ row }) => {
- const rfqCode = row.getValue("rfqCode") as string;
- return (
- <div className="min-w-32">
- <span className="font-mono text-sm">{rfqCode || "N/A"}</span>
- </div>
- );
- },
- enableSorting: true,
- enableHiding: false,
- },
- // {
- // accessorKey: "vendorName",
- // header: ({ column }) => (
- // <DataTableColumnHeaderSimple column={column} title="벤더명" />
- // ),
- // cell: ({ row }) => {
- // const vendorName = row.getValue("vendorName") as string;
- // return (
- // <div className="min-w-32">
- // <span className="text-sm">{vendorName || "N/A"}</span>
- // </div>
- // );
- // },
- // enableSorting: true,
- // enableHiding: false,
- // },
- // {
- // accessorKey: "vendorCode",
- // header: ({ column }) => (
- // <DataTableColumnHeaderSimple column={column} title="벤더 코드" />
- // ),
- // cell: ({ row }) => {
- // const vendorCode = row.getValue("vendorCode") as string;
- // return (
- // <div className="min-w-24">
- // <span className="font-mono text-sm">{vendorCode || "N/A"}</span>
- // </div>
- // );
- // },
- // enableSorting: true,
- // enableHiding: true,
- // },
- // {
- // accessorKey: "materialCode",
- // header: ({ column }) => (
- // <DataTableColumnHeaderSimple column={column} title="자재 그룹" />
- // ),
- // cell: ({ row }) => {
- // const materialCode = row.getValue("materialCode") as string;
- // return (
- // <div className="min-w-32">
- // <span className="font-mono text-sm">{materialCode || "N/A"}</span>
- // </div>
- // );
- // },
- // enableSorting: true,
- // enableHiding: true,
- // },
- // {
- // accessorKey: "itemName",
- // header: ({ column }) => (
- // <DataTableColumnHeaderSimple column={column} title="자재명" />
- // ),
- // cell: ({ row }) => {
- // const itemName = row.getValue("itemName") as string;
- // return (
- // <div className="min-w-48 max-w-64">
- // <TooltipProvider>
- // <Tooltip>
- // <TooltipTrigger asChild>
- // <span className="truncate block text-sm">
- // {itemName || "N/A"}
- // </span>
- // </TooltipTrigger>
- // <TooltipContent>
- // <p className="max-w-xs">{itemName || "N/A"}</p>
- // </TooltipContent>
- // </Tooltip>
- // </TooltipProvider>
- // </div>
- // );
- // },
- // enableSorting: true,
- // enableHiding: true,
- // },
- {
- accessorKey: "description",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="RFQ title" />
- ),
- cell: ({ row }) => {
- const description = row.getValue("description") as string;
- return (
- <div className="min-w-48 max-w-64">
- <TooltipProvider>
- <Tooltip>
- <TooltipTrigger asChild>
- <span className="truncate block text-sm">
- {description || "N/A"}
- </span>
- </TooltipTrigger>
- <TooltipContent>
- <p className="max-w-xs">{description || "N/A"}</p>
- </TooltipContent>
- </Tooltip>
- </TooltipProvider>
- </div>
- );
- },
- enableSorting: true,
- enableHiding: true,
- },
- {
- accessorKey: "projNm",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="프로젝트명" />
- ),
- cell: ({ row }) => {
- const projNm = row.getValue("projNm") as string;
- return (
- <div className="min-w-48 max-w-64">
- <TooltipProvider>
- <Tooltip>
- <TooltipTrigger asChild>
- <span className="truncate block text-sm">
- {projNm || "N/A"}
- </span>
- </TooltipTrigger>
- <TooltipContent>
- <p className="max-w-xs">{projNm || "N/A"}</p>
- </TooltipContent>
- </Tooltip>
- </TooltipProvider>
- </div>
- );
- },
- enableSorting: true,
- enableHiding: true,
- },
- // {
- // accessorKey: "quotationCode",
- // header: ({ column }) => (
- // <DataTableColumnHeaderSimple column={column} title="견적서 번호" />
- // ),
- // cell: ({ row }) => {
- // const quotationCode = row.getValue("quotationCode") as string;
- // return (
- // <div className="min-w-32">
- // <span className="font-mono text-sm">{quotationCode || "미부여"}</span>
- // </div>
- // );
- // },
- // enableSorting: true,
- // enableHiding: true,
- // },
- // {
- // accessorKey: "quotationVersion",
- // header: ({ column }) => (
- // <DataTableColumnHeaderSimple column={column} title="버전" />
- // ),
- // cell: ({ row }) => {
- // const quotationVersion = row.getValue("quotationVersion") as number;
- // return (
- // <div className="w-16 text-center">
- // <span className="text-sm">{quotationVersion || 1}</span>
- // </div>
- // );
- // },
- // enableSorting: true,
- // enableHiding: true,
- // },
- {
- id: "items",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="아이템" />
- ),
- cell: ({ row }) => {
- const quotation = row.original
- const itemCount = quotation.itemCount || 0
-
- const handleClick = () => {
- const rfq = {
- id: quotation.rfqId,
- rfqCode: quotation.rfqCode,
- status: quotation.rfqStatus,
- rfqType: "SHIP" as const, // 기본값
- }
- openItemsDialog(rfq)
- }
-
- return (
- <div className="w-20">
- <TooltipProvider>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="ghost"
- size="sm"
- className="relative h-8 w-8 p-0 group"
- onClick={handleClick}
- aria-label={`View ${itemCount} items`}
- >
- <Package className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
- {itemCount > 0 && (
- <span className="pointer-events-none absolute -top-1 -right-1 inline-flex h-4 min-w-[1rem] items-center justify-center rounded-full bg-primary px-1 text-[0.625rem] font-medium leading-none text-primary-foreground">
- {itemCount}
- </span>
- )}
- <span className="sr-only">
- {itemCount > 0 ? `${itemCount} 아이템` : "아이템 없음"}
- </span>
- </Button>
- </TooltipTrigger>
- <TooltipContent>
- <p>{itemCount > 0 ? `${itemCount}개 아이템 보기` : "아이템 없음"}</p>
- </TooltipContent>
- </Tooltip>
- </TooltipProvider>
- </div>
- )
- },
- enableSorting: false,
- enableHiding: true,
- },
- {
- id: "attachments",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="첨부파일" />
- ),
- cell: ({ row }) => {
- const quotation = row.original
- const attachmentCount = quotation.attachmentCount || 0
- const handleClick = () => {
- openAttachmentsSheet(quotation.rfqId)
- }
-
- return (
- <div className="w-20">
- <TooltipProvider>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="ghost"
- size="sm"
- className="relative h-8 w-8 p-0 group"
- onClick={handleClick}
- aria-label={
- attachmentCount > 0 ? `View ${attachmentCount} attachments` : "No attachments"
- }
- >
- <Paperclip className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
- {attachmentCount > 0 && (
- <span className="pointer-events-none absolute -top-1 -right-1 inline-flex h-4 min-w-[1rem] items-center justify-center rounded-full bg-primary px-1 text-[0.625rem] font-medium leading-none text-primary-foreground">
- {attachmentCount}
- </span>
- )}
- <span className="sr-only">
- {attachmentCount > 0 ? `${attachmentCount} 첨부파일` : "첨부파일 없음"}
- </span>
- </Button>
- </TooltipTrigger>
- <TooltipContent>
- <p>{attachmentCount > 0 ? `${attachmentCount}개 첨부파일 보기` : "첨부파일 없음"}</p>
- </TooltipContent>
- </Tooltip>
- </TooltipProvider>
- </div>
- )
- },
- enableSorting: false,
- enableHiding: true,
- },
- {
- accessorKey: "status",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="상태" />
- ),
- cell: ({ row }) => {
- const status = row.getValue("status") as string;
-
- const statusConfig = TECH_SALES_QUOTATION_STATUS_CONFIG[status as keyof typeof TECH_SALES_QUOTATION_STATUS_CONFIG] || {
- label: status,
- variant: "secondary" as const
- };
-
- return (
- <div className="w-24">
- <Badge variant={statusConfig.variant} className="text-xs">
- {statusConfig.label}
- </Badge>
- </div>
- );
- },
- enableSorting: true,
- enableHiding: false,
- filterFn: (row, id, value) => {
- return value.includes(row.getValue(id));
- },
- },
- {
- accessorKey: "currency",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="통화" />
- ),
- cell: ({ row }) => {
- const currency = row.getValue("currency") as string;
- return (
- <div className="w-16">
- <span className="font-mono text-sm">{currency || "N/A"}</span>
- </div>
- );
- },
- enableSorting: true,
- enableHiding: true,
- },
- {
- accessorKey: "totalPrice",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="총액" />
- ),
- cell: ({ row }) => {
- const totalPrice = row.getValue("totalPrice") as string;
- const currency = row.getValue("currency") as string;
-
- if (!totalPrice || totalPrice === "0") {
- return (
- <div className="w-32 text-right">
- <span className="text-muted-foreground text-sm">미입력</span>
- </div>
- );
- }
-
- return (
- <div className="w-32 text-right">
- <span className="font-mono text-sm">
- {formatCurrency(parseFloat(totalPrice), currency || "USD")}
- </span>
- </div>
- );
- },
- enableSorting: true,
- enableHiding: true,
- },
- {
- accessorKey: "validUntil",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="유효기간" />
- ),
- cell: ({ row }) => {
- const validUntil = row.getValue("validUntil") as Date;
- return (
- <div className="w-28">
- <span className="text-sm">
- {validUntil ? formatDate(validUntil) : "N/A"}
- </span>
- </div>
- );
- },
- enableSorting: true,
- enableHiding: true,
- },
- {
- accessorKey: "submittedAt",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="제출일" />
- ),
- cell: ({ row }) => {
- const submittedAt = row.getValue("submittedAt") as Date;
- return (
- <div className="w-36">
- <span className="text-sm">
- {submittedAt ? formatDateTime(submittedAt) : "미제출"}
- </span>
- </div>
- );
- },
- enableSorting: true,
- enableHiding: true,
- },
- // {
- // accessorKey: "acceptedAt",
- // header: ({ column }) => (
- // <DataTableColumnHeaderSimple column={column} title="승인일" />
- // ),
- // cell: ({ row }) => {
- // const acceptedAt = row.getValue("acceptedAt") as Date;
- // return (
- // <div className="w-36">
- // <span className="text-sm">
- // {acceptedAt ? formatDateTime(acceptedAt) : "미승인"}
- // </span>
- // </div>
- // );
- // },
- // enableSorting: true,
- // enableHiding: true,
- // },
- {
- accessorKey: "dueDate",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="마감일" />
- ),
- cell: ({ row }) => {
- const dueDate = row.getValue("dueDate") as Date;
- const isOverdue = dueDate && new Date() > new Date(dueDate);
-
- return (
- <div className="w-28">
- <span className={`text-sm ${isOverdue ? "text-red-600 font-medium" : ""}`}>
- {dueDate ? formatDate(dueDate) : "N/A"}
- </span>
- </div>
- );
- },
- enableSorting: true,
- enableHiding: true,
- },
- // {
- // accessorKey: "rejectionReason",
- // header: ({ column }) => (
- // <DataTableColumnHeaderSimple column={column} title="반려사유" />
- // ),
- // cell: ({ row }) => {
- // const rejectionReason = row.getValue("rejectionReason") as string;
- // return (
- // <div className="min-w-48 max-w-64">
- // {rejectionReason ? (
- // <TooltipProvider>
- // <Tooltip>
- // <TooltipTrigger asChild>
- // <span className="truncate block text-sm text-red-600">
- // {rejectionReason}
- // </span>
- // </TooltipTrigger>
- // <TooltipContent>
- // <p className="max-w-xs">{rejectionReason}</p>
- // </TooltipContent>
- // </Tooltip>
- // </TooltipProvider>
- // ) : (
- // <span className="text-sm text-muted-foreground">N/A</span>
- // )}
- // </div>
- // );
- // },
- // enableSorting: false,
- // enableHiding: true,
- // },
- {
- accessorKey: "createdAt",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="생성일" />
- ),
- cell: ({ row }) => {
- const createdAt = row.getValue("createdAt") as Date;
- return (
- <div className="w-36">
- <span className="text-sm">
- {createdAt ? formatDateTime(createdAt) : "N/A"}
- </span>
- </div>
- );
- },
- enableSorting: true,
- enableHiding: true,
- },
- {
- accessorKey: "updatedAt",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="수정일" />
- ),
- cell: ({ row }) => {
- const updatedAt = row.getValue("updatedAt") as Date;
- return (
- <div className="w-36">
- <span className="text-sm">
- {updatedAt ? formatDateTime(updatedAt) : "N/A"}
- </span>
- </div>
- );
- },
- enableSorting: true,
- enableHiding: true,
- },
- // {
- // accessorKey: "createdByName",
- // header: ({ column }) => (
- // <DataTableColumnHeaderSimple column={column} title="생성자" />
- // ),
- // cell: ({ row }) => {
- // const createdByName = row.getValue("createdByName") as string;
- // return (
- // <div className="w-24">
- // <span className="text-sm">{createdByName || "N/A"}</span>
- // </div>
- // );
- // },
- // enableSorting: true,
- // enableHiding: true,
- // },
- // {
- // accessorKey: "updatedByName",
- // header: ({ column }) => (
- // <DataTableColumnHeaderSimple column={column} title="수정자" />
- // ),
- // cell: ({ row }) => {
- // const updatedByName = row.getValue("updatedByName") as string;
- // return (
- // <div className="w-24">
- // <span className="text-sm">{updatedByName || "N/A"}</span>
- // </div>
- // );
- // },
- // enableSorting: true,
- // enableHiding: true,
- // },
- {
- id: "actions",
- header: "작업",
- cell: ({ row }) => {
- const quotation = row.original;
- const rfqCode = quotation.rfqCode || "N/A";
- const tooltipText = `${rfqCode} 견적서 작성`;
- const isRejected = quotation.status === "Rejected";
- const isAccepted = quotation.status === "Accepted";
- const isDisabled = isRejected || isAccepted;
-
- return (
- <div className="w-16">
- <TooltipProvider>
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="ghost"
- size="icon"
- onClick={() => {
- if (!isDisabled) {
- router.push(`/ko/partners/techsales/rfq-ship/${quotation.id}`);
- }
- }}
- className="h-8 w-8"
- disabled={isDisabled}
- >
- <Edit className="h-4 w-4" />
- <span className="sr-only">견적서 작성</span>
- </Button>
- </TooltipTrigger>
- <TooltipContent>
- <p>{isRejected ? "거절된 견적서는 편집할 수 없습니다" : isAccepted ? "승인된 견적서는 편집할 수 없습니다" : tooltipText}</p>
- </TooltipContent>
- </Tooltip>
- </TooltipProvider>
- </div>
- );
- },
- enableSorting: false,
- enableHiding: false,
- },
- ];
+"use client"
+
+import * as React from "react"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Edit, Paperclip, Package, Users } from "lucide-react"
+import { formatCurrency, formatDate, formatDateTime } from "@/lib/utils"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+import {
+ TechSalesVendorQuotations,
+ TECH_SALES_QUOTATION_STATUS_CONFIG,
+ TECH_SALES_QUOTATION_STATUSES
+} from "@/db/schema"
+import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+
+interface QuotationWithRfqCode extends TechSalesVendorQuotations {
+ // RFQ 관련 정보
+ rfqCode?: string;
+ materialCode?: string;
+ dueDate?: Date;
+ rfqStatus?: string;
+
+ // 아이템 정보
+ itemName?: string;
+ itemCount?: number;
+
+ // 프로젝트 정보
+ projNm?: string;
+ pspid?: string;
+ sector?: string;
+
+ // RFQ 정보
+ description?: string;
+
+ // 벤더 정보
+ vendorName?: string;
+ vendorCode?: string;
+
+ // 사용자 정보
+ createdByName?: string | null;
+ updatedByName?: string | null;
+
+ // 첨부파일 개수
+ attachmentCount?: number;
+}
+
+interface GetColumnsProps {
+ router: AppRouterInstance;
+ openAttachmentsSheet: (rfqId: number) => void;
+ openItemsDialog: (rfq: { id: number; rfqCode?: string; status?: string; rfqType?: "SHIP" | "TOP" | "HULL"; }) => void;
+ openContactsDialog: (quotationId: number, vendorName?: string) => void;
+}
+
+export function getColumns({ router, openAttachmentsSheet, openItemsDialog, openContactsDialog }: GetColumnsProps): ColumnDef<QuotationWithRfqCode>[] {
+ return [
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="모두 선택"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => {
+ const isRejected = row.original.status === TECH_SALES_QUOTATION_STATUSES.REJECTED;
+ const isAccepted = row.original.status === TECH_SALES_QUOTATION_STATUSES.ACCEPTED;
+ const isDisabled = isRejected || isAccepted;
+
+ return (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="행 선택"
+ className="translate-y-0.5"
+ disabled={isDisabled}
+ />
+ );
+ },
+ enableSorting: false,
+ enableHiding: false,
+ },
+ // {
+ // accessorKey: "id",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="ID" />
+ // ),
+ // cell: ({ row }) => (
+ // <div className="w-20">
+ // <span className="font-mono text-xs">{row.getValue("id")}</span>
+ // </div>
+ // ),
+ // enableSorting: true,
+ // enableHiding: true,
+ // },
+ {
+ accessorKey: "rfqCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ 번호" />
+ ),
+ cell: ({ row }) => {
+ const rfqCode = row.getValue("rfqCode") as string;
+ return (
+ <div className="min-w-32">
+ <span className="font-mono text-sm">{rfqCode || "N/A"}</span>
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: false,
+ },
+ // {
+ // accessorKey: "vendorName",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="벤더명" />
+ // ),
+ // cell: ({ row }) => {
+ // const vendorName = row.getValue("vendorName") as string;
+ // return (
+ // <div className="min-w-32">
+ // <span className="text-sm">{vendorName || "N/A"}</span>
+ // </div>
+ // );
+ // },
+ // enableSorting: true,
+ // enableHiding: false,
+ // },
+ // {
+ // accessorKey: "vendorCode",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="벤더 코드" />
+ // ),
+ // cell: ({ row }) => {
+ // const vendorCode = row.getValue("vendorCode") as string;
+ // return (
+ // <div className="min-w-24">
+ // <span className="font-mono text-sm">{vendorCode || "N/A"}</span>
+ // </div>
+ // );
+ // },
+ // enableSorting: true,
+ // enableHiding: true,
+ // },
+ // {
+ // accessorKey: "materialCode",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="자재 그룹" />
+ // ),
+ // cell: ({ row }) => {
+ // const materialCode = row.getValue("materialCode") as string;
+ // return (
+ // <div className="min-w-32">
+ // <span className="font-mono text-sm">{materialCode || "N/A"}</span>
+ // </div>
+ // );
+ // },
+ // enableSorting: true,
+ // enableHiding: true,
+ // },
+ // {
+ // accessorKey: "itemName",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="자재명" />
+ // ),
+ // cell: ({ row }) => {
+ // const itemName = row.getValue("itemName") as string;
+ // return (
+ // <div className="min-w-48 max-w-64">
+ // <TooltipProvider>
+ // <Tooltip>
+ // <TooltipTrigger asChild>
+ // <span className="truncate block text-sm">
+ // {itemName || "N/A"}
+ // </span>
+ // </TooltipTrigger>
+ // <TooltipContent>
+ // <p className="max-w-xs">{itemName || "N/A"}</p>
+ // </TooltipContent>
+ // </Tooltip>
+ // </TooltipProvider>
+ // </div>
+ // );
+ // },
+ // enableSorting: true,
+ // enableHiding: true,
+ // },
+ {
+ accessorKey: "description",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ title" />
+ ),
+ cell: ({ row }) => {
+ const description = row.getValue("description") as string;
+ return (
+ <div className="min-w-48 max-w-64">
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <span className="truncate block text-sm">
+ {description || "N/A"}
+ </span>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p className="max-w-xs">{description || "N/A"}</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: true,
+ },
+ {
+ accessorKey: "projNm",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="프로젝트명" />
+ ),
+ cell: ({ row }) => {
+ const projNm = row.getValue("projNm") as string;
+ return (
+ <div className="min-w-48 max-w-64">
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <span className="truncate block text-sm">
+ {projNm || "N/A"}
+ </span>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p className="max-w-xs">{projNm || "N/A"}</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: true,
+ },
+ // {
+ // accessorKey: "quotationCode",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="견적서 번호" />
+ // ),
+ // cell: ({ row }) => {
+ // const quotationCode = row.getValue("quotationCode") as string;
+ // return (
+ // <div className="min-w-32">
+ // <span className="font-mono text-sm">{quotationCode || "미부여"}</span>
+ // </div>
+ // );
+ // },
+ // enableSorting: true,
+ // enableHiding: true,
+ // },
+ // {
+ // accessorKey: "quotationVersion",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="버전" />
+ // ),
+ // cell: ({ row }) => {
+ // const quotationVersion = row.getValue("quotationVersion") as number;
+ // return (
+ // <div className="w-16 text-center">
+ // <span className="text-sm">{quotationVersion || 1}</span>
+ // </div>
+ // );
+ // },
+ // enableSorting: true,
+ // enableHiding: true,
+ // },
+ {
+ id: "items",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="아이템" />
+ ),
+ cell: ({ row }) => {
+ const quotation = row.original
+ const itemCount = quotation.itemCount || 0
+
+ const handleClick = () => {
+ const rfq = {
+ id: quotation.rfqId,
+ rfqCode: quotation.rfqCode,
+ status: quotation.rfqStatus,
+ rfqType: "SHIP" as const, // 기본값
+ }
+ openItemsDialog(rfq)
+ }
+
+ return (
+ <div className="w-20">
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label={`View ${itemCount} items`}
+ >
+ <Package className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ {itemCount > 0 && (
+ <span className="pointer-events-none absolute -top-1 -right-1 inline-flex h-4 min-w-[1rem] items-center justify-center rounded-full bg-primary px-1 text-[0.625rem] font-medium leading-none text-primary-foreground">
+ {itemCount}
+ </span>
+ )}
+ <span className="sr-only">
+ {itemCount > 0 ? `${itemCount} 아이템` : "아이템 없음"}
+ </span>
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>{itemCount > 0 ? `${itemCount}개 아이템 보기` : "아이템 없음"}</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ )
+ },
+ enableSorting: false,
+ enableHiding: true,
+ },
+ {
+ id: "attachments",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="첨부파일" />
+ ),
+ cell: ({ row }) => {
+ const quotation = row.original
+ const attachmentCount = quotation.attachmentCount || 0
+ const handleClick = () => {
+ openAttachmentsSheet(quotation.rfqId)
+ }
+
+ return (
+ <div className="w-20">
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label={
+ attachmentCount > 0 ? `View ${attachmentCount} attachments` : "No attachments"
+ }
+ >
+ <Paperclip className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ {attachmentCount > 0 && (
+ <span className="pointer-events-none absolute -top-1 -right-1 inline-flex h-4 min-w-[1rem] items-center justify-center rounded-full bg-primary px-1 text-[0.625rem] font-medium leading-none text-primary-foreground">
+ {attachmentCount}
+ </span>
+ )}
+ <span className="sr-only">
+ {attachmentCount > 0 ? `${attachmentCount} 첨부파일` : "첨부파일 없음"}
+ </span>
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>{attachmentCount > 0 ? `${attachmentCount}개 첨부파일 보기` : "첨부파일 없음"}</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ )
+ },
+ enableSorting: false,
+ enableHiding: true,
+ },
+ {
+ accessorKey: "status",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="상태" />
+ ),
+ cell: ({ row }) => {
+ const status = row.getValue("status") as string;
+
+ const statusConfig = TECH_SALES_QUOTATION_STATUS_CONFIG[status as keyof typeof TECH_SALES_QUOTATION_STATUS_CONFIG] || {
+ label: status,
+ variant: "secondary" as const
+ };
+
+ return (
+ <div className="w-24">
+ <Badge variant={statusConfig.variant} className="text-xs">
+ {statusConfig.label}
+ </Badge>
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: false,
+ filterFn: (row, id, value) => {
+ return value.includes(row.getValue(id));
+ },
+ },
+ {
+ accessorKey: "currency",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="통화" />
+ ),
+ cell: ({ row }) => {
+ const currency = row.getValue("currency") as string;
+ return (
+ <div className="w-16">
+ <span className="font-mono text-sm">{currency || "N/A"}</span>
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: true,
+ },
+ {
+ accessorKey: "totalPrice",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="총액" />
+ ),
+ cell: ({ row }) => {
+ const totalPrice = row.getValue("totalPrice") as string;
+ const currency = row.getValue("currency") as string;
+
+ if (!totalPrice || totalPrice === "0") {
+ return (
+ <div className="w-32 text-right">
+ <span className="text-muted-foreground text-sm">미입력</span>
+ </div>
+ );
+ }
+
+ return (
+ <div className="w-32 text-right">
+ <span className="font-mono text-sm">
+ {formatCurrency(parseFloat(totalPrice), currency || "USD")}
+ </span>
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: true,
+ },
+ {
+ accessorKey: "validUntil",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="유효기간" />
+ ),
+ cell: ({ row }) => {
+ const validUntil = row.getValue("validUntil") as Date;
+ return (
+ <div className="w-28">
+ <span className="text-sm">
+ {validUntil ? formatDate(validUntil) : "N/A"}
+ </span>
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: true,
+ },
+ {
+ accessorKey: "submittedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="제출일" />
+ ),
+ cell: ({ row }) => {
+ const submittedAt = row.getValue("submittedAt") as Date;
+ return (
+ <div className="w-36">
+ <span className="text-sm">
+ {submittedAt ? formatDateTime(submittedAt) : "미제출"}
+ </span>
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: true,
+ },
+ // {
+ // accessorKey: "acceptedAt",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="승인일" />
+ // ),
+ // cell: ({ row }) => {
+ // const acceptedAt = row.getValue("acceptedAt") as Date;
+ // return (
+ // <div className="w-36">
+ // <span className="text-sm">
+ // {acceptedAt ? formatDateTime(acceptedAt) : "미승인"}
+ // </span>
+ // </div>
+ // );
+ // },
+ // enableSorting: true,
+ // enableHiding: true,
+ // },
+ {
+ accessorKey: "dueDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="마감일" />
+ ),
+ cell: ({ row }) => {
+ const dueDate = row.getValue("dueDate") as Date;
+ const isOverdue = dueDate && new Date() > new Date(dueDate);
+
+ return (
+ <div className="w-28">
+ <span className={`text-sm ${isOverdue ? "text-red-600 font-medium" : ""}`}>
+ {dueDate ? formatDate(dueDate) : "N/A"}
+ </span>
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: true,
+ },
+ // {
+ // accessorKey: "rejectionReason",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="반려사유" />
+ // ),
+ // cell: ({ row }) => {
+ // const rejectionReason = row.getValue("rejectionReason") as string;
+ // return (
+ // <div className="min-w-48 max-w-64">
+ // {rejectionReason ? (
+ // <TooltipProvider>
+ // <Tooltip>
+ // <TooltipTrigger asChild>
+ // <span className="truncate block text-sm text-red-600">
+ // {rejectionReason}
+ // </span>
+ // </TooltipTrigger>
+ // <TooltipContent>
+ // <p className="max-w-xs">{rejectionReason}</p>
+ // </TooltipContent>
+ // </Tooltip>
+ // </TooltipProvider>
+ // ) : (
+ // <span className="text-sm text-muted-foreground">N/A</span>
+ // )}
+ // </div>
+ // );
+ // },
+ // enableSorting: false,
+ // enableHiding: true,
+ // },
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="생성일" />
+ ),
+ cell: ({ row }) => {
+ const createdAt = row.getValue("createdAt") as Date;
+ return (
+ <div className="w-36">
+ <span className="text-sm">
+ {createdAt ? formatDateTime(createdAt) : "N/A"}
+ </span>
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: true,
+ },
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="수정일" />
+ ),
+ cell: ({ row }) => {
+ const updatedAt = row.getValue("updatedAt") as Date;
+ return (
+ <div className="w-36">
+ <span className="text-sm">
+ {updatedAt ? formatDateTime(updatedAt) : "N/A"}
+ </span>
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: true,
+ },
+ // {
+ // accessorKey: "createdByName",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="생성자" />
+ // ),
+ // cell: ({ row }) => {
+ // const createdByName = row.getValue("createdByName") as string;
+ // return (
+ // <div className="w-24">
+ // <span className="text-sm">{createdByName || "N/A"}</span>
+ // </div>
+ // );
+ // },
+ // enableSorting: true,
+ // enableHiding: true,
+ // },
+ // {
+ // accessorKey: "updatedByName",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="수정자" />
+ // ),
+ // cell: ({ row }) => {
+ // const updatedByName = row.getValue("updatedByName") as string;
+ // return (
+ // <div className="w-24">
+ // <span className="text-sm">{updatedByName || "N/A"}</span>
+ // </div>
+ // );
+ // },
+ // enableSorting: true,
+ // enableHiding: true,
+ // },
+ {
+ id: "actions",
+ header: "작업",
+ cell: ({ row }) => {
+ const quotation = row.original;
+ const rfqCode = quotation.rfqCode || "N/A";
+ const tooltipText = `${rfqCode} 견적서 작성`;
+ const isRejected = quotation.status === "Rejected";
+ const isAccepted = quotation.status === "Accepted";
+ const isDisabled = isRejected || isAccepted;
+
+ return (
+ <div className="w-16">
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={() => {
+ if (!isDisabled) {
+ router.push(`/ko/partners/techsales/rfq-ship/${quotation.id}`);
+ }
+ }}
+ className="h-8 w-8"
+ disabled={isDisabled}
+ >
+ <Edit className="h-4 w-4" />
+ <span className="sr-only">견적서 작성</span>
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>{isRejected ? "거절된 견적서는 편집할 수 없습니다" : isAccepted ? "승인된 견적서는 편집할 수 없습니다" : tooltipText}</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ );
+ },
+ enableSorting: false,
+ enableHiding: false,
+ },
+ {
+ id: "contacts",
+ header: "담당자",
+ cell: ({ row }) => {
+ const quotation = row.original;
+
+ const handleClick = () => {
+ openContactsDialog(quotation.id, quotation.vendorName);
+ };
+
+ return (
+ <div className="w-20">
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label="담당자 정보 보기"
+ >
+ <Users className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ <span className="sr-only">담당자 정보 보기</span>
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>RFQ 발송 담당자 보기</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ );
+ },
+ enableSorting: false,
+ enableHiding: true,
+ },
+ ];
} \ No newline at end of file
diff --git a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx
index e79d7c4d..5bb219bf 100644
--- a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx
+++ b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx
@@ -1,505 +1,525 @@
-// lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx
-"use client"
-
-import * as React from "react"
-import { useSearchParams } from "next/navigation"
-import { type DataTableAdvancedFilterField, type DataTableFilterField } from "@/types/table"
-import { useDataTable } from "@/hooks/use-data-table"
-import { DataTable } from "@/components/data-table/data-table"
-import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
-import { TechSalesVendorQuotations, TECH_SALES_QUOTATION_STATUSES, TECH_SALES_QUOTATION_STATUS_CONFIG } from "@/db/schema"
-import { useRouter } from "next/navigation"
-import { getColumns } from "./vendor-quotations-table-columns"
-import { TechSalesRfqAttachmentsSheet, ExistingTechSalesAttachment } from "../../table/tech-sales-rfq-attachments-sheet"
-import { RfqItemsViewDialog } from "../../table/rfq-items-view-dialog"
-import { getTechSalesRfqAttachments, getVendorQuotations, rejectTechSalesVendorQuotations } from "@/lib/techsales-rfq/service"
-import { toast } from "sonner"
-import { Skeleton } from "@/components/ui/skeleton"
-import { Button } from "@/components/ui/button"
-import { X } from "lucide-react"
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
- AlertDialogTrigger,
-} from "@/components/ui/alert-dialog"
-import { Textarea } from "@/components/ui/textarea"
-import { Label } from "@/components/ui/label"
-
-interface QuotationWithRfqCode extends TechSalesVendorQuotations {
- rfqCode?: string | null;
- materialCode?: string | null;
- dueDate?: Date;
- rfqStatus?: string;
- itemName?: string | null;
- projNm?: string | null;
- description?: string | null;
- attachmentCount?: number;
- itemCount?: number;
- pspid?: string | null;
- sector?: string | null;
- vendorName?: string | null;
- vendorCode?: string | null;
- createdByName?: string | null;
- updatedByName?: string | null;
-}
-
-interface VendorQuotationsTableProps {
- vendorId: string;
- rfqType?: "SHIP" | "TOP" | "HULL";
-}
-
-// 로딩 스켈레톤 컴포넌트
-function TableLoadingSkeleton() {
- return (
- <div className="w-full space-y-3">
- {/* 툴바 스켈레톤 */}
- <div className="flex items-center justify-between">
- <div className="flex items-center space-x-2">
- <Skeleton className="h-10 w-[250px]" />
- <Skeleton className="h-10 w-[100px]" />
- </div>
- <div className="flex items-center space-x-2">
- <Skeleton className="h-10 w-[120px]" />
- <Skeleton className="h-10 w-[100px]" />
- </div>
- </div>
-
- {/* 테이블 헤더 스켈레톤 */}
- <div className="rounded-md border">
- <div className="border-b p-4">
- <div className="flex items-center space-x-4">
- <Skeleton className="h-4 w-[100px]" />
- <Skeleton className="h-4 w-[150px]" />
- <Skeleton className="h-4 w-[120px]" />
- <Skeleton className="h-4 w-[100px]" />
- <Skeleton className="h-4 w-[130px]" />
- <Skeleton className="h-4 w-[100px]" />
- <Skeleton className="h-4 w-[80px]" />
- </div>
- </div>
-
- {/* 테이블 행 스켈레톤 */}
- {Array.from({ length: 5 }).map((_, index) => (
- <div key={index} className="border-b p-4 last:border-b-0">
- <div className="flex items-center space-x-4">
- <Skeleton className="h-4 w-[100px]" />
- <Skeleton className="h-4 w-[150px]" />
- <Skeleton className="h-4 w-[120px]" />
- <Skeleton className="h-4 w-[100px]" />
- <Skeleton className="h-4 w-[130px]" />
- <Skeleton className="h-4 w-[100px]" />
- <Skeleton className="h-4 w-[80px]" />
- </div>
- </div>
- ))}
- </div>
-
- {/* 페이지네이션 스켈레톤 */}
- <div className="flex items-center justify-between">
- <Skeleton className="h-8 w-[200px]" />
- <div className="flex items-center space-x-2">
- <Skeleton className="h-8 w-[100px]" />
- <Skeleton className="h-8 w-[60px]" />
- <Skeleton className="h-8 w-[100px]" />
- </div>
- </div>
- </div>
- )
-}
-
-export function VendorQuotationsTable({ vendorId, rfqType }: VendorQuotationsTableProps) {
- const searchParams = useSearchParams()
- const router = useRouter()
-
- // 첨부파일 시트 상태
- const [attachmentsOpen, setAttachmentsOpen] = React.useState(false)
- const [selectedRfqForAttachments, setSelectedRfqForAttachments] = React.useState<{ id: number; rfqCode: string | null; status: string } | null>(null)
- const [attachmentsDefault, setAttachmentsDefault] = React.useState<ExistingTechSalesAttachment[]>([])
-
- // 아이템 다이얼로그 상태
- const [itemsDialogOpen, setItemsDialogOpen] = React.useState(false)
- const [selectedRfqForItems, setSelectedRfqForItems] = React.useState<{ id: number; rfqCode?: string; status?: string; rfqType?: "SHIP" | "TOP" | "HULL"; } | null>(null)
-
- // 거절 다이얼로그 상태
- const [rejectDialogOpen, setRejectDialogOpen] = React.useState(false)
- const [rejectionReason, setRejectionReason] = React.useState("")
- const [isRejecting, setIsRejecting] = React.useState(false)
-
- // 데이터 로딩 상태
- const [data, setData] = React.useState<QuotationWithRfqCode[]>([])
- const [pageCount, setPageCount] = React.useState(0)
- const [total, setTotal] = React.useState(0)
- const [isLoading, setIsLoading] = React.useState(true)
- const [isInitialLoad, setIsInitialLoad] = React.useState(true) // 최초 로딩 구분
-
- // URL 파라미터에서 설정 읽기
- const initialSettings = React.useMemo(() => ({
- page: parseInt(searchParams?.get('page') || '1'),
- perPage: parseInt(searchParams?.get('perPage') || '10'),
- sort: searchParams?.get('sort') ? JSON.parse(searchParams.get('sort')!) : [{ id: "updatedAt", desc: true }],
- filters: searchParams?.get('filters') ? JSON.parse(searchParams.get('filters')!) : [],
- joinOperator: (searchParams?.get('joinOperator') as "and" | "or") || "and",
- basicFilters: searchParams?.get('basicFilters') ? JSON.parse(searchParams.get('basicFilters')!) : [],
- basicJoinOperator: (searchParams?.get('basicJoinOperator') as "and" | "or") || "and",
- search: searchParams?.get('search') || '',
- from: searchParams?.get('from') || '',
- to: searchParams?.get('to') || '',
- }), [searchParams])
-
- // 데이터 로드 함수
- const loadData = React.useCallback(async () => {
- try {
- setIsLoading(true)
-
- console.log('🔍 [VendorQuotationsTable] 데이터 로드 요청:', {
- vendorId,
- settings: initialSettings
- })
-
- const result = await getVendorQuotations({
- page: initialSettings.page,
- perPage: initialSettings.perPage,
- sort: initialSettings.sort,
- filters: initialSettings.filters,
- joinOperator: initialSettings.joinOperator,
- basicFilters: initialSettings.basicFilters,
- basicJoinOperator: initialSettings.basicJoinOperator,
- search: initialSettings.search,
- from: initialSettings.from,
- to: initialSettings.to,
- rfqType: rfqType,
- }, vendorId)
-
- console.log('🔍 [VendorQuotationsTable] 데이터 로드 결과:', {
- dataLength: result.data.length,
- pageCount: result.pageCount,
- total: result.total
- })
-
- setData(result.data as QuotationWithRfqCode[])
- setPageCount(result.pageCount)
- setTotal(result.total)
- } catch (error) {
- console.error('데이터 로드 오류:', error)
- toast.error('데이터를 불러오는 중 오류가 발생했습니다.')
- } finally {
- setIsLoading(false)
- setIsInitialLoad(false)
- }
- }, [vendorId, initialSettings, rfqType])
-
- // URL 파라미터 변경 감지 및 데이터 재로드 (초기 로드 포함)
- React.useEffect(() => {
- loadData()
- }, [
- searchParams?.get('page'),
- searchParams?.get('perPage'),
- searchParams?.get('sort'),
- searchParams?.get('filters'),
- searchParams?.get('joinOperator'),
- searchParams?.get('basicFilters'),
- searchParams?.get('basicJoinOperator'),
- searchParams?.get('search'),
- searchParams?.get('from'),
- searchParams?.get('to'),
- // vendorId와 rfqType 변경도 감지
- vendorId,
- rfqType
- ])
-
- // 데이터 안정성을 위한 메모이제이션
- const stableData = React.useMemo(() => {
- return data;
- }, [data.length, data.map(item => `${item.id}-${item.status}-${item.updatedAt}`).join(',')]);
-
- // 첨부파일 시트 열기 함수 (벤더는 RFQ_COMMON 타입만 조회)
- const openAttachmentsSheet = React.useCallback(async (rfqId: number) => {
- try {
- // RFQ 정보 조회 (data에서 rfqId에 해당하는 데이터 찾기)
- const quotationWithRfq = data.find(item => item.rfqId === rfqId)
- if (!quotationWithRfq) {
- toast.error("RFQ 정보를 찾을 수 없습니다.")
- return
- }
-
- // 실제 첨부파일 목록 조회 API 호출
- const result = await getTechSalesRfqAttachments(rfqId)
-
- if (result.error) {
- toast.error(result.error)
- return
- }
-
- // API 응답을 ExistingTechSalesAttachment 형식으로 변환하고 RFQ_COMMON 타입만 필터링
- const attachments: ExistingTechSalesAttachment[] = result.data
- .filter(att => att.attachmentType === "RFQ_COMMON") // 벤더는 RFQ_COMMON 타입만 조회
- .map(att => ({
- id: att.id,
- techSalesRfqId: att.techSalesRfqId || rfqId,
- fileName: att.fileName,
- originalFileName: att.originalFileName,
- filePath: att.filePath,
- fileSize: att.fileSize || undefined,
- fileType: att.fileType || undefined,
- attachmentType: att.attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT",
- description: att.description || undefined,
- createdBy: att.createdBy,
- createdAt: att.createdAt,
- }))
-
- setAttachmentsDefault(attachments)
- setSelectedRfqForAttachments({
- id: rfqId,
- rfqCode: quotationWithRfq.rfqCode || null,
- status: quotationWithRfq.rfqStatus || "Unknown"
- })
- setAttachmentsOpen(true)
- } catch (error) {
- console.error("첨부파일 조회 오류:", error)
- toast.error("첨부파일 조회 중 오류가 발생했습니다.")
- }
- }, [data])
-
- // 아이템 다이얼로그 열기 함수
- const openItemsDialog = React.useCallback((rfq: { id: number; rfqCode?: string; status?: string; rfqType?: "SHIP" | "TOP" | "HULL"; }) => {
- setSelectedRfqForItems(rfq)
- setItemsDialogOpen(true)
- }, [])
-
- // 거절 처리 함수
- const handleRejectQuotations = React.useCallback(async () => {
- if (!table) return;
-
- const selectedRows = table.getFilteredSelectedRowModel().rows;
- const quotationIds = selectedRows.map(row => row.original.id);
-
- if (quotationIds.length === 0) {
- toast.error("거절할 견적서를 선택해주세요.");
- return;
- }
-
- // 거절할 수 없는 상태의 견적서가 있는지 확인
- const invalidStatuses = selectedRows.filter(row =>
- row.original.status === "Accepted" || row.original.status === "Rejected"
- );
-
- if (invalidStatuses.length > 0) {
- toast.error("이미 승인되었거나 거절된 견적서는 거절할 수 없습니다.");
- return;
- }
-
- setIsRejecting(true);
-
- try {
- const result = await rejectTechSalesVendorQuotations({
- quotationIds,
- rejectionReason: rejectionReason.trim() || undefined,
- });
-
- if (result.success) {
- toast.success(result.message);
- setRejectDialogOpen(false);
- setRejectionReason("");
- table.resetRowSelection();
- // 데이터 다시 로드
- await loadData();
- } else {
- toast.error(result.error || "견적서 거절 중 오류가 발생했습니다.");
- }
- } catch (error) {
- console.error("견적서 거절 오류:", error);
- toast.error("견적서 거절 중 오류가 발생했습니다.");
- } finally {
- setIsRejecting(false);
- }
- }, [rejectionReason, loadData]);
-
- // 테이블 컬럼 정의
- const columns = React.useMemo(() => getColumns({
- router,
- openAttachmentsSheet,
- openItemsDialog,
- }), [router, openAttachmentsSheet, openItemsDialog])
-
- // 필터 필드
- const filterFields = React.useMemo<DataTableFilterField<QuotationWithRfqCode>[]>(() => [
- {
- id: "status",
- label: "상태",
- options: Object.entries(TECH_SALES_QUOTATION_STATUSES).map(([, statusValue]) => ({
- label: TECH_SALES_QUOTATION_STATUS_CONFIG[statusValue].label,
- value: statusValue,
- }))
- },
- {
- id: "rfqCode",
- label: "RFQ 번호",
- placeholder: "RFQ 번호 검색...",
- },
- {
- id: "materialCode",
- label: "자재 그룹",
- placeholder: "자재 그룹 검색...",
- }
- ], [])
-
- // 고급 필터 필드
- const advancedFilterFields = React.useMemo<DataTableAdvancedFilterField<QuotationWithRfqCode>[]>(() => [
- {
- id: "rfqCode",
- label: "RFQ 번호",
- type: "text",
- },
- {
- id: "materialCode",
- label: "자재 그룹",
- type: "text",
- },
- {
- id: "status",
- label: "상태",
- type: "multi-select",
- options: Object.entries(TECH_SALES_QUOTATION_STATUSES).map(([, statusValue]) => ({
- label: TECH_SALES_QUOTATION_STATUS_CONFIG[statusValue].label,
- value: statusValue,
- })),
- },
- {
- id: "validUntil",
- label: "유효기간",
- type: "date",
- },
- {
- id: "submittedAt",
- label: "제출일",
- type: "date",
- },
- ], [])
-
- // useDataTable 훅 사용
- const { table } = useDataTable({
- data: stableData,
- columns: columns as any, // 타입 오류 임시 해결
- pageCount,
- rowCount: total,
- filterFields,
- enablePinning: true,
- enableAdvancedFilter: true,
- enableColumnResizing: true,
- columnResizeMode: 'onChange',
- enableRowSelection: true, // 행 선택 활성화
- initialState: {
- sorting: initialSettings.sort,
- columnPinning: { right: ["actions", "items", "attachments"] },
- },
- getRowId: (originalRow) => String(originalRow.id),
- shallow: false,
- clearOnDefault: true,
- defaultColumn: {
- minSize: 50,
- maxSize: 500,
- },
- })
-
- // 최초 로딩 시 전체 스켈레톤 표시
- if (isInitialLoad && isLoading) {
- return (
- <div className="w-full">
- <div className="overflow-x-auto">
- <TableLoadingSkeleton />
- </div>
- </div>
- )
- }
-
- return (
- <div className="w-full">
- <div className="overflow-x-auto">
- <div className="relative">
- <DataTable
- table={table}
- className="min-w-full"
- >
- <DataTableAdvancedToolbar
- table={table}
- filterFields={advancedFilterFields}
- shallow={false}
- >
- {/* 선택된 행이 있을 때 거절 버튼 표시 */}
- {table && table.getFilteredSelectedRowModel().rows.length > 0 && (
- <AlertDialog open={rejectDialogOpen} onOpenChange={setRejectDialogOpen}>
- <AlertDialogTrigger asChild>
- <Button variant="destructive" size="sm">
- <X className="mr-2 h-4 w-4" />
- 선택한 견적서 거절 ({table.getFilteredSelectedRowModel().rows.length}개)
- </Button>
- </AlertDialogTrigger>
- <AlertDialogContent>
- <AlertDialogHeader>
- <AlertDialogTitle>견적서 거절</AlertDialogTitle>
- <AlertDialogDescription>
- 선택한 {table.getFilteredSelectedRowModel().rows.length}개의 견적서를 거절하시겠습니까?
- 거절된 견적서는 다시 되돌릴 수 없습니다.
- </AlertDialogDescription>
- </AlertDialogHeader>
- <div className="grid gap-4 py-4">
- <div className="grid gap-2">
- <Label htmlFor="rejection-reason">거절 사유 (선택사항)</Label>
- <Textarea
- id="rejection-reason"
- placeholder="거절 사유를 입력하세요..."
- value={rejectionReason}
- onChange={(e) => setRejectionReason(e.target.value)}
- />
- </div>
- </div>
- <AlertDialogFooter>
- <AlertDialogCancel>취소</AlertDialogCancel>
- <AlertDialogAction
- onClick={handleRejectQuotations}
- disabled={isRejecting}
- className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
- >
- {isRejecting ? "처리 중..." : "거절"}
- </AlertDialogAction>
- </AlertDialogFooter>
- </AlertDialogContent>
- </AlertDialog>
- )}
-
- {!isInitialLoad && isLoading && (
- <div className="flex items-center gap-2 text-sm text-muted-foreground">
- <div className="animate-spin h-4 w-4 border-2 border-current border-t-transparent rounded-full" />
- 데이터 업데이트 중...
- </div>
- )}
- </DataTableAdvancedToolbar>
- </DataTable>
- </div>
- </div>
-
- {/* 첨부파일 관리 시트 (읽기 전용) */}
- <TechSalesRfqAttachmentsSheet
- open={attachmentsOpen}
- onOpenChange={setAttachmentsOpen}
- defaultAttachments={attachmentsDefault}
- rfq={selectedRfqForAttachments}
- attachmentType="RFQ_COMMON" // 벤더는 RFQ_COMMON 타입만 조회
- readOnly={true} // 벤더는 항상 읽기 전용
- />
-
- {/* 아이템 보기 다이얼로그 */}
- <RfqItemsViewDialog
- open={itemsDialogOpen}
- onOpenChange={setItemsDialogOpen}
- rfq={selectedRfqForItems}
- />
- </div>
- );
+// lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx
+"use client"
+
+import * as React from "react"
+import { useSearchParams } from "next/navigation"
+import { type DataTableAdvancedFilterField, type DataTableFilterField } from "@/types/table"
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import { TechSalesVendorQuotations, TECH_SALES_QUOTATION_STATUSES, TECH_SALES_QUOTATION_STATUS_CONFIG } from "@/db/schema"
+import { useRouter } from "next/navigation"
+import { getColumns } from "./vendor-quotations-table-columns"
+import { TechSalesRfqAttachmentsSheet, ExistingTechSalesAttachment } from "../../table/tech-sales-rfq-attachments-sheet"
+import { RfqItemsViewDialog } from "../../table/rfq-items-view-dialog"
+import { QuotationContactsViewDialog } from "../../table/detail-table/quotation-contacts-view-dialog"
+import { getTechSalesRfqAttachments, getVendorQuotations, rejectTechSalesVendorQuotations } from "@/lib/techsales-rfq/service"
+import { toast } from "sonner"
+import { Skeleton } from "@/components/ui/skeleton"
+import { Button } from "@/components/ui/button"
+import { X } from "lucide-react"
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@/components/ui/alert-dialog"
+import { Textarea } from "@/components/ui/textarea"
+import { Label } from "@/components/ui/label"
+
+interface QuotationWithRfqCode extends TechSalesVendorQuotations {
+ rfqCode?: string | null;
+ materialCode?: string | null;
+ dueDate?: Date;
+ rfqStatus?: string;
+ itemName?: string | null;
+ projNm?: string | null;
+ description?: string | null;
+ attachmentCount?: number;
+ itemCount?: number;
+ pspid?: string | null;
+ sector?: string | null;
+ vendorName?: string | null;
+ vendorCode?: string | null;
+ createdByName?: string | null;
+ updatedByName?: string | null;
+}
+
+interface VendorQuotationsTableProps {
+ vendorId: string;
+ rfqType?: "SHIP" | "TOP" | "HULL";
+}
+
+// 로딩 스켈레톤 컴포넌트
+function TableLoadingSkeleton() {
+ return (
+ <div className="w-full space-y-3">
+ {/* 툴바 스켈레톤 */}
+ <div className="flex items-center justify-between">
+ <div className="flex items-center space-x-2">
+ <Skeleton className="h-10 w-[250px]" />
+ <Skeleton className="h-10 w-[100px]" />
+ </div>
+ <div className="flex items-center space-x-2">
+ <Skeleton className="h-10 w-[120px]" />
+ <Skeleton className="h-10 w-[100px]" />
+ </div>
+ </div>
+
+ {/* 테이블 헤더 스켈레톤 */}
+ <div className="rounded-md border">
+ <div className="border-b p-4">
+ <div className="flex items-center space-x-4">
+ <Skeleton className="h-4 w-[100px]" />
+ <Skeleton className="h-4 w-[150px]" />
+ <Skeleton className="h-4 w-[120px]" />
+ <Skeleton className="h-4 w-[100px]" />
+ <Skeleton className="h-4 w-[130px]" />
+ <Skeleton className="h-4 w-[100px]" />
+ <Skeleton className="h-4 w-[80px]" />
+ </div>
+ </div>
+
+ {/* 테이블 행 스켈레톤 */}
+ {Array.from({ length: 5 }).map((_, index) => (
+ <div key={index} className="border-b p-4 last:border-b-0">
+ <div className="flex items-center space-x-4">
+ <Skeleton className="h-4 w-[100px]" />
+ <Skeleton className="h-4 w-[150px]" />
+ <Skeleton className="h-4 w-[120px]" />
+ <Skeleton className="h-4 w-[100px]" />
+ <Skeleton className="h-4 w-[130px]" />
+ <Skeleton className="h-4 w-[100px]" />
+ <Skeleton className="h-4 w-[80px]" />
+ </div>
+ </div>
+ ))}
+ </div>
+
+ {/* 페이지네이션 스켈레톤 */}
+ <div className="flex items-center justify-between">
+ <Skeleton className="h-8 w-[200px]" />
+ <div className="flex items-center space-x-2">
+ <Skeleton className="h-8 w-[100px]" />
+ <Skeleton className="h-8 w-[60px]" />
+ <Skeleton className="h-8 w-[100px]" />
+ </div>
+ </div>
+ </div>
+ )
+}
+
+export function VendorQuotationsTable({ vendorId, rfqType }: VendorQuotationsTableProps) {
+ const searchParams = useSearchParams()
+ const router = useRouter()
+
+ // 첨부파일 시트 상태
+ const [attachmentsOpen, setAttachmentsOpen] = React.useState(false)
+ const [selectedRfqForAttachments, setSelectedRfqForAttachments] = React.useState<{ id: number; rfqCode: string | null; status: string } | null>(null)
+ const [attachmentsDefault, setAttachmentsDefault] = React.useState<ExistingTechSalesAttachment[]>([])
+
+ // 아이템 다이얼로그 상태
+ const [itemsDialogOpen, setItemsDialogOpen] = React.useState(false)
+ const [selectedRfqForItems, setSelectedRfqForItems] = React.useState<{ id: number; rfqCode?: string; status?: string; rfqType?: "SHIP" | "TOP" | "HULL"; } | null>(null)
+
+ // 담당자 조회 다이얼로그 상태
+ const [contactsDialogOpen, setContactsDialogOpen] = React.useState(false)
+ const [selectedQuotationForContacts, setSelectedQuotationForContacts] = React.useState<{ id: number; vendorName?: string } | null>(null)
+
+ // 거절 다이얼로그 상태
+ const [rejectDialogOpen, setRejectDialogOpen] = React.useState(false)
+ const [rejectionReason, setRejectionReason] = React.useState("")
+ const [isRejecting, setIsRejecting] = React.useState(false)
+
+ // 데이터 로딩 상태
+ const [data, setData] = React.useState<QuotationWithRfqCode[]>([])
+ const [pageCount, setPageCount] = React.useState(0)
+ const [total, setTotal] = React.useState(0)
+ const [isLoading, setIsLoading] = React.useState(true)
+ const [isInitialLoad, setIsInitialLoad] = React.useState(true) // 최초 로딩 구분
+
+ // URL 파라미터에서 설정 읽기
+ const initialSettings = React.useMemo(() => ({
+ page: parseInt(searchParams?.get('page') || '1'),
+ perPage: parseInt(searchParams?.get('perPage') || '10'),
+ sort: searchParams?.get('sort') ? JSON.parse(searchParams.get('sort')!) : [{ id: "updatedAt", desc: true }],
+ filters: searchParams?.get('filters') ? JSON.parse(searchParams.get('filters')!) : [],
+ joinOperator: (searchParams?.get('joinOperator') as "and" | "or") || "and",
+ basicFilters: searchParams?.get('basicFilters') ? JSON.parse(searchParams.get('basicFilters')!) : [],
+ basicJoinOperator: (searchParams?.get('basicJoinOperator') as "and" | "or") || "and",
+ search: searchParams?.get('search') || '',
+ from: searchParams?.get('from') || '',
+ to: searchParams?.get('to') || '',
+ }), [searchParams])
+
+ // 데이터 로드 함수
+ const loadData = React.useCallback(async () => {
+ try {
+ setIsLoading(true)
+
+ console.log('🔍 [VendorQuotationsTable] 데이터 로드 요청:', {
+ vendorId,
+ settings: initialSettings
+ })
+
+ const result = await getVendorQuotations({
+ page: initialSettings.page,
+ perPage: initialSettings.perPage,
+ sort: initialSettings.sort,
+ filters: initialSettings.filters,
+ joinOperator: initialSettings.joinOperator,
+ basicFilters: initialSettings.basicFilters,
+ basicJoinOperator: initialSettings.basicJoinOperator,
+ search: initialSettings.search,
+ from: initialSettings.from,
+ to: initialSettings.to,
+ rfqType: rfqType,
+ }, vendorId)
+
+ console.log('🔍 [VendorQuotationsTable] 데이터 로드 결과:', {
+ dataLength: result.data.length,
+ pageCount: result.pageCount,
+ total: result.total
+ })
+
+ setData(result.data as QuotationWithRfqCode[])
+ setPageCount(result.pageCount)
+ setTotal(result.total)
+ } catch (error) {
+ console.error('데이터 로드 오류:', error)
+ toast.error('데이터를 불러오는 중 오류가 발생했습니다.')
+ } finally {
+ setIsLoading(false)
+ setIsInitialLoad(false)
+ }
+ }, [vendorId, initialSettings, rfqType])
+
+ // URL 파라미터 변경 감지 및 데이터 재로드 (초기 로드 포함)
+ React.useEffect(() => {
+ loadData()
+ }, [
+ searchParams?.get('page'),
+ searchParams?.get('perPage'),
+ searchParams?.get('sort'),
+ searchParams?.get('filters'),
+ searchParams?.get('joinOperator'),
+ searchParams?.get('basicFilters'),
+ searchParams?.get('basicJoinOperator'),
+ searchParams?.get('search'),
+ searchParams?.get('from'),
+ searchParams?.get('to'),
+ // vendorId와 rfqType 변경도 감지
+ vendorId,
+ rfqType
+ ])
+
+ // 데이터 안정성을 위한 메모이제이션
+ const stableData = React.useMemo(() => {
+ return data;
+ }, [data.length, data.map(item => `${item.id}-${item.status}-${item.updatedAt}`).join(',')]);
+
+ // 첨부파일 시트 열기 함수 (벤더는 RFQ_COMMON 타입만 조회)
+ const openAttachmentsSheet = React.useCallback(async (rfqId: number) => {
+ try {
+ // RFQ 정보 조회 (data에서 rfqId에 해당하는 데이터 찾기)
+ const quotationWithRfq = data.find(item => item.rfqId === rfqId)
+ if (!quotationWithRfq) {
+ toast.error("RFQ 정보를 찾을 수 없습니다.")
+ return
+ }
+
+ // 실제 첨부파일 목록 조회 API 호출
+ const result = await getTechSalesRfqAttachments(rfqId)
+
+ if (result.error) {
+ toast.error(result.error)
+ return
+ }
+
+ // API 응답을 ExistingTechSalesAttachment 형식으로 변환하고 RFQ_COMMON 타입만 필터링
+ const attachments: ExistingTechSalesAttachment[] = result.data
+ .filter(att => att.attachmentType === "RFQ_COMMON") // 벤더는 RFQ_COMMON 타입만 조회
+ .map(att => ({
+ id: att.id,
+ techSalesRfqId: att.techSalesRfqId || rfqId,
+ fileName: att.fileName,
+ originalFileName: att.originalFileName,
+ filePath: att.filePath,
+ fileSize: att.fileSize || undefined,
+ fileType: att.fileType || undefined,
+ attachmentType: att.attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT",
+ description: att.description || undefined,
+ createdBy: att.createdBy,
+ createdAt: att.createdAt,
+ }))
+
+ setAttachmentsDefault(attachments)
+ setSelectedRfqForAttachments({
+ id: rfqId,
+ rfqCode: quotationWithRfq.rfqCode || null,
+ status: quotationWithRfq.rfqStatus || "Unknown"
+ })
+ setAttachmentsOpen(true)
+ } catch (error) {
+ console.error("첨부파일 조회 오류:", error)
+ toast.error("첨부파일 조회 중 오류가 발생했습니다.")
+ }
+ }, [data])
+
+ // 아이템 다이얼로그 열기 함수
+ const openItemsDialog = React.useCallback((rfq: { id: number; rfqCode?: string; status?: string; rfqType?: "SHIP" | "TOP" | "HULL"; }) => {
+ setSelectedRfqForItems(rfq)
+ setItemsDialogOpen(true)
+ }, [])
+
+ // 담당자 조회 다이얼로그 열기 함수
+ const openContactsDialog = React.useCallback((quotationId: number, vendorName?: string) => {
+ setSelectedQuotationForContacts({ id: quotationId, vendorName })
+ setContactsDialogOpen(true)
+ }, [])
+
+ // 거절 처리 함수
+ const handleRejectQuotations = React.useCallback(async () => {
+ if (!table) return;
+
+ const selectedRows = table.getFilteredSelectedRowModel().rows;
+ const quotationIds = selectedRows.map(row => row.original.id);
+
+ if (quotationIds.length === 0) {
+ toast.error("거절할 견적서를 선택해주세요.");
+ return;
+ }
+
+ // 거절할 수 없는 상태의 견적서가 있는지 확인
+ const invalidStatuses = selectedRows.filter(row =>
+ row.original.status === "Accepted" || row.original.status === "Rejected"
+ );
+
+ if (invalidStatuses.length > 0) {
+ toast.error("이미 승인되었거나 거절된 견적서는 거절할 수 없습니다.");
+ return;
+ }
+
+ setIsRejecting(true);
+
+ try {
+ const result = await rejectTechSalesVendorQuotations({
+ quotationIds,
+ rejectionReason: rejectionReason.trim() || undefined,
+ });
+
+ if (result.success) {
+ toast.success(result.message);
+ setRejectDialogOpen(false);
+ setRejectionReason("");
+ table.resetRowSelection();
+ // 데이터 다시 로드
+ await loadData();
+ } else {
+ toast.error(result.error || "견적서 거절 중 오류가 발생했습니다.");
+ }
+ } catch (error) {
+ console.error("견적서 거절 오류:", error);
+ toast.error("견적서 거절 중 오류가 발생했습니다.");
+ } finally {
+ setIsRejecting(false);
+ }
+ }, [rejectionReason, loadData]);
+
+ // 테이블 컬럼 정의
+ const columns = React.useMemo(() => getColumns({
+ router,
+ openAttachmentsSheet,
+ openItemsDialog,
+ openContactsDialog,
+ }), [router, openAttachmentsSheet, openItemsDialog, openContactsDialog])
+
+ // 필터 필드
+ const filterFields = React.useMemo<DataTableFilterField<QuotationWithRfqCode>[]>(() => [
+ {
+ id: "status",
+ label: "상태",
+ options: Object.entries(TECH_SALES_QUOTATION_STATUSES).map(([, statusValue]) => ({
+ label: TECH_SALES_QUOTATION_STATUS_CONFIG[statusValue].label,
+ value: statusValue,
+ }))
+ },
+ {
+ id: "rfqCode",
+ label: "RFQ 번호",
+ placeholder: "RFQ 번호 검색...",
+ },
+ {
+ id: "materialCode",
+ label: "자재 그룹",
+ placeholder: "자재 그룹 검색...",
+ }
+ ], [])
+
+ // 고급 필터 필드
+ const advancedFilterFields = React.useMemo<DataTableAdvancedFilterField<QuotationWithRfqCode>[]>(() => [
+ {
+ id: "rfqCode",
+ label: "RFQ 번호",
+ type: "text",
+ },
+ {
+ id: "materialCode",
+ label: "자재 그룹",
+ type: "text",
+ },
+ {
+ id: "status",
+ label: "상태",
+ type: "multi-select",
+ options: Object.entries(TECH_SALES_QUOTATION_STATUSES).map(([, statusValue]) => ({
+ label: TECH_SALES_QUOTATION_STATUS_CONFIG[statusValue].label,
+ value: statusValue,
+ })),
+ },
+ {
+ id: "validUntil",
+ label: "유효기간",
+ type: "date",
+ },
+ {
+ id: "submittedAt",
+ label: "제출일",
+ type: "date",
+ },
+ ], [])
+
+ // useDataTable 훅 사용
+ const { table } = useDataTable({
+ data: stableData,
+ columns: columns as any, // 타입 오류 임시 해결
+ pageCount,
+ rowCount: total,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ enableColumnResizing: true,
+ columnResizeMode: 'onChange',
+ enableRowSelection: true, // 행 선택 활성화
+ initialState: {
+ sorting: initialSettings.sort,
+ columnPinning: { right: ["actions", "items", "attachments"] },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ defaultColumn: {
+ minSize: 50,
+ maxSize: 500,
+ },
+ })
+
+ // 최초 로딩 시 전체 스켈레톤 표시
+ if (isInitialLoad && isLoading) {
+ return (
+ <div className="w-full">
+ <div className="overflow-x-auto">
+ <TableLoadingSkeleton />
+ </div>
+ </div>
+ )
+ }
+
+ return (
+ <div className="w-full">
+ <div className="overflow-x-auto">
+ <div className="relative">
+ <DataTable
+ table={table}
+ className="min-w-full"
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ {/* 선택된 행이 있을 때 거절 버튼 표시 */}
+ {table && table.getFilteredSelectedRowModel().rows.length > 0 && (
+ <AlertDialog open={rejectDialogOpen} onOpenChange={setRejectDialogOpen}>
+ <AlertDialogTrigger asChild>
+ <Button variant="destructive" size="sm">
+ <X className="mr-2 h-4 w-4" />
+ 선택한 견적서 거절 ({table.getFilteredSelectedRowModel().rows.length}개)
+ </Button>
+ </AlertDialogTrigger>
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle>견적서 거절</AlertDialogTitle>
+ <AlertDialogDescription>
+ 선택한 {table.getFilteredSelectedRowModel().rows.length}개의 견적서를 거절하시겠습니까?
+ 거절된 견적서는 다시 되돌릴 수 없습니다.
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <div className="grid gap-4 py-4">
+ <div className="grid gap-2">
+ <Label htmlFor="rejection-reason">거절 사유 (선택사항)</Label>
+ <Textarea
+ id="rejection-reason"
+ placeholder="거절 사유를 입력하세요..."
+ value={rejectionReason}
+ onChange={(e) => setRejectionReason(e.target.value)}
+ />
+ </div>
+ </div>
+ <AlertDialogFooter>
+ <AlertDialogCancel>취소</AlertDialogCancel>
+ <AlertDialogAction
+ onClick={handleRejectQuotations}
+ disabled={isRejecting}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ >
+ {isRejecting ? "처리 중..." : "거절"}
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+ )}
+
+ {!isInitialLoad && isLoading && (
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <div className="animate-spin h-4 w-4 border-2 border-current border-t-transparent rounded-full" />
+ 데이터 업데이트 중...
+ </div>
+ )}
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ </div>
+ </div>
+
+ {/* 첨부파일 관리 시트 (읽기 전용) */}
+ <TechSalesRfqAttachmentsSheet
+ open={attachmentsOpen}
+ onOpenChange={setAttachmentsOpen}
+ defaultAttachments={attachmentsDefault}
+ rfq={selectedRfqForAttachments}
+ attachmentType="RFQ_COMMON" // 벤더는 RFQ_COMMON 타입만 조회
+ readOnly={true} // 벤더는 항상 읽기 전용
+ />
+
+ {/* 아이템 보기 다이얼로그 */}
+ <RfqItemsViewDialog
+ open={itemsDialogOpen}
+ onOpenChange={setItemsDialogOpen}
+ rfq={selectedRfqForItems}
+ />
+
+ {/* 담당자 조회 다이얼로그 */}
+ <QuotationContactsViewDialog
+ open={contactsDialogOpen}
+ onOpenChange={setContactsDialogOpen}
+ quotationId={selectedQuotationForContacts?.id || null}
+ vendorName={selectedQuotationForContacts?.vendorName}
+ />
+ </div>
+ );
} \ No newline at end of file