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 --- .../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 ++-- 28 files changed, 8450 insertions(+), 5880 deletions(-) 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 (limited to 'lib/tech-vendors') diff --git a/lib/tech-vendors/contacts-table/add-contact-dialog.tsx b/lib/tech-vendors/contacts-table/add-contact-dialog.tsx index ff845e20..93ea6761 100644 --- a/lib/tech-vendors/contacts-table/add-contact-dialog.tsx +++ b/lib/tech-vendors/contacts-table/add-contact-dialog.tsx @@ -1,196 +1,196 @@ -"use client" - -import * as React from "react" -import { useForm } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" - -import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" - -import { - createTechVendorContactSchema, - type CreateTechVendorContactSchema, -} from "@/lib/tech-vendors/validations" -import { createTechVendorContact } from "@/lib/tech-vendors/service" - -interface AddContactDialogProps { - vendorId: number -} - -export function AddContactDialog({ vendorId }: AddContactDialogProps) { - const [open, setOpen] = React.useState(false) - - // react-hook-form 세팅 - const form = useForm({ - resolver: zodResolver(createTechVendorContactSchema), - defaultValues: { - // vendorId는 form에 표시할 필요가 없다면 hidden으로 관리하거나, submit 시 추가 - vendorId, - contactName: "", - contactPosition: "", - contactEmail: "", - contactPhone: "", - country: "", - isPrimary: false, - }, - }) - - async function onSubmit(data: CreateTechVendorContactSchema) { - // 혹은 여기서 data.vendorId = vendorId; 해줘도 됨 - const result = await createTechVendorContact(data) - if (result.error) { - alert(`에러: ${result.error}`) - return - } - - // 성공 시 메시지 표시 - if (result.data?.message) { - alert(result.data.message) - } - - // 성공 시 모달 닫고 폼 리셋 - form.reset() - setOpen(false) - } - - function handleDialogOpenChange(nextOpen: boolean) { - if (!nextOpen) { - form.reset() - } - setOpen(nextOpen) - } - - return ( - - {/* 모달을 열기 위한 버튼 */} - - - - - - - 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 -- cgit v1.2.3