From 14f61e24947fb92dd71ec0a7196a6e815f8e66da Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 21 Jul 2025 07:54:26 +0000 Subject: (최겸)기술영업 RFQ 담당자 초대, 요구사항 반영 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/[lng]/auth/tech-signup/page.tsx | 17 - .../evcp/(evcp)/contact-possible-items/page.tsx | 56 + .../(evcp)/tech-vendors/[id]/info/items/page.tsx | 48 - .../evcp/(evcp)/tech-vendors/[id]/info/layout.tsx | 162 +- .../evcp/(evcp)/tech-vendors/[id]/info/page.tsx | 108 +- .../tech-vendors/[id]/info/possible-items/page.tsx | 54 + .../tech-vendors/[id]/info/rfq-history/page.tsx | 41 +- app/[lng]/partners/tech-signup/page.tsx | 17 + .../(sales)/tech-vendors/[id]/info/layout.tsx | 162 +- .../sales/(sales)/tech-vendors/[id]/info/page.tsx | 108 +- .../tech-vendors/[id]/info/possible-items/page.tsx | 54 + .../tech-vendors/[id]/info/rfq-history/page.tsx | 41 +- .../[rfqId]/vendors/[vendorId]/comments/route.ts | 32 +- .../signup/tech-vendor-item-selector-dialog.tsx | 254 + components/signup/tech-vendor-join-form.tsx | 69 +- components/tech-vendors/tech-vendor-container.tsx | 30 +- .../tech-vendors/tech-vendor-items-container.tsx | 121 - config/techVendorColumnsConfig.ts | 60 +- config/techVendorContactsColumnsConfig.ts | 88 +- lib/contact-possible-items/service.ts | 190 + .../table/contact-possible-items-table-columns.tsx | 301 + ...ontact-possible-items-table-toolbar-actions.tsx | 92 + .../table/contact-possible-items-table.tsx | 102 + .../table/delete-contact-possible-items-dialog.tsx | 111 + lib/contact-possible-items/validations.ts | 59 + lib/items-tech/repository.ts | 248 +- lib/items-tech/service.ts | 34 +- lib/items-tech/table/delete-items-dialog.tsx | 388 +- lib/items-tech/table/feature-flags.tsx | 192 +- lib/items-tech/table/hull/import-item-handler.tsx | 254 +- lib/items-tech/table/hull/item-excel-template.tsx | 210 +- lib/items-tech/table/import-excel-button.tsx | 606 +- lib/items-tech/table/ship/import-item-handler.tsx | 266 +- lib/items-tech/table/ship/item-excel-template.tsx | 220 +- .../table/ship/items-table-toolbar-actions.tsx | 352 +- lib/items-tech/table/top/import-item-handler.tsx | 271 +- lib/items-tech/table/top/item-excel-template.tsx | 218 +- lib/tech-vendor-invitation-token.ts | 7 +- lib/tech-vendor-possible-items/repository.ts | 123 +- lib/tech-vendor-possible-items/service.ts | 298 +- .../table/add-possible-item-dialog.tsx | 450 ++ .../table/delete-possible-items-dialog.tsx | 175 + .../table/excel-export.tsx | 106 +- .../table/excel-import.tsx | 130 +- .../table/excel-template.tsx | 151 +- .../table/possible-items-data-table.tsx | 26 +- .../table/possible-items-table-columns.tsx | 147 +- .../table/possible-items-table-toolbar-actions.tsx | 86 +- lib/tech-vendor-possible-items/validations.ts | 16 +- .../contacts-table/add-contact-dialog.tsx | 390 +- .../contacts-table/contact-table-columns.tsx | 350 +- .../contact-table-toolbar-actions.tsx | 264 +- lib/tech-vendors/contacts-table/contact-table.tsx | 178 +- .../contacts-table/feature-flags-provider.tsx | 216 +- .../contacts-table/update-contact-sheet.tsx | 217 + .../possible-items/add-item-dialog.tsx | 284 + .../possible-items/possible-items-columns.tsx | 206 + .../possible-items/possible-items-table.tsx | 171 + .../possible-items-toolbar-actions.tsx | 119 + lib/tech-vendors/repository.ts | 851 +-- .../tech-vendor-rfq-history-table-columns.tsx | 56 +- lib/tech-vendors/service.ts | 4506 +++++++------ lib/tech-vendors/table/add-vendor-dialog.tsx | 48 +- lib/tech-vendors/table/attachmentButton.tsx | 152 +- lib/tech-vendors/table/excel-template-download.tsx | 380 +- lib/tech-vendors/table/feature-flags-provider.tsx | 216 +- lib/tech-vendors/table/import-button.tsx | 692 +- .../tech-vendor-possible-items-view-dialog.tsx | 201 - .../table/tech-vendors-filter-sheet.tsx | 617 ++ .../table/tech-vendors-table-columns.tsx | 788 ++- .../table/tech-vendors-table-floating-bar.tsx | 240 - .../table/tech-vendors-table-toolbar-actions.tsx | 396 +- lib/tech-vendors/table/tech-vendors-table.tsx | 470 +- lib/tech-vendors/table/update-vendor-sheet.tsx | 1035 +-- lib/tech-vendors/table/vendor-all-export.ts | 512 +- lib/tech-vendors/utils.ts | 56 +- lib/tech-vendors/validations.ts | 719 +- lib/techsales-rfq/actions.ts | 62 +- lib/techsales-rfq/repository.ts | 1204 ++-- lib/techsales-rfq/service.ts | 7112 ++++++++++---------- lib/techsales-rfq/table/README.md | 41 - lib/techsales-rfq/table/create-rfq-hull-dialog.tsx | 1294 ++-- lib/techsales-rfq/table/create-rfq-ship-dialog.tsx | 1450 ++-- lib/techsales-rfq/table/create-rfq-top-dialog.tsx | 1220 ++-- lib/techsales-rfq/table/delete-vendors-dialog.tsx | 236 +- .../table/detail-table/add-vendor-dialog.tsx | 946 +-- .../table/detail-table/delete-vendors-dialog.tsx | 297 +- .../quotation-contacts-view-dialog.tsx | 173 + .../detail-table/quotation-history-dialog.tsx | 10 +- .../table/detail-table/rfq-detail-column.tsx | 850 +-- .../table/detail-table/rfq-detail-table.tsx | 1483 ++-- .../detail-table/vendor-communication-drawer.tsx | 1238 ++-- .../vendor-contact-selection-dialog.tsx | 343 + lib/techsales-rfq/table/project-detail-dialog.tsx | 238 +- lib/techsales-rfq/table/rfq-filter-sheet.tsx | 1516 ++--- lib/techsales-rfq/table/rfq-items-view-dialog.tsx | 6 +- lib/techsales-rfq/table/rfq-table-column.tsx | 831 ++- .../table/rfq-table-toolbar-actions.tsx | 158 +- lib/techsales-rfq/table/rfq-table.tsx | 1223 ++-- .../tech-sales-quotation-attachments-sheet.tsx | 39 +- .../table/tech-sales-rfq-attachments-sheet.tsx | 1118 +-- lib/techsales-rfq/validations.ts | 382 +- .../vendor-response/buyer-communication-drawer.tsx | 1438 ++-- .../vendor-response/detail/communication-tab.tsx | 416 +- .../vendor-response/detail/project-info-tab.tsx | 296 +- .../detail/quotation-response-tab.tsx | 1043 +-- .../vendor-response/detail/quotation-tabs.tsx | 166 +- .../table/vendor-quotations-table-columns.tsx | 1380 ++-- .../table/vendor-quotations-table.tsx | 1028 +-- 109 files changed, 28528 insertions(+), 22390 deletions(-) delete mode 100644 app/[lng]/auth/tech-signup/page.tsx create mode 100644 app/[lng]/evcp/(evcp)/contact-possible-items/page.tsx delete mode 100644 app/[lng]/evcp/(evcp)/tech-vendors/[id]/info/items/page.tsx create mode 100644 app/[lng]/evcp/(evcp)/tech-vendors/[id]/info/possible-items/page.tsx create mode 100644 app/[lng]/partners/tech-signup/page.tsx create mode 100644 app/[lng]/sales/(sales)/tech-vendors/[id]/info/possible-items/page.tsx create mode 100644 components/signup/tech-vendor-item-selector-dialog.tsx delete mode 100644 components/tech-vendors/tech-vendor-items-container.tsx create mode 100644 lib/contact-possible-items/service.ts create mode 100644 lib/contact-possible-items/table/contact-possible-items-table-columns.tsx create mode 100644 lib/contact-possible-items/table/contact-possible-items-table-toolbar-actions.tsx create mode 100644 lib/contact-possible-items/table/contact-possible-items-table.tsx create mode 100644 lib/contact-possible-items/table/delete-contact-possible-items-dialog.tsx create mode 100644 lib/contact-possible-items/validations.ts create mode 100644 lib/tech-vendor-possible-items/table/add-possible-item-dialog.tsx create mode 100644 lib/tech-vendor-possible-items/table/delete-possible-items-dialog.tsx create mode 100644 lib/tech-vendors/contacts-table/update-contact-sheet.tsx create mode 100644 lib/tech-vendors/possible-items/add-item-dialog.tsx create mode 100644 lib/tech-vendors/possible-items/possible-items-columns.tsx create mode 100644 lib/tech-vendors/possible-items/possible-items-table.tsx create mode 100644 lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx delete mode 100644 lib/tech-vendors/table/tech-vendor-possible-items-view-dialog.tsx create mode 100644 lib/tech-vendors/table/tech-vendors-filter-sheet.tsx delete mode 100644 lib/tech-vendors/table/tech-vendors-table-floating-bar.tsx delete mode 100644 lib/techsales-rfq/table/README.md create mode 100644 lib/techsales-rfq/table/detail-table/quotation-contacts-view-dialog.tsx create mode 100644 lib/techsales-rfq/table/detail-table/vendor-contact-selection-dialog.tsx diff --git a/app/[lng]/auth/tech-signup/page.tsx b/app/[lng]/auth/tech-signup/page.tsx deleted file mode 100644 index d5b019ed..00000000 --- a/app/[lng]/auth/tech-signup/page.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Suspense } from "react" -import { Metadata } from "next" -import { TechVendorJoinForm } from "@/components/signup/tech-vendor-join-form" -import { JoinFormSkeleton } from "@/components/signup/join-form-skeleton" - -export const metadata: Metadata = { - title: "기술영업 협력업체 등록", - description: "기술영업 협력업체 등록 페이지입니다.", -} - -export default function TechVendorSignUpPage() { - return ( - }> - - - ) -} \ No newline at end of file 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 +} + +export default async function ContactPossibleItemsPage({ + searchParams, +}: ContactPossibleItemsPageProps) { + const resolvedSearchParams = await searchParams + const search = searchParamsCache.parse(resolvedSearchParams) + + const contactPossibleItemsPromise = getContactPossibleItems(search) + + return ( + +
+
+
+

+ 담당자별 아이템 관리 +

+

+ 기술영업 담당자별 가능 아이템을 관리합니다. +

+
+
+
+ + + + } + > + + + +
+ ) +} \ 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 -// } - -// 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 ( -//
-//
-//

-// 공급품목 -//

-//

-// 기술영업 벤더의 공급 가능한 품목을 확인하세요. -//

-//
-// -//
-// -//
-//
-// ) -// } \ 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 ( - <> -
-
-
- {/* RFQ 목록으로 돌아가는 링크 추가 */} -
- - - -
-
- {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */} -

- {vendor - ? `${vendor.vendorCode ?? ""} - ${vendor.vendorName} 상세 정보` - : "Loading Vendor..."} -

-

기술영업 벤더 관련 상세사항을 확인하세요.

-
- -
- -
{children}
-
-
-
-
- - ) +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 ( + <> +
+
+
+ {/* RFQ 목록으로 돌아가는 링크 추가 */} +
+ + + +
+
+ {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */} +

+ {vendor + ? `${vendor.vendorCode ?? ""} - ${vendor.vendorName} 상세 정보` + : "Loading Vendor..."} +

+

기술영업 벤더 관련 상세사항을 확인하세요.

+
+ +
+ +
{children}
+
+
+
+
+ + ) } \ 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 -} - -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 ( -
-
-

- Contacts -

-

- 업무별 담당자 정보를 확인하세요. -

-
- -
- -
-
- ) +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 +} + +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 ( +
+
+

+ Contacts +

+

+ 업무별 담당자 정보를 확인하세요. +

+
+ +
+ +
+
+ ) } \ 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 +} + +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 ( +
+
+

+ 공급가능 아이템 목록 +

+

+ 해당 벤더가 공급 가능한 아이템 목록을 확인할 수 있습니다. +

+
+ +
+ +
+
+ ) +} \ 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 ( - -
-
-

RFQ 히스토리

-

벤더가 참여한 기술영업 RFQ 목록입니다.

-
-
- - - } - > - - -
+
+
+

+ RFQ 히스토리 +

+

+ 벤더가 참여한 기술영업 RFQ 목록입니다. +

+
+ +
+ +
+
+ ) } \ No newline at end of file diff --git a/app/[lng]/partners/tech-signup/page.tsx b/app/[lng]/partners/tech-signup/page.tsx new file mode 100644 index 00000000..d5b019ed --- /dev/null +++ b/app/[lng]/partners/tech-signup/page.tsx @@ -0,0 +1,17 @@ +import { Suspense } from "react" +import { Metadata } from "next" +import { TechVendorJoinForm } from "@/components/signup/tech-vendor-join-form" +import { JoinFormSkeleton } from "@/components/signup/join-form-skeleton" + +export const metadata: Metadata = { + title: "기술영업 협력업체 등록", + description: "기술영업 협력업체 등록 페이지입니다.", +} + +export default function TechVendorSignUpPage() { + return ( + }> + + + ) +} \ No newline at end of file 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 ( - <> -
-
-
- {/* RFQ 목록으로 돌아가는 링크 추가 */} -
- - - -
-
- {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */} -

- {vendor - ? `${vendor.vendorCode ?? ""} - ${vendor.vendorName} 상세 정보` - : "Loading Vendor..."} -

-

기술영업 벤더 관련 상세사항을 확인하세요.

-
- -
- -
{children}
-
-
-
-
- - ) +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 ( + <> +
+
+
+ {/* RFQ 목록으로 돌아가는 링크 추가 */} +
+ + + +
+
+ {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */} +

+ {vendor + ? `${vendor.vendorCode ?? ""} - ${vendor.vendorName} 상세 정보` + : "Loading Vendor..."} +

+

기술영업 벤더 관련 상세사항을 확인하세요.

+
+ +
+ +
{children}
+
+
+
+
+ + ) } \ 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 -} - -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 ( -
-
-

- Contacts -

-

- 업무별 담당자 정보를 확인하세요. -

-
- -
- -
-
- ) +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 +} + +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 ( +
+
+

+ Contacts +

+

+ 업무별 담당자 정보를 확인하세요. +

+
+ +
+ +
+
+ ) } \ 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 +} + +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 ( +
+
+

+ 공급가능 아이템 목록 +

+

+ 해당 벤더가 공급 가능한 아이템 목록을 확인할 수 있습니다. +

+
+ +
+ +
+
+ ) +} \ 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 ( - -
-
-

RFQ 히스토리

-

벤더가 참여한 기술영업 RFQ 목록입니다.

-
-
- - - } - > - - -
+
+
+

+ RFQ 히스토리 +

+

+ 벤더가 참여한 기술영업 RFQ 목록입니다. +

+
+ +
+ +
+
+ ) } \ 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([]) + const [filteredItems, setFilteredItems] = useState([]) + const [searchTerm, setSearchTerm] = useState("") + const [selectedItems, setSelectedItems] = useState>(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 ( + + + + 공급가능품목 선택 + + {Array.isArray(vendorType) ? vendorType.join(", ") : vendorType} 관련 아이템 중에서 공급 가능한 품목을 선택해주세요. + + + +
+ {/* 검색바 */} +
+ + setSearchTerm(e.target.value)} + className="pl-10" + /> +
+ + {/* 선택된 아이템 표시 */} + {selectedItems.size > 0 && ( +
+
선택된 아이템 ({selectedItems.size}개)
+
+ {Array.from(selectedItems).map((itemCode) => { + const item = items.find((i) => i.itemCode === itemCode) + return ( + + {item?.itemList || itemCode} + + + ) + })} +
+
+ )} + + {/* 아이템 목록 */} +
+ + {isLoading ? ( +
로딩 중...
+ ) : filteredItems.length === 0 ? ( +
+ {searchTerm ? "검색 결과가 없습니다." : "아이템이 없습니다."} +
+ ) : ( +
+ {filteredItems.map((item) => ( +
+ handleItemToggle(item.itemCode)} + className="mt-1" + /> +
+
+ {item.itemList} + + {item.itemCode} + +
+ {item.subItemList && ( +
+ {item.subItemList} +
+ )} +
+ {item.workType && ( + 공종: {item.workType} + )} + {item.shipTypes && ( + 선종: {item.shipTypes} + )} +
+
+
+ ))} +
+ )} +
+
+
+ + + + + +
+
+ ) +} \ 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(null) + const [isItemSelectorOpen, setIsItemSelectorOpen] = React.useState(false) + const [selectedItemCodes, setSelectedItemCodes] = React.useState([]) // React Hook Form (항상 최상위에서 호출) const form = useForm({ @@ -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() { )} /> - {/* 이메일 */} + {/* 이메일 (수정 불가, 뷰 전용) */} - + @@ -616,11 +635,29 @@ export function TechVendorJoinForm() { 주요 품목 - - - +
+ + + +
+ + {selectedItemCodes.length > 0 && ( + + {selectedItemCodes.length}개 아이템 선택됨 + + )} +
+
- 회사에서 주로 다루는 품목들을 쉼표로 구분하여 입력하세요. + 공급가능품목 선택 버튼을 클릭하여 아이템을 선택하세요. 원하는 아이템이 없다면 텍스트로 입력하세요. @@ -902,6 +939,14 @@ export function TechVendorJoinForm() { + + {/* 공급가능품목 선택 다이얼로그 */} + ) } \ 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({ {/* 왼쪽: 타이틀 & 설명 */}
-

기술영업 벤더 관리

+

기술영업 벤더 리스트

{/*

@@ -70,7 +70,7 @@ export function TechVendorContainer({

*/}
- {/* 오른쪽: 벤더 타입 드롭다운 */} + {/* 오른쪽: 벤더 타입 드롭다운 - - - {itemTypes.map((item) => ( - handleItemTypeChange(item.id)} - className={item.id === itemType ? "bg-muted" : ""} - > - {item.name} - - ))} - - - )} - - - {/* 컨텐츠 영역 */} -
-
- {selectedItemType && ( - - )} -
-
- - ) -} \ 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 @@ -75,6 +75,18 @@ export const techVendorColumnsConfig: VendorColumnConfig[] = [ excelHeader: "국가", }, + { + id: "countryEng", + label: "국가(영문)", + excelHeader: "국가(영문)", + }, + + { + id: "countryFab", + label: "제조국가", + excelHeader: "제조국가", + }, + { id: "phone", label: "전화번호", @@ -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 | null>>; +} + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef = { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 2) actions 컬럼 (Dropdown 메뉴) + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef = { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + return ( + + + + + + setRowAction({ row, type: "delete" })} + > + 삭제 + ⌘⌫ + + + + ) + }, + size: 40, + } + + // ---------------------------------------------------------------- + // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 + // ---------------------------------------------------------------- + const baseColumns: ColumnDef[] = [ + // 벤더 정보 + { + id: "vendorInfo", + header: "벤더 정보", + columns: [ + { + accessorKey: "vendorCode", + enableResizing: true, + header: ({ column }) => ( + + ), + cell: ({ row }) => row.original.vendorCode ?? "", + }, + { + accessorKey: "vendorName", + enableResizing: true, + header: ({ column }) => ( + + ), + cell: ({ row }) => row.original.vendorName ?? "", + }, + { + accessorKey: "vendorCountry", + enableResizing: true, + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const country = row.original.vendorCountry + return country || - + }, + }, + { + accessorKey: "techVendorType", + enableResizing: true, + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const type = row.original.techVendorType + return type || - + }, + }, + ] + }, + // 담당자 정보 + { + id: "contactInfo", + header: "담당자 정보", + columns: [ + { + accessorKey: "contactName", + enableResizing: true, + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const contactName = row.original.contactName + return contactName || - + }, + }, + { + accessorKey: "contactPosition", + enableResizing: true, + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const position = row.original.contactPosition + return position || - + }, + }, + { + accessorKey: "contactEmail", + enableResizing: true, + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const contactEmail = row.original.contactEmail + return contactEmail || - + }, + }, + { + accessorKey: "contactPhone", + enableResizing: true, + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const contactPhone = row.original.contactPhone + return contactPhone || - + }, + }, + { + accessorKey: "contactCountry", + enableResizing: true, + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const contactCountry = row.original.contactCountry + return contactCountry || - + }, + }, + { + accessorKey: "isPrimary", + enableResizing: true, + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const isPrimary = row.original.isPrimary + return isPrimary ? "예" : "아니오" + }, + }, + ] + }, + // 아이템 정보 + { + id: "itemInfo", + header: "아이템 정보", + columns: [ + { + accessorKey: "itemCode", + enableResizing: true, + header: ({ column }) => ( + + ), + cell: ({ row }) => row.original.itemCode ?? "", + }, + { + accessorKey: "itemList", + enableResizing: true, + header: ({ column }) => ( + + ), + cell: ({ row }) => row.original.itemList ?? "", + }, + { + accessorKey: "workType", + enableResizing: true, + header: ({ column }) => ( + + ), + cell: ({ row }) => row.original.workType ?? "", + }, + { + accessorKey: "shipTypes", + enableResizing: true, + header: ({ column }) => ( + + ), + cell: ({ row }) => row.original.shipTypes ?? "", + }, + { + accessorKey: "subItemList", + enableResizing: true, + header: ({ column }) => ( + + ), + cell: ({ row }) => row.original.subItemList ?? "", + }, + ] + }, + + // 시스템 정보 + { + id: "systemInfo", + header: "시스템 정보", + columns: [ + { + accessorKey: "createdAt", + enableResizing: true, + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const dateVal = row.getValue("createdAt") as Date + return formatDate(dateVal) + }, + }, + { + accessorKey: "updatedAt", + enableResizing: true, + header: ({ column }) => ( + + ), + 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 +} + +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 ( +
+ +
+ ) +} + +// Excel 파일 다운로드 +function downloadExcel(data: Array>, 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[] = [ + { + 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(null) + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + const { table } = useDataTable({ + data, + columns, + pageCount, + rowCount: total, + }) + + return ( +
+ {/* 메인 테이블 */} + + + + + + + setRowAction(null)} + contactPossibleItems={ + rowAction?.type === "delete" && rowAction.row + ? [rowAction.row.original] + : [] + } + showTrigger={false} + onSuccess={() => setRowAction(null)} + /> +
+ ) +} \ 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 ( + + {showTrigger && ( + + {trigger ?? ( + + )} + + )} + + + + {isMultiple + ? `${contactPossibleItems.length}개의 담당자별 아이템을 삭제하시겠습니까?` + : "담당자별 아이템을 삭제하시겠습니까?"} + + + 이 작업은 되돌릴 수 없습니다. 선택한 담당자별 아이템{isMultiple ? "들이" : "이"} 영구적으로 삭제됩니다. + + + + 취소 + + + + + ) +} \ 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 + +// 조회용 스키마 (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 \ 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, - params: { - where?: any; // drizzle-orm의 조건식 (and, eq...) 등 - orderBy?: (ReturnType | ReturnType)[]; - 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, - 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, - 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, - data: Item[] -) { - return tx.insert(items).values(data).onConflictDoNothing(); -} - - - -/** 단건 삭제 */ -export async function deleteItemById( - tx: PgTransaction, - itemId: number -) { - return tx.delete(items).where(eq(items.id, itemId)); -} - -/** 복수 삭제 */ -export async function deleteItemsByIds( - tx: PgTransaction, - ids: number[] -) { - return tx.delete(items).where(inArray(items.id, ids)); -} - -/** 전체 삭제 */ -export async function deleteAllItems( - tx: PgTransaction, -) { - return tx.delete(items); -} - -/** 단건 업데이트 */ -export async function updateItem( - tx: PgTransaction, - itemId: number, - data: Partial -) { - return tx - .update(items) - .set(data) - .where(eq(items.id, itemId)) - .returning({ id: items.id, createdAt: items.createdAt }); -} - -/** 복수 업데이트 */ -export async function updateItems( - tx: PgTransaction, - ids: number[], - data: Partial -) { - return tx - .update(items) - .set(data) - .where(inArray(items.id, ids)) - .returning({ id: items.id, createdAt: items.createdAt }); -} - -export async function findAllItems(): Promise { - 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, + params: { + where?: any; // drizzle-orm의 조건식 (and, eq...) 등 + orderBy?: (ReturnType | ReturnType)[]; + 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, + 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, + 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, + data: Item[] +) { + return tx.insert(items).values(data).onConflictDoNothing(); +} + + + +/** 단건 삭제 */ +export async function deleteItemById( + tx: PgTransaction, + itemId: number +) { + return tx.delete(items).where(eq(items.id, itemId)); +} + +/** 복수 삭제 */ +export async function deleteItemsByIds( + tx: PgTransaction, + ids: number[] +) { + return tx.delete(items).where(inArray(items.id, ids)); +} + +/** 전체 삭제 */ +export async function deleteAllItems( + tx: PgTransaction, +) { + return tx.delete(items); +} + +/** 단건 업데이트 */ +export async function updateItem( + tx: PgTransaction, + itemId: number, + data: Partial +) { + return tx + .update(items) + .set(data) + .where(eq(items.id, itemId)) + .returning({ id: items.id, createdAt: items.createdAt }); +} + +/** 복수 업데이트 */ +export async function updateItems( + tx: PgTransaction, + ids: number[], + data: Partial +) { + return tx + .update(items) + .set(data) + .where(inArray(items.id, ids)) + .returning({ id: items.id, createdAt: items.createdAt }); +} + +export async function findAllItems(): Promise { + 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 { - items: Row["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 ( - - {showTrigger ? ( - - - - ) : null} - - - 정말로 삭제하시겠습니까? - - 이 작업은 되돌릴 수 없습니다. 선택한{" "} - {items.length} - 개의 {getItemTypeLabel()}이(가) 영구적으로 삭제됩니다. - - - - - - - - - - - ) - } - - return ( - - {showTrigger ? ( - - - - ) : null} - - - 정말로 삭제하시겠습니까? - - 이 작업은 되돌릴 수 없습니다. 선택한{" "} - {items.length} - 개의 {getItemTypeLabel()}이(가) 영구적으로 삭제됩니다. - - - - - - - - - - - ) -} +"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 { + items: Row["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 ( + + {showTrigger ? ( + + + + ) : null} + + + 정말로 삭제하시겠습니까? + + 이 작업은 되돌릴 수 없습니다. 선택한{" "} + {items.length} + 개의 {getItemTypeLabel()}이(가) 영구적으로 삭제됩니다. + + + + + + + + + + + ) + } + + return ( + + {showTrigger ? ( + + + + ) : null} + + + 정말로 삭제하시겠습니까? + + 이 작업은 되돌릴 수 없습니다. 선택한{" "} + {items.length} + 개의 {getItemTypeLabel()}이(가) 영구적으로 삭제됩니다. + + + + + + + + + + + ) +} 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({ - 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( - "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 ( - void setFeatureFlags(value), - }} - > -
- setFeatureFlags(value)} - className="w-fit" - > - {dataTableConfig.featureFlags.map((flag) => ( - - - - - - -
{flag.tooltipTitle}
-
- {flag.tooltipDescription} -
-
-
- ))} -
-
- {children} -
- ) -} +"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({ + 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( + "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 ( + void setFeatureFlags(value), + }} + > +
+ setFeatureFlags(value)} + className="w-fit" + > + {dataTableConfig.featureFlags.map((flag) => ( + + + + + + +
{flag.tooltipTitle}
+
+ {flag.tooltipDescription} +
+
+
+ ))} +
+
+ {children} +
+ ) +} 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[], - progressCallback?: (current: number, total: number) => void -): Promise { - // 결과 카운터 초기화 - 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[], + progressCallback?: (current: number, total: number) => void +): Promise { + // 결과 카운터 초기화 + 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(null); - const [isUploading, setIsUploading] = React.useState(false); - const [progress, setProgress] = React.useState(0); - const [error, setError] = React.useState(null); - - const fileInputRef = React.useRef(null); - - // 파일 선택 처리 - const handleFileChange = (e: React.ChangeEvent) => { - 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 = {}; - 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[] = []; - - worksheet.eachRow((row, rowNumber) => { - if (rowNumber > headerRowIndex) { - const rowData: Record = {}; - 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 ( - <> - - - - - - {ITEM_TYPE_NAMES[itemType]} 가져오기 - - {ITEM_TYPE_NAMES[itemType]}을 Excel 파일에서 가져옵니다. -
- 올바른 형식의 Excel 파일(.xlsx)을 업로드하세요. -
-
- -
-
- -
- - {file && ( -
- 선택된 파일: {file.name} ({(file.size / 1024).toFixed(1)} KB) -
- )} - - {isUploading && ( -
- -

- {progress}% 완료 -

-
- )} - - {error && ( -
- {error} -
- )} -
- - - - - -
-
- - ); +"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(null); + const [isUploading, setIsUploading] = React.useState(false); + const [progress, setProgress] = React.useState(0); + const [error, setError] = React.useState(null); + + const fileInputRef = React.useRef(null); + + // 파일 선택 처리 + const handleFileChange = (e: React.ChangeEvent) => { + 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 = {}; + 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[] = []; + + worksheet.eachRow((row, rowNumber) => { + if (rowNumber > headerRowIndex) { + const rowData: Record = {}; + 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 ( + <> + + + + + + {ITEM_TYPE_NAMES[itemType]} 가져오기 + + {ITEM_TYPE_NAMES[itemType]}을 Excel 파일에서 가져옵니다. +
+ 올바른 형식의 Excel 파일(.xlsx)을 업로드하세요. +
+
+ +
+
+ +
+ + {file && ( +
+ 선택된 파일: {file.name} ({(file.size / 1024).toFixed(1)} KB) +
+ )} + + {isUploading && ( +
+ +

+ {progress}% 완료 +

+
+ )} + + {error && ( +
+ {error} +
+ )} +
+ + + + + +
+
+ + ); } \ 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[], - progressCallback?: (current: number, total: number) => void -): Promise { - // 결과 카운터 초기화 - 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[], + progressCallback?: (current: number, total: number) => void +): Promise { + // 결과 카운터 초기화 + 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 -} - -export function ItemsTableToolbarActions({ table }: ItemsTableToolbarActionsProps) { - const [refreshKey, setRefreshKey] = React.useState(0) - - // 가져오기 성공 후 테이블 갱신 - const handleImportSuccess = () => { - setRefreshKey(prev => prev + 1) - } - - // Excel 내보내기 함수 - const exportTableToExcel = async ( - table: Table, - 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 = {}; - 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 ( -
- {/* 선택된 로우가 있으면 삭제 다이얼로그 */} - {table.getFilteredSelectedRowModel().rows.length > 0 ? ( - row.original) as any} - onSuccess={() => table.toggleAllRowsSelected(false)} - itemType="shipbuilding" - /> - ) : null} - - {/* 새 아이템 추가 다이얼로그 */} - - - {/* Import 버튼 */} - - - {/* Export 드롭다운 메뉴 */} - - - - - - - exportTableToExcel(table, { - filename: "shipbuilding_items", - excludeColumns: ["select", "actions"], - sheetName: "조선 아이템 목록" - }) - } - > - - 현재 데이터 내보내기 - - exportItemTemplate()}> - - 템플릿 다운로드 - - - -
- ) +"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 +} + +export function ItemsTableToolbarActions({ table }: ItemsTableToolbarActionsProps) { + const [refreshKey, setRefreshKey] = React.useState(0) + + // 가져오기 성공 후 테이블 갱신 + const handleImportSuccess = () => { + setRefreshKey(prev => prev + 1) + } + + // Excel 내보내기 함수 + const exportTableToExcel = async ( + table: Table, + 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 = {}; + 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 ( +
+ {/* 선택된 로우가 있으면 삭제 다이얼로그 */} + {table.getFilteredSelectedRowModel().rows.length > 0 ? ( + row.original) as any} + onSuccess={() => table.toggleAllRowsSelected(false)} + itemType="shipbuilding" + /> + ) : null} + + {/* 새 아이템 추가 다이얼로그 */} + + + {/* Import 버튼 */} + + + {/* Export 드롭다운 메뉴 */} + + + + + + + exportTableToExcel(table, { + filename: "shipbuilding_items", + excludeColumns: ["select", "actions"], + sheetName: "조선 아이템 목록" + }) + } + > + + 현재 데이터 내보내기 + + exportItemTemplate()}> + + 템플릿 다운로드 + + + +
+ ) } \ 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[], - progressCallback?: (current: number, total: number) => void -): Promise { - // 결과 카운터 초기화 - 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[], + progressCallback?: (current: number, total: number) => void +): Promise { + // 결과 카운터 초기화 + 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 { 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 { - 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, + 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, + 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, + groupBy: 'workType' | 'shipTypes' | 'vendorCode' | 'vendorEmail', + where?: SQL | undefined +) { + const groupField = techVendorPossibleItems[groupBy]; + + return await tx + .select({ + groupValue: groupField, + count: count(), + vendorCount: sql`COUNT(DISTINCT ${techVendorPossibleItems.vendorId})`.as('vendorCount'), + itemCount: sql`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, + where?: SQL | undefined +) { + return await tx + .select({ + workType: techVendorPossibleItems.workType, + count: count(), + vendorCount: sql`COUNT(DISTINCT ${techVendorPossibleItems.vendorId})`.as('vendorCount'), + itemCount: sql`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, + where?: SQL | undefined +) { + return await tx + .select({ + shipTypes: techVendorPossibleItems.shipTypes, + count: count(), + vendorCount: sql`COUNT(DISTINCT ${techVendorPossibleItems.vendorId})`.as('vendorCount'), + itemCount: sql`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, + where?: SQL | undefined +) { + return await tx + .select({ + vendorId: techVendorPossibleItems.vendorId, + vendorCode: techVendorPossibleItems.vendorCode, + vendorName: techVendors.vendorName, + vendorEmail: techVendorPossibleItems.vendorEmail, + itemCount: count(), + distinctItemCount: sql`COUNT(DISTINCT ${techVendorPossibleItems.itemCode})`.as('distinctItemCount'), + workTypeCount: sql`COUNT(DISTINCT ${techVendorPossibleItems.workType})`.as('workTypeCount'), + shipTypeCount: sql`COUNT(DISTINCT ${techVendorPossibleItems.shipTypes})`.as('shipTypeCount'), + latestUpdate: sql`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 { // 오류 발생시 기본 벤더 타입 반환 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([]); + const [filteredVendors, setFilteredVendors] = React.useState([]); + const [vendorSearch, setVendorSearch] = React.useState(""); + const [selectedVendor, setSelectedVendor] = React.useState(null); + + // 아이템 관련 상태 + const [items, setItems] = React.useState([]); + const [filteredItems, setFilteredItems] = React.useState([]); + const [itemSearch, setItemSearch] = React.useState(""); + const [selectedItems, setSelectedItems] = React.useState([]); + + 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 ( + + + {children || ( + + )} + + + + + 벤더별 아이템 추가 + + + 왼쪽에서 벤더를 선택하고, 오른쪽에서 아이템을 선택하세요. + + + +
+
+ {/* 왼쪽: 벤더 선택/표시 */} +
+ {!selectedVendor ? ( + <> +
+ +
+ setVendorSearch(e.target.value)} + className="pl-10" + /> +
+
+ +
+
+ {isLoading ? ( +
로딩 중...
+ ) : filteredVendors.length === 0 ? ( +
+ 검색 결과가 없습니다. +
+ ) : ( + filteredVendors.map((vendor) => ( +
handleVendorSelect(vendor)} + > +
{vendor.vendorName}
+
+ {vendor.vendorCode} +
+
+ {parseVendorTypes(vendor.techVendorType).map((type, index) => ( + + {type} + + ))} +
+
+ )) + )} +
+
+ + ) : ( +
+
+ + +
+
+
{selectedVendor?.vendorName}
+
+ {selectedVendor?.vendorCode} +
+
+ {selectedVendor && parseVendorTypes(selectedVendor.techVendorType).map((type, index) => ( + + {type} + + ))} +
+
+
+ )} +
+ + + + {/* 오른쪽: 아이템 선택 */} +
+ {selectedVendor ? ( + <> + + + +
+ setItemSearch(e.target.value)} + className="pl-10" + /> +
+ + + {selectedItems.length > 0 && ( +
+ +
+ {selectedItems.map((item) => ( + + {item.itemCode} + { + e.stopPropagation(); + handleItemToggle(item); + }} + /> + + ))} +
+
+ )} + +
+
+ {isLoading ? ( +
아이템 로딩 중...
+ ) : filteredItems.length === 0 && items.length === 0 ? ( +
+ 해당 벤더 타입에 대한 아이템이 없습니다. +
+ ) : filteredItems.length === 0 ? ( +
+ 검색 결과가 없습니다. +
+ ) : ( + filteredItems.map((item) => { + const isSelected = selectedItems.some(i => i.itemCode === item.itemCode); + return ( +
handleItemToggle(item)} + > +
{item.itemCode}
+
+ {item.itemList || "-"} +
+
+ 공종: {item.workType || "-"} + {item.shipTypes && 선종: {item.shipTypes}} + {item.subItemList && 서브아이템: {item.subItemList}} +
+
+ ); + }) + )} +
+
+ + ) : ( +
+ 왼쪽에서 벤더를 선택하세요. +
+ )} +
+
+
+ +
+ + +
+
+
+ ); +} \ 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 ( + + + {children || ( + + )} + + + + + + 아이템 삭제 확인 + + + 선택한 {selectedItems.length}개의 벤더-아이템 조합을 삭제하시겠습니까? + 이 작업은 되돌릴 수 없습니다. + + + +
+
삭제될 아이템 목록:
+ +
+ {selectedItems.map((item) => ( +
+
+
+
+ {item.vendorName} ({item.vendorCode}) +
+
+ 아이템코드: {item.itemCode} +
+ {item.itemList && ( +
+ 아이템리스트: {item.itemList} +
+ )} + {item.workType && ( +
+ 공종: {item.workType} +
+ )} +
+
+ {parseVendorTypes(item.techVendorType).map((type, index) => ( + + {type} + + ))} +
+
+
+ ))} +
+
+
+ + + + + +
+
+ ); +} \ 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(); + + 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(); + + 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 { + 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; }; @@ -51,6 +55,26 @@ export function PossibleItemsDataTable({ promises }: PossibleItemsDataTableProps label: "아이템코드", 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: "벤더타입", @@ -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; }; @@ -55,6 +60,70 @@ export function getColumns(): ColumnDef[] { return
{itemCode}
; }, }, + { + accessorKey: "itemList", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const itemList = row.getValue("itemList") as string | null; + return
{itemList || "-"}
; + }, + 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 }) => ( + + ), + cell: ({ row }) => { + const workType = row.getValue("workType") as string | null; + return
{workType || "-"}
; + }, + 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 }) => ( + + ), + cell: ({ row }) => { + const shipTypes = row.getValue("shipTypes") as string | null; + return
{shipTypes || "-"}
; + }, + 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 }) => ( + + ), + cell: ({ row }) => { + const subItemList = row.getValue("subItemList") as string | null; + return
{subItemList || "-"}
; + }, + 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 }) => ( @@ -91,37 +160,85 @@ export function getColumns(): ColumnDef[] { 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 (
- {types.map((type, index) => ( - + {types.length > 0 ? types.map((type, index) => ( + {type} - ))} + )) : ( + - + )}
); }, 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 }) => ( + + ), + 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 ( + + {vendorStatus} + + ); + }, + }, { accessorKey: "createdAt", header: ({ column }) => ( 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 (
- {hasSelection && ( - + {hasSelection && ( + )} + + + + + - - - - - Create New Contact - - 새 Contact 정보를 입력하고 Create 버튼을 누르세요. - - - - {/* shadcn/ui Form을 이용해 react-hook-form과 연결 */} -
- -
- ( - - Contact Name - - - - - - )} - /> - - ( - - Position / Title - - - - - - )} - /> - - ( - - Email - - - - - - )} - /> - - ( - - Phone - - - - - - )} - /> - - ( - - Country - - - - - - )} - /> - - {/* 단순 checkbox */} - ( - -
- field.onChange(e.target.checked)} - /> - Is Primary? -
- -
- )} - /> -
- - - - - -
- -
- - ) +"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({ + 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 ( + + {/* 모달을 열기 위한 버튼 */} + + + + + + + Create New Contact + + 새 Contact 정보를 입력하고 Create 버튼을 누르세요. + + + + {/* shadcn/ui Form을 이용해 react-hook-form과 연결 */} +
+ +
+ ( + + Contact Name + + + + + + )} + /> + + ( + + Position / Title + + + + + + )} + /> + + ( + + Email + + + + + + )} + /> + + ( + + Phone + + + + + + )} + /> + + ( + + Contact Country + + + + + + )} + /> + + {/* 단순 checkbox */} + ( + +
+ field.onChange(e.target.checked)} + /> + Is Primary? +
+ +
+ )} + /> +
+ + + + + +
+ +
+
+ ) } \ 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 | null>>; -} - -/** - * tanstack table 컬럼 정의 (중첩 헤더 버전) - */ -export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef[] { - // ---------------------------------------------------------------- - // 1) select 컬럼 (체크박스) - // ---------------------------------------------------------------- - const selectColumn: ColumnDef = { - id: "select", - header: ({ table }) => ( - table.toggleAllPageRowsSelected(!!value)} - aria-label="Select all" - className="translate-y-0.5" - /> - ), - cell: ({ row }) => ( - row.toggleSelected(!!value)} - aria-label="Select row" - className="translate-y-0.5" - /> - ), - size:40, - enableSorting: false, - enableHiding: false, - } - - // ---------------------------------------------------------------- - // 2) actions 컬럼 (Dropdown 메뉴) - // ---------------------------------------------------------------- - const actionsColumn: ColumnDef = { - id: "actions", - enableHiding: false, - cell: function Cell({ row }) { - return ( - - - - - - { - setRowAction({ row, type: "update" }) - }} - > - Edit - - - - setRowAction({ row, type: "delete" })} - > - Delete - ⌘⌫ - - - - ) - }, - size: 40, - } - - // ---------------------------------------------------------------- - // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 - // ---------------------------------------------------------------- - // 3-1) groupMap: { [groupName]: ColumnDef[] } - const groupMap: Record[]> = {} - - techVendorContactsColumnsConfig.forEach((cfg) => { - // 만약 group가 없으면 "_noGroup" 처리 - const groupName = cfg.group || "_noGroup" - - if (!groupMap[groupName]) { - groupMap[groupName] = [] - } - - // child column 정의 - const childCol: ColumnDef = { - accessorKey: cfg.id, - enableResizing: true, - header: ({ column }) => ( - - ), - 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[] = [] - - // 순서를 고정하고 싶다면 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 | null>>; +} + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef = { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size:40, + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 2) actions 컬럼 (Dropdown 메뉴) + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef = { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + return ( + + + + + + { + setRowAction({ row, type: "update" }) + }} + > + Edit + + + + setRowAction({ row, type: "delete" })} + > + Delete + ⌘⌫ + + + + ) + }, + size: 40, + } + + // ---------------------------------------------------------------- + // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 + // ---------------------------------------------------------------- + // 3-1) groupMap: { [groupName]: ColumnDef[] } + const groupMap: Record[]> = {} + + techVendorContactsColumnsConfig.forEach((cfg) => { + // 만약 group가 없으면 "_noGroup" 처리 + const groupName = cfg.group || "_noGroup" + + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // child column 정의 + const childCol: ColumnDef = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + + ), + 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[] = [] + + // 순서를 고정하고 싶다면 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 - vendorId: number -} - -export function TechVendorContactsTableToolbarActions({ table, vendorId }: TechVendorContactsTableToolbarActionsProps) { - // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 - const fileInputRef = React.useRef(null) - - // 파일이 선택되었을 때 처리 - async function onFileChange(event: React.ChangeEvent) { - 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() { - // 숨겨진 요소를 클릭 - fileInputRef.current?.click() - } - - return ( -
- - - - {/** 3) Import 버튼 (파일 업로드) */} - - {/* - 실제로는 숨겨진 input과 연결: - - accept=".xlsx,.xls" 등으로 Excel 파일만 업로드 허용 - */} - - - {/** 4) Export 버튼 */} - -
- ) +"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 + vendorId: number +} + +export function TechVendorContactsTableToolbarActions({ table, vendorId }: TechVendorContactsTableToolbarActionsProps) { + // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 + const fileInputRef = React.useRef(null) + + // 파일이 선택되었을 때 처리 + async function onFileChange(event: React.ChangeEvent) { + 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() { + // 숨겨진 요소를 클릭 + 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 ( +
+ + + + {/** 템플릿 다운로드 버튼 */} + + + {/** Import 버튼 (파일 업로드) */} + + {/* + 실제로는 숨겨진 input과 연결: + - accept=".xlsx,.xls" 등으로 Excel 파일만 업로드 허용 + */} + + + {/** Export 버튼 */} + +
+ ) } \ 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>, - ] - >, - vendorId:number -} - -export function TechVendorContactsTable({ promises , vendorId}: TechVendorContactsTableProps) { - const { featureFlags } = useFeatureFlags() - - // Suspense로 받아온 데이터 - const [{ data, pageCount }] = React.use(promises) - - const [rowAction, setRowAction] = React.useState | null>(null) - - // getColumns() 호출 시, router를 주입 - const columns = React.useMemo( - () => getColumns({ setRowAction }), - [setRowAction] - ) - - const filterFields: DataTableFilterField[] = [ - - ] - - const advancedFilterFields: DataTableAdvancedFilterField[] = [ - { 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 ( - <> - - - - - - - ) +"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>, + ] + >, + vendorId:number +} + +export function TechVendorContactsTable({ promises , vendorId}: TechVendorContactsTableProps) { + + // Suspense로 받아온 데이터 + const [{ data, pageCount }] = React.use(promises) + + const [rowAction, setRowAction] = React.useState | null>(null) + + // getColumns() 호출 시, router를 주입 + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + const filterFields: DataTableFilterField[] = [ + + ] + + const advancedFilterFields: DataTableAdvancedFilterField[] = [ + { 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 ( + <> + + + + + + + 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({ - 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( - "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 ( - void setFeatureFlags(value), - }} - > -
- setFeatureFlags(value)} - className="w-fit gap-0" - > - {dataTableConfig.featureFlags.map((flag, index) => ( - - - - - - -
{flag.tooltipTitle}
-
- {flag.tooltipDescription} -
-
-
- ))} -
-
- {children} -
- ) -} +"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({ + 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( + "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 ( + void setFeatureFlags(value), + }} + > +
+ setFeatureFlags(value)} + className="w-fit gap-0" + > + {dataTableConfig.featureFlags.map((flag, index) => ( + + + + + + +
{flag.tooltipTitle}
+
+ {flag.tooltipDescription} +
+
+
+ ))} +
+
+ {children} +
+ ) +} 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 { + contact: TechVendorContact | null + vendorId: number +} + +export function UpdateContactSheet({ contact, vendorId, ...props }: UpdateContactSheetProps) { + const [isPending, startTransition] = React.useTransition() + + const form = useForm({ + 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 ( + + + + 연락처 수정 + + 연락처 정보를 수정하세요. 완료되면 저장 버튼을 클릭하세요. + + + +
+ +
+ ( + + 담당자명 * + + + + + + )} + /> + + ( + + 직책 + + + + + + )} + /> + + ( + + 이메일 + + + + + + )} + /> + + ( + + 전화번호 + + + + + + )} + /> + + ( + + 국가 + + + + + + )} + /> + + ( + + + + +
+ 주 담당자 +
+
+ )} + /> +
+ +
+ + +
+
+ +
+
+ ) +} \ 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([]); + const [filteredItems, setFilteredItems] = React.useState([]); + const [itemSearch, setItemSearch] = React.useState(""); + const [selectedItems, setSelectedItems] = React.useState([]); + + 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 ( + + + + 아이템 추가 + + 추가할 아이템을 선택하세요. 복수 선택이 가능합니다. + + + +
+ {/* 검색 */} +
+ +
+ + setItemSearch(e.target.value)} + className="pl-10" + /> +
+
+ + {/* 선택된 아이템 표시 */} + {selectedItems.length > 0 && ( +
+ +
+ {selectedItems.map((item) => { + if (!item.itemCode) return null; + const itemKey = `${item.itemCode}${item.shipTypes ? `-${item.shipTypes}` : ''}`; + return ( + + {itemKey} + { + e.stopPropagation(); + handleItemToggle(item); + }} + /> + + ); + })} +
+
+ )} + + {/* 아이템 목록 */} +
+
+ {isLoading ? ( +
아이템 로딩 중...
+ ) : filteredItems.length === 0 && items.length === 0 ? ( +
+ 해당 벤더 타입에 대한 추가 가능한 아이템이 없습니다. +
+ ) : filteredItems.length === 0 ? ( +
+ 검색 결과가 없습니다. +
+ ) : ( + 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 ( +
handleItemToggle(item)} + > +
+ {itemKey} +
+
+ {item.itemList || "-"} +
+
+ 공종: {item.workType || "-"} + {item.shipTypes && 선종: {item.shipTypes}} + {item.subItemList && 서브아이템: {item.subItemList}} +
+
+ ); + }) + )} +
+
+
+ +
+ + +
+
+
+ ); +} \ 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 | null>>; +} + +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef[] { + return [ + // 선택 체크박스 + { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + }, + + // 아이템 코드 + { + accessorKey: "itemCode", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ {row.getValue("itemCode")} +
+ ), + size: 150, + }, + + // 공종 + { + accessorKey: "workType", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const workType = row.getValue("workType") as string | null + return workType ? ( + + {workType} + + ) : ( + - + ) + }, + size: 100, + }, + + // 아이템명 + { + accessorKey: "itemList", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const itemList = row.getValue("itemList") as string | null + return ( +
+ {itemList || -} +
+ ) + }, + size: 300, + }, + + // 선종 (조선용) + { + accessorKey: "shipTypes", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const shipTypes = row.getValue("shipTypes") as string | null + return shipTypes ? ( + + {shipTypes} + + ) : ( + - + ) + }, + size: 120, + }, + + // 서브아이템 (해양용) + { + accessorKey: "subItemList", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const subItemList = row.getValue("subItemList") as string | null + return ( +
+ {subItemList || -} +
+ ) + }, + size: 200, + }, + + // 등록일 + { + accessorKey: "createdAt", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const date = row.getValue("createdAt") as Date + return ( +
+ {formatDate(date)} +
+ ) + }, + size: 120, + }, + + // 수정일 + { + accessorKey: "updatedAt", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const date = row.getValue("updatedAt") as Date + return ( +
+ {formatDate(date)} +
+ ) + }, + size: 120, + }, + + // 액션 메뉴 + { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + return ( + + + + + + setRowAction({ row, type: "delete" })} + > + 삭제 + ⌘⌫ + + + + ) + }, + 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>, + ] + > + vendorId: number +} + +export function TechVendorPossibleItemsTable({ + promises, + vendorId, +}: TechVendorPossibleItemsTableProps) { + // Suspense로 받아온 데이터 + const [{ data, pageCount }] = React.use(promises) + const [rowAction, setRowAction] = React.useState | 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[] = [ + { id: "itemCode", label: "아이템 코드" }, + { id: "workType", label: "공종" }, + ] + + const advancedFilterFields: DataTableAdvancedFilterField[] = [ + { 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 ( + <> + + + setShowAddDialog(true)} + /> + + + + {/* Add Item Dialog */} + + + {/* Delete Confirmation Dialog */} + + + + 아이템 삭제 + + 이 아이템을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다. + + + + setRowAction(null)}> + 취소 + + + {isDeleting ? "삭제 중..." : "삭제"} + + + + + + ) +} \ 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 + 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 ( + <> +
+ + + {selectedRows.length > 0 && ( + <> + + + + + + + 선택된 {selectedRows.length}개 아이템을 삭제합니다 + + + + )} +
+ + + + + 아이템 삭제 + + 선택된 {selectedRows.length}개의 아이템을 삭제하시겠습니까? + 이 작업은 되돌릴 수 없습니다. + + + + 취소 + + {isDeleting ? "삭제 중..." : "삭제"} + + + + + + ) +} \ 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; - orderBy?: SQL[]; - } & 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 -) { - 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; - orderBy?: SQL[]; - } & 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) { - 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 -) { - return tx - .insert(techVendors) - .values({ - ...data, - createdAt: new Date(), - updatedAt: new Date(), - }) - .returning(); -} - -// 벤더 정보 업데이트 (단일) -export async function updateTechVendor( - tx: any, - id: string | number, - data: Partial -) { - 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 -) { - 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; - orderBy?: SQL[]; - } & 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) { - 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 -) { - return tx - .insert(techVendorContacts) - .values({ - ...data, - createdAt: new Date(), - updatedAt: new Date(), - }) - .returning(); -} - -// 아이템 목록 조회 -export async function selectTechVendorItems( - tx: any, - params: { - where?: SQL; - orderBy?: SQL[]; - } & 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) { - 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 -) { - 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 { - 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; + orderBy?: SQL[]; + } & 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 +) { + 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; + orderBy?: SQL[]; + } & 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) { + 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 +) { + return tx + .insert(techVendors) + .values({ + ...data, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(); +} + +// 벤더 정보 업데이트 (단일) +export async function updateTechVendor( + tx: any, + id: string | number, + data: Partial +) { + 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 +) { + 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; + orderBy?: SQL[]; + } & 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) { + 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 +) { + return tx + .insert(techVendorContacts) + .values({ + ...data, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(); +} + +// 아이템 목록 조회 +export async function selectTechVendorItems( + tx: any, + params: { + where?: SQL; + orderBy?: SQL[]; + } & 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) { + 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 +) { + 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 { + 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 = { - id: "actions", - enableHiding: false, - cell: function Cell({ row }) { - return ( - - - - - - setRowAction({ row, type: "update" })} - > - View Details - - - - ) - }, - size: 40, - } + // const actionsColumn: ColumnDef = { + // id: "actions", + // enableHiding: false, + // cell: function Cell({ row }) { + // return ( + // + // + // + // + // + // setRowAction({ row, type: "update" })} + // > + // View Details + // + // + // + // ) + // }, + // size: 40, + // } // ---------------------------------------------------------------- // 3) 일반 컬럼들 @@ -238,6 +238,6 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef { - 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 = { - "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>((acc, { status, count }) => { - acc[status] = count; - return acc; - }, initial); - }); - - return result; - } catch (err) { - return {} as Record; - } - }, - ["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 & { 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 => { - 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`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`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 { - 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 { - try { - const result = await db - .select({ count: sql`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 = { + "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>((acc, { status, count }) => { + acc[status] = count; + return acc; + }, initial); + }); + + return result; + } catch (err) { + return {} as Record; + } + }, + ["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 & { 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 => { + 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`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`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 { + 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 { + try { + const result = await db + .select({ count: sql`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`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 { + 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 { + 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 { + 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" /> -
+
견적비교용 벤더 @@ -361,6 +361,52 @@ export function AddVendorDialog({ onSuccess }: AddVendorDialogProps) {
+ {/* 에이전트 정보 */} +
+

에이전트 정보

+
+ ( + + 에이전트명 + + + + + + )} + /> + ( + + 에이전트 전화번호 + + + + + + )} + /> +
+ ( + + 에이전트 이메일 + + + + + + )} + /> +
+ {/* 대표자 정보 */}

대표자 정보

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 && - - } - - ); -} +'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 && + + } + + ); +} 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({ - 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( - "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 ( - void setFeatureFlags(value), - }} - > -
- setFeatureFlags(value)} - className="w-fit gap-0" - > - {dataTableConfig.featureFlags.map((flag, index) => ( - - - - - - -
{flag.tooltipTitle}
-
- {flag.tooltipDescription} -
-
-
- ))} -
-
- {children} -
- ) -} +"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({ + 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( + "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 ( + void setFeatureFlags(value), + }} + > +
+ setFeatureFlags(value)} + className="w-fit gap-0" + > + {dataTableConfig.featureFlags.map((flag, index) => ( + + + + + + +
{flag.tooltipTitle}
+
+ {flag.tooltipDescription} +
+
+
+ ))} +
+
+ {children} +
+ ) +} 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(null); - const [isUploading, setIsUploading] = React.useState(false); - const [progress, setProgress] = React.useState(0); - const [error, setError] = React.useState(null); - - const fileInputRef = React.useRef(null); - - // 파일 선택 처리 - const handleFileChange = (e: React.ChangeEvent) => { - 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 = {}; - 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[] = []; - - worksheet.eachRow((row, rowNumber) => { - if (rowNumber > headerRowIndex) { - const rowData: Record = {}; - 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 ( - <> - - - - - - 기술영업 벤더 가져오기 - - 기술영업 벤더를 Excel 파일에서 가져옵니다. -
- 올바른 형식의 Excel 파일(.xlsx)을 업로드하세요. -
-
- -
-
- -
- - {file && ( -
- 선택된 파일: {file.name} ({(file.size / 1024).toFixed(1)} KB) -
- )} - - {isUploading && ( -
- -

- {progress}% 완료 -

-
- )} - - {error && ( -
- {error} -
- )} -
- - - - - -
-
- - ); +"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(null); + const [isUploading, setIsUploading] = React.useState(false); + const [progress, setProgress] = React.useState(0); + const [error, setError] = React.useState(null); + + const fileInputRef = React.useRef(null); + + // 파일 선택 처리 + const handleFileChange = (e: React.ChangeEvent) => { + 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 = {}; + 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[] = []; + + worksheet.eachRow((row, rowNumber) => { + if (rowNumber > headerRowIndex) { + const rowData: Record = {}; + 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 ( + <> + + + + + + 기술영업 벤더 가져오기 + + 기술영업 벤더를 Excel 파일에서 가져옵니다. +
+ 올바른 형식의 Excel 파일(.xlsx)을 업로드하세요. +
+
+ +
+
+ +
+ + {file && ( +
+ 선택된 파일: {file.name} ({(file.size / 1024).toFixed(1)} KB) +
+ )} + + {isUploading && ( +
+ +

+ {progress}% 완료 +

+
+ )} + + {error && ( +
+ {error} +
+ )} +
+ + + + + +
+
+ + ); } \ 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([]); - 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 ( - - - - - 벤더 Possible Items 조회 - - {vendor?.vendorName || `Vendor #${vendor?.id}`} - - {vendor?.techVendorType && ( - - {getTypeLabel(vendor.techVendorType)} - - )} - - - 해당 벤더가 공급 가능한 아이템 목록을 확인할 수 있습니다. - - - -
-
- {loading ? ( -
-
-
-

아이템을 불러오는 중...

-
-
- ) : items.length === 0 ? ( -
- -

등록된 아이템이 없습니다

-

- 이 벤더에 등록된 아이템이 없습니다. -

-
- ) : ( - <> - {/* 헤더 행 (라벨) */} -
-
No.
-
타입
-
자재 그룹
-
공종
-
자재명
-
선종/자재명(상세)
-
- - {/* 아이템 행들 */} -
- {items.map((item, index) => ( -
-
- {index + 1} -
-
- - {getTypeLabel(item.techVendorType)} - -
-
- {item.itemCode} -
-
- {item.workType || '-'} -
-
- {item.itemList} -
-
- {item.techVendorType === '조선' ? item.shipTypes : item.subItemList} -
-
- ))} -
- -
-
- - - 총 {items.length}개 아이템 - -
-
- - )} -
-
- - - - -
-
- ) -} \ 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 + +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("") + + // nuqs로 URL 상태 관리 - 파라미터명을 'filters'로 변경하여 searchParamsCache와 일치 + const [filters] = useQueryState( + "filters", + getFiltersStateParser().withDefault([]) + ) + + // joinOperator 설정 + const [joinOperator, setJoinOperator] = useQueryState( + "joinOperator", + parseAsStringEnum(["and", "or"]).withDefault("and") + ) + + // 폼 상태 초기화 + const form = useForm({ + 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 ( +
+ {/* Filter Panel Header */} +
+

벤더 검색 필터

+
+ {getActiveFilterCount() > 0 && ( + + {getActiveFilterCount()}개 필터 적용됨 + + )} +
+
+ + {/* Join Operator Selection */} +
+ + +
+ +
+ + {/* Scrollable content area */} +
+
+ {/* 벤더코드 */} + ( + + 벤더코드 + +
+ + {field.value && ( + + )} +
+
+ +
+ )} + /> + + {/* 벤더명 */} + ( + + 벤더명 + +
+ + {field.value && ( + + )} +
+
+ +
+ )} + /> + + {/* 국가 */} + ( + + 국가 + +
+ + {field.value && ( + + )} +
+
+ +
+ )} + /> + + {/* 상태 */} + ( + + 상태 + + + + )} + /> + + {/* 벤더 타입 */} + ( + + 벤더 타입 +
+ {vendorTypeOptions.map((option) => ( +
+ { + const updatedValue = checked + ? [...(field.value || []), option.value] + : (field.value || []).filter((value) => value !== option.value); + field.onChange(updatedValue); + }} + disabled={isInitializing} + /> + +
+ ))} +
+ +
+ )} + /> + + {/* 공종 */} + ( + + 공종 +
+ {workTypeOptions.map((option) => ( +
+ { + const updatedValue = checked + ? [...(field.value || []), option.value] + : (field.value || []).filter((value) => value !== option.value); + field.onChange(updatedValue); + }} + disabled={isInitializing} + /> + +
+ ))} +
+ +
+ )} + /> +
+
+ + {/* Action buttons */} +
+
+ + +
+
+
+ +
+ ) +} \ 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; - -interface GetColumnsProps { - setRowAction: React.Dispatch | null>>; - router: NextRouter; - openItemsDialog: (vendor: TechVendor) => void; -} - - - - -/** - * tanstack table 컬럼 정의 (중첩 헤더 버전) - */ -export function getColumns({ setRowAction, router, openItemsDialog }: GetColumnsProps): ColumnDef[] { - // ---------------------------------------------------------------- - // 1) select 컬럼 (체크박스) - // ---------------------------------------------------------------- - const selectColumn: ColumnDef = { - id: "select", - header: ({ table }) => ( - table.toggleAllPageRowsSelected(!!value)} - aria-label="Select all" - className="translate-y-0.5" - /> - ), - cell: ({ row }) => ( - row.toggleSelected(!!value)} - aria-label="Select row" - className="translate-y-0.5" - /> - ), - size: 40, - enableSorting: false, - enableHiding: false, - } - - // ---------------------------------------------------------------- - // 2) actions 컬럼 (Dropdown 메뉴) - // ---------------------------------------------------------------- - const actionsColumn: ColumnDef = { - id: "actions", - enableHiding: false, - cell: function Cell({ row }) { - const [isUpdatePending, startUpdateTransition] = React.useTransition() - - return ( - - - - - - setRowAction({ row, type: "update" })} - > - 레코드 편집 - - - { - // 1) 만약 rowAction을 열고 싶다면 - // setRowAction({ row, type: "update" }) - - // 2) 자세히 보기 페이지로 클라이언트 라우팅 - router.push(`/evcp/tech-vendors/${row.original.id}/info`); - }} - > - 상세보기 - - { - // 새창으로 열기 위해 window.open() 사용 - window.open(`/evcp/tech-vendors/${row.original.id}/info`, '_blank'); - }} - > - 상세보기(새창) - - - - - Status - - { - 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) => ( - - {status} - - ))} - - - - - - - - ) - }, - size: 40, - } - - // ---------------------------------------------------------------- - // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 - // ---------------------------------------------------------------- - // 3-1) groupMap: { [groupName]: ColumnDef[] } - const groupMap: Record[]> = {} - - techVendorColumnsConfig.forEach((cfg) => { - // 만약 group가 없으면 "_noGroup" 처리 - const groupName = cfg.group || "_noGroup" - - if (!groupMap[groupName]) { - groupMap[groupName] = [] - } - - // child column 정의 - const childCol: ColumnDef = { - accessorKey: cfg.id, - enableResizing: true, - header: ({ column }) => ( - - ), - 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 ( -
- - - {displayText} - -
- ); - } - // 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 ( -
- {types.length > 0 ? types.map((type, index) => ( - - {type} - - )) : ( - - - )} -
- ); - } - - // 날짜 컬럼 포맷팅 - 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[] = [ - 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 = { - id: "possibleItems", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const vendor = row.original; - - const handleClick = () => { - openItemsDialog(vendor); - }; - - return ( - - ); - }, - 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; + +interface GetColumnsProps { + setRowAction: React.Dispatch | null>>; + router: NextRouter; +} + + + + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef = { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 2) actions 컬럼 (Dropdown 메뉴) + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef = { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + return ( + + + + + + setRowAction({ row, type: "update" })} + > + 레코드 편집 + + + { + // 1) 만약 rowAction을 열고 싶다면 + // setRowAction({ row, type: "update" }) + + // 2) 자세히 보기 페이지로 클라이언트 라우팅 + router.push(`/evcp/tech-vendors/${row.original.id}/info`); + }} + > + 상세보기 + + { + // 새창으로 열기 위해 window.open() 사용 + window.open(`/evcp/tech-vendors/${row.original.id}/info`, '_blank'); + }} + > + 상세보기(새창) + + + + + Status + + { + 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) => ( + + {status} + + ))} + + + + + + + + ) + }, + size: 40, + } + + // ---------------------------------------------------------------- + // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 + // ---------------------------------------------------------------- + // 3-1) groupMap: { [groupName]: ColumnDef[] } + const groupMap: Record[]> = {} + + techVendorColumnsConfig.forEach((cfg) => { + // 만약 group가 없으면 "_noGroup" 처리 + const groupName = cfg.group || "_noGroup" + + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // child column 정의 + const childCol: ColumnDef = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + + ), + 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 ( +
+ + + {displayText} + +
+ ); + } + // 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 ( +
+ {types.length > 0 ? types.map((type, index) => ( + + {type} + + )) : ( + - + )} +
+ ); + } + + // 날짜 컬럼 포맷팅 + 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[] = [ + 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 -} - - -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 - }>({ - 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 ( - -
-
-
-
- - {rows.length} selected - - - - - - - -

Clear selection

- - Esc - -
-
-
- -
- - - - - - -

Export vendors

-
-
- -
-
-
-
- - - {/* 공용 Confirm Dialog */} - -
- ) -} 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 - 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 ( -
- {/* 초대 버튼 - 선택된 PENDING_REVIEW 벤더들이 있을 때만 표시 */} - {invitableVendors.length > 0 && ( - - )} - - {/* 벤더 추가 다이얼로그 추가 */} - - - {/* Import 버튼 추가 */} - { - // 성공 시 테이블 새로고침 - toast.success("업체 정보 가져오기가 완료되었습니다."); - }} - /> - - {/* Export 드롭다운 메뉴로 변경 */} - - - - - - {/* 템플릿 다운로드 추가 */} - exportTechVendorTemplate()} - disabled={isExporting} - > - - Excel 템플릿 다운로드 - - - - - {/* 기본 내보내기 - 현재 테이블에 보이는 데이터만 */} - - exportTableToExcel(table, { - filename: "vendors", - excludeColumns: ["select", "actions"], - }) - } - disabled={isExporting} - > - - 현재 테이블 데이터 내보내기 - - - - - {/* 선택된 벤더만 상세 내보내기 */} - - - 선택한 업체 상세 정보 내보내기 - {selectedVendors.length > 0 && ( - ({selectedVendors.length}개) - )} - - - {/* 모든 필터링된 벤더 상세 내보내기 */} - - - 모든 업체 상세 정보 내보내기 - {allFilteredVendors.length > 0 && ( - ({allFilteredVendors.length}개) - )} - - - -
- ) +"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 + 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 ( +
+ {/* 초대 버튼 - 선택된 PENDING_REVIEW 벤더들이 있을 때만 표시 */} + {invitableVendors.length > 0 && ( + + )} + + {/* 벤더 추가 다이얼로그 추가 */} + + + {/* Import 버튼 추가 */} + { + // 성공 시 테이블 새로고침 + toast.success("업체 정보 가져오기가 완료되었습니다."); + }} + /> + + {/* Export 드롭다운 메뉴로 변경 */} + + + + + + {/* 템플릿 다운로드 추가 */} + exportTechVendorTemplate()} + disabled={isExporting} + > + + Excel 템플릿 다운로드 + + + + + {/* 기본 내보내기 - 현재 테이블에 보이는 데이터만 */} + + exportTableToExcel(table, { + filename: "vendors", + excludeColumns: ["select", "actions"], + }) + } + disabled={isExporting} + > + + 현재 테이블 데이터 내보내기 + + + + + {/* 선택된 벤더만 상세 내보내기 */} + {/* + + 선택한 업체 상세 정보 내보내기 + {selectedVendors.length > 0 && ( + ({selectedVendors.length}개) + )} + */} + + {/* 모든 필터링된 벤더 상세 내보내기 */} + {/* + + 모든 업체 상세 정보 내보내기 + {allFilteredVendors.length > 0 && ( + ({allFilteredVendors.length}개) + )} + */} + + +
+ ) } \ 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>, - Awaited> - ] - > -} - -export function TechVendorsTable({ promises }: TechVendorsTableProps) { - // Suspense로 받아온 데이터 - const [{ data, pageCount }, statusCounts] = React.use(promises) - const [isCompact, setIsCompact] = React.useState(false) - - const [rowAction, setRowAction] = React.useState | null>(null) - const [itemsDialogOpen, setItemsDialogOpen] = React.useState(false) - const [selectedVendorForItems, setSelectedVendorForItems] = React.useState(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 = { - "ACTIVE": "활성 상태", - "INACTIVE": "비활성 상태", - "BLACKLISTED": "거래 금지", - "PENDING_INVITE": "초대 대기", - "INVITED": "초대 완료", - "QUOTE_COMPARISON": "견적 비교", - }; - - return statusMap[status] || status; - }; - - const filterFields: DataTableFilterField[] = [ - { - id: "status", - label: "상태", - options: techVendors.status.enumValues.map((status) => ({ - label: getStatusDisplay(status), - value: status, - count: statusCounts[status], - })), - }, - - { id: "vendorCode", label: "업체 코드" }, - ] - - const advancedFilterFields: DataTableAdvancedFilterField[] = [ - { 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 ( - <> - } - > - - - - - setRowAction(null)} - vendor={rowAction?.row.original ?? null} - /> - - - - ) +"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>, + Awaited> + ] + > + className?: string; + calculatedHeight?: string; +} + +export function TechVendorsTable({ + promises, + className, + calculatedHeight +}: TechVendorsTableProps) { + // Suspense로 받아온 데이터 + const [{ data, pageCount }, statusCounts] = React.use(promises) + const [isCompact, setIsCompact] = React.useState(false) + + const [rowAction, setRowAction] = React.useState | 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 = { + "ACTIVE": "활성 상태", + "INACTIVE": "비활성 상태", + "BLACKLISTED": "거래 금지", + "PENDING_INVITE": "초대 대기", + "INVITED": "초대 완료", + "QUOTE_COMPARISON": "견적 비교", + }; + + return statusMap[status] || status; + }; + + const filterFields: DataTableFilterField[] = [ + { + id: "status", + label: "상태", + options: techVendors.status.enumValues.map((status) => ({ + label: getStatusDisplay(status), + value: status, + count: statusCounts[status], + })), + }, + + { id: "vendorCode", label: "업체 코드" }, + ] + + const advancedFilterFields: DataTableAdvancedFilterField[] = [ + { 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 ( +
+ {/* Filter Panel */} +
+ {/* Filter Content */} +
+ setIsFilterPanelOpen(false)} + onSearch={handleSearch} + isLoading={false} + /> +
+
+ + {/* Main Content */} +
+ {/* Header Bar - 고정 높이 */} +
+
+ +
+ + {/* Right side info +
+ {data && ( + 총 {data.length || 0}건 + )} +
*/} +
+ + {/* DataTable */} +
+ } + > + + + + +
+
+ + setRowAction(null)} + vendor={rowAction?.row.original ?? null} + /> +
+ ) } \ 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 { - 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({ - 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 ( - - - - 업체 정보 수정 - - 업체 세부 정보를 수정하고 변경 사항을 저장하세요 - - -
- - {/* 업체 기본 정보 섹션 */} -
-
- -

업체 기본 정보

-
- - 업체가 제공한 기본 정보입니다. 필요시 수정하세요. - -
- {/* vendorName */} - ( - - 업체명 - - - - - - )} - /> - - {/* vendorCode */} - ( - - 업체 코드 - - - - - - )} - /> - - {/* address */} - ( - - 주소 - - - - - - )} - /> - - {/* country */} - ( - - 국가 - - - - - - )} - /> - - {/* phone */} - ( - - 전화번호 - - - - - - )} - /> - - {/* email */} - ( - - 이메일 - - - - - - )} - /> - - {/* website */} - ( - - 웹사이트 - - - - - - )} - /> - - {/* techVendorType */} - ( - - 벤더 타입 * -
- {["조선", "해양TOP", "해양HULL"].map((type) => ( -
- { - 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" - /> - -
- ))} -
- -
- )} - /> - - {/* status with icons */} - { - // 현재 선택된 상태의 구성 정보 가져오기 - const selectedConfig = getStatusConfig(field.value ?? "ACTIVE"); - const SelectedIcon = selectedConfig?.Icon || CircleIcon; - - return ( - - 업체승인상태 - - - - - - ); - }} - /> - - - - -
-
- - - - - - - -
- -
-
- ) +"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 { + 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({ + 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 ( + + + + 업체 정보 수정 + + 업체 세부 정보를 수정하고 변경 사항을 저장하세요 + + + +
+ + + {/* 업체 기본 정보 섹션 */} + + + + 업체 기본 정보 + + + 업체의 기본 정보를 관리합니다 + + + +
+ {/* 업체명 */} + ( + + 업체명 + + + + + + )} + /> + + {/* 업체 코드 */} + ( + + 업체 코드 + + + + + + )} + /> + + {/* 이메일 */} + ( + + 이메일 + + + + + + )} + /> + + {/* 전화번호 */} + ( + + 전화번호 + + + + + + )} + /> + + {/* 웹사이트 */} + ( + + 웹사이트 + + + + + + )} + /> +
+ + {/* 주소 */} + ( + + 주소 + + + + + + )} + /> + +
+ {/* 국가 */} + ( + + 국가 + + + + + + )} + /> + + {/* 국가(영문) */} + ( + + 국가(영문) + + + + + + )} + /> + + {/* 제조국가 */} + ( + + 제조국가 + + + + + + )} + /> +
+ + {/* 벤더 타입 */} + ( + + 벤더 타입 * +
+ {["조선", "해양TOP", "해양HULL"].map((type) => ( +
+ { + 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" + /> + +
+ ))} +
+ +
+ )} + /> +
+
+ + {/* 승인 상태 섹션 */} + + + + 승인 상태 + + + 업체의 승인 상태를 관리합니다 + + + + { + const selectedConfig = getStatusConfig(field.value ?? "ACTIVE"); + const SelectedIcon = selectedConfig?.Icon || CircleIcon; + + return ( + + 업체 승인 상태 + + + + + + ); + }} + /> + + + + {/* 에이전트 정보 섹션 */} + + + + 에이전트 정보 + + + 해당 업체의 에이전트 정보를 관리합니다 + + + +
+ {/* 에이전트명 */} + ( + + 에이전트명 + + + + + + )} + /> + + {/* 에이전트 전화번호 */} + ( + + 에이전트 전화번호 + + + + + + )} + /> + + {/* 에이전트 이메일 */} + ( + + 에이전트 이메일 + + + + + + )} + /> +
+
+
+ + {/* 대표자 정보 섹션 */} + + + + 대표자 정보 + + + 업체 대표자의 정보를 관리합니다 + + + +
+ {/* 대표자명 */} + ( + + 대표자명 + + + + + + )} + /> + + {/* 대표자 생년월일 */} + ( + + 대표자 생년월일 + + + + + + )} + /> + + {/* 대표자 전화번호 */} + ( + + 대표자 전화번호 + + + + + + )} + /> + + {/* 대표자 이메일 */} + ( + + 대표자 이메일 + + + + + + )} + /> +
+
+
+ + + + + + + +
+ +
+
+ ) } \ 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 { - 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 = { - "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 { + 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 = { + "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().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().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().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> -export type GetTechVendorContactsSchema = Awaited> -export type GetTechVendorItemsSchema = Awaited> -export type GetTechVendorRfqHistorySchema = Awaited> - -export type UpdateTechVendorSchema = z.infer -export type CreateTechVendorSchema = z.infer -export type CreateTechVendorContactSchema = z.infer -export type UpdateTechVendorContactSchema = z.infer -export type CreateTechVendorItemSchema = z.infer -export type UpdateTechVendorItemSchema = z.infer \ 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().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().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().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().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> +export type GetTechVendorContactsSchema = Awaited> +export type GetTechVendorItemsSchema = Awaited> +export type GetTechVendorPossibleItemsSchema = Awaited> +export type GetTechVendorRfqHistorySchema = Awaited> + +export type UpdateTechVendorSchema = z.infer +export type CreateTechVendorSchema = z.infer +export type CreateTechVendorContactSchema = z.infer +export type UpdateTechVendorContactSchema = z.infer +export type CreateTechVendorItemSchema = z.infer +export type UpdateTechVendorItemSchema = z.infer +export type CreateTechVendorPossibleItemSchema = z.infer +export type UpdateTechVendorPossibleItemSchema = z.infer \ 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, - data: NewTechSalesRfq -) { - return tx - .insert(techSalesRfqs) - .values(data) - .returning({ id: techSalesRfqs.id, createdAt: techSalesRfqs.createdAt }); -} - -/** - * 단건/복수 조회 시 공통으로 사용 가능한 SELECT 함수 예시 - * - 트랜잭션(tx)을 받아서 사용하도록 구현 - */ -export async function selectTechSalesRfqs( - tx: PgTransaction, - params: { - where?: any; - orderBy?: (ReturnType | ReturnType)[]; - 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, - 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, - options: { - where?: SQL; - orderBy?: (ReturnType | ReturnType | SQL)[]; - 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`created_user.name`, - updatedBy: techSalesRfqs.updatedBy, - updatedByName: sql`updated_user.name`, - sentBy: techSalesRfqs.sentBy, - sentByName: sql`sent_user.name`, - - // 프로젝트 정보 (조인) - pspid: biddingProjects.pspid, - projNm: biddingProjects.projNm, - sector: biddingProjects.sector, - projMsrm: biddingProjects.projMsrm, - ptypeNm: biddingProjects.ptypeNm, - - // 첨부파일 개수 (타입별로 분리) - attachmentCount: sql`( - 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`( - 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`( - 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`( - SELECT COUNT(*) - FROM tech_sales_vendor_quotations - WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id} - )`, - - // 아이템 개수 - itemCount: sql`( - 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, - 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, - params: { - where?: any; - orderBy?: (ReturnType | ReturnType)[]; - 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`created_user.name`, - updatedBy: techSalesVendorQuotations.updatedBy, - updatedByName: sql`updated_user.name`, - - // 프로젝트 정보 - materialCode: techSalesRfqs.materialCode, - - // 프로젝트 핵심 정보 - null 체크 추가 - pspid: techSalesRfqs.biddingProjectId, - projNm: biddingProjects.projNm, - sector: biddingProjects.sector, - - // 첨부파일 개수 - attachmentCount: sql`( - SELECT COUNT(*) - FROM tech_sales_attachments - WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id} - )`, - - // 견적서 첨부파일 개수 - quotationAttachmentCount: sql`( - SELECT COUNT(*) - FROM tech_sales_vendor_quotation_attachments - WHERE tech_sales_vendor_quotation_attachments.quotation_id = ${techSalesVendorQuotations.id} - )`, - - // RFQ 아이템 개수 - itemCount: sql`( - 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, - 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, - params: { - where?: any; - orderBy?: (ReturnType | ReturnType | SQL)[]; - 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`( - SELECT COUNT(DISTINCT vendor_id) - FROM tech_sales_vendor_quotations - WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id} - )`, - - quotationCount: sql`( - SELECT COUNT(*) - FROM tech_sales_vendor_quotations - WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id} - )`, - - submittedQuotationCount: sql`( - 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`( - 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`( - 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`( - 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`( - SELECT COUNT(*) - FROM tech_sales_attachments - WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id} - )`, - - // 코멘트 통계 - commentCount: sql`( - SELECT COUNT(*) - FROM tech_sales_rfq_comments - WHERE tech_sales_rfq_comments.rfq_id = ${techSalesRfqs.id} - )`, - - unreadCommentCount: sql`( - 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`created_user.name`, - - // 아이템 정보 - rfqType에 따라 다른 테이블에서 조회 - itemName: sql` - 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, - 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`rfq_created_user.name`, - rfqCreatedByEmail: sql`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, + data: NewTechSalesRfq +) { + return tx + .insert(techSalesRfqs) + .values(data) + .returning({ id: techSalesRfqs.id, createdAt: techSalesRfqs.createdAt }); +} + +/** + * 단건/복수 조회 시 공통으로 사용 가능한 SELECT 함수 예시 + * - 트랜잭션(tx)을 받아서 사용하도록 구현 + */ +export async function selectTechSalesRfqs( + tx: PgTransaction, + params: { + where?: any; + orderBy?: (ReturnType | ReturnType)[]; + 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, + 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, + options: { + where?: SQL; + orderBy?: (ReturnType | ReturnType | SQL)[]; + 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`created_user.name`, + updatedBy: techSalesRfqs.updatedBy, + updatedByName: sql`updated_user.name`, + sentBy: techSalesRfqs.sentBy, + sentByName: sql`sent_user.name`, + + // 프로젝트 정보 (조인) + pspid: biddingProjects.pspid, + projNm: biddingProjects.projNm, + sector: biddingProjects.sector, + projMsrm: biddingProjects.projMsrm, + ptypeNm: biddingProjects.ptypeNm, + + // 첨부파일 개수 (타입별로 분리) + attachmentCount: sql`( + 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`( + 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`( + 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`( + SELECT COUNT(*) + FROM tech_sales_vendor_quotations + WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id} + )`, + + // 아이템 개수 + itemCount: sql`( + SELECT COUNT(*) + FROM tech_sales_rfq_items + WHERE tech_sales_rfq_items.rfq_id = ${techSalesRfqs.id} + )`, + + // WorkTypes aggregation - RFQ에 연결된 모든 아이템들의 workType을 콤마로 구분하여 반환 + workTypes: sql`( + 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, + 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, + params: { + where?: any; + orderBy?: (ReturnType | ReturnType)[]; + 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`created_user.name`, + updatedBy: techSalesVendorQuotations.updatedBy, + updatedByName: sql`updated_user.name`, + + // 프로젝트 정보 + materialCode: techSalesRfqs.materialCode, + + // 프로젝트 핵심 정보 - null 체크 추가 + pspid: techSalesRfqs.biddingProjectId, + projNm: biddingProjects.projNm, + sector: biddingProjects.sector, + + // 첨부파일 개수 + attachmentCount: sql`( + SELECT COUNT(*) + FROM tech_sales_attachments + WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id} + )`, + + // 견적서 첨부파일 개수 + quotationAttachmentCount: sql`( + SELECT COUNT(*) + FROM tech_sales_vendor_quotation_attachments + WHERE tech_sales_vendor_quotation_attachments.quotation_id = ${techSalesVendorQuotations.id} + )`, + + // RFQ 아이템 개수 + itemCount: sql`( + 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, + 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, + params: { + where?: any; + orderBy?: (ReturnType | ReturnType | SQL)[]; + 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`( + SELECT COUNT(DISTINCT vendor_id) + FROM tech_sales_vendor_quotations + WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id} + )`, + + quotationCount: sql`( + SELECT COUNT(*) + FROM tech_sales_vendor_quotations + WHERE tech_sales_vendor_quotations.rfq_id = ${techSalesRfqs.id} + )`, + + submittedQuotationCount: sql`( + 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`( + 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`( + 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`( + 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`( + SELECT COUNT(*) + FROM tech_sales_attachments + WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id} + )`, + + // 코멘트 통계 + commentCount: sql`( + SELECT COUNT(*) + FROM tech_sales_rfq_comments + WHERE tech_sales_rfq_comments.rfq_id = ${techSalesRfqs.id} + )`, + + unreadCommentCount: sql`( + 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`created_user.name`, + + // 아이템 정보 - rfqType에 따라 다른 테이블에서 조회 + itemName: sql` + 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, + 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`rfq_created_user.name`, + rfqCreatedByEmail: sql`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 { - 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[]; - 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[], - 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[]; - 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[], - 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 = { - 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[]; - joinOperator?: "and" | "or"; - basicFilters?: Filter[]; - 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[], - 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`( - SELECT COUNT(*) - FROM tech_sales_rfq_items - WHERE tech_sales_rfq_items.rfq_id = ${techSalesRfqs.id} - )`, - // RFQ 첨부파일 개수 (RFQ_COMMON 타입만 카운트) - attachmentCount: sql`( - 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`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> { - try { - // 벤더가 보낸 읽지 않은 메시지를 벤더별로 카운트 - const unreadCounts = await db - .select({ - vendorId: techSalesRfqComments.vendorId, - count: sql`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 형태로 변환 - const result: Record = {}; - 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 { - 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 { - 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[]; - 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[]; - 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[]; - 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` - array_agg(DISTINCT ${techVendorPossibleItems.itemCode}) - `, - matchedItemCount: sql` - 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[]; - 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`vendors.vendor_name`, - vendorCode: sql`vendors.vendor_code`, - vendorEmail: sql`vendors.email`, - vendorCountry: sql`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 { - 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 { + 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[]; + 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[], + 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[]; + 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[], + 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 = { + 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[]; + joinOperator?: "and" | "or"; + basicFilters?: Filter[]; + 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[], + 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`( + SELECT COUNT(*) + FROM tech_sales_rfq_items + WHERE tech_sales_rfq_items.rfq_id = ${techSalesRfqs.id} + )`, + // RFQ 첨부파일 개수 (RFQ_COMMON 타입만 카운트) + attachmentCount: sql`( + 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`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> { + try { + // 벤더가 보낸 읽지 않은 메시지를 벤더별로 카운트 + const unreadCounts = await db + .select({ + vendorId: techSalesRfqComments.vendorId, + count: sql`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 형태로 변환 + const result: Record = {}; + 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 { + 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 { + 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[]; + 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[]; + 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[]; + 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` + array_agg(DISTINCT ${techVendorPossibleItems.itemCode}) + `, + matchedItemCount: sql` + 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[]; + 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`vendors.vendor_name`, + vendorCode: sql`vendors.vendor_code`, + vendorEmail: sql`vendors.email`, + vendorCountry: sql`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 { + 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; + }>); + + 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 - -// 공종 타입 정의 -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(null) - - // 검색 및 필터링 상태 - const [itemSearchQuery, setItemSearchQuery] = React.useState("") - const [selectedWorkType, setSelectedWorkType] = React.useState(null) - const [selectedItems, setSelectedItems] = React.useState([]) - - // 데이터 상태 - const [workTypes, setWorkTypes] = React.useState([]) - const [allItems, setAllItems] = React.useState([]) - const [isLoadingItems, setIsLoadingItems] = React.useState(false) - const [dataLoadError, setDataLoadError] = React.useState(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({ - 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 ( - { - setIsDialogOpen(open) - if (!open) { - form.reset({ - biddingProjectId: undefined, - itemIds: [], - dueDate: undefined, - description: "", - }) - setSelectedProject(null) - setItemSearchQuery("") - setSelectedWorkType(null) - setSelectedItems([]) - setDataLoadError(null) - setRetryCount(0) - } - }} - > - - - - - - 해양 Hull RFQ 생성 - - -
-
- - {/* 프로젝트 선택 */} -
- ( - - 입찰 프로젝트 - - - - - - )} - /> - - {/* RFQ 설명 */} - ( - - RFQ Title - - - - - - )} - /> - - {/* 마감일 설정 */} - ( - - 마감일 - - - - - - - - - date < new Date() || date < new Date("1900-01-01") - } - initialFocus - /> - - - - - )} - /> - - - -
- {/* 아이템 선택 영역 */} -
-
- 해양 Hull 아이템 선택 - - 해양 Hull 아이템을 선택하세요 - -
- - {/* 데이터 로딩 에러 표시 */} - {dataLoadError && ( -
-
-
- - {dataLoadError} -
- -
-
- )} - - {/* 아이템 검색 및 필터 */} -
-
-
- - setItemSearchQuery(e.target.value)} - className="pl-8 pr-8" - disabled={isLoadingItems || dataLoadError !== null} - /> - {itemSearchQuery && ( - - )} -
- - {/* 공종 필터 */} - - - - - - setSelectedWorkType(null)} - > - 전체 공종 - - {workTypes.map(workType => ( - setSelectedWorkType(workType.code)} - > - {workType.name} - - ))} - - -
-
- - {/* 아이템 목록 */} -
- -
- {dataLoadError ? ( -
-
-
- -
-

데이터 로딩에 실패했습니다

-

{dataLoadError}

-
- -
-
-
- ) : isLoadingItems ? ( -
- - 아이템을 불러오는 중... - {retryCount > 0 && ( -

재시도 {retryCount}회

- )} -
- ) : 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 ( -
handleItemToggle(item)} - > -
- {isSelected ? ( - - ) : ( - - )} -
- {/* Hull 아이템 표시: "item_list / sub_item_list" / item_code / 공종 */} -
- {item.itemList || '아이템명 없음'} - {item.subItemList && ` / ${item.subItemList}`} -
-
- {item.itemCode || '아이템코드 없음'} -
-
- 공종: {item.workType} -
-
-
-
- ) - }) - ) : ( -
- {itemSearchQuery ? "검색 결과가 없습니다" : "아이템이 없습니다"} -
- )} -
-
-
-
-
-
-
- -
- - {/* Footer - Sticky 버튼 영역 */} -
-
- - -
-
-
-
- ) +"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 + +// 공종 타입 정의 +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(null) + + // 검색 및 필터링 상태 + const [itemSearchQuery, setItemSearchQuery] = React.useState("") + const [selectedWorkType, setSelectedWorkType] = React.useState(null) + const [selectedItems, setSelectedItems] = React.useState([]) + + // 데이터 상태 + const [workTypes, setWorkTypes] = React.useState([]) + const [allItems, setAllItems] = React.useState([]) + const [isLoadingItems, setIsLoadingItems] = React.useState(false) + const [dataLoadError, setDataLoadError] = React.useState(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({ + 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 ( + { + setIsDialogOpen(open) + if (!open) { + form.reset({ + biddingProjectId: undefined, + itemIds: [], + dueDate: undefined, + description: "", + }) + setSelectedProject(null) + setItemSearchQuery("") + setSelectedWorkType(null) + setSelectedItems([]) + setDataLoadError(null) + setRetryCount(0) + } + }} + > + + + + + + 해양 Hull RFQ 생성 + + +
+
+ + {/* 프로젝트 선택 */} +
+ ( + + 입찰 프로젝트 + + + + + + )} + /> + + {/* RFQ 설명 */} + ( + + RFQ Title + + + + + + )} + /> + + {/* 마감일 설정 */} + ( + + 마감일 + + + + + + + + + date < new Date() || date < new Date("1900-01-01") + } + initialFocus + /> + + + + + )} + /> + + + +
+ {/* 아이템 선택 영역 */} +
+
+ 해양 Hull 아이템 선택 + + 해양 Hull 아이템을 선택하세요 + +
+ + {/* 데이터 로딩 에러 표시 */} + {dataLoadError && ( +
+
+
+ + {dataLoadError} +
+ +
+
+ )} + + {/* 아이템 검색 및 필터 */} +
+
+
+ + setItemSearchQuery(e.target.value)} + className="pl-8 pr-8" + disabled={isLoadingItems || dataLoadError !== null} + /> + {itemSearchQuery && ( + + )} +
+ + {/* 공종 필터 */} + + + + + + setSelectedWorkType(null)} + > + 전체 공종 + + {workTypes.map(workType => ( + setSelectedWorkType(workType.code)} + > + {workType.name} + + ))} + + +
+
+ + {/* 아이템 목록 */} +
+ +
+ {dataLoadError ? ( +
+
+
+ +
+

데이터 로딩에 실패했습니다

+

{dataLoadError}

+
+ +
+
+
+ ) : isLoadingItems ? ( +
+ + 아이템을 불러오는 중... + {retryCount > 0 && ( +

재시도 {retryCount}회

+ )} +
+ ) : 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 ( +
handleItemToggle(item)} + > +
+ {isSelected ? ( + + ) : ( + + )} +
+ {/* Hull 아이템 표시: "item_list / sub_item_list" / item_code / 공종 */} +
+ {item.itemList || '아이템명 없음'} + {item.subItemList && ` / ${item.subItemList}`} +
+
+ {item.itemCode || '아이템코드 없음'} +
+
+ 공종: {item.workType} +
+
+
+
+ ) + }) + ) : ( +
+ {itemSearchQuery ? "검색 결과가 없습니다" : "아이템이 없습니다"} +
+ )} +
+
+
+
+
+
+
+ +
+ + {/* Footer - Sticky 버튼 영역 */} +
+
+ + +
+
+
+
+ ) } \ 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 - -// 공종 타입 정의 -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(null) - - // 검색 및 필터링 상태 - const [itemSearchQuery, setItemSearchQuery] = React.useState("") - const [selectedWorkType, setSelectedWorkType] = React.useState(null) - const [selectedShipType, setSelectedShipType] = React.useState(null) - const [selectedItems, setSelectedItems] = React.useState([]) - - // 데이터 상태 - const [workTypes, setWorkTypes] = React.useState([]) - const [allItems, setAllItems] = React.useState([]) - const [shipTypes, setShipTypes] = React.useState([]) - const [isLoadingItems, setIsLoadingItems] = React.useState(false) - const [dataLoadError, setDataLoadError] = React.useState(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({ - 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 ( - { - setIsDialogOpen(open) - if (!open) { - form.reset({ - biddingProjectId: undefined, - itemIds: [], - dueDate: undefined, - description: "", - }) - setSelectedProject(null) - setItemSearchQuery("") - setSelectedWorkType(null) - setSelectedShipType(null) - setSelectedItems([]) - setDataLoadError(null) - setRetryCount(0) - } - }} - > - - - - - - 조선 RFQ 생성 - - -
-
- - {/* 프로젝트 선택 */} -
- ( - - 입찰 프로젝트 - - - - - - )} - /> - - - - {/* RFQ 설명 */} - ( - - RFQ Title - - - - - - )} - /> - - - - {/* 선종 선택 */} -
-
- 선종 선택 -
- - {/* 데이터 로딩 에러 표시 */} - {dataLoadError && ( -
-
-
- - {dataLoadError} -
- -
-
- )} - - - - - - - { - setSelectedShipType(null) - setSelectedItems([]) - form.setValue("itemIds", []) - }} - > - 전체 선종 - - {availableShipTypes.map(shipType => ( - { - setSelectedShipType(shipType) - setSelectedItems([]) - form.setValue("itemIds", []) - }} - > - {shipType} - - ))} - - -
- - - - {/* 마감일 설정 */} - ( - - 마감일 - - - - - - - - - date < new Date() || date < new Date("1900-01-01") - } - initialFocus - /> - - - - - )} - /> - - - -
- {/* 아이템 선택 영역 */} -
-
- 조선 아이템 선택 - - {selectedShipType - ? `선종 ${selectedShipType}의 공종별 아이템을 선택하세요` - : "먼저 선종을 선택해주세요" - } - -
- - {/* 아이템 검색 및 필터 */} -
-
-
- - setItemSearchQuery(e.target.value)} - className="pl-8 pr-8" - disabled={!selectedShipType || isLoadingItems || dataLoadError !== null} - /> - {itemSearchQuery && ( - - )} -
- - {/* 공종 필터 */} - - - - - - setSelectedWorkType(null)} - > - 전체 공종 - - {workTypes.map(workType => ( - setSelectedWorkType(workType.code)} - > - {workType.name} - - ))} - - -
-
- - {/* 아이템 목록 */} -
- -
- {dataLoadError ? ( -
-
-
- -
-

데이터 로딩에 실패했습니다

-

{dataLoadError}

-
- -
-
-
- ) : isLoadingItems ? ( -
- - 아이템을 불러오는 중... - {retryCount > 0 && ( -

재시도 {retryCount}회

- )} -
- ) : 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 ( -
handleItemToggle(item)} - > -
- {isSelected ? ( - - ) : ( - - )} -
-
- {item.itemList || '아이템명 없음'} -
-
- {item.itemCode || '자재그룹코드 없음'} -
-
- 공종: {item.workType} • 선종: {item.shipTypes} -
-
-
-
- ) - }) - ) : ( -
- {itemSearchQuery ? "검색 결과가 없습니다" : "아이템이 없습니다"} -
- )} -
-
-
-
-
-
-
- -
- - {/* Footer - Sticky 버튼 영역 */} -
-
- - -
-
-
-
- ) +"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 + +// 공종 타입 정의 +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(null) + + // 검색 및 필터링 상태 + const [itemSearchQuery, setItemSearchQuery] = React.useState("") + const [selectedWorkType, setSelectedWorkType] = React.useState(null) + const [selectedShipType, setSelectedShipType] = React.useState(null) + const [selectedItems, setSelectedItems] = React.useState([]) + + // 데이터 상태 + const [workTypes, setWorkTypes] = React.useState([]) + const [allItems, setAllItems] = React.useState([]) + const [shipTypes, setShipTypes] = React.useState([]) + const [isLoadingItems, setIsLoadingItems] = React.useState(false) + const [dataLoadError, setDataLoadError] = React.useState(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({ + 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 ( + { + setIsDialogOpen(open) + if (!open) { + form.reset({ + biddingProjectId: undefined, + itemIds: [], + dueDate: undefined, + description: "", + }) + setSelectedProject(null) + setItemSearchQuery("") + setSelectedWorkType(null) + setSelectedShipType(null) + setSelectedItems([]) + setDataLoadError(null) + setRetryCount(0) + } + }} + > + + + + + + 조선 RFQ 생성 + + +
+
+ + {/* 프로젝트 선택 */} +
+ ( + + 입찰 프로젝트 + + + + + + )} + /> + + + + {/* RFQ 설명 */} + ( + + RFQ Title + + + + + + )} + /> + + + + {/* 선종 선택 */} +
+
+ 선종 선택 +
+ + {/* 데이터 로딩 에러 표시 */} + {dataLoadError && ( +
+
+
+ + {dataLoadError} +
+ +
+
+ )} + + + + + + + { + setSelectedShipType(null) + setSelectedItems([]) + form.setValue("itemIds", []) + }} + > + 전체 선종 + + {availableShipTypes.map(shipType => ( + { + setSelectedShipType(shipType) + setSelectedItems([]) + form.setValue("itemIds", []) + }} + > + {shipType} + + ))} + + +
+ + + + {/* 마감일 설정 */} + ( + + 마감일 + + + + + + + + + date < new Date() || date < new Date("1900-01-01") + } + initialFocus + /> + + + + + )} + /> + + + +
+ {/* 아이템 선택 영역 */} +
+
+ 조선 아이템 선택 + + {selectedShipType + ? `선종 ${selectedShipType}의 공종별 아이템을 선택하세요` + : "먼저 선종을 선택해주세요" + } + +
+ + {/* 아이템 검색 및 필터 */} +
+
+
+ + setItemSearchQuery(e.target.value)} + className="pl-8 pr-8" + disabled={!selectedShipType || isLoadingItems || dataLoadError !== null} + /> + {itemSearchQuery && ( + + )} +
+ + {/* 공종 필터 */} + + + + + + setSelectedWorkType(null)} + > + 전체 공종 + + {workTypes.map(workType => ( + setSelectedWorkType(workType.code)} + > + {workType.name} + + ))} + + +
+
+ + {/* 아이템 목록 */} +
+ +
+ {dataLoadError ? ( +
+
+
+ +
+

데이터 로딩에 실패했습니다

+

{dataLoadError}

+
+ +
+
+
+ ) : isLoadingItems ? ( +
+ + 아이템을 불러오는 중... + {retryCount > 0 && ( +

재시도 {retryCount}회

+ )} +
+ ) : 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 ( +
handleItemToggle(item)} + > +
+ {isSelected ? ( + + ) : ( + + )} +
+
+ {item.itemList || '아이템명 없음'} +
+
+ {item.itemCode || '자재그룹코드 없음'} +
+
+ 공종: {item.workType} • 선종: {item.shipTypes} +
+
+
+
+ ) + }) + ) : ( +
+ {itemSearchQuery ? "검색 결과가 없습니다" : "아이템이 없습니다"} +
+ )} +
+
+
+
+
+
+
+ +
+ + {/* Footer - Sticky 버튼 영역 */} +
+
+ + +
+
+
+
+ ) } \ 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 - -// 공종 타입 정의 -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(null) - - // 검색 및 필터링 상태 - const [itemSearchQuery, setItemSearchQuery] = React.useState("") - const [selectedWorkType, setSelectedWorkType] = React.useState(null) - const [selectedItems, setSelectedItems] = React.useState([]) - - // 데이터 상태 - const [workTypes, setWorkTypes] = React.useState([]) - const [allItems, setAllItems] = React.useState([]) - const [isLoadingItems, setIsLoadingItems] = React.useState(false) - const [dataLoadError, setDataLoadError] = React.useState(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({ - 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 ( - { - setIsDialogOpen(open) - if (!open) { - form.reset({ - biddingProjectId: undefined, - itemIds: [], - dueDate: undefined, - description: "", - }) - setSelectedProject(null) - setItemSearchQuery("") - setSelectedWorkType(null) - setSelectedItems([]) - setDataLoadError(null) - setRetryCount(0) - } - }} - > - - - - - - 해양 TOP RFQ 생성 - - -
-
- - {/* 프로젝트 선택 */} -
- ( - - 입찰 프로젝트 - - - - - - )} - /> - - - {/* RFQ 설명 */} - ( - - RFQ Title - - - - - - )} - /> - - {/* 마감일 설정 */} - ( - - 마감일 - - - - - - - - - date < new Date() || date < new Date("1900-01-01") - } - initialFocus - /> - - - - - )} - /> - - - -
- {/* 아이템 선택 영역 */} -
-
- 아이템 선택 - - 해양 TOP RFQ를 생성하려면 아이템을 선택하세요 - -
- - {/* 아이템 검색 및 필터 */} -
-
-
- - setItemSearchQuery(e.target.value)} - className="pl-8 pr-8" - disabled={isLoadingItems || dataLoadError !== null} - /> - {itemSearchQuery && ( - - )} -
- - {/* 공종 필터 */} - - - - - - setSelectedWorkType(null)} - > - 전체 공종 - - {workTypes.map(workType => ( - setSelectedWorkType(workType.code)} - > - {workType.name} - - ))} - - -
-
- - {/* 아이템 목록 */} -
- -
- {dataLoadError ? ( -
-
-
- -
-

데이터 로딩에 실패했습니다

-

{dataLoadError}

-
- -
-
-
- ) : isLoadingItems ? ( -
- - 아이템을 불러오는 중... - {retryCount > 0 && ( -

재시도 {retryCount}회

- )} -
- ) : 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 ( -
handleItemToggle(item)} - > -
- {isSelected ? ( - - ) : ( - - )} -
-
- {item.itemList || '아이템명 없음'} - {item.subItemList && ` / ${item.subItemList}`} -
-
- {item.itemCode || '아이템코드 없음'} -
-
- 공종: {item.workType} -
-
-
-
- ) - }) - ) : ( -
- {itemSearchQuery ? "검색 결과가 없습니다" : "아이템이 없습니다"} -
- )} -
-
-
-
-
-
-
- -
- - {/* Footer - Sticky 버튼 영역 */} -
-
- - -
-
-
-
- ) +"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 + +// 공종 타입 정의 +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(null) + + // 검색 및 필터링 상태 + const [itemSearchQuery, setItemSearchQuery] = React.useState("") + const [selectedWorkType, setSelectedWorkType] = React.useState(null) + const [selectedItems, setSelectedItems] = React.useState([]) + + // 데이터 상태 + const [workTypes, setWorkTypes] = React.useState([]) + const [allItems, setAllItems] = React.useState([]) + const [isLoadingItems, setIsLoadingItems] = React.useState(false) + const [dataLoadError, setDataLoadError] = React.useState(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({ + 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 ( + { + setIsDialogOpen(open) + if (!open) { + form.reset({ + biddingProjectId: undefined, + itemIds: [], + dueDate: undefined, + description: "", + }) + setSelectedProject(null) + setItemSearchQuery("") + setSelectedWorkType(null) + setSelectedItems([]) + setDataLoadError(null) + setRetryCount(0) + } + }} + > + + + + + + 해양 TOP RFQ 생성 + + +
+
+ + {/* 프로젝트 선택 */} +
+ ( + + 입찰 프로젝트 + + + + + + )} + /> + + + {/* RFQ 설명 */} + ( + + RFQ Title + + + + + + )} + /> + + {/* 마감일 설정 */} + ( + + 마감일 + + + + + + + + + date < new Date() || date < new Date("1900-01-01") + } + initialFocus + /> + + + + + )} + /> + + + +
+ {/* 아이템 선택 영역 */} +
+
+ 아이템 선택 + + 해양 TOP RFQ를 생성하려면 아이템을 선택하세요 + +
+ + {/* 아이템 검색 및 필터 */} +
+
+
+ + setItemSearchQuery(e.target.value)} + className="pl-8 pr-8" + disabled={isLoadingItems || dataLoadError !== null} + /> + {itemSearchQuery && ( + + )} +
+ + {/* 공종 필터 */} + + + + + + setSelectedWorkType(null)} + > + 전체 공종 + + {workTypes.map(workType => ( + setSelectedWorkType(workType.code)} + > + {workType.name} + + ))} + + +
+
+ + {/* 아이템 목록 */} +
+ +
+ {dataLoadError ? ( +
+
+
+ +
+

데이터 로딩에 실패했습니다

+

{dataLoadError}

+
+ +
+
+
+ ) : isLoadingItems ? ( +
+ + 아이템을 불러오는 중... + {retryCount > 0 && ( +

재시도 {retryCount}회

+ )} +
+ ) : 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 ( +
handleItemToggle(item)} + > +
+ {isSelected ? ( + + ) : ( + + )} +
+
+ {item.itemList || '아이템명 없음'} + {item.subItemList && ` / ${item.subItemList}`} +
+
+ {item.itemCode || '아이템코드 없음'} +
+
+ 공종: {item.workType} +
+
+
+
+ ) + }) + ) : ( +
+ {itemSearchQuery ? "검색 결과가 없습니다" : "아이템이 없습니다"} +
+ )} +
+
+
+
+
+
+
+ +
+ + {/* Footer - Sticky 버튼 영역 */} +
+
+ + +
+
+
+
+ ) } \ 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 { - 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 ( - - - - 벤더 삭제 확인 - - 정말로 선택한 {vendors.length}개의 벤더를 삭제하시겠습니까? -
-
- 삭제될 벤더: {vendorNames} -
-
- 이 작업은 되돌릴 수 없습니다. -
-
- - - - - - -
-
- ) - } - - return ( - - - - 벤더 삭제 확인 - - 정말로 선택한 {vendors.length}개의 벤더를 삭제하시겠습니까? -
-
- 삭제될 벤더: {vendorNames} -
-
- 이 작업은 되돌릴 수 없습니다. -
-
- - - - - - -
-
- ) +"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 { + 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 ( + + + + 벤더 삭제 확인 + + 정말로 선택한 {vendors.length}개의 벤더를 삭제하시겠습니까? +
+
+ 삭제될 벤더: {vendorNames} +
+
+ 이 작업은 되돌릴 수 없습니다. +
+
+ + + + + + +
+
+ ) + } + + return ( + + + + 벤더 삭제 확인 + + 정말로 선택한 {vendors.length}개의 벤더를 삭제하시겠습니까? +
+
+ 삭제될 벤더: {vendorNames} +
+
+ 이 작업은 되돌릴 수 없습니다. +
+
+ + + + + + +
+
+ ) } \ 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 - -// 기술영업 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([]) - const [candidateVendors, setCandidateVendors] = useState([]) - const [isSearching, setIsSearching] = useState(false) - const [isLoadingCandidates, setIsLoadingCandidates] = useState(false) - const [hasSearched, setHasSearched] = useState(false) - const [hasCandidatesLoaded, setHasCandidatesLoaded] = useState(false) - // 선택된 벤더들을 별도로 관리하여 검색과 독립적으로 유지 - const [selectedVendorData, setSelectedVendorData] = useState([]) - const [activeTab, setActiveTab] = useState("candidates") - - const form = useForm({ - 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) => ( - -
- {vendors.length > 0 ? ( - vendors.map((vendor, index) => ( -
handleVendorToggle(vendor)} - > -
- -
-
- {vendor.vendorName} - {showMatchCount && vendor.matchedItemCount && vendor.matchedItemCount > 0 && ( - - - {vendor.matchedItemCount}개 매칭 - - )} - {vendor.techVendorType && ( - - {vendor.techVendorType} - - )} -
-
- {vendor.vendorCode || 'N/A'} {vendor.country && `• ${vendor.country}`} -
-
-
-
- )) - ) : ( -
- {showMatchCount ? "매칭되는 후보 벤더가 없습니다" : "검색 결과가 없습니다"} -
- )} -
-
- ) - - return ( - - - {/* 헤더 */} - - 벤더 추가 - - {selectedRfq ? ( - <> - {selectedRfq.rfqCode} RFQ에 벤더를 추가합니다. - - ) : ( - "RFQ에 벤더를 추가합니다." - )} - - - - {/* 콘텐츠 */} -
-
- - {/* 탭 메뉴 */} - - - - 후보 벤더 ({candidateVendors.length}) - - - 벤더 검색 - - - - {/* 후보 벤더 탭 */} - -
-
- - -
- - {isLoadingCandidates ? ( -
-
- - 후보 벤더를 불러오는 중... -
-
- ) : ( - renderVendorList(candidateVendors, true) - )} - -
- 💡 RFQ 아이템과 매칭되는 벤더들이 매칭 아이템 수가 많은 순으로 표시됩니다. -
-
-
- - {/* 벤더 검색 탭 */} - - {/* 벤더 검색 필드 */} -
- -
- - setSearchTerm(e.target.value)} - className="pl-10" - /> - {isSearching && ( - - )} -
-
- - {/* 검색 결과 */} - {hasSearched ? ( -
-
- 검색 결과 ({searchResults.length}개) -
- {renderVendorList(searchResults)} -
- ) : ( -
- 벤더명 또는 벤더코드를 입력하여 검색해주세요 -
- )} -
-
- - {/* 선택된 벤더 목록 - 하단에 항상 표시 */} - ( - -
- 선택된 벤더 ({selectedVendorData.length}개) -
- {selectedVendorData.length > 0 ? ( -
- {selectedVendorData.map((vendor) => ( - - {vendor.vendorName} ({vendor.vendorCode || 'N/A'}) - handleRemoveVendor(vendor.id)} - /> - - ))} -
- ) : ( -
- 선택된 벤더가 없습니다 -
- )} -
-
- -
- )} - /> - - {/* 안내 메시지 */} -
-

• 후보 벤더는 RFQ 아이템 코드와 매칭되는 기술영업 벤더들입니다.

-

• 선택된 벤더들은 Draft 상태로 추가됩니다.

-

• 벤더별 견적 정보는 추가 후 개별적으로 입력할 수 있습니다.

-
- - -
- - {/* 푸터 */} - - - - -
-
- ) +"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 + +// 기술영업 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([]) + const [candidateVendors, setCandidateVendors] = useState([]) + const [isSearching, setIsSearching] = useState(false) + const [isLoadingCandidates, setIsLoadingCandidates] = useState(false) + const [hasSearched, setHasSearched] = useState(false) + const [hasCandidatesLoaded, setHasCandidatesLoaded] = useState(false) + // 선택된 벤더들을 별도로 관리하여 검색과 독립적으로 유지 + const [selectedVendorData, setSelectedVendorData] = useState([]) + const [activeTab, setActiveTab] = useState("candidates") + + const form = useForm({ + 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) => ( + +
+ {vendors.length > 0 ? ( + vendors.map((vendor, index) => ( +
handleVendorToggle(vendor)} + > +
+ +
+
+ {vendor.vendorName} + {showMatchCount && vendor.matchedItemCount && vendor.matchedItemCount > 0 && ( + + + {vendor.matchedItemCount}개 매칭 + + )} + {vendor.techVendorType && ( + + {vendor.techVendorType} + + )} +
+
+ {vendor.vendorCode || 'N/A'} {vendor.country && `• ${vendor.country}`} +
+
+
+
+ )) + ) : ( +
+ {showMatchCount ? "매칭되는 후보 벤더가 없습니다" : "검색 결과가 없습니다"} +
+ )} +
+
+ ) + + return ( + + + {/* 헤더 */} + + 벤더 추가 + + {selectedRfq ? ( + <> + {selectedRfq.rfqCode} RFQ에 벤더를 추가합니다. + + ) : ( + "RFQ에 벤더를 추가합니다." + )} + + + + {/* 콘텐츠 */} +
+
+ + {/* 탭 메뉴 */} + + + + 후보 벤더 ({candidateVendors.length}) + + + 벤더 검색 + + + + {/* 후보 벤더 탭 */} + +
+
+ + +
+ + {isLoadingCandidates ? ( +
+
+ + 후보 벤더를 불러오는 중... +
+
+ ) : ( + renderVendorList(candidateVendors, true) + )} + +
+ 💡 RFQ 아이템과 매칭되는 벤더들이 매칭 아이템 수가 많은 순으로 표시됩니다. +
+
+
+ + {/* 벤더 검색 탭 */} + + {/* 벤더 검색 필드 */} +
+ +
+ + setSearchTerm(e.target.value)} + className="pl-10" + /> + {isSearching && ( + + )} +
+
+ + {/* 검색 결과 */} + {hasSearched ? ( +
+
+ 검색 결과 ({searchResults.length}개) +
+ {renderVendorList(searchResults)} +
+ ) : ( +
+ 벤더명 또는 벤더코드를 입력하여 검색해주세요 +
+ )} +
+
+ + {/* 선택된 벤더 목록 - 하단에 항상 표시 */} + ( + +
+ 선택된 벤더 ({selectedVendorData.length}개) +
+ {selectedVendorData.length > 0 ? ( +
+ {selectedVendorData.map((vendor) => ( + + {vendor.vendorName} ({vendor.vendorCode || 'N/A'}) + handleRemoveVendor(vendor.id)} + /> + + ))} +
+ ) : ( +
+ 선택된 벤더가 없습니다 +
+ )} +
+
+ +
+ )} + /> + + {/* 안내 메시지 +
+

• 후보 벤더는 RFQ 아이템 코드와 매칭되는 기술영업 벤더들입니다.

+

• 선택된 벤더들은 Draft 상태로 추가됩니다.

+

• 벤더별 견적 정보는 추가 후 개별적으로 입력할 수 있습니다.

+
*/} + + +
+ + {/* 푸터 */} + + + + +
+
+ ) } \ 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 { - 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 ( - - {showTrigger ? ( - - - - ) : null} - - - 정말로 삭제하시겠습니까? - - 이 작업은 되돌릴 수 없습니다. 벤더 "{detail?.vendorName}"({detail?.vendorCode})의 RFQ 정보가 영구적으로 삭제됩니다. - - - - - - - - - - - ) - } - - return ( - - {showTrigger ? ( - - - - ) : null} - - - 정말로 삭제하시겠습니까? - - 이 작업은 되돌릴 수 없습니다. 벤더 "{detail?.vendorName}"({detail?.vendorCode})의 RFQ 정보가 영구적으로 삭제됩니다. - - - - - - - - - - - ) +"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 { + 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 ( + + {showTrigger ? ( + + + + ) : null} + + + 정말로 삭제하시겠습니까? + + 이 작업은 되돌릴 수 없습니다. 벤더 "{detail?.vendorName}"({detail?.vendorCode})의 RFQ 정보가 영구적으로 삭제됩니다. + + + + + + + + + + + ) + } + + return ( + + {showTrigger ? ( + + + + ) : null} + + + 정말로 삭제하시겠습니까? + + 이 작업은 되돌릴 수 없습니다. 벤더 "{detail?.vendorName}"({detail?.vendorCode})의 RFQ 정보가 영구적으로 삭제됩니다. + + + + + + + + + + + ) } \ 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([]) + 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 ( + + + + + + RFQ 발송 담당자 목록 + + + {vendorName && ( + {vendorName} + )} 에게 발송된 RFQ의 담당자 정보입니다. + + + +
+ {isLoading ? ( +
+ {[1, 2, 3].map((i) => ( + + ))} +
+ ) : contacts.length === 0 ? ( +
+ +

발송된 담당자 정보가 없습니다.

+

아직 RFQ가 발송되지 않았거나 담당자 정보가 기록되지 않았습니다.

+
+ ) : ( +
+ {contacts.map((contact) => ( +
+
+ +
+
+ {contact.contactName} + {contact.isPrimary && ( + + 주담당자 + + )} +
+ {contact.contactPosition && ( +

+ {contact.contactPosition} +

+ )} + {contact.contactCountry && ( +

+ {contact.contactCountry} +

+ )} +
+
+ +
+
+ + {contact.contactEmail} +
+ {contact.contactPhone && ( +
+ + {contact.contactPhone} +
+ )} +
+ 발송일: {new Date(contact.createdAt).toLocaleDateString('ko-KR')} +
+
+
+ ))} + +
+ 총 {contacts.length}명의 담당자에게 발송됨 +
+
+ )} +
+ +
+ +
+
+
+ ) +} \ 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({

유효 기한

- {data.validUntil ? formatDate(data.validUntil, "KR") : "미설정"} + {data.validUntil ? formatDate(data.validUntil) : "미설정"}

@@ -187,8 +185,8 @@ function QuotationCard({ {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"}` }
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 { - row: Row; - 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 { - setRowAction: React.Dispatch< - React.SetStateAction | null> - >; - unreadMessages?: Record; // 읽지 않은 메시지 개수 - onQuotationClick?: (quotationId: number) => void; // 견적 클릭 핸들러 - openQuotationAttachmentsSheet?: (quotationId: number, quotationInfo: QuotationInfo) => void; // 견적서 첨부파일 sheet 열기 -} - -export function getRfqDetailColumns({ - setRowAction, - unreadMessages = {}, - onQuotationClick, - openQuotationAttachmentsSheet -}: GetColumnsProps): ColumnDef[] { - return [ - { - id: "select", - header: ({ table }) => ( - table.toggleAllPageRowsSelected(!!value)} - aria-label="모두 선택" - /> - ), - cell: ({ row }) => { - const status = row.original.status; - const isSelectable = status ? !["Accepted", "Rejected"].includes(status) : true; - - return ( - row.toggleSelected(!!value)} - disabled={!isSelectable} - aria-label="행 선택" - className={!isSelectable ? "opacity-50 cursor-not-allowed" : ""} - /> - ); - }, - enableSorting: false, - enableHiding: false, - size: 40, - }, - { - accessorKey: "status", - header: ({ column }) => ( - - ), - 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 ( - {status || "Draft"} - ); - }, - meta: { - excelHeader: "견적 상태" - }, - enableResizing: true, - size: 120, - }, - { - accessorKey: "vendorCode", - header: ({ column }) => ( - - ), - cell: ({ row }) =>
{row.getValue("vendorCode")}
, - meta: { - excelHeader: "벤더 코드" - }, - enableResizing: true, - size: 120, - }, - { - accessorKey: "vendorName", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const vendorName = row.getValue("vendorName") as string | null; - const vendorId = row.original.vendorId; - - if (!vendorName) return
-
; - - if (vendorId) { - return ( - - ); - } - - return
{vendorName}
; - }, - meta: { - excelHeader: "벤더명" - }, - enableResizing: true, - size: 160, - }, - { - accessorKey: "totalPrice", - header: ({ column }) => ( - - ), - 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 ( - - ); - } - - return ( -
- {displayValue} {currency} -
- ); - }, - meta: { - excelHeader: "견적 금액" - }, - enableResizing: true, - size: 140, - }, - { - accessorKey: "quotationAttachments", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const attachments = row.original.quotationAttachments || []; - const attachmentCount = attachments.length; - - if (attachmentCount === 0) { - return
-
; - } - - return ( - - ); - }, - meta: { - excelHeader: "첨부파일" - }, - enableResizing: false, - size: 80, - }, - { - accessorKey: "currency", - header: ({ column }) => ( - - ), - cell: ({ row }) =>
{row.getValue("currency")}
, - meta: { - excelHeader: "통화" - }, - enableResizing: true, - size: 80, - }, - { - accessorKey: "validUntil", - header: ({ column }) => ( - - ), - cell: ({ cell }) => { - const value = cell.getValue() as Date | null; - return value ? formatDate(value, "KR") : "-"; - }, - meta: { - excelHeader: "유효기간" - }, - enableResizing: true, - size: 120, - }, - { - accessorKey: "submittedAt", - header: ({ column }) => ( - - ), - cell: ({ cell }) => { - const value = cell.getValue() as Date | null; - return value ? formatDate(value, "KR") : "-"; - }, - meta: { - excelHeader: "제출일" - }, - enableResizing: true, - size: 120, - }, - { - accessorKey: "createdByName", - header: ({ column }) => ( - - ), - cell: ({ row }) =>
{row.getValue("createdByName")}
, - meta: { - excelHeader: "등록자" - }, - enableResizing: true, - size: 120, - }, - { - accessorKey: "remark", - header: ({ column }) => ( - - ), - cell: ({ row }) =>
{row.getValue("remark") || "-"}
, - meta: { - excelHeader: "비고" - }, - enableResizing: true, - size: 200, - }, - { - id: "actions", - header: () =>
동작
, - 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 ( -
- {/* 커뮤니케이션 버튼 */} -
- - {unreadCount > 0 && ( - - {unreadCount > 9 ? '9+' : unreadCount} - - )} -
- - {/* 컨텍스트 메뉴 */} - - - - - - setRowAction({ row, type: "delete" })} - disabled={!isDraft} - className={!isDraft ? "opacity-50 cursor-not-allowed" : "text-destructive focus:text-destructive"} - > - - 벤더 삭제 - - - -
- ); - }, - 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 { + row: Row; + 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 { + setRowAction: React.Dispatch< + React.SetStateAction | null> + >; + unreadMessages?: Record; // 읽지 않은 메시지 개수 + 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): ColumnDef[] { + return [ + { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="모두 선택" + /> + ), + cell: ({ row }) => { + const status = row.original.status; + const isSelectable = status ? !["Accepted", "Rejected"].includes(status) : true; + + return ( + row.toggleSelected(!!value)} + disabled={!isSelectable} + aria-label="행 선택" + className={!isSelectable ? "opacity-50 cursor-not-allowed" : ""} + /> + ); + }, + enableSorting: false, + enableHiding: false, + size: 40, + }, + { + accessorKey: "status", + header: ({ column }) => ( + + ), + 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 ( + {status || "Draft"} + ); + }, + meta: { + excelHeader: "견적 상태" + }, + enableResizing: true, + size: 120, + }, + { + accessorKey: "vendorCode", + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue("vendorCode")}
, + meta: { + excelHeader: "벤더 코드" + }, + enableResizing: true, + size: 120, + }, + { + accessorKey: "vendorName", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const vendorName = row.getValue("vendorName") as string | null; + const vendorId = row.original.vendorId; + + if (!vendorName) return
-
; + + if (vendorId) { + return ( + + ); + } + + return
{vendorName}
; + }, + meta: { + excelHeader: "벤더명" + }, + enableResizing: true, + size: 160, + }, + { + accessorKey: "totalPrice", + header: ({ column }) => ( + + ), + 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 ( + + ); + } + + return ( +
+ {displayValue} {currency} +
+ ); + }, + meta: { + excelHeader: "견적 금액" + }, + enableResizing: true, + size: 140, + }, + { + accessorKey: "quotationAttachments", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const attachments = row.original.quotationAttachments || []; + const attachmentCount = attachments.length; + + if (attachmentCount === 0) { + return
-
; + } + + return ( + + ); + }, + 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 ( +
+ + + + + + +

RFQ 발송 담당자 보기

+
+
+
+
+ ); + }, + meta: { + excelHeader: "담당자" + }, + enableResizing: false, + size: 80, + }, + { + accessorKey: "currency", + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue("currency")}
, + meta: { + excelHeader: "통화" + }, + enableResizing: true, + size: 80, + }, + { + accessorKey: "validUntil", + header: ({ column }) => ( + + ), + cell: ({ cell }) => { + const value = cell.getValue() as Date | null; + return value ? formatDate(value, "KR") : "-"; + }, + meta: { + excelHeader: "유효기간" + }, + enableResizing: true, + size: 120, + }, + { + accessorKey: "submittedAt", + header: ({ column }) => ( + + ), + cell: ({ cell }) => { + const value = cell.getValue() as Date | null; + return value ? formatDate(value, "KR") : "-"; + }, + meta: { + excelHeader: "제출일" + }, + enableResizing: true, + size: 120, + }, + { + accessorKey: "createdByName", + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue("createdByName")}
, + meta: { + excelHeader: "등록자" + }, + enableResizing: true, + size: 120, + }, + { + accessorKey: "remark", + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue("remark") || "-"}
, + meta: { + excelHeader: "비고" + }, + enableResizing: true, + size: 200, + }, + { + id: "actions", + header: () =>
동작
, + 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 ( +
+ {/* 커뮤니케이션 버튼 */} +
+ + {unreadCount > 0 && ( + + {unreadCount > 9 ? '9+' : unreadCount} + + )} +
+ + {/* 컨텍스트 메뉴 */} + + + + + + setRowAction({ row, type: "delete" })} + disabled={!isDraft} + className={!isDraft ? "opacity-50 cursor-not-allowed" : "text-destructive focus:text-destructive"} + > + + 벤더 삭제 + + + +
+ ); + }, + 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([]) - const [vendorDialogOpen, setVendorDialogOpen] = React.useState(false) - - const [isAdddialogLoading, setIsAdddialogLoading] = useState(false) - - const [rowAction, setRowAction] = React.useState | null>(null) - - // 벤더 커뮤니케이션 상태 관리 - const [communicationDrawerOpen, setCommunicationDrawerOpen] = useState(false) - const [selectedVendor, setSelectedVendor] = useState(null) - - // 읽지 않은 메시지 개수 - const [unreadMessages, setUnreadMessages] = useState>({}) - - // 테이블 선택 상태 관리 - const [selectedRows, setSelectedRows] = useState([]) - const [isSendingRfq, setIsSendingRfq] = useState(false) - const [isDeletingVendors, setIsDeletingVendors] = useState(false) - - // 벤더 삭제 확인 다이얼로그 상태 추가 - const [deleteConfirmDialogOpen, setDeleteConfirmDialogOpen] = useState(false) - - // 견적 히스토리 다이얼로그 상태 관리 - const [historyDialogOpen, setHistoryDialogOpen] = useState(false) - const [selectedQuotationId, setSelectedQuotationId] = useState(null) - - // 견적서 첨부파일 sheet 상태 관리 - const [quotationAttachmentsSheetOpen, setQuotationAttachmentsSheetOpen] = useState(false) - const [selectedQuotationInfo, setSelectedQuotationInfo] = useState(null) - const [quotationAttachments, setQuotationAttachments] = useState([]) - 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 ( -
- RFQ를 선택하세요 -
- ) - } - - // 로딩 중인 경우 - if (isLoading) { - return ( -
- - - -
- ) - } - - return ( -
- {/* 테이블 또는 빈 상태 표시 */} - {details.length > 0 ? ( - -
-
- {selectedRows.length > 0 && ( - - {selectedRows.length}개 선택됨 - - )} - {/* {totalUnreadMessages > 0 && ( - - 읽지 않은 메시지: {totalUnreadMessages}건 - - )} */} - {vendorsWithQuotations > 0 && ( - - 견적 제출: {vendorsWithQuotations}개 벤더 - - )} -
-
- {/* 벤더 선택 버튼 */} - - - {/* RFQ 발송 버튼 */} - - - {/* 벤더 삭제 버튼 */} - - - {/* 벤더 추가 버튼 */} - -
-
-
- ) : ( -
-
-

벤더가 없습니다

-

벤더를 추가하여 RFQ를 시작하세요

- -
-
- )} - - {/* 다이얼로그들 */} - - - {/* 벤더 커뮤니케이션 드로어 */} - - - {/* 다중 벤더 삭제 확인 다이얼로그 */} - - - {/* 견적 히스토리 다이얼로그 */} - - - {/* 견적서 첨부파일 Sheet */} - -
- ) +"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([]) + const [vendorDialogOpen, setVendorDialogOpen] = React.useState(false) + + const [isAdddialogLoading, setIsAdddialogLoading] = useState(false) + + const [rowAction, setRowAction] = React.useState | null>(null) + + // 벤더 커뮤니케이션 상태 관리 + const [communicationDrawerOpen, setCommunicationDrawerOpen] = useState(false) + const [selectedVendor, setSelectedVendor] = useState(null) + + // 읽지 않은 메시지 개수 + const [unreadMessages, setUnreadMessages] = useState>({}) + + // 테이블 선택 상태 관리 + const [selectedRows, setSelectedRows] = useState([]) + const [isSendingRfq, setIsSendingRfq] = useState(false) + const [isDeletingVendors, setIsDeletingVendors] = useState(false) + + // 벤더 삭제 확인 다이얼로그 상태 추가 + const [deleteConfirmDialogOpen, setDeleteConfirmDialogOpen] = useState(false) + + // 견적 히스토리 다이얼로그 상태 관리 + const [historyDialogOpen, setHistoryDialogOpen] = useState(false) + const [selectedQuotationId, setSelectedQuotationId] = useState(null) + + // 견적서 첨부파일 sheet 상태 관리 + const [quotationAttachmentsSheetOpen, setQuotationAttachmentsSheetOpen] = useState(false) + const [selectedQuotationInfo, setSelectedQuotationInfo] = useState(null) + const [quotationAttachments, setQuotationAttachments] = useState([]) + 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 ( +
+ RFQ를 선택하세요 +
+ ) + } + + // 로딩 중인 경우 + if (isLoading) { + return ( +
+ + + +
+ ) + } + + return ( +
+ {/* 테이블 또는 빈 상태 표시 */} + {details.length > 0 ? ( + +
+
+ {selectedRows.length > 0 && ( + + {selectedRows.length}개 선택됨 + + )} + {/* {totalUnreadMessages > 0 && ( + + 읽지 않은 메시지: {totalUnreadMessages}건 + + )} */} + {vendorsWithQuotations > 0 && ( + + 견적 제출: {vendorsWithQuotations}개 벤더 + + )} +
+
+ {/* 벤더 선택 버튼 */} + + + {/* RFQ 발송 버튼 */} + + + {/* 벤더 삭제 버튼 */} + + + {/* 벤더 추가 버튼 */} + +
+
+
+ ) : ( +
+
+

벤더가 없습니다

+

벤더를 추가하여 RFQ를 시작하세요

+ +
+
+ )} + + {/* 다이얼로그들 */} + + + {/* 벤더 커뮤니케이션 드로어 */} + + + {/* 다중 벤더 삭제 확인 다이얼로그 */} + + + {/* 견적 히스토리 다이얼로그 */} + + + {/* 견적서 첨부파일 Sheet */} + + + {/* 벤더 contact 선택 다이얼로그 */} + row.vendorId).filter(Boolean) as number[]} + onSendRfq={handleSendRfqWithContacts} + /> + + {/* 담당자 조회 다이얼로그 */} + +
+ ) } \ 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 { - 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([]); - const [newComment, setNewComment] = useState(""); - const [attachments, setAttachments] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [isSubmitting, setIsSubmitting] = useState(false); - const fileInputRef = useRef(null); - const messagesEndRef = useRef(null); - - // 자동 새로고침 관련 상태 - const [autoRefresh, setAutoRefresh] = useState(true); - const [lastMessageCount, setLastMessageCount] = useState(0); - const intervalRef = useRef(null); - - // 첨부파일 관련 상태 - const [previewDialogOpen, setPreviewDialogOpen] = useState(false); - const [selectedAttachment, setSelectedAttachment] = useState(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) => { - 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 ; - if (fileType.includes("pdf")) return ; - if (fileType.includes("spreadsheet") || fileType.includes("excel")) - return ; - if (fileType.includes("document") || fileType.includes("word")) - return ; - return ; - }; - - // 첨부파일 미리보기 다이얼로그 - const renderAttachmentPreviewDialog = () => { - if (!selectedAttachment) return null; - - const isImage = selectedAttachment.fileType?.startsWith("image/"); - const isPdf = selectedAttachment.fileType?.includes("pdf"); - - return ( - - - - - {getFileIcon(selectedAttachment.fileType || '')} - {selectedAttachment.fileName} - - - {formatFileSize(selectedAttachment.fileSize)} • {formatDateTime(selectedAttachment.uploadedAt, "KR")} - - - -
- {isImage ? ( - {selectedAttachment.fileName} - ) : isPdf ? ( -