From 2650b7c0bb0ea12b68a58c0439f72d61df04b2f1 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Fri, 25 Jul 2025 07:51:15 +0000 Subject: (대표님) 정기평가 대상, 미들웨어 수정, nextauth 토큰 처리 개선, GTC 등 (최겸) 기술영업 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/basic-contract/service.ts | 16 +- .../add-basic-contract-template-dialog.tsx | 30 +- lib/basic-contract/validations.ts | 4 +- lib/contact-possible-items/service.ts | 99 +- .../table/contact-possible-items-table-columns.tsx | 301 +- .../table/evaluation-submissions-table-columns.tsx | 2 +- lib/evaluation-target-list/service.ts | 62 +- .../table/evaluation-target-table.tsx | 36 +- .../table/evaluation-targets-toolbar-actions.tsx | 1 + .../manual-create-evaluation-target-dialog.tsx | 7 +- .../table/update-evaluation-target.tsx | 5 + lib/evaluation-target-list/validation.ts | 1 - lib/evaluation/service.ts | 2 +- lib/evaluation/table/evaluation-filter-sheet.tsx | 48 +- lib/file-stroage.ts | 107 +- lib/gtc-contract/service.ts | 105 +- .../status/create-gtc-document-dialog.tsx | 18 +- .../status/delete-gtc-documents-dialog.tsx | 8 + lib/gtc-contract/status/gtc-contract-table.tsx | 8 +- .../status/gtc-documents-table-columns.tsx | 4 +- .../status/gtc-documents-table-floating-bar.tsx | 4 +- .../status/gtc-documents-table-toolbar-actions.tsx | 4 +- lib/gtc-contract/validations.ts | 62 +- lib/tech-vendor-possible-items/repository.ts | 158 +- lib/tech-vendor-possible-items/service.ts | 893 +--- .../table/add-possible-item-dialog.tsx | 822 +-- .../table/possible-items-data-table.tsx | 33 +- .../table/possible-items-table-columns.tsx | 86 +- .../table/possible-items-table-toolbar-actions.tsx | 247 +- .../contacts-table/add-contact-dialog.tsx | 15 + lib/tech-vendors/contacts-table/contact-table.tsx | 55 +- .../contacts-table/update-contact-sheet.tsx | 16 + .../possible-items/add-item-dialog.tsx | 29 +- .../possible-items/possible-items-columns.tsx | 470 +- .../possible-items/possible-items-table.tsx | 425 +- .../possible-items-toolbar-actions.tsx | 235 +- lib/tech-vendors/repository.ts | 164 +- lib/tech-vendors/service.ts | 5391 ++++++++++---------- .../table/tech-vendors-filter-sheet.tsx | 1 + .../table/tech-vendors-table-columns.tsx | 4 +- lib/tech-vendors/table/tech-vendors-table.tsx | 3 +- lib/tech-vendors/validations.ts | 783 ++- lib/techsales-rfq/repository.ts | 1 + lib/techsales-rfq/service.ts | 493 +- .../quotation-contacts-view-dialog.tsx | 6 + .../detail-table/quotation-history-dialog.tsx | 163 +- .../table/detail-table/rfq-detail-column.tsx | 18 + .../table/detail-table/rfq-detail-table.tsx | 4 +- .../detail-table/vendor-communication-drawer.tsx | 6 +- .../vendor-contact-selection-dialog.tsx | 6 + lib/techsales-rfq/table/rfq-filter-sheet.tsx | 135 +- lib/techsales-rfq/table/rfq-table-column.tsx | 33 + lib/techsales-rfq/table/rfq-table.tsx | 28 +- lib/techsales-rfq/table/update-rfq-sheet.tsx | 267 + .../vendor-response/detail/project-info-tab.tsx | 4 +- .../detail/quotation-response-tab.tsx | 17 +- .../table/vendor-quotations-table-columns.tsx | 38 +- .../enhanced-document-service.ts | 41 +- 58 files changed, 6316 insertions(+), 5708 deletions(-) create mode 100644 lib/techsales-rfq/table/update-rfq-sheet.tsx (limited to 'lib') diff --git a/lib/basic-contract/service.ts b/lib/basic-contract/service.ts index 014f32ab..9b5505b5 100644 --- a/lib/basic-contract/service.ts +++ b/lib/basic-contract/service.ts @@ -189,12 +189,12 @@ export async function getBasicContractTemplates( // 템플릿 생성 (서버 액션) export async function createBasicContractTemplate(input: CreateBasicContractTemplateSchema) { unstable_noStore(); - + try { const newTemplate = await db.transaction(async (tx) => { const [row] = await insertBasicContractTemplate(tx, { templateName: input.templateName, - revision: 1, + revision: input.revision || 1, legalReviewRequired: input.legalReviewRequired, shipBuildingApplicable: input.shipBuildingApplicable, windApplicable: input.windApplicable, @@ -204,16 +204,18 @@ export async function createBasicContractTemplate(input: CreateBasicContractTemp gyApplicable: input.gyApplicable, sysApplicable: input.sysApplicable, infraApplicable: input.infraApplicable, - status: input.status, - fileName: input.fileName, - filePath: input.filePath, - // 필요하면 createdAt/updatedAt 등도 여기서 + status: input.status || "ACTIVE", + + // 📝 null 처리 추가 + fileName: input.fileName || null, + filePath: input.filePath || null, }); return row; }); - + return { data: newTemplate, error: null }; } catch (error) { + console.log(error); return { data: null, error: getErrorMessage(error) }; } } diff --git a/lib/basic-contract/template/add-basic-contract-template-dialog.tsx b/lib/basic-contract/template/add-basic-contract-template-dialog.tsx index fd1bd333..6b6ab105 100644 --- a/lib/basic-contract/template/add-basic-contract-template-dialog.tsx +++ b/lib/basic-contract/template/add-basic-contract-template-dialog.tsx @@ -219,16 +219,21 @@ export function AddTemplateDialog() { setIsLoading(true); try { let uploadResult = null; - + + // 📝 파일 업로드가 필요한 경우에만 업로드 진행 if (formData.file) { const fileId = uuidv4(); uploadResult = await uploadFileInChunks(formData.file, fileId); - + if (!uploadResult?.success) { throw new Error("파일 업로드에 실패했습니다."); } } - + + // 📝 General GTC이고 파일이 없는 경우와 다른 경우 구분 처리 + const isGeneralGTC = formData.templateName === "General GTC"; + const hasFile = uploadResult && uploadResult.success; + const saveResponse = await fetch('/api/upload/basicContract/complete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -245,17 +250,28 @@ export function AddTemplateDialog() { sysApplicable: formData.sysApplicable, infraApplicable: formData.infraApplicable, status: "ACTIVE", - fileName: uploadResult?.fileName || `${formData.templateName}_v1.docx`, - filePath: uploadResult?.filePath || "", + + // 📝 파일이 있는 경우에만 fileName과 filePath 전송 + ...(hasFile && { + fileName: uploadResult.fileName, + filePath: uploadResult.filePath, + }), + + // 📝 파일이 없는 경우 null 전송 (스키마가 nullable이어야 함) + ...(!hasFile && { + fileName: null, + filePath: null, + }) }), next: { tags: ["basic-contract-templates"] }, }); - + const saveResult = await saveResponse.json(); if (!saveResult.success) { + console.log(saveResult.error); throw new Error(saveResult.error || "템플릿 정보 저장에 실패했습니다."); } - + toast.success('템플릿이 성공적으로 추가되었습니다.'); form.reset(); setSelectedFile(null); diff --git a/lib/basic-contract/validations.ts b/lib/basic-contract/validations.ts index 39248b4a..e8b28e73 100644 --- a/lib/basic-contract/validations.ts +++ b/lib/basic-contract/validations.ts @@ -76,8 +76,8 @@ export const createBasicContractTemplateSchema = z.object({ infraApplicable: z.boolean().default(false), status: z.enum(["ACTIVE", "DISPOSED"]).default("ACTIVE"), - fileName: z.string().min(1), - filePath: z.string().min(1), + fileName: z.string().nullable().optional(), + filePath: z.string().nullable().optional(), // 기존에 쓰시던 validityPeriod 를 계속 쓰실 거라면 남기고, 아니라면 지우세요. // 예: 문자열(YYYY-MM-DD ~ YYYY-MM-DD) 또는 number(개월 수) 등 구체화 필요 diff --git a/lib/contact-possible-items/service.ts b/lib/contact-possible-items/service.ts index f4b89368..960df17e 100644 --- a/lib/contact-possible-items/service.ts +++ b/lib/contact-possible-items/service.ts @@ -3,6 +3,7 @@ import db from "@/db/db" import { techSalesContactPossibleItems } from "@/db/schema/techSales" import { techVendors, techVendorContacts, techVendorPossibleItems } from "@/db/schema/techVendors" +import { itemShipbuilding, itemOffshoreTop, itemOffshoreHull } from "@/db/schema/items" import { eq, desc, ilike, count, or } from "drizzle-orm" import { revalidatePath } from "next/cache" import { unstable_noStore } from "next/cache" @@ -29,15 +30,16 @@ export interface ContactPossibleItemDetail { // 연락처 정보 contactName: string | null contactPosition: string | null + contactTitle: string | null contactEmail: string | null contactPhone: string | null contactCountry: string | null isPrimary: boolean | null // 아이템 정보 - itemCode: string | null workType: string | null shipTypes: string | null + itemCode: string | null itemList: string | null subItemList: string | null } @@ -55,13 +57,11 @@ export async function getContactPossibleItems(input: GetContactPossibleItemsSche console.log("Input:", input) console.log("Offset:", offset) - // 검색 조건 + // 검색 조건 (벤더명, 연락처명으로만 검색) let whereCondition if (input.search) { const searchTerm = `%${input.search}%` whereCondition = or( - ilike(techVendorPossibleItems.itemCode, searchTerm), - ilike(techVendorPossibleItems.itemList, searchTerm), ilike(techVendors.vendorName, searchTerm), ilike(techVendorContacts.contactName, searchTerm) ) @@ -70,9 +70,11 @@ export async function getContactPossibleItems(input: GetContactPossibleItemsSche console.log("No search condition") } - // 원본 테이블들을 직접 조인해서 데이터 조회 + // 새로운 스키마에 맞게 수정 - 아이템 정보를 별도로 조회해야 함 console.log("Executing data query...") - const items = await db + + // 1단계: 기본 매핑 정보 조회 + const basicItems = await db .select({ // 기본 매핑 정보 id: techSalesContactPossibleItems.id, @@ -94,17 +96,16 @@ export async function getContactPossibleItems(input: GetContactPossibleItemsSche // 연락처 정보 contactName: techVendorContacts.contactName, contactPosition: techVendorContacts.contactPosition, + contactTitle: techVendorContacts.contactTitle, contactEmail: techVendorContacts.contactEmail, contactPhone: techVendorContacts.contactPhone, contactCountry: techVendorContacts.contactCountry, isPrimary: techVendorContacts.isPrimary, - // 벤더 가능 아이템 정보 - itemCode: techVendorPossibleItems.itemCode, - workType: techVendorPossibleItems.workType, - shipTypes: techVendorPossibleItems.shipTypes, - itemList: techVendorPossibleItems.itemList, - subItemList: techVendorPossibleItems.subItemList, + // 벤더 가능 아이템 ID 정보 + shipbuildingItemId: techVendorPossibleItems.shipbuildingItemId, + offshoreTopItemId: techVendorPossibleItems.offshoreTopItemId, + offshoreHullItemId: techVendorPossibleItems.offshoreHullItemId, }) .from(techSalesContactPossibleItems) .leftJoin(techVendorContacts, eq(techSalesContactPossibleItems.contactId, techVendorContacts.id)) @@ -115,6 +116,80 @@ export async function getContactPossibleItems(input: GetContactPossibleItemsSche .offset(offset) .limit(input.per_page) + // 2단계: 각 아이템의 상세 정보를 별도로 조회하여 합치기 + const items = await Promise.all(basicItems.map(async (item) => { + let itemCode = null; + let workType = null; + let shipTypes = null; + let itemList = null; + let subItemList = null; + + if (item.shipbuildingItemId) { + const shipItem = await db + .select({ + itemCode: itemShipbuilding.itemCode, + workType: itemShipbuilding.workType, + shipTypes: itemShipbuilding.shipTypes, + itemList: itemShipbuilding.itemList, + }) + .from(itemShipbuilding) + .where(eq(itemShipbuilding.id, item.shipbuildingItemId)) + .limit(1); + + if (shipItem.length > 0) { + itemCode = shipItem[0].itemCode; + workType = shipItem[0].workType; + shipTypes = shipItem[0].shipTypes; + itemList = shipItem[0].itemList; + } + } else if (item.offshoreTopItemId) { + const topItem = await db + .select({ + itemCode: itemOffshoreTop.itemCode, + workType: itemOffshoreTop.workType, + itemList: itemOffshoreTop.itemList, + subItemList: itemOffshoreTop.subItemList, + }) + .from(itemOffshoreTop) + .where(eq(itemOffshoreTop.id, item.offshoreTopItemId)) + .limit(1); + + if (topItem.length > 0) { + itemCode = topItem[0].itemCode; + workType = topItem[0].workType; + itemList = topItem[0].itemList; + subItemList = topItem[0].subItemList; + } + } else if (item.offshoreHullItemId) { + const hullItem = await db + .select({ + itemCode: itemOffshoreHull.itemCode, + workType: itemOffshoreHull.workType, + itemList: itemOffshoreHull.itemList, + subItemList: itemOffshoreHull.subItemList, + }) + .from(itemOffshoreHull) + .where(eq(itemOffshoreHull.id, item.offshoreHullItemId)) + .limit(1); + + if (hullItem.length > 0) { + itemCode = hullItem[0].itemCode; + workType = hullItem[0].workType; + itemList = hullItem[0].itemList; + subItemList = hullItem[0].subItemList; + } + } + + return { + ...item, + itemCode, + workType, + shipTypes, + itemList, + subItemList, + }; + })) + console.log("Items found:", items.length) console.log("First 3 items:", items.slice(0, 3)) diff --git a/lib/contact-possible-items/table/contact-possible-items-table-columns.tsx b/lib/contact-possible-items/table/contact-possible-items-table-columns.tsx index a3b198ae..552497e3 100644 --- a/lib/contact-possible-items/table/contact-possible-items-table-columns.tsx +++ b/lib/contact-possible-items/table/contact-possible-items-table-columns.tsx @@ -2,12 +2,13 @@ import * as React from "react" import { type DataTableRowAction } from "@/types/table" -import { type ColumnDef } from "@tanstack/react-table" +import { type ColumnDef, type Row, type Column } 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 { Badge } from "@/components/ui/badge" import { DropdownMenu, DropdownMenuContent, @@ -18,6 +19,7 @@ import { import { ContactPossibleItemDetail } from "../service" import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { contactPossibleItemsColumnsConfig } from "@/config/contactPossibleItemsColumnsConfig" interface GetColumnsProps { @@ -90,205 +92,106 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef[] = [ - // 벤더 정보 - { - id: "vendorInfo", - header: "벤더 정보", - columns: [ - { - accessorKey: "vendorCode", - enableResizing: true, - header: ({ column }) => ( - - ), - cell: ({ row }) => row.original.vendorCode ?? "", - }, - { - accessorKey: "vendorName", - enableResizing: true, - header: ({ column }) => ( - - ), - cell: ({ row }) => row.original.vendorName ?? "", - }, - { - accessorKey: "vendorCountry", - enableResizing: true, - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const country = row.original.vendorCountry - return country || - - }, - }, - { - accessorKey: "techVendorType", - enableResizing: true, - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const type = row.original.techVendorType - return type || - - }, - }, - ] - }, - // 담당자 정보 - { - id: "contactInfo", - header: "담당자 정보", - columns: [ - { - accessorKey: "contactName", - enableResizing: true, - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const contactName = row.original.contactName - return contactName || - - }, - }, - { - accessorKey: "contactPosition", - enableResizing: true, - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const position = row.original.contactPosition - return position || - - }, - }, - { - accessorKey: "contactEmail", - enableResizing: true, - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const contactEmail = row.original.contactEmail - return contactEmail || - - }, - }, - { - accessorKey: "contactPhone", - enableResizing: true, - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const contactPhone = row.original.contactPhone - return contactPhone || - - }, - }, - { - accessorKey: "contactCountry", - enableResizing: true, - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const contactCountry = row.original.contactCountry - return contactCountry || - - }, - }, - { - accessorKey: "isPrimary", - enableResizing: true, - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const isPrimary = row.original.isPrimary - return isPrimary ? "예" : "아니오" - }, - }, - ] - }, - // 아이템 정보 - { - id: "itemInfo", - header: "아이템 정보", - columns: [ - { - accessorKey: "itemCode", - enableResizing: true, - header: ({ column }) => ( - - ), - cell: ({ row }) => row.original.itemCode ?? "", - }, - { - accessorKey: "itemList", - enableResizing: true, - header: ({ column }) => ( - - ), - cell: ({ row }) => row.original.itemList ?? "", - }, - { - accessorKey: "workType", - enableResizing: true, - header: ({ column }) => ( - - ), - cell: ({ row }) => row.original.workType ?? "", - }, - { - accessorKey: "shipTypes", - enableResizing: true, - header: ({ column }) => ( - - ), - cell: ({ row }) => row.original.shipTypes ?? "", - }, - { - accessorKey: "subItemList", - enableResizing: true, - header: ({ column }) => ( - - ), - cell: ({ row }) => row.original.subItemList ?? "", - }, - ] - }, - - // 시스템 정보 - { - id: "systemInfo", - header: "시스템 정보", - columns: [ - { - accessorKey: "createdAt", - enableResizing: true, - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const dateVal = row.getValue("createdAt") as Date - return formatDate(dateVal) - }, - }, - { - accessorKey: "updatedAt", - enableResizing: true, - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const dateVal = row.getValue("updatedAt") as Date - return formatDate(dateVal) - }, - }, - ] - }, - ] + + // 특수한 셀 렌더링이 필요한 컬럼들을 위한 헬퍼 함수 + const getCellRenderer = (accessorKey: keyof ContactPossibleItemDetail) => { + switch (accessorKey) { + case 'createdAt': + case 'updatedAt': + return function DateCell({ row }: { row: Row }) { + const dateVal = row.getValue(accessorKey) as Date + return formatDate(dateVal, "ko-KR") + } + case 'isPrimary': + return function PrimaryCell({ row }: { row: Row }) { + const isPrimary = row.original.isPrimary + return isPrimary ? "Y" : "N" + } + case 'techVendorType': + return function VendorTypeCell({ row }: { row: Row }) { + const techVendorType = row.original.techVendorType + + // 벤더 타입 파싱 개선 - 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} + + )) : ( + - + )} +
+ ) + } + case 'vendorCountry': + case 'contactName': + case 'contactPosition': + case 'contactTitle': + case 'contactEmail': + case 'contactPhone': + case 'contactCountry': + return function OptionalCell({ row }: { row: Row }) { + const value = row.original[accessorKey] + return value || - + } + default: + return function DefaultCell({ row }: { row: Row }) { + return row.original[accessorKey] ?? "" + } + } + } + + const baseColumns: ColumnDef[] = contactPossibleItemsColumnsConfig.map(group => ({ + id: group.id, + header: group.header, + columns: group.columns.map(colConfig => ({ + accessorKey: colConfig.accessorKey, + enableResizing: colConfig.enableResizing, + enableSorting: colConfig.enableSorting, + size: colConfig.size, + minSize: colConfig.minSize, + maxSize: colConfig.maxSize, + header: function HeaderCell({ column }: { column: Column }) { + return + }, + cell: getCellRenderer(colConfig.accessorKey), + })), + })) // ---------------------------------------------------------------- // 4) 최종 컬럼 배열: select, baseColumns, actions diff --git a/lib/evaluation-submit/table/evaluation-submissions-table-columns.tsx b/lib/evaluation-submit/table/evaluation-submissions-table-columns.tsx index 73c4f378..b1334862 100644 --- a/lib/evaluation-submit/table/evaluation-submissions-table-columns.tsx +++ b/lib/evaluation-submit/table/evaluation-submissions-table-columns.tsx @@ -196,7 +196,7 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef ), cell: ({ row }) => { const materialType = row.original.materialType; - const material = materialType ==="BULK" ? "벌크": materialType ==="EQUIPMENT" ? "기자재" :"기자재/벌크" + const material = materialType ==="BULK" ? "벌크" :"장비재" return (
diff --git a/lib/evaluation-target-list/service.ts b/lib/evaluation-target-list/service.ts index 8d890604..36251c2d 100644 --- a/lib/evaluation-target-list/service.ts +++ b/lib/evaluation-target-list/service.ts @@ -35,7 +35,7 @@ import { authOptions } from "@/app/api/auth/[...nextauth]/route" import { sendEmail } from "../mail/sendEmail"; import type { SQL } from "drizzle-orm" import { DEPARTMENT_CODE_LABELS } from "@/types/evaluation"; -import { revalidatePath } from "next/cache"; +import { revalidatePath,unstable_noStore } from "next/cache"; export async function selectEvaluationTargetsFromView( tx: PgTransaction, @@ -72,11 +72,8 @@ export async function countEvaluationTargetsFromView( // ============= 메인 서버 액션도 함께 수정 ============= export async function getEvaluationTargets(input: GetEvaluationTargetsSchema) { + unstable_noStore() try { - console.log("=== 서버 액션 호출 ==="); - console.log("필터 수:", input.filters?.length || 0); - console.log("조인 연산자:", input.joinOperator); - const offset = (input.page - 1) * input.perPage; // ✅ 단순화된 필터 처리 @@ -297,6 +294,8 @@ export async function createEvaluationTarget( ) .limit(1); + console.log(existing,input ) + if (existing.length > 0) { throw new Error("이미 동일한 평가 대상이 존재합니다."); } @@ -434,6 +433,8 @@ export async function updateEvaluationTarget(input: UpdateEvaluationTargetInput) throw new Error("인증이 필요합니다.") } + console.log(input,"input") + return await db.transaction(async (tx) => { // 평가 대상 존재 확인 const existing = await tx @@ -533,6 +534,9 @@ export async function updateEvaluationTarget(input: UpdateEvaluationTargetInput) for (const review of reviewUpdates) { if (review.isApproved !== undefined) { + + console.log(review.departmentCode,"review.departmentCode"); + // 해당 부서의 담당자 조회 const reviewer = await tx .select({ @@ -547,6 +551,8 @@ export async function updateEvaluationTarget(input: UpdateEvaluationTargetInput) ) .limit(1) + console.log(reviewer,"reviewer") + if (reviewer.length > 0) { // 기존 평가 결과 삭제 await tx @@ -554,21 +560,33 @@ export async function updateEvaluationTarget(input: UpdateEvaluationTargetInput) .where( and( eq(evaluationTargetReviews.evaluationTargetId, input.id), - eq(evaluationTargetReviews.reviewerUserId, reviewer[0].reviewerUserId) + eq(evaluationTargetReviews.reviewerUserId, reviewer[0].reviewerUserId), + eq(evaluationTargetReviews.departmentCode, review.departmentCode) // 추가 + ) ) // 새 평가 결과 추가 (null이 아닌 경우만) if (review.isApproved !== null) { - await tx - .insert(evaluationTargetReviews) - .values({ - evaluationTargetId: input.id, - reviewerUserId: reviewer[0].reviewerUserId, - departmentCode: review.departmentCode, - isApproved: review.isApproved, - reviewedAt: new Date(), - }) + console.log("INSERT 시도:", review.departmentCode, review.isApproved); + + try { + const insertResult = await tx + .insert(evaluationTargetReviews) + .values({ + evaluationTargetId: input.id, + reviewerUserId: reviewer[0].reviewerUserId, + departmentCode: review.departmentCode, + isApproved: review.isApproved, + reviewedAt: new Date(), + }) + .returning({ id: evaluationTargetReviews.id }); // returning 추가 + + console.log("INSERT 성공:", insertResult); + } catch (insertError) { + console.error("INSERT 에러:", insertError); + throw insertError; + } } } } @@ -599,8 +617,12 @@ export async function updateEvaluationTarget(input: UpdateEvaluationTargetInput) reviewedDepartments.includes(dept) ) + console.log(allRequiredDepartmentsReviewed,"allRequiredDepartmentsReviewed") + + if (allRequiredDepartmentsReviewed) { const approvals = currentReviews.map(r => r.isApproved) + console.log(approvals,"approvals") const allApproved = approvals.every(approval => approval === true) const allRejected = approvals.every(approval => approval === false) const hasConsensus = allApproved || allRejected @@ -621,7 +643,11 @@ export async function updateEvaluationTarget(input: UpdateEvaluationTargetInput) updatedAt: new Date() }) .where(eq(evaluationTargets.id, input.id)) - } + } + + + revalidatePath('/evcp/evaluation-target-list') + revalidatePath('/procurement/evaluation-target-list') return { success: true, @@ -649,7 +675,7 @@ export async function getAvailableReviewers(departmentCode?: string) { }) .from(users) .orderBy(users.name) - .limit(100); + // .limit(100); return reviewers; } catch (error) { @@ -683,7 +709,7 @@ export async function getAvailableVendors(search?: string) { ) ) .orderBy(vendors.vendorName) - .limit(100); + // .limit(100); return await query; } catch (error) { diff --git a/lib/evaluation-target-list/table/evaluation-target-table.tsx b/lib/evaluation-target-list/table/evaluation-target-table.tsx index c65a7815..9cc73003 100644 --- a/lib/evaluation-target-list/table/evaluation-target-table.tsx +++ b/lib/evaluation-target-list/table/evaluation-target-table.tsx @@ -323,6 +323,39 @@ export function EvaluationTargetsTable({ promises, evaluationYear, className }: return () => clearTimeout(timeoutId); }, [searchString, evaluationYear, getSearchParam]); + const refreshData = React.useCallback(async () => { + try { + setIsDataLoading(true); + + // 현재 URL 파라미터로 데이터 새로고침 + const currentFilters = getSearchParam("filters"); + const currentJoinOperator = getSearchParam("joinOperator", "and"); + const currentPage = parseInt(getSearchParam("page", "1")); + const currentPerPage = parseInt(getSearchParam("perPage", "10")); + const currentSort = getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }]; + const currentSearch = getSearchParam("search", ""); + + const searchParams = { + filters: currentFilters ? JSON.parse(currentFilters) : [], + joinOperator: currentJoinOperator as "and" | "or", + page: currentPage, + perPage: currentPerPage, + sort: currentSort, + search: currentSearch, + evaluationYear: evaluationYear + }; + + const newData = await getEvaluationTargets(searchParams); + setTableData(newData); + + console.log("=== 데이터 새로고침 완료 ===", newData.data.length, "건"); + } catch (error) { + console.error("데이터 새로고침 오류:", error); + } finally { + setIsDataLoading(false); + } + }, [evaluationYear, getSearchParam]); + /* --------------------------- layout refs --------------------------- */ const containerRef = React.useRef(null); const [containerTop, setContainerTop] = React.useState(0); @@ -597,7 +630,7 @@ export function EvaluationTargetsTable({ promises, evaluationYear, className }: onRenamePreset={renamePreset} /> - +
@@ -607,6 +640,7 @@ export function EvaluationTargetsTable({ promises, evaluationYear, className }: open={rowAction?.type === "update"} onOpenChange={() => setRowAction(null)} evaluationTarget={rowAction?.row.original ?? null} + onDataChange={refreshData} /> diff --git a/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx b/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx index 6a493d8e..714f96c3 100644 --- a/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx +++ b/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx @@ -364,6 +364,7 @@ export function EvaluationTargetsTableToolbarActions({ open={manualCreateDialogOpen} onOpenChange={setManualCreateDialogOpen} onSuccess={handleActionSuccess} + onDataChange={onRefresh} /> {/* 확정 컨펌 다이얼로그 */} diff --git a/lib/evaluation-target-list/table/manual-create-evaluation-target-dialog.tsx b/lib/evaluation-target-list/table/manual-create-evaluation-target-dialog.tsx index 44497cdb..a00df0f0 100644 --- a/lib/evaluation-target-list/table/manual-create-evaluation-target-dialog.tsx +++ b/lib/evaluation-target-list/table/manual-create-evaluation-target-dialog.tsx @@ -61,15 +61,18 @@ import { EVALUATION_TARGET_FILTER_OPTIONS, getDefaultEvaluationYear } from "../v import { useSession } from "next-auth/react" - interface ManualCreateEvaluationTargetDialogProps { open: boolean onOpenChange: (open: boolean) => void + onSuccess?: () => void + onDataChange?: () => void } export function ManualCreateEvaluationTargetDialog({ open, onOpenChange, + onSuccess, + onDataChange }: ManualCreateEvaluationTargetDialogProps) { const router = useRouter() const [isSubmitting, setIsSubmitting] = React.useState(false) @@ -262,6 +265,8 @@ type CreateEvaluationTargetFormValues = z.infer void evaluationTarget: EvaluationTargetWithDepartments | null + onDataChange?: () => void // ✅ 새로 추가 } // 권한 타입 정의 @@ -102,6 +103,7 @@ export function EditEvaluationTargetSheet({ open, onOpenChange, evaluationTarget, + onDataChange }: EditEvaluationTargetSheetProps) { const router = useRouter() const [isSubmitting, setIsSubmitting] = React.useState(false) @@ -259,7 +261,10 @@ export function EditEvaluationTargetSheet({ if (result.success) { toast.success(result.message || "평가 대상이 성공적으로 수정되었습니다.") onOpenChange(false) + onDataChange?.() router.refresh() + // window.location.reload() + } else { toast.error(result.error || "평가 대상 수정에 실패했습니다.") } diff --git a/lib/evaluation-target-list/validation.ts b/lib/evaluation-target-list/validation.ts index d37ca0ed..c24b8de5 100644 --- a/lib/evaluation-target-list/validation.ts +++ b/lib/evaluation-target-list/validation.ts @@ -27,7 +27,6 @@ export const searchParamsEvaluationTargetsCache = createSearchParamsCache({ // 검색 search: parseAsString.withDefault(""), - aggregated: z.boolean().default(false), }); diff --git a/lib/evaluation/service.ts b/lib/evaluation/service.ts index 9889a110..879876ed 100644 --- a/lib/evaluation/service.ts +++ b/lib/evaluation/service.ts @@ -598,7 +598,7 @@ export async function getReviewersForEvaluations( } // 4. 모든 리뷰어 합치기 (중복 제거 없이) - const allReviewers = [...designatedReviewers, ...expandedRoleBasedReviewers] + const allReviewers = [...designatedReviewers] // 정렬만 수행 (evaluationTargetId로 먼저 정렬, 그 다음 이름으로 정렬) return allReviewers.sort((a, b) => { diff --git a/lib/evaluation/table/evaluation-filter-sheet.tsx b/lib/evaluation/table/evaluation-filter-sheet.tsx index 8f435e36..b0bf9139 100644 --- a/lib/evaluation/table/evaluation-filter-sheet.tsx +++ b/lib/evaluation/table/evaluation-filter-sheet.tsx @@ -281,7 +281,7 @@ export function PeriodicEvaluationFilterSheet({ type="number" placeholder="평가년도 입력" {...field} - disabled={isInitializing} + disabled={isPending} className={cn(field.value && "pr-8", "bg-white")} /> {field.value && ( @@ -293,7 +293,7 @@ export function PeriodicEvaluationFilterSheet({ e.stopPropagation(); form.setValue("evaluationYear", ""); }} - disabled={isInitializing} + disabled={isPending} className="absolute right-0 top-0 h-full px-2" > @@ -317,7 +317,7 @@ export function PeriodicEvaluationFilterSheet({ @@ -380,7 +380,7 @@ export function PeriodicEvaluationFilterSheet({ e.stopPropagation(); form.setValue("status", ""); }} - disabled={isInitializing} + disabled={isPending} > @@ -411,7 +411,7 @@ export function PeriodicEvaluationFilterSheet({ @@ -474,7 +474,7 @@ export function PeriodicEvaluationFilterSheet({ e.stopPropagation(); form.setValue("materialType", ""); }} - disabled={isInitializing} + disabled={isPending} > @@ -507,7 +507,7 @@ export function PeriodicEvaluationFilterSheet({ {field.value && ( @@ -520,7 +520,7 @@ export function PeriodicEvaluationFilterSheet({ e.stopPropagation(); form.setValue("vendorCode", ""); }} - disabled={isInitializing} + disabled={isPending} > @@ -544,7 +544,7 @@ export function PeriodicEvaluationFilterSheet({ {field.value && ( @@ -557,7 +557,7 @@ export function PeriodicEvaluationFilterSheet({ e.stopPropagation(); form.setValue("vendorName", ""); }} - disabled={isInitializing} + disabled={isPending} > @@ -579,7 +579,7 @@ export function PeriodicEvaluationFilterSheet({ @@ -642,7 +642,7 @@ export function PeriodicEvaluationFilterSheet({ e.stopPropagation(); form.setValue("evaluationGrade", ""); }} - disabled={isInitializing} + disabled={isPending} > @@ -673,7 +673,7 @@ export function PeriodicEvaluationFilterSheet({ setVendorSearch(e.target.value)} - className="pl-10" - /> - - +//
+//
+// {/* 왼쪽: 벤더 선택/표시 */} +//
+// {!selectedVendor ? ( +// <> +//
+// +//
+// setVendorSearch(e.target.value)} +// className="pl-10" +// /> +//
+//
-
-
- {isLoading ? ( -
로딩 중...
- ) : filteredVendors.length === 0 ? ( -
- 검색 결과가 없습니다. -
- ) : ( - filteredVendors.map((vendor) => ( -
handleVendorSelect(vendor)} - > -
{vendor.vendorName}
-
- {vendor.vendorCode} -
-
- {parseVendorTypes(vendor.techVendorType).map((type, index) => ( - - {type} - - ))} -
-
- )) - )} -
-
- - ) : ( -
-
- - -
-
-
{selectedVendor?.vendorName}
-
- {selectedVendor?.vendorCode} -
-
- {selectedVendor && parseVendorTypes(selectedVendor.techVendorType).map((type, index) => ( - - {type} - - ))} -
-
-
- )} -
+//
+//
+// {isLoading ? ( +//
로딩 중...
+// ) : filteredVendors.length === 0 ? ( +//
+// 검색 결과가 없습니다. +//
+// ) : ( +// filteredVendors.map((vendor) => ( +//
handleVendorSelect(vendor)} +// > +//
{vendor.vendorName}
+//
+// {vendor.vendorCode} +//
+//
+// {parseVendorTypes(vendor.techVendorType).map((type, index) => ( +// +// {type} +// +// ))} +//
+//
+// )) +// )} +//
+//
+// +// ) : ( +//
+//
+// +// +//
+//
+//
{selectedVendor?.vendorName}
+//
+// {selectedVendor?.vendorCode} +//
+//
+// {selectedVendor && parseVendorTypes(selectedVendor.techVendorType).map((type, index) => ( +// +// {type} +// +// ))} +//
+//
+//
+// )} +//
- {/* 오른쪽: 아이템 선택 */} -
- {selectedVendor ? ( - <> +// {/* 오른쪽: 아이템 선택 */} +//
+// {selectedVendor ? ( +// <> - -
- setItemSearch(e.target.value)} - className="pl-10" - /> -
+// +//
+// setItemSearch(e.target.value)} +// className="pl-10" +// /> +//
- {selectedItems.length > 0 && ( -
- -
- {selectedItems.map((item) => ( - - {item.itemCode} - { - e.stopPropagation(); - handleItemToggle(item); - }} - /> - - ))} -
-
- )} +// {selectedItems.length > 0 && ( +//
+// +//
+// {selectedItems.map((item) => ( +// +// {item.itemCode} +// { +// e.stopPropagation(); +// handleItemToggle(item); +// }} +// /> +// +// ))} +//
+//
+// )} -
-
- {isLoading ? ( -
아이템 로딩 중...
- ) : filteredItems.length === 0 && items.length === 0 ? ( -
- 해당 벤더 타입에 대한 아이템이 없습니다. -
- ) : filteredItems.length === 0 ? ( -
- 검색 결과가 없습니다. -
- ) : ( - filteredItems.map((item) => { - const isSelected = selectedItems.some(i => i.itemCode === item.itemCode); - return ( -
handleItemToggle(item)} - > -
{item.itemCode}
-
- {item.itemList || "-"} -
-
- 공종: {item.workType || "-"} - {item.shipTypes && 선종: {item.shipTypes}} - {item.subItemList && 서브아이템: {item.subItemList}} -
-
- ); - }) - )} -
-
- - ) : ( -
- 왼쪽에서 벤더를 선택하세요. -
- )} -
-
-
+//
+//
+// {isLoading ? ( +//
아이템 로딩 중...
+// ) : filteredItems.length === 0 && items.length === 0 ? ( +//
+// 해당 벤더 타입에 대한 아이템이 없습니다. +//
+// ) : filteredItems.length === 0 ? ( +//
+// 검색 결과가 없습니다. +//
+// ) : ( +// filteredItems.map((item) => { +// const isSelected = selectedItems.some(i => i.itemCode === item.itemCode); +// return ( +//
handleItemToggle(item)} +// > +//
{item.itemCode}
+//
+// {item.itemList || "-"} +//
+//
+// 공종: {item.workType || "-"} +// {item.shipTypes && 선종: {item.shipTypes}} +// {item.subItemList && 서브아이템: {item.subItemList}} +//
+//
+// ); +// }) +// )} +//
+//
+// +// ) : ( +//
+// 왼쪽에서 벤더를 선택하세요. +//
+// )} +// +// +// -
- - -
- - - ); -} \ No newline at end of file +//
+// +// +//
+// +// +// ); +// } \ No newline at end of file diff --git a/lib/tech-vendor-possible-items/table/possible-items-data-table.tsx b/lib/tech-vendor-possible-items/table/possible-items-data-table.tsx index 28b9774f..42417059 100644 --- a/lib/tech-vendor-possible-items/table/possible-items-data-table.tsx +++ b/lib/tech-vendor-possible-items/table/possible-items-data-table.tsx @@ -9,21 +9,8 @@ import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-adv import { getColumns } from "./possible-items-table-columns"; import { PossibleItemsTableToolbarActions } from "./possible-items-table-toolbar-actions"; -// 타입만 import -type TechVendorPossibleItemsData = { - id: number; - vendorId: number; - vendorCode: string | null; - vendorName: string; - techVendorType: string; - itemCode: string; - itemList: string | null; - workType: string | null; - shipTypes: string | null; - subItemList: string | null; - createdAt: Date; - updatedAt: Date; -}; +// 새로운 스키마에 맞는 타입 import +import type { TechVendorPossibleItemsData } from "../service"; import type { DataTableAdvancedFilterField } from "@/types/table"; interface PossibleItemsDataTableProps { @@ -50,6 +37,22 @@ export function PossibleItemsDataTable({ promises }: PossibleItemsDataTableProps label: "벤더명", type: "text", }, + { + id: "vendorEmail", + label: "벤더이메일", + type: "text", + }, + { + id: "vendorStatus", + label: "벤더상태", + type: "multi-select", + options: [ + { label: "ACTIVE", value: "ACTIVE", count: 0 }, + { label: "PENDING_INVITE", value: "PENDING_INVITE", count: 0 }, + { label: "PENDING_REVIEW", value: "PENDING_REVIEW", count: 0 }, + { label: "INACTIVE", value: "INACTIVE", count: 0 }, + ], + }, { id: "itemCode", label: "아이템코드", diff --git a/lib/tech-vendor-possible-items/table/possible-items-table-columns.tsx b/lib/tech-vendor-possible-items/table/possible-items-table-columns.tsx index 7fdcc900..e9707a88 100644 --- a/lib/tech-vendor-possible-items/table/possible-items-table-columns.tsx +++ b/lib/tech-vendor-possible-items/table/possible-items-table-columns.tsx @@ -4,22 +4,8 @@ import { ColumnDef } from "@tanstack/react-table"; import { Checkbox } from "@/components/ui/checkbox"; import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"; import Link from "next/link"; -// 타입만 import -type TechVendorPossibleItemsData = { - id: number; - vendorId: number; - vendorCode: string | null; - vendorName: string; - techVendorType: string; - vendorStatus: string; - itemCode: string; - itemList: string | null; - workType: string | null; - shipTypes: string | null; - subItemList: string | null; - createdAt: Date; - updatedAt: Date; -}; +// 새로운 스키마에 맞는 타입 import +import type { TechVendorPossibleItemsData } from "../service"; import { format } from "date-fns"; import { ko } from "date-fns/locale"; import { Badge } from "@/components/ui/badge"; @@ -152,6 +138,22 @@ export function getColumns(): ColumnDef[] { ); }, }, + { + accessorKey: "vendorEmail", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const vendorEmail = row.getValue("vendorEmail") as string | null; + return
{vendorEmail || "-"}
; + }, + filterFn: (row, id, value) => { + const vendorEmail = row.getValue(id) as string | null; + if (!value) return true; + if (!vendorEmail) return false; + return vendorEmail.toLowerCase().includes(value.toLowerCase()); + }, + }, { accessorKey: "techVendorType", header: ({ column }) => ( @@ -216,29 +218,35 @@ export function getColumns(): ColumnDef[] { }, }, - { - accessorKey: "vendorStatus", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const vendorStatus = row.getValue("vendorStatus") as string; - const getStatusColor = (status: string) => { - switch (status) { - case "ACTIVE": return "bg-green-100 text-green-800"; - case "PENDING_INVITE": return "bg-yellow-100 text-yellow-800"; - case "PENDING_REVIEW": return "bg-blue-100 text-blue-800"; - case "INACTIVE": return "bg-gray-100 text-gray-800"; - default: return "bg-gray-100 text-gray-800"; - } - }; - return ( - - {vendorStatus} - - ); - }, - }, + // { + // accessorKey: "vendorStatus", + // header: ({ column }) => ( + // + // ), + // cell: ({ row }) => { + // const vendorStatus = row.getValue("vendorStatus") as string; + // const getStatusColor = (status: string) => { + // switch (status) { + // case "ACTIVE": return "bg-green-100 text-green-800"; + // case "PENDING_INVITE": return "bg-yellow-100 text-yellow-800"; + // case "PENDING_REVIEW": return "bg-blue-100 text-blue-800"; + // case "INACTIVE": return "bg-gray-100 text-gray-800"; + // default: return "bg-gray-100 text-gray-800"; + // } + // }; + // return ( + // + // {vendorStatus} + // + // ); + // }, + // filterFn: (row, id, value) => { + // const vendorStatus = row.getValue(id) as string; + // if (!value) return true; + // if (!vendorStatus) return false; + // return vendorStatus === value; + // }, + // }, { accessorKey: "createdAt", header: ({ column }) => ( diff --git a/lib/tech-vendor-possible-items/table/possible-items-table-toolbar-actions.tsx b/lib/tech-vendor-possible-items/table/possible-items-table-toolbar-actions.tsx index dc67221f..2914fb9c 100644 --- a/lib/tech-vendor-possible-items/table/possible-items-table-toolbar-actions.tsx +++ b/lib/tech-vendor-possible-items/table/possible-items-table-toolbar-actions.tsx @@ -2,152 +2,143 @@ import * as React from "react"; import { type Table } from "@tanstack/react-table"; -import { Download, Upload, FileSpreadsheet, Plus } from "lucide-react"; +// import { Plus } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { useToast } from "@/hooks/use-toast"; -import { AddPossibleItemDialog } from "./add-possible-item-dialog"; -import { DeletePossibleItemsDialog } from "./delete-possible-items-dialog"; -// Excel 함수들을 동적 import로만 사용하기 위해 타입만 import -type TechVendorPossibleItemsData = { - id: number; - vendorId: number; - vendorCode: string | null; - vendorName: string; - techVendorType: string; - itemCode: string; - itemList: string | null; - workType: string | null; - shipTypes: string | null; - subItemList: string | null; - createdAt: Date; - updatedAt: Date; -}; +// import { Button } from "@/components/ui/button"; +// import { Input } from "@/components/ui/input"; // 주석처리 (Excel 기능 사용 안함) +// import { useToast } from "@/hooks/use-toast"; // 주석처리 (Excel 기능 사용 안함) +// import { AddPossibleItemDialog } from "./add-possible-item-dialog"; +// import { DeletePossibleItemsDialog } from "./delete-possible-items-dialog"; +// 새로운 스키마에 맞는 타입 import +import type { TechVendorPossibleItemsData } from "../service"; interface PossibleItemsTableToolbarActionsProps { table: Table; } export function PossibleItemsTableToolbarActions({ - table, + // table, }: PossibleItemsTableToolbarActionsProps) { - const { toast } = useToast(); + // const { toast } = useToast(); // 주석처리 (Excel 기능 사용 안함) - const selectedRows = table.getFilteredSelectedRowModel().rows; - const hasSelection = selectedRows.length > 0; - const selectedItems = selectedRows.map(row => row.original); + // const selectedRows = table.getFilteredSelectedRowModel().rows; + // const hasSelection = selectedRows.length > 0; + // const selectedItems = selectedRows.map(row => row.original); - const handleSuccess = () => { - table.toggleAllRowsSelected(false); - // 페이지 새로고침이나 데이터 다시 로드 필요 - window.location.reload(); - }; + // const handleSuccess = () => { + // table.toggleAllRowsSelected(false); + // // 페이지 새로고침이나 데이터 다시 로드 필요 + // window.location.reload(); + // }; - const handleExport = async () => { - try { - const { exportTechVendorPossibleItemsToExcel } = await import("./excel-export"); - const result = await exportTechVendorPossibleItemsToExcel(table.getFilteredRowModel().rows.map(row => row.original)); - - if (result.success) { - toast({ - title: "성공", - description: "Excel 파일이 다운로드되었습니다.", - }); - } else { - toast({ - title: "오류", - description: result.error || "내보내기 중 오류가 발생했습니다.", - variant: "destructive", - }); - } - } catch (error) { - console.error("Export error:", error); - toast({ - title: "오류", - description: "내보내기 중 오류가 발생했습니다.", - variant: "destructive", - }); - } - }; + // Excel Export 함수 주석처리 (새 스키마에서 사용하지 않음) + // const handleExport = async () => { + // try { + // const { exportTechVendorPossibleItemsToExcel } = await import("./excel-export"); + // const result = await exportTechVendorPossibleItemsToExcel(table.getFilteredRowModel().rows.map(row => row.original)); + // + // if (result.success) { + // toast({ + // title: "성공", + // description: "Excel 파일이 다운로드되었습니다.", + // }); + // } else { + // toast({ + // title: "오류", + // description: result.error || "내보내기 중 오류가 발생했습니다.", + // variant: "destructive", + // }); + // } + // } catch (error) { + // console.error("Export error:", error); + // toast({ + // title: "오류", + // description: "내보내기 중 오류가 발생했습니다.", + // variant: "destructive", + // }); + // } + // }; - const handleImport = async (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - if (!file) return; + // Excel Import 함수 주석처리 (새 스키마에서 사용하지 않음) + // const handleImport = async (event: React.ChangeEvent) => { + // const file = event.target.files?.[0]; + // if (!file) return; - try { - const { importTechVendorPossibleItemsFromExcel } = await import("./excel-import"); - const result = await importTechVendorPossibleItemsFromExcel(file); - - if (result.success) { - toast({ - title: "성공", - description: `${result.successCount}개의 아이템이 가져와졌습니다.`, - }); - // 페이지 새로고침이나 데이터 다시 로드 필요 - window.location.reload(); - } else { - toast({ - title: "가져오기 완료", - description: `${result.successCount}개 성공, ${result.failedRows.length}개 실패`, - variant: result.successCount > 0 ? "default" : "destructive", - }); - } - } catch (error) { - console.error("Import error:", error); - toast({ - title: "오류", - description: "가져오기 중 오류가 발생했습니다.", - variant: "destructive", - }); - } + // try { + // const { importTechVendorPossibleItemsFromExcel } = await import("./excel-import"); + // const result = await importTechVendorPossibleItemsFromExcel(file); + // + // if (result.success) { + // toast({ + // title: "성공", + // description: `${result.successCount}개의 아이템이 가져와졌습니다.`, + // }); + // // 페이지 새로고침이나 데이터 다시 로드 필요 + // window.location.reload(); + // } else { + // toast({ + // title: "가져오기 완료", + // description: `${result.successCount}개 성공, ${result.failedRows.length}개 실패`, + // variant: result.successCount > 0 ? "default" : "destructive", + // }); + // } + // } catch (error) { + // console.error("Import error:", error); + // toast({ + // title: "오류", + // description: "가져오기 중 오류가 발생했습니다.", + // variant: "destructive", + // }); + // } - // Reset input - event.target.value = ""; - }; + // // Reset input + // event.target.value = ""; + // }; - const handleDownloadTemplate = async () => { - try { - const { exportTechVendorPossibleItemsTemplate } = await import("./excel-template"); - const result = await exportTechVendorPossibleItemsTemplate(); - if (result.success) { - toast({ - title: "성공", - description: "템플릿 파일이 다운로드되었습니다.", - }); - } else { - toast({ - title: "오류", - description: result.error || "템플릿 다운로드 중 오류가 발생했습니다.", - variant: "destructive", - }); - } - } catch (error) { - console.error("Template download error:", error); - toast({ - title: "오류", - description: "템플릿 다운로드 중 오류가 발생했습니다.", - variant: "destructive", - }); - } - }; + // Excel Template 함수 주석처리 (새 스키마에서 사용하지 않음) + // const handleDownloadTemplate = async () => { + // try { + // const { exportTechVendorPossibleItemsTemplate } = await import("./excel-template"); + // const result = await exportTechVendorPossibleItemsTemplate(); + // if (result.success) { + // toast({ + // title: "성공", + // description: "템플릿 파일이 다운로드되었습니다.", + // }); + // } else { + // toast({ + // title: "오류", + // description: result.error || "템플릿 다운로드 중 오류가 발생했습니다.", + // variant: "destructive", + // }); + // } + // } catch (error) { + // console.error("Template download error:", error); + // toast({ + // title: "오류", + // description: "템플릿 다운로드 중 오류가 발생했습니다.", + // variant: "destructive", + // }); + // } + // }; return (
- {hasSelection && ( - - )} - - - + {/* {hasSelection && ( */} + {/* // + // )} + // + // + // - + */}
); } \ No newline at end of file diff --git a/lib/tech-vendors/contacts-table/add-contact-dialog.tsx b/lib/tech-vendors/contacts-table/add-contact-dialog.tsx index 93ea6761..90ba4e04 100644 --- a/lib/tech-vendors/contacts-table/add-contact-dialog.tsx +++ b/lib/tech-vendors/contacts-table/add-contact-dialog.tsx @@ -37,6 +37,7 @@ export function AddContactDialog({ vendorId }: AddContactDialogProps) { vendorId, contactName: "", contactPosition: "", + contactTitle: "", contactEmail: "", contactPhone: "", contactCountry: "", @@ -118,6 +119,20 @@ export function AddContactDialog({ vendorId }: AddContactDialogProps) { )} /> + ( + + Contact Title + + + + + + )} + /> + | null>(null) + const [isDeleting, setIsDeleting] = React.useState(false) + const [showDeleteAlert, setShowDeleteAlert] = React.useState(false) + + React.useEffect(() => { + if (rowAction?.type === "delete") { + setShowDeleteAlert(true) + } + }, [rowAction]) + + async function handleDeleteContact() { + if (!rowAction || rowAction.type !== "delete") return + setIsDeleting(true) + try { + const contactId = rowAction.row.original.id + const vId = rowAction.row.original.vendorId + const { data, error } = await deleteTechVendorContact(contactId, vId) + if (error) throw new Error(error) + toast.success("연락처가 삭제되었습니다.") + setShowDeleteAlert(false) + setRowAction(null) + } catch (err) { + toast.error(err instanceof Error ? err.message : "삭제 중 오류가 발생했습니다.") + } finally { + setIsDeleting(false) + } + } // getColumns() 호출 시, router를 주입 const columns = React.useMemo( @@ -47,7 +76,7 @@ export function TechVendorContactsTable({ promises , vendorId}: TechVendorContac { 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: "contactCountry", label: "Country", type: "text" }, { id: "createdAt", label: "Created at", type: "date" }, { id: "updatedAt", label: "Updated at", type: "date" }, ] @@ -88,6 +117,30 @@ export function TechVendorContactsTable({ promises , vendorId}: TechVendorContac contact={rowAction?.type === "update" ? rowAction.row.original : null} vendorId={vendorId} /> + + {/* Delete Confirmation Dialog */} + + + + 연락처 삭제 + + 이 연락처를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다. + + + + setRowAction(null)}> + 취소 + + + {isDeleting ? "삭제 중..." : "삭제"} + + + + ) } \ No newline at end of file diff --git a/lib/tech-vendors/contacts-table/update-contact-sheet.tsx b/lib/tech-vendors/contacts-table/update-contact-sheet.tsx index b75ddd1e..4713790c 100644 --- a/lib/tech-vendors/contacts-table/update-contact-sheet.tsx +++ b/lib/tech-vendors/contacts-table/update-contact-sheet.tsx @@ -46,6 +46,7 @@ export function UpdateContactSheet({ contact, vendorId, ...props }: UpdateContac contactEmail: contact?.contactEmail ?? "", contactPhone: contact?.contactPhone ?? "", contactCountry: contact?.contactCountry ?? "", + contactTitle: contact?.contactTitle ?? "", isPrimary: contact?.isPrimary ?? false, }, }) @@ -58,6 +59,7 @@ export function UpdateContactSheet({ contact, vendorId, ...props }: UpdateContac contactEmail: contact.contactEmail, contactPhone: contact.contactPhone ?? "", contactCountry: contact.contactCountry ?? "", + contactTitle: contact.contactTitle ?? "", isPrimary: contact.isPrimary, }) } @@ -126,6 +128,20 @@ export function UpdateContactSheet({ contact, vendorId, ...props }: UpdateContac )} /> + ( + + 직책 + + + + + + )} + /> + item.itemCode != null); - setItems(validItems); + setItems(validItems as ItemData[]); } catch (error) { console.error("Failed to load items:", error); toast.error("아이템 목록을 불러오는데 실패했습니다."); @@ -93,13 +94,13 @@ export function AddItemDialog({ open, onOpenChange, vendorId }: AddItemDialogPro if (!item.itemCode) return; // itemCode가 null인 경우 처리하지 않음 setSelectedItems(prev => { - // itemCode + shipTypes 조합으로 중복 체크 + // id + itemType 조합으로 중복 체크 const isSelected = prev.some(i => - i.itemCode === item.itemCode && i.shipTypes === item.shipTypes + i.id === item.id && i.itemType === item.itemType ); if (isSelected) { return prev.filter(i => - !(i.itemCode === item.itemCode && i.shipTypes === item.shipTypes) + !(i.id === item.id && i.itemType === item.itemType) ); } else { return [...prev, item]; @@ -120,11 +121,8 @@ export function AddItemDialog({ open, onOpenChange, vendorId }: AddItemDialogPro 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, + itemId: item.id, + itemType: item.itemType, }); if (result.success) { @@ -197,10 +195,11 @@ export function AddItemDialog({ open, onOpenChange, vendorId }: AddItemDialogPro
{selectedItems.map((item) => { if (!item.itemCode) return null; - const itemKey = `${item.itemCode}${item.shipTypes ? `-${item.shipTypes}` : ''}`; + const itemKey = `${item.itemType}-${item.id}-${item.itemCode}${item.shipTypes ? `-${item.shipTypes}` : ''}`; + const displayText = `[${item.itemType}] ${item.itemCode}${item.shipTypes ? `-${item.shipTypes}` : ''}`; return ( - {itemKey} + {displayText} { @@ -232,11 +231,11 @@ export function AddItemDialog({ open, onOpenChange, vendorId }: AddItemDialogPro filteredItems.map((item) => { if (!item.itemCode) return null; // itemCode가 null인 경우 렌더링하지 않음 - // itemCode + shipTypes 조합으로 선택 여부 체크 + // id + itemType 조합으로 선택 여부 체크 const isSelected = selectedItems.some(i => - i.itemCode === item.itemCode && i.shipTypes === item.shipTypes + i.id === item.id && i.itemType === item.itemType ); - const itemKey = `${item.itemCode}${item.shipTypes ? `-${item.shipTypes}` : ''}`; + const itemKey = `${item.itemType}-${item.id}-${item.itemCode}${item.shipTypes ? `-${item.shipTypes}` : ''}`; return (
handleItemToggle(item)} >
- {itemKey} + {`[${item.itemType}] ${item.itemCode}${item.shipTypes ? `-${item.shipTypes}` : ''}`}
{item.itemList || "-"} diff --git a/lib/tech-vendors/possible-items/possible-items-columns.tsx b/lib/tech-vendors/possible-items/possible-items-columns.tsx index ef48c5b5..252dae9b 100644 --- a/lib/tech-vendors/possible-items/possible-items-columns.tsx +++ b/lib/tech-vendors/possible-items/possible-items-columns.tsx @@ -1,206 +1,266 @@ -"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, - }, - ] +"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 }) => { + const itemCode = row.getValue("itemCode") as string | undefined + return ( +
+ {itemCode || -} +
+ ) + }, + size: 150, + }, + + // // 타입 + // { + // accessorKey: "techVendorType", + // header: ({ column }) => ( + // + // ), + // cell: ({ row }) => { + // const techVendorType = row.getValue("techVendorType") as string | 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} + // + // )) : ( + // - + // )} + //
+ // ) + // }, + // size: 120, + // }, + + // 공종 + { + 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, "ko-KR")} +
+ ) + }, + size: 120, + }, + + // 수정일 + { + accessorKey: "updatedAt", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const date = row.getValue("updatedAt") as Date + return ( +
+ {formatDate(date, "ko-KR")} +
+ ) + }, + 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 index 9c024a93..b54e12d4 100644 --- a/lib/tech-vendors/possible-items/possible-items-table.tsx +++ b/lib/tech-vendors/possible-items/possible-items-table.tsx @@ -1,171 +1,256 @@ -"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 ? "삭제 중..." : "삭제"} - - - - - - ) +"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 { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { ScrollArea } from "@/components/ui/scroll-area" + +import { getColumns } from "./possible-items-columns" +import { getTechVendorPossibleItems } from "../../tech-vendor-possible-items/service" +import { deleteTechVendorPossibleItem, getTechVendorDetailById } 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) + + // vendor 정보와 items 다이얼로그 관련 상태 + const [vendorInfo, setVendorInfo] = React.useState<{ + id: number + vendorName: string + status: string + items: string | null + } | null>(null) + const [showItemsDialog, setShowItemsDialog] = React.useState(false) + const [isLoadingVendor, setIsLoadingVendor] = React.useState(false) + + // getColumns() 호출 시, setRowAction을 주입 + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + // vendor 정보 가져오기 + React.useEffect(() => { + async function fetchVendorInfo() { + try { + setIsLoadingVendor(true) + const vendorData = await getTechVendorDetailById(vendorId) + setVendorInfo(vendorData) + } catch (error) { + console.error("Error fetching vendor info:", error) + toast.error("벤더 정보를 불러오는데 실패했습니다") + } finally { + setIsLoadingVendor(false) + } + } + + fetchVendorInfo() + }, [vendorId]) + + // 단일 아이템 삭제 핸들러 + 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: "vendorId", label: "벤더 ID" }, + { id: "techVendorType", label: "아이템 타입" }, + ] + + const advancedFilterFields: DataTableAdvancedFilterField[] = [ + { id: "vendorId", label: "벤더 ID", type: "number" }, + { 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: "techVendorType", label: "아이템 타입", type: "text" }, + { id: "createdAt", label: "등록일", type: "date" }, + { id: "updatedAt", label: "수정일", type: "date" }, + ] + + const { table } = useDataTable({ + data: data as TechVendorPossibleItem[], // 타입 단언 추가 + 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]) + + // 견적비교용 벤더인지 확인 + const isQuoteComparisonVendor = vendorInfo?.status === "QUOTE_COMPARISON" + + + return ( + <> + + +
+ {/* 견적비교용 벤더일 때만 items 버튼 표시 */} + {isQuoteComparisonVendor && ( + + )} + + setShowAddDialog(true)} // 주석처리 + /> +
+
+
+ + {/* Add Item Dialog */} + + + {/* Vendor Items Dialog */} + + + + 벤더 수기입력 자재 항목 + + {vendorInfo?.vendorName} 벤더가 직접 입력한 자재 항목입니다. + + + + + {vendorInfo?.items && vendorInfo.items.length > 0 ? ( +
+

+ {vendorInfo.items} +

+
+ ) : ( +
+

등록된 수기입력 자재 항목이 없습니다.

+
+ )} +
+
+
+ + {/* 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 index 074dc187..192bf614 100644 --- a/lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx +++ b/lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx @@ -1,119 +1,118 @@ -"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 ? "삭제 중..." : "삭제"} - - - - - - ) +"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 { 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 6f9aafbf..35301eb7 100644 --- a/lib/tech-vendors/repository.ts +++ b/lib/tech-vendors/repository.ts @@ -1,9 +1,9 @@ // src/lib/vendors/repository.ts -import { eq, inArray, count, desc } from "drizzle-orm"; +import { eq, inArray, count, desc, not, isNull, and } 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 { techVendors, techVendorContacts, techVendorPossibleItems, type TechVendor, type TechVendorContact, type TechVendorWithAttachments, techVendorAttachments } from "@/db/schema/techVendors"; import { itemShipbuilding, itemOffshoreTop, itemOffshoreHull } from "@/db/schema/items"; export type NewTechVendorContact = typeof techVendorContacts.$inferInsert @@ -279,15 +279,23 @@ export async function insertTechVendorContact( .returning(); } -// 아이템 목록 조회 -export async function selectTechVendorItems( +// 아이템 목록 조회 (새 스키마용) +export async function selectTechVendorPossibleItems( tx: any, params: { where?: SQL; orderBy?: SQL[]; } & PaginationParams ) { - const query = tx.select().from(techVendorItemsView); + const query = tx.select({ + id: techVendorPossibleItems.id, + vendorId: techVendorPossibleItems.vendorId, + shipbuildingItemId: techVendorPossibleItems.shipbuildingItemId, + offshoreTopItemId: techVendorPossibleItems.offshoreTopItemId, + offshoreHullItemId: techVendorPossibleItems.offshoreHullItemId, + createdAt: techVendorPossibleItems.createdAt, + updatedAt: techVendorPossibleItems.updatedAt, + }).from(techVendorPossibleItems); if (params.where) { query.where(params.where); @@ -296,7 +304,7 @@ export async function selectTechVendorItems( if (params.orderBy && params.orderBy.length > 0) { query.orderBy(...params.orderBy); } else { - query.orderBy(desc(techVendorItemsView.createdAt)); + query.orderBy(desc(techVendorPossibleItems.createdAt)); } query.offset(params.offset).limit(params.limit); @@ -304,9 +312,9 @@ export async function selectTechVendorItems( return query; } -// 아이템 수 카운트 -export async function countTechVendorItems(tx: any, where?: SQL) { - const query = tx.select({ count: count() }).from(techVendorItemsView); +// 아이템 수 카운트 (새 스키마용) +export async function countTechVendorPossibleItems(tx: any, where?: SQL) { + const query = tx.select({ count: count() }).from(techVendorPossibleItems); if (where) { query.where(where); @@ -319,7 +327,7 @@ export async function countTechVendorItems(tx: any, where?: SQL) { // 아이템 생성 export async function insertTechVendorItem( tx: any, - data: Omit + data: Omit ) { return tx .insert(techVendorPossibleItems) @@ -338,118 +346,52 @@ export async function getVendorWorkTypes( 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)); - 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)); - } + // 조선 아이템들의 workType 조회 + const shipWorkTypes = await tx + .select({ workType: itemShipbuilding.workType }) + .from(techVendorPossibleItems) + .leftJoin(itemShipbuilding, eq(techVendorPossibleItems.shipbuildingItemId, itemShipbuilding.id)) + .where(and( + eq(techVendorPossibleItems.vendorId, vendorId), + not(isNull(techVendorPossibleItems.shipbuildingItemId)) + )); + 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 + // 해양 TOP 아이템들의 workType 조회 + const topWorkTypes = await tx + .select({ workType: itemOffshoreTop.workType }) + .from(techVendorPossibleItems) + .leftJoin(itemOffshoreTop, eq(techVendorPossibleItems.offshoreTopItemId, itemOffshoreTop.id)) + .where(and( + eq(techVendorPossibleItems.vendorId, vendorId), + not(isNull(techVendorPossibleItems.offshoreTopItemId)) + )); + workTypes.push( + ...topWorkTypes + .map((item: { workType: string | null }) => item.workType) + .filter(Boolean) as string[] ); - 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)); - } - } + if (vendorType.includes('해양HULL')) { + // 해양 HULL 아이템들의 workType 조회 + const hullWorkTypes = await tx + .select({ workType: itemOffshoreHull.workType }) + .from(techVendorPossibleItems) + .leftJoin(itemOffshoreHull, eq(techVendorPossibleItems.offshoreHullItemId, itemOffshoreHull.id)) + .where(and( + eq(techVendorPossibleItems.vendorId, vendorId), + not(isNull(techVendorPossibleItems.offshoreHullItemId)) + )); + workTypes.push(...hullWorkTypes.map((item: { workType: string | null }) => item.workType).filter(Boolean)); } + // 중복 제거 후 반환 const uniqueWorkTypes = [...new Set(workTypes)]; diff --git a/lib/tech-vendors/service.ts b/lib/tech-vendors/service.ts index 65e23d14..4eba6b2b 100644 --- a/lib/tech-vendors/service.ts +++ b/lib/tech-vendors/service.ts @@ -1,2606 +1,2787 @@ -"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 } 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; - } - - return { vendor: vendorResult, userId }; - }); - - // 캐시 무효화 - revalidateTag("tech-vendors"); - revalidateTag("tech-vendor-possible-items"); - 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 +"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택) + +import { revalidateTag, unstable_noStore } from "next/cache"; +import db from "@/db/db"; +import { techVendorAttachments, techVendorContacts, techVendorPossibleItems, techVendors, type TechVendor } from "@/db/schema/techVendors"; +import { 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, + selectTechVendorsWithAttachments, + countTechVendorsWithAttachments, +} from "./repository"; + +import type { + CreateTechVendorSchema, + UpdateTechVendorSchema, + GetTechVendorsSchema, + GetTechVendorContactsSchema, + CreateTechVendorContactSchema, + GetTechVendorItemsSchema, + GetTechVendorRfqHistorySchema, + GetTechVendorPossibleItemsSchema, + CreateTechVendorPossibleItemSchema, + 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.shipbuildingItemId, itemShipbuilding.id)) + .leftJoin(itemOffshoreTop, eq(techVendorPossibleItems.offshoreTopItemId, itemOffshoreTop.id)) + .leftJoin(itemOffshoreHull, eq(techVendorPossibleItems.offshoreHullItemId, itemOffshoreHull.id)) + .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, + contactTitle: contact.contactTitle || 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) }; + } +} +/* ----------------------------------------------------- + 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, + contactTitle: input.contactTitle || "", + }); + + 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, + contactTitle: input.contactTitle || null, + 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; + + // 벤더 ID 조건 + const vendorWhere = eq(techVendorPossibleItems.vendorId, id); + + // 조선 아이템들 조회 + const shipItems = await db + .select({ + id: techVendorPossibleItems.id, + vendorId: techVendorPossibleItems.vendorId, + shipbuildingItemId: techVendorPossibleItems.shipbuildingItemId, + offshoreTopItemId: techVendorPossibleItems.offshoreTopItemId, + offshoreHullItemId: techVendorPossibleItems.offshoreHullItemId, + createdAt: techVendorPossibleItems.createdAt, + updatedAt: techVendorPossibleItems.updatedAt, + itemCode: itemShipbuilding.itemCode, + workType: itemShipbuilding.workType, + itemList: itemShipbuilding.itemList, + shipTypes: itemShipbuilding.shipTypes, + subItemList: sql`null`.as("subItemList"), + techVendorType: techVendors.techVendorType, + }) + .from(techVendorPossibleItems) + .leftJoin(itemShipbuilding, eq(techVendorPossibleItems.shipbuildingItemId, itemShipbuilding.id)) + .leftJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id)) + .where(and(vendorWhere, not(isNull(techVendorPossibleItems.shipbuildingItemId)))); + + // 해양 TOP 아이템들 조회 + const topItems = await db + .select({ + id: techVendorPossibleItems.id, + vendorId: techVendorPossibleItems.vendorId, + shipbuildingItemId: techVendorPossibleItems.shipbuildingItemId, + offshoreTopItemId: techVendorPossibleItems.offshoreTopItemId, + offshoreHullItemId: techVendorPossibleItems.offshoreHullItemId, + createdAt: techVendorPossibleItems.createdAt, + updatedAt: techVendorPossibleItems.updatedAt, + itemCode: itemOffshoreTop.itemCode, + workType: itemOffshoreTop.workType, + itemList: itemOffshoreTop.itemList, + shipTypes: sql`null`.as("shipTypes"), + subItemList: itemOffshoreTop.subItemList, + techVendorType: techVendors.techVendorType, + }) + .from(techVendorPossibleItems) + .leftJoin(itemOffshoreTop, eq(techVendorPossibleItems.offshoreTopItemId, itemOffshoreTop.id)) + .leftJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id)) + .where(and(vendorWhere, not(isNull(techVendorPossibleItems.offshoreTopItemId)))); + + // 해양 HULL 아이템들 조회 + const hullItems = await db + .select({ + id: techVendorPossibleItems.id, + vendorId: techVendorPossibleItems.vendorId, + shipbuildingItemId: techVendorPossibleItems.shipbuildingItemId, + offshoreTopItemId: techVendorPossibleItems.offshoreTopItemId, + offshoreHullItemId: techVendorPossibleItems.offshoreHullItemId, + createdAt: techVendorPossibleItems.createdAt, + updatedAt: techVendorPossibleItems.updatedAt, + itemCode: itemOffshoreHull.itemCode, + workType: itemOffshoreHull.workType, + itemList: itemOffshoreHull.itemList, + shipTypes: sql`null`.as("shipTypes"), + subItemList: itemOffshoreHull.subItemList, + techVendorType: techVendors.techVendorType, + }) + .from(techVendorPossibleItems) + .leftJoin(itemOffshoreHull, eq(techVendorPossibleItems.offshoreHullItemId, itemOffshoreHull.id)) + .leftJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id)) + .where(and(vendorWhere, not(isNull(techVendorPossibleItems.offshoreHullItemId)))); + + // 모든 아이템들 합치기 + const allItems = [...shipItems, ...topItems, ...hullItems]; + + // 필터링 적용 + let filteredItems = allItems; + + if (input.search) { + const s = input.search.toLowerCase(); + filteredItems = filteredItems.filter(item => + item.itemCode?.toLowerCase().includes(s) || + item.workType?.toLowerCase().includes(s) || + item.itemList?.toLowerCase().includes(s) || + item.shipTypes?.toLowerCase().includes(s) || + item.subItemList?.toLowerCase().includes(s) + ); + } + + // 정렬 적용 + if (input.sort.length > 0) { + const sortConfig = input.sort[0]; + filteredItems.sort((a, b) => { + const aVal = a[sortConfig.id as keyof typeof a]; + const bVal = b[sortConfig.id as keyof typeof b]; + + if (aVal == null && bVal == null) return 0; + if (aVal == null) return sortConfig.desc ? 1 : -1; + if (bVal == null) return sortConfig.desc ? -1 : 1; + + const comparison = aVal < bVal ? -1 : aVal > bVal ? 1 : 0; + return sortConfig.desc ? -comparison : comparison; + }); + } + + // 페이지네이션 적용 + const total = filteredItems.length; + const paginatedItems = filteredItems.slice(offset, offset + input.perPage); + const pageCount = Math.ceil(total / input.perPage); + + return { data: paginatedItems, pageCount }; + } catch (err) { + console.error("기술영업 벤더 아이템 조회 오류:", 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. 해당 벤더가 이미 가지고 있는 아이템 ID 목록 조회 + const existingItems = await db + .select({ + shipbuildingItemId: techVendorPossibleItems.shipbuildingItemId, + offshoreTopItemId: techVendorPossibleItems.offshoreTopItemId, + offshoreHullItemId: techVendorPossibleItems.offshoreHullItemId, + }) + .from(techVendorPossibleItems) + .where(eq(techVendorPossibleItems.vendorId, vendorId)); + + const existingShipItemIds = existingItems.map(item => item.shipbuildingItemId).filter(id => id !== null); + const existingTopItemIds = existingItems.map(item => item.offshoreTopItemId).filter(id => id !== null); + const existingHullItemIds = existingItems.map(item => item.offshoreHullItemId).filter(id => id !== null); + + // 3. 벤더 타입에 따라 해당 타입의 아이템만 조회 (기존에 없는 것만) + const availableItems: Array<{ + id: number; + itemCode: string | null; + itemList: string | null; + workType: string | null; + shipTypes?: string | null; + subItemList?: string | null; + itemType: "SHIP" | "TOP" | "HULL"; + createdAt: Date; + updatedAt: Date; + }> = []; + + // 벤더 타입 파싱 - 콤마로 구분된 문자열을 배열로 변환 + let vendorTypes: string[] = []; + if (typeof vendor.techVendorType === 'string') { + // 콤마로 구분된 문자열을 split하여 배열로 변환하고 공백 제거 + vendorTypes = vendor.techVendorType.split(',').map(type => type.trim()).filter(type => type.length > 0); + } else { + vendorTypes = [vendor.techVendorType]; + } + + // 각 벤더 타입별로 아이템 조회 + for (const vendorType of vendorTypes) { + if (vendorType === "조선") { + 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( + existingShipItemIds.length > 0 + ? not(inArray(itemShipbuilding.id, existingShipItemIds)) + : undefined + ) + .orderBy(asc(itemShipbuilding.itemCode)); + + availableItems.push(...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, + itemType: "SHIP" as const + }))); + } + + if (vendorType === "해양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( + existingTopItemIds.length > 0 + ? not(inArray(itemOffshoreTop.id, existingTopItemIds)) + : undefined + ) + .orderBy(asc(itemOffshoreTop.itemCode)); + + availableItems.push(...offshoreTopItems + .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, + subItemList: item.subItemList, + itemType: "TOP" as const + }))); + } + + if (vendorType === "해양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( + existingHullItemIds.length > 0 + ? not(inArray(itemOffshoreHull.id, existingHullItemIds)) + : undefined + ) + .orderBy(asc(itemOffshoreHull.itemCode)); + + availableItems.push(...offshoreHullItems + .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, + subItemList: item.subItemList, + itemType: "HULL" as const + }))); + } + } + + // 중복 제거 (같은 id와 itemType을 가진 아이템) + const uniqueItems = availableItems.filter((item, index, self) => + index === self.findIndex((t) => t.id === item.id && t.itemType === item.itemType) + ); + + return { + data: uniqueItems, + 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" }; + } +} + +/* ----------------------------------------------------- + 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 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) { + // 아이템 코드로 각 테이블에서 찾기 + let itemId = null; + let itemType = null; + + // 조선 아이템에서 찾기 + const shipbuildingItem = await tx.query.itemShipbuilding.findFirst({ + where: eq(itemShipbuilding.itemCode, itemCode) + }); + if (shipbuildingItem) { + itemId = shipbuildingItem.id; + itemType = "SHIP"; + } else { + // 해양 TOP 아이템에서 찾기 + const offshoreTopItem = await tx.query.itemOffshoreTop.findFirst({ + where: eq(itemOffshoreTop.itemCode, itemCode) + }); + if (offshoreTopItem) { + itemId = offshoreTopItem.id; + itemType = "TOP"; + } else { + // 해양 HULL 아이템에서 찾기 + const offshoreHullItem = await tx.query.itemOffshoreHull.findFirst({ + where: eq(itemOffshoreHull.itemCode, itemCode) + }); + if (offshoreHullItem) { + itemId = offshoreHullItem.id; + itemType = "HULL"; + } + } + } + + if (itemId && itemType) { + // 중복 체크 + // let existingItem; + const whereConditions = [eq(techVendorPossibleItems.vendorId, vendorResult.id)]; + + if (itemType === "SHIP") { + whereConditions.push(eq(techVendorPossibleItems.shipbuildingItemId, itemId)); + } else if (itemType === "TOP") { + whereConditions.push(eq(techVendorPossibleItems.offshoreTopItemId, itemId)); + } else if (itemType === "HULL") { + whereConditions.push(eq(techVendorPossibleItems.offshoreHullItemId, itemId)); + } + + const existingItem = await tx.query.techVendorPossibleItems.findFirst({ + where: and(...whereConditions) + }); + + if (!existingItem) { + // 새 아이템 추가 + const insertData: { + vendorId: number; + shipbuildingItemId?: number; + offshoreTopItemId?: number; + offshoreHullItemId?: number; + } = { + vendorId: vendorResult.id, + }; + + if (itemType === "SHIP") { + insertData.shipbuildingItemId = itemId; + } else if (itemType === "TOP") { + insertData.offshoreTopItemId = itemId; + } else if (itemType === "HULL") { + insertData.offshoreHullItemId = itemId; + } + + await tx.insert(techVendorPossibleItems).values(insertData); + } + } + } + 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; + } + + return { vendor: vendorResult, userId }; + }); + + // 캐시 무효화 + revalidateTag("tech-vendors"); + revalidateTag("tech-vendor-possible-items"); + 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 + + // 벤더 ID 조건 + const vendorWhere = eq(techVendorPossibleItems.vendorId, vendorId) + + // 조선 아이템들 조회 + const shipItems = await db + .select({ + id: techVendorPossibleItems.id, + vendorId: techVendorPossibleItems.vendorId, + shipbuildingItemId: techVendorPossibleItems.shipbuildingItemId, + offshoreTopItemId: techVendorPossibleItems.offshoreTopItemId, + offshoreHullItemId: techVendorPossibleItems.offshoreHullItemId, + createdAt: techVendorPossibleItems.createdAt, + updatedAt: techVendorPossibleItems.updatedAt, + itemCode: itemShipbuilding.itemCode, + workType: itemShipbuilding.workType, + itemList: itemShipbuilding.itemList, + shipTypes: itemShipbuilding.shipTypes, + subItemList: sql`null`.as("subItemList"), + techVendorType: techVendors.techVendorType, + }) + .from(techVendorPossibleItems) + .leftJoin(itemShipbuilding, eq(techVendorPossibleItems.shipbuildingItemId, itemShipbuilding.id)) + .leftJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id)) + .where(and(vendorWhere, not(isNull(techVendorPossibleItems.shipbuildingItemId)))) + + // 해양 TOP 아이템들 조회 + const topItems = await db + .select({ + id: techVendorPossibleItems.id, + vendorId: techVendorPossibleItems.vendorId, + shipbuildingItemId: techVendorPossibleItems.shipbuildingItemId, + offshoreTopItemId: techVendorPossibleItems.offshoreTopItemId, + offshoreHullItemId: techVendorPossibleItems.offshoreHullItemId, + createdAt: techVendorPossibleItems.createdAt, + updatedAt: techVendorPossibleItems.updatedAt, + itemCode: itemOffshoreTop.itemCode, + workType: itemOffshoreTop.workType, + itemList: itemOffshoreTop.itemList, + shipTypes: sql`null`.as("shipTypes"), + subItemList: itemOffshoreTop.subItemList, + techVendorType: techVendors.techVendorType, + }) + .from(techVendorPossibleItems) + .leftJoin(itemOffshoreTop, eq(techVendorPossibleItems.offshoreTopItemId, itemOffshoreTop.id)) + .leftJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id)) + .where(and(vendorWhere, not(isNull(techVendorPossibleItems.offshoreTopItemId)))) + + // 해양 HULL 아이템들 조회 + const hullItems = await db + .select({ + id: techVendorPossibleItems.id, + vendorId: techVendorPossibleItems.vendorId, + shipbuildingItemId: techVendorPossibleItems.shipbuildingItemId, + offshoreTopItemId: techVendorPossibleItems.offshoreTopItemId, + offshoreHullItemId: techVendorPossibleItems.offshoreHullItemId, + createdAt: techVendorPossibleItems.createdAt, + updatedAt: techVendorPossibleItems.updatedAt, + itemCode: itemOffshoreHull.itemCode, + workType: itemOffshoreHull.workType, + itemList: itemOffshoreHull.itemList, + shipTypes: sql`null`.as("shipTypes"), + subItemList: itemOffshoreHull.subItemList, + techVendorType: techVendors.techVendorType, + }) + .from(techVendorPossibleItems) + .leftJoin(itemOffshoreHull, eq(techVendorPossibleItems.offshoreHullItemId, itemOffshoreHull.id)) + .leftJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id)) + .where(and(vendorWhere, not(isNull(techVendorPossibleItems.offshoreHullItemId)))) + + // 모든 아이템들 합치기 + const allItems = [...shipItems, ...topItems, ...hullItems] + + // 필터링 적용 + let filteredItems = allItems + + if (input.search) { + const s = input.search.toLowerCase() + filteredItems = filteredItems.filter(item => + item.itemCode?.toLowerCase().includes(s) || + item.workType?.toLowerCase().includes(s) || + item.itemList?.toLowerCase().includes(s) || + item.shipTypes?.toLowerCase().includes(s) || + item.subItemList?.toLowerCase().includes(s) + ) + } + + if (input.itemCode) { + filteredItems = filteredItems.filter(item => + item.itemCode?.toLowerCase().includes(input.itemCode!.toLowerCase()) + ) + } + + if (input.workType) { + filteredItems = filteredItems.filter(item => + item.workType?.toLowerCase().includes(input.workType!.toLowerCase()) + ) + } + + if (input.itemList) { + filteredItems = filteredItems.filter(item => + item.itemList?.toLowerCase().includes(input.itemList!.toLowerCase()) + ) + } + + if (input.shipTypes) { + filteredItems = filteredItems.filter(item => + item.shipTypes?.toLowerCase().includes(input.shipTypes!.toLowerCase()) + ) + } + + if (input.subItemList) { + filteredItems = filteredItems.filter(item => + item.subItemList?.toLowerCase().includes(input.subItemList!.toLowerCase()) + ) + } + + // 정렬 + if (input.sort.length > 0) { + filteredItems.sort((a, b) => { + for (const sortItem of input.sort) { + let aVal = (a as any)[sortItem.id] + let bVal = (b as any)[sortItem.id] + + if (aVal === null || aVal === undefined) aVal = "" + if (bVal === null || bVal === undefined) bVal = "" + + if (aVal < bVal) return sortItem.desc ? 1 : -1 + if (aVal > bVal) return sortItem.desc ? -1 : 1 + } + return 0 + }) + } else { + // 기본 정렬: createdAt 내림차순 + filteredItems.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) + } + + const total = filteredItems.length + const pageCount = Math.ceil(total / input.perPage) + + // 페이지네이션 적용 + const data = filteredItems.slice(offset, offset + 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 { + // 중복 체크 - 새 스키마에 맞게 수정 + let existing = null + + if (input.shipbuildingItemId) { + existing = await db + .select({ id: techVendorPossibleItems.id }) + .from(techVendorPossibleItems) + .where( + and( + eq(techVendorPossibleItems.vendorId, input.vendorId), + eq(techVendorPossibleItems.shipbuildingItemId, input.shipbuildingItemId) + ) + ) + .limit(1) + } else if (input.offshoreTopItemId) { + existing = await db + .select({ id: techVendorPossibleItems.id }) + .from(techVendorPossibleItems) + .where( + and( + eq(techVendorPossibleItems.vendorId, input.vendorId), + eq(techVendorPossibleItems.offshoreTopItemId, input.offshoreTopItemId) + ) + ) + .limit(1) + } else if (input.offshoreHullItemId) { + existing = await db + .select({ id: techVendorPossibleItems.id }) + .from(techVendorPossibleItems) + .where( + and( + eq(techVendorPossibleItems.vendorId, input.vendorId), + eq(techVendorPossibleItems.offshoreHullItemId, input.offshoreHullItemId) + ) + ) + .limit(1) + } + + if (existing && existing.length > 0) { + return { data: null, error: "이미 등록된 아이템입니다." } + } + + const [newItem] = await db + .insert(techVendorPossibleItems) + .values({ + vendorId: input.vendorId, + shipbuildingItemId: input.shipbuildingItemId || null, + offshoreTopItemId: input.offshoreTopItemId || null, + offshoreHullItemId: input.offshoreHullItemId || null, + }) + .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 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; + itemId: number; + itemType: "SHIP" | "TOP" | "HULL"; +}) { + unstable_noStore(); + try { + // 중복 체크 + // let existingItem; + const whereConditions = [eq(techVendorPossibleItems.vendorId, input.vendorId)]; + + if (input.itemType === "SHIP") { + whereConditions.push(eq(techVendorPossibleItems.shipbuildingItemId, input.itemId)); + } else if (input.itemType === "TOP") { + whereConditions.push(eq(techVendorPossibleItems.offshoreTopItemId, input.itemId)); + } else if (input.itemType === "HULL") { + whereConditions.push(eq(techVendorPossibleItems.offshoreHullItemId, input.itemId)); + } + + const existingItem = await db.query.techVendorPossibleItems.findFirst({ + where: and(...whereConditions) + }); + + if (existingItem) { + return { success: false, error: "이미 추가된 아이템입니다." }; + } + + // 새 아이템 추가 + const insertData: { + vendorId: number; + shipbuildingItemId?: number; + offshoreTopItemId?: number; + offshoreHullItemId?: number; + } = { + vendorId: input.vendorId, + }; + + if (input.itemType === "SHIP") { + insertData.shipbuildingItemId = input.itemId; + } else if (input.itemType === "TOP") { + insertData.offshoreTopItemId = input.itemId; + } else if (input.itemType === "HULL") { + insertData.offshoreHullItemId = input.itemId; + } + + const [newItem] = await db + .insert(techVendorPossibleItems) + .values(insertData) + .returning(); + + revalidateTag(`tech-vendor-possible-items-${input.vendorId}`); + + return { success: true, data: newItem }; + } catch (err) { + return { success: false, error: getErrorMessage(err) }; + } +} + +/** + * 아이템 추가 시 중복 체크 함수 + * 조선의 경우 아이템코드+선종 조합으로, 나머지는 아이템코드만으로 중복 체크 + */ +export async function checkTechVendorItemDuplicate( + vendorId: number, + itemType: "SHIP" | "TOP" | "HULL", + itemCode: string, + shipTypes?: string +) { + try { + if (itemType === "SHIP") { + // 조선의 경우 아이템코드 + 선종 조합으로 중복 체크 + const shipItem = await db + .select({ id: itemShipbuilding.id }) + .from(itemShipbuilding) + .where( + and( + eq(itemShipbuilding.itemCode, itemCode), + shipTypes ? eq(itemShipbuilding.shipTypes, shipTypes) : isNull(itemShipbuilding.shipTypes) + ) + ) + .limit(1) + + if (!shipItem.length) { + return { isDuplicate: false, error: null } + } + + const existing = await db + .select({ id: techVendorPossibleItems.id }) + .from(techVendorPossibleItems) + .where( + and( + eq(techVendorPossibleItems.vendorId, vendorId), + eq(techVendorPossibleItems.shipbuildingItemId, shipItem[0].id) + ) + ) + .limit(1) + + if (existing.length > 0) { + return { + isDuplicate: true, + error: "이미 사용중인 아이템 코드 및 선종 입니다" + } + } + } else if (itemType === "TOP") { + // 해양 TOP의 경우 아이템코드만으로 중복 체크 + const topItem = await db + .select({ id: itemOffshoreTop.id }) + .from(itemOffshoreTop) + .where(eq(itemOffshoreTop.itemCode, itemCode)) + .limit(1) + + if (!topItem.length) { + return { isDuplicate: false, error: null } + } + + const existing = await db + .select({ id: techVendorPossibleItems.id }) + .from(techVendorPossibleItems) + .where( + and( + eq(techVendorPossibleItems.vendorId, vendorId), + eq(techVendorPossibleItems.offshoreTopItemId, topItem[0].id) + ) + ) + .limit(1) + + if (existing.length > 0) { + return { + isDuplicate: true, + error: "이미 사용중인 아이템 코드 입니다" + } + } + } else if (itemType === "HULL") { + // 해양 HULL의 경우 아이템코드만으로 중복 체크 + const hullItem = await db + .select({ id: itemOffshoreHull.id }) + .from(itemOffshoreHull) + .where(eq(itemOffshoreHull.itemCode, itemCode)) + .limit(1) + + if (!hullItem.length) { + return { isDuplicate: false, error: null } + } + + const existing = await db + .select({ id: techVendorPossibleItems.id }) + .from(techVendorPossibleItems) + .where( + and( + eq(techVendorPossibleItems.vendorId, vendorId), + eq(techVendorPossibleItems.offshoreHullItemId, hullItem[0].id) + ) + ) + .limit(1) + + if (existing.length > 0) { + return { + isDuplicate: true, + error: "이미 사용중인 아이템 코드 입니다" + } + } + } + + return { isDuplicate: false, error: null } + } catch (err) { + console.error("Error checking duplicate:", err) + return { isDuplicate: 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/tech-vendors-filter-sheet.tsx b/lib/tech-vendors/table/tech-vendors-filter-sheet.tsx index c6beb7a9..b1fcee34 100644 --- a/lib/tech-vendors/table/tech-vendors-filter-sheet.tsx +++ b/lib/tech-vendors/table/tech-vendors-filter-sheet.tsx @@ -74,6 +74,7 @@ const workTypeOptions = [ { value: "TS", label: "TS" }, { value: "TE", label: "TE" }, { value: "TP", label: "TP" }, + { value: "TA", label: "TA" }, // 해양HULL workTypes { value: "HA", label: "HA" }, { value: "HE", label: "HE" }, diff --git a/lib/tech-vendors/table/tech-vendors-table-columns.tsx b/lib/tech-vendors/table/tech-vendors-table-columns.tsx index 5184e3f3..da17a975 100644 --- a/lib/tech-vendors/table/tech-vendors-table-columns.tsx +++ b/lib/tech-vendors/table/tech-vendors-table-columns.tsx @@ -3,7 +3,7 @@ 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 { Ellipsis } from "lucide-react" import { toast } from "sonner" import { getErrorMessage } from "@/lib/handle-error" @@ -340,7 +340,7 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef // 날짜 컬럼 포맷팅 if (cfg.type === "date" && cell.getValue()) { - return formatDate(cell.getValue() as Date); + return formatDate(cell.getValue() as Date, "ko-KR"); } return cell.getValue(); diff --git a/lib/tech-vendors/table/tech-vendors-table.tsx b/lib/tech-vendors/table/tech-vendors-table.tsx index 7f9625cf..553ff109 100644 --- a/lib/tech-vendors/table/tech-vendors-table.tsx +++ b/lib/tech-vendors/table/tech-vendors-table.tsx @@ -134,6 +134,7 @@ export function TechVendorsTable({ { label: "TS", value: "TS" }, { label: "TE", value: "TE" }, { label: "TP", value: "TP" }, + { label: "TA", value: "TA" }, // 해양HULL workTypes { label: "HA", value: "HA" }, { label: "HE", value: "HE" }, @@ -157,7 +158,7 @@ export function TechVendorsTable({ enableAdvancedFilter: true, initialState: { sorting: [{ id: "createdAt", desc: true }], - columnPinning: { right: ["actions", "possibleItems"] }, + columnPinning: { right: ["actions"] }, }, getRowId: (originalRow) => String(originalRow.id), shallow: false, diff --git a/lib/tech-vendors/validations.ts b/lib/tech-vendors/validations.ts index 618ad22e..d217bee0 100644 --- a/lib/tech-vendors/validations.ts +++ b/lib/tech-vendors/validations.ts @@ -1,398 +1,387 @@ -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 +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server" +import * as z from "zod" + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { techVendors, TechVendor, TechVendorContact, VENDOR_TYPES } from "@/db/schema/techVendors"; + +// TechVendorPossibleItem 타입 정의 - 새 스키마에 맞게 수정 +export interface TechVendorPossibleItem { + id: number; + vendorId: number; + shipbuildingItemId: number | null; + offshoreTopItemId: number | null; + offshoreHullItemId: number | null; + createdAt: Date; + updatedAt: Date; + + // 조인된 정보 (어떤 타입의 아이템인지에 따라) + itemCode?: string; + workType?: string | null; + shipTypes?: string | null; + itemList?: string | null; + subItemList?: string | null; + 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(""), + contactTitle: 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(), + contactTitle: z.string().max(100).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"), + contactTitle: z.string().max(100, "Max length 100").optional(), + 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(), + contactTitle: 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(), +}); + +// Possible Items 생성 스키마 - 새 스키마에 맞게 수정 +export const createTechVendorPossibleItemSchema = z.object({ + vendorId: z.number(), + shipbuildingItemId: z.number().optional(), + offshoreTopItemId: z.number().optional(), + offshoreHullItemId: z.number().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 CreateTechVendorPossibleItemSchema = z.infer export type UpdateTechVendorPossibleItemSchema = z.infer \ No newline at end of file diff --git a/lib/techsales-rfq/repository.ts b/lib/techsales-rfq/repository.ts index 07c9ddf8..abf831c1 100644 --- a/lib/techsales-rfq/repository.ts +++ b/lib/techsales-rfq/repository.ts @@ -260,6 +260,7 @@ export async function selectTechSalesVendorQuotationsWithJoin( validUntil: techSalesVendorQuotations.validUntil, status: techSalesVendorQuotations.status, remark: techSalesVendorQuotations.remark, + quotationVersion: techSalesVendorQuotations.quotationVersion, rejectionReason: techSalesVendorQuotations.rejectionReason, // 날짜 정보 diff --git a/lib/techsales-rfq/service.ts b/lib/techsales-rfq/service.ts index 44537876..afbd2f55 100644 --- a/lib/techsales-rfq/service.ts +++ b/lib/techsales-rfq/service.ts @@ -33,6 +33,7 @@ import { getServerSession } from "next-auth/next"; import { authOptions } from "@/app/api/auth/[...nextauth]/route"; import { sendEmail } from "../mail/sendEmail"; import { formatDate } from "../utils"; +import { itemShipbuilding, itemOffshoreTop, itemOffshoreHull } from "@/db/schema/items"; import { techVendors, techVendorPossibleItems, techVendorContacts } from "@/db/schema/techVendors"; import { deleteFile, saveDRMFile, saveFile } from "@/lib/file-stroage"; import { decryptWithServerAction } from "@/components/drm/drmUtils"; @@ -101,22 +102,26 @@ export async function getTechSalesRfqsWithJoin(input: GetTechSalesRfqsSchema & { try { // 마감일이 지났고 아직 Closed가 아닌 RFQ를 일괄 Closed로 변경 await db.update(techSalesRfqs) - .set({ status: "Closed", updatedAt: new Date() }) - .where( - and( - lt(techSalesRfqs.dueDate, new Date()), - ne(techSalesRfqs.status, "Closed") - ) - ); + .set({ status: "Closed", updatedAt: new Date() }) + .where( + and( + lt(techSalesRfqs.dueDate, new Date()), + ne(techSalesRfqs.status, "Closed") + ) + ); const offset = (input.page - 1) * input.perPage; // 기본 필터 처리 - RFQFilterBox에서 오는 필터 const basicFilters = input.basicFilters || []; const basicJoinOperator = input.basicJoinOperator || "and"; - // 고급 필터 처리 - 테이블의 DataTableFilterList에서 오는 필터 - const advancedFilters = input.filters || []; + + // 고급 필터 처리 - workTypes을 먼저 제외 + const advancedFilters = (input.filters || []).filter(f => f.id !== "workTypes"); const advancedJoinOperator = input.joinOperator || "and"; + // workTypes 필터는 별도로 추출 + const workTypesFilter = (input.filters || []).find(f => f.id === "workTypes"); + // 기본 필터 조건 생성 let basicWhere; if (basicFilters.length > 0) { @@ -127,7 +132,7 @@ export async function getTechSalesRfqsWithJoin(input: GetTechSalesRfqsSchema & { }); } - // 고급 필터 조건 생성 + // 고급 필터 조건 생성 (workTypes 제외) let advancedWhere; if (advancedFilters.length > 0) { advancedWhere = filterColumns({ @@ -149,11 +154,33 @@ export async function getTechSalesRfqsWithJoin(input: GetTechSalesRfqsSchema & { ); } + // workTypes 필터 처리 (고급 필터에서 제외된 workTypes만 별도 처리) + let workTypesWhere; + if (workTypesFilter && Array.isArray(workTypesFilter.value) && workTypesFilter.value.length > 0) { + // RFQ 아이템 테이블들과 조인하여 workType이 포함된 RFQ만 추출 + // (조선, 해양TOP, 해양HULL 모두 포함) + const rfqIdsWithWorkTypes = db + .selectDistinct({ rfqId: techSalesRfqItems.rfqId }) + .from(techSalesRfqItems) + .leftJoin(itemShipbuilding, eq(techSalesRfqItems.itemShipbuildingId, itemShipbuilding.id)) + .leftJoin(itemOffshoreTop, eq(techSalesRfqItems.itemOffshoreTopId, itemOffshoreTop.id)) + .leftJoin(itemOffshoreHull, eq(techSalesRfqItems.itemOffshoreHullId, itemOffshoreHull.id)) + .where( + or( + inArray(itemShipbuilding.workType, workTypesFilter.value), + inArray(itemOffshoreTop.workType, workTypesFilter.value), + inArray(itemOffshoreHull.workType, workTypesFilter.value) + ) + ); + workTypesWhere = inArray(techSalesRfqs.id, rfqIdsWithWorkTypes); + } + // 모든 조건 결합 const whereConditions = []; if (basicWhere) whereConditions.push(basicWhere); if (advancedWhere) whereConditions.push(advancedWhere); if (globalWhere) whereConditions.push(globalWhere); + if (workTypesWhere) whereConditions.push(workTypesWhere); // 조건이 있을 때만 and() 사용 const finalWhere = whereConditions.length > 0 @@ -743,13 +770,51 @@ export async function sendTechSalesRfqToVendors(input: { // 5. 담당자별 아이템 매핑 정보 저장 (중복 방지) for (const item of rfqItems) { - // tech_vendor_possible_items에서 해당 벤더의 아이템 찾기 - const vendorPossibleItem = await tx.query.techVendorPossibleItems.findFirst({ - where: and( - eq(techVendorPossibleItems.vendorId, quotation.vendor!.id), - eq(techVendorPossibleItems.itemCode, item.itemCode || '') - ) - }); + let vendorPossibleItem = null; + // 조선: 아이템코드 + 선종으로 조선아이템테이블에서 찾기, 해양: 아이템코드로만 찾기 + if (item.itemType === "SHIP" && item.itemCode && item.shipTypes) { + // 조선: itemShipbuilding에서 itemCode, shipTypes로 찾기 + const shipbuildingItem = await tx.query.itemShipbuilding.findFirst({ + where: and( + eq(itemShipbuilding.itemCode, item.itemCode), + eq(itemShipbuilding.shipTypes, item.shipTypes) + ) + }); + if (shipbuildingItem?.id) { + vendorPossibleItem = await tx.query.techVendorPossibleItems.findFirst({ + where: and( + eq(techVendorPossibleItems.vendorId, quotation.vendor!.id), + eq(techVendorPossibleItems.shipbuildingItemId, shipbuildingItem.id) + ) + }); + } + } else if (item.itemType === "TOP" && item.itemCode) { + // 해양 TOP: itemOffshoreTop에서 itemCode로 찾기 + const offshoreTopItem = await tx.query.itemOffshoreTop.findFirst({ + where: eq(itemOffshoreTop.itemCode, item.itemCode) + }); + if (offshoreTopItem?.id) { + vendorPossibleItem = await tx.query.techVendorPossibleItems.findFirst({ + where: and( + eq(techVendorPossibleItems.vendorId, quotation.vendor!.id), + eq(techVendorPossibleItems.offshoreTopItemId, offshoreTopItem.id) + ) + }); + } + } else if (item.itemType === "HULL" && item.itemCode) { + // 해양 HULL: itemOffshoreHull에서 itemCode로 찾기 + const offshoreHullItem = await tx.query.itemOffshoreHull.findFirst({ + where: eq(itemOffshoreHull.itemCode, item.itemCode) + }); + if (offshoreHullItem?.id) { + vendorPossibleItem = await tx.query.techVendorPossibleItems.findFirst({ + where: and( + eq(techVendorPossibleItems.vendorId, quotation.vendor!.id), + eq(techVendorPossibleItems.offshoreHullItemId, offshoreHullItem.id) + ) + }); + } + } if (vendorPossibleItem) { // contact_possible_items 중복 체크 @@ -2665,75 +2730,157 @@ export async function getTechSalesRfqCandidateVendors(rfqId: number) { return { data: [], error: null }; } - // 3. 아이템 코드들 추출 - const itemCodes: string[] = []; + // 3. 아이템 ID들 추출 (타입별로) + const shipItemIds: number[] = []; + const topItemIds: number[] = []; + const hullItemIds: number[] = []; + rfqItems.forEach(item => { - if (item.itemType === "SHIP" && item.itemShipbuilding?.itemCode) { - itemCodes.push(item.itemShipbuilding.itemCode); - } else if (item.itemType === "TOP" && item.itemOffshoreTop?.itemCode) { - itemCodes.push(item.itemOffshoreTop.itemCode); - } else if (item.itemType === "HULL" && item.itemOffshoreHull?.itemCode) { - itemCodes.push(item.itemOffshoreHull.itemCode); + if (item.itemType === "SHIP" && item.itemShipbuilding?.id) { + shipItemIds.push(item.itemShipbuilding.id); + } else if (item.itemType === "TOP" && item.itemOffshoreTop?.id) { + topItemIds.push(item.itemOffshoreTop.id); + } else if (item.itemType === "HULL" && item.itemOffshoreHull?.id) { + hullItemIds.push(item.itemOffshoreHull.id); } }); - if (itemCodes.length === 0) { + if (shipItemIds.length === 0 && topItemIds.length === 0 && hullItemIds.length === 0) { return { data: [], error: null }; } - // 4. RFQ 타입에 따른 벤더 타입 매핑 - const vendorTypeFilter = rfq.rfqType === "SHIP" ? "SHIP" : - rfq.rfqType === "TOP" ? "OFFSHORE_TOP" : - rfq.rfqType === "HULL" ? "OFFSHORE_HULL" : null; + // 4. 각 타입별로 매칭되는 벤더들 조회 + const candidateVendorsMap = new Map(); + + // 조선 아이템 매칭 벤더들 + if (shipItemIds.length > 0) { + const shipVendors = await tx + .select({ + id: techVendors.id, + vendorId: techVendors.id, + vendorName: techVendors.vendorName, + vendorCode: techVendors.vendorCode, + country: techVendors.country, + email: techVendors.email, + phone: techVendors.phone, + status: techVendors.status, + techVendorType: techVendors.techVendorType, + matchedItemCode: itemShipbuilding.itemCode, + }) + .from(techVendorPossibleItems) + .innerJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id)) + .innerJoin(itemShipbuilding, eq(techVendorPossibleItems.shipbuildingItemId, itemShipbuilding.id)) + .where( + and( + inArray(techVendorPossibleItems.shipbuildingItemId, shipItemIds), + or( + eq(techVendors.status, "ACTIVE"), + eq(techVendors.status, "QUOTE_COMPARISON") + ) + ) + ); + + shipVendors.forEach(vendor => { + const key = vendor.vendorId; + if (!candidateVendorsMap.has(key)) { + candidateVendorsMap.set(key, { + ...vendor, + matchedItemCodes: [], + matchedItemCount: 0 + }); + } + candidateVendorsMap.get(key).matchedItemCodes.push(vendor.matchedItemCode); + candidateVendorsMap.get(key).matchedItemCount++; + }); + } + + // 해양 TOP 아이템 매칭 벤더들 + if (topItemIds.length > 0) { + const topVendors = await tx + .select({ + id: techVendors.id, + vendorId: techVendors.id, + vendorName: techVendors.vendorName, + vendorCode: techVendors.vendorCode, + country: techVendors.country, + email: techVendors.email, + phone: techVendors.phone, + status: techVendors.status, + techVendorType: techVendors.techVendorType, + matchedItemCode: itemOffshoreTop.itemCode, + }) + .from(techVendorPossibleItems) + .innerJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id)) + .innerJoin(itemOffshoreTop, eq(techVendorPossibleItems.offshoreTopItemId, itemOffshoreTop.id)) + .where( + and( + inArray(techVendorPossibleItems.offshoreTopItemId, topItemIds), + or( + eq(techVendors.status, "ACTIVE"), + eq(techVendors.status, "QUOTE_COMPARISON") + ) + ) + ); - if (!vendorTypeFilter) { - return { data: [], error: "지원되지 않는 RFQ 타입입니다." }; + topVendors.forEach(vendor => { + const key = vendor.vendorId; + if (!candidateVendorsMap.has(key)) { + candidateVendorsMap.set(key, { + ...vendor, + matchedItemCodes: [], + matchedItemCount: 0 + }); + } + candidateVendorsMap.get(key).matchedItemCodes.push(vendor.matchedItemCode); + candidateVendorsMap.get(key).matchedItemCount++; + }); } - // 5. 매칭되는 벤더들 조회 (타입 필터링 포함) - const candidateVendors = await tx - .select({ - id: techVendors.id, // 벤더 ID를 id로 명명하여 key 문제 해결 - vendorId: techVendors.id, // 호환성을 위해 유지 - vendorName: techVendors.vendorName, - vendorCode: techVendors.vendorCode, - country: techVendors.country, - email: techVendors.email, - phone: techVendors.phone, - status: techVendors.status, - techVendorType: techVendors.techVendorType, - matchedItemCodes: sql` - array_agg(DISTINCT ${techVendorPossibleItems.itemCode}) - `, - matchedItemCount: sql` - count(DISTINCT ${techVendorPossibleItems.itemCode}) - `, - }) - .from(techVendorPossibleItems) - .innerJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id)) - .where( - and( - inArray(techVendorPossibleItems.itemCode, itemCodes), - or( - eq(techVendors.status, "ACTIVE"), - eq(techVendors.status, "QUOTE_COMPARISON") // 견적비교용 벤더도 RFQ 초대 가능 + // 해양 HULL 아이템 매칭 벤더들 + if (hullItemIds.length > 0) { + const hullVendors = await tx + .select({ + id: techVendors.id, + vendorId: techVendors.id, + vendorName: techVendors.vendorName, + vendorCode: techVendors.vendorCode, + country: techVendors.country, + email: techVendors.email, + phone: techVendors.phone, + status: techVendors.status, + techVendorType: techVendors.techVendorType, + matchedItemCode: itemOffshoreHull.itemCode, + }) + .from(techVendorPossibleItems) + .innerJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id)) + .innerJoin(itemOffshoreHull, eq(techVendorPossibleItems.offshoreHullItemId, itemOffshoreHull.id)) + .where( + and( + inArray(techVendorPossibleItems.offshoreHullItemId, hullItemIds), + or( + eq(techVendors.status, "ACTIVE"), + eq(techVendors.status, "QUOTE_COMPARISON") + ) ) - // 벤더 타입 필터링 임시 제거 - 데이터 확인 후 다시 추가 - // eq(techVendors.techVendorType, vendorTypeFilter) - ) - ) - .groupBy( - techVendorPossibleItems.vendorId, - techVendors.id, - techVendors.vendorName, - techVendors.vendorCode, - techVendors.country, - techVendors.email, - techVendors.phone, - techVendors.status, - techVendors.techVendorType - ) - .orderBy(desc(sql`count(DISTINCT ${techVendorPossibleItems.itemCode})`)); + ); + + hullVendors.forEach(vendor => { + const key = vendor.vendorId; + if (!candidateVendorsMap.has(key)) { + candidateVendorsMap.set(key, { + ...vendor, + matchedItemCodes: [], + matchedItemCount: 0 + }); + } + candidateVendorsMap.get(key).matchedItemCodes.push(vendor.matchedItemCode); + candidateVendorsMap.get(key).matchedItemCount++; + }); + } + + // 5. 결과 정렬 (매칭된 아이템 수 기준 내림차순) + const candidateVendors = Array.from(candidateVendorsMap.values()) + .sort((a, b) => b.matchedItemCount - a.matchedItemCount); return { data: candidateVendors, error: null }; }); @@ -2830,44 +2977,78 @@ export async function addTechVendorsToTechSalesRfq(input: { }) .returning({ id: techSalesVendorQuotations.id }); - // 🆕 RFQ의 아이템 코드들을 tech_vendor_possible_items에 추가 + // 🆕 RFQ의 아이템들을 tech_vendor_possible_items에 추가 try { // RFQ의 아이템들 조회 const rfqItemsResult = await getTechSalesRfqItems(input.rfqId); if (rfqItemsResult.data && rfqItemsResult.data.length > 0) { for (const item of rfqItemsResult.data) { - const { - itemCode, - itemList, - workType, // 공종 - shipTypes, // 선종 (배열일 수 있음) - subItemList // 서브아이템리스트 (있을 수도 있음) - } = item; - - // 동적 where 조건 생성: 값이 있으면 비교, 없으면 비교하지 않음 - const whereConds = [ - eq(techVendorPossibleItems.vendorId, vendorId), - itemCode ? eq(techVendorPossibleItems.itemCode, itemCode) : undefined, - itemList ? eq(techVendorPossibleItems.itemList, itemList) : undefined, - workType ? eq(techVendorPossibleItems.workType, workType) : undefined, - shipTypes ? eq(techVendorPossibleItems.shipTypes, shipTypes) : undefined, - subItemList ? eq(techVendorPossibleItems.subItemList, subItemList) : undefined, - ].filter(Boolean); - - const existing = await tx.query.techVendorPossibleItems.findFirst({ - where: and(...whereConds) - }); - - if (!existing) { - await tx.insert(techVendorPossibleItems).values({ - vendorId : vendorId, - itemCode: itemCode ?? null, - itemList: itemList ?? null, - workType: workType ?? null, - shipTypes: shipTypes ?? null, - subItemList: subItemList ?? null, + let vendorPossibleItem = null; + // 조선: 아이템코드 + 선종으로 조선아이템테이블에서 찾기, 해양: 아이템코드로만 찾기 + if (item.itemType === "SHIP" && item.itemCode && item.shipTypes) { + // 조선: itemShipbuilding에서 itemCode, shipTypes로 찾기 + const shipbuildingItem = await tx.query.itemShipbuilding.findFirst({ + where: and( + eq(itemShipbuilding.itemCode, item.itemCode), + eq(itemShipbuilding.shipTypes, item.shipTypes) + ) }); + if (shipbuildingItem?.id) { + vendorPossibleItem = await tx.query.techVendorPossibleItems.findFirst({ + where: and( + eq(techVendorPossibleItems.vendorId, vendorId), + eq(techVendorPossibleItems.shipbuildingItemId, shipbuildingItem.id) + ) + }); + + if (!vendorPossibleItem) { + await tx.insert(techVendorPossibleItems).values({ + vendorId: vendorId, + shipbuildingItemId: shipbuildingItem.id, + }); + } + } + } else if (item.itemType === "TOP" && item.itemCode) { + // 해양 TOP: itemOffshoreTop에서 itemCode로 찾기 + const offshoreTopItem = await tx.query.itemOffshoreTop.findFirst({ + where: eq(itemOffshoreTop.itemCode, item.itemCode) + }); + if (offshoreTopItem?.id) { + vendorPossibleItem = await tx.query.techVendorPossibleItems.findFirst({ + where: and( + eq(techVendorPossibleItems.vendorId, vendorId), + eq(techVendorPossibleItems.offshoreTopItemId, offshoreTopItem.id) + ) + }); + + if (!vendorPossibleItem) { + await tx.insert(techVendorPossibleItems).values({ + vendorId: vendorId, + offshoreTopItemId: offshoreTopItem.id, + }); + } + } + } else if (item.itemType === "HULL" && item.itemCode) { + // 해양 HULL: itemOffshoreHull에서 itemCode로 찾기 + const offshoreHullItem = await tx.query.itemOffshoreHull.findFirst({ + where: eq(itemOffshoreHull.itemCode, item.itemCode) + }); + if (offshoreHullItem?.id) { + vendorPossibleItem = await tx.query.techVendorPossibleItems.findFirst({ + where: and( + eq(techVendorPossibleItems.vendorId, vendorId), + eq(techVendorPossibleItems.offshoreHullItemId, offshoreHullItem.id) + ) + }); + + if (!vendorPossibleItem) { + await tx.insert(techVendorPossibleItems).values({ + vendorId: vendorId, + offshoreHullItemId: offshoreHullItem.id, + }); + } + } } } } @@ -3367,11 +3548,6 @@ export async function getAcceptedTechSalesVendorQuotations(input: { try { const offset = (input.page - 1) * input.perPage; - // 기본 WHERE 조건: status = 'Accepted'만 조회, rfqType이 'SHIP'이 아닌 것만 - // const baseConditions = [ - // eq(techSalesVendorQuotations.status, 'Accepted'), - // sql`${techSalesRfqs.rfqType} != 'SHIP'` // 조선 RFQ 타입 제외 - // ]; // 기본 WHERE 조건: status = 'Accepted'만 조회, rfqType이 'SHIP'이 아닌 것만 const baseConditions = [or( eq(techSalesVendorQuotations.status, 'Submitted'), @@ -3566,6 +3742,7 @@ export async function getTechVendorsContacts(vendorIds: number[]) { contactId: techVendorContacts.id, contactName: techVendorContacts.contactName, contactPosition: techVendorContacts.contactPosition, + contactTitle: techVendorContacts.contactTitle, contactEmail: techVendorContacts.contactEmail, contactPhone: techVendorContacts.contactPhone, isPrimary: techVendorContacts.isPrimary, @@ -3599,6 +3776,7 @@ export async function getTechVendorsContacts(vendorIds: number[]) { id: row.contactId, contactName: row.contactName, contactPosition: row.contactPosition, + contactTitle: row.contactTitle, contactEmail: row.contactEmail, contactPhone: row.contactPhone, isPrimary: row.isPrimary @@ -3614,6 +3792,7 @@ export async function getTechVendorsContacts(vendorIds: number[]) { id: number; contactName: string; contactPosition: string | null; + contactTitle: string | null; contactEmail: string; contactPhone: string | null; isPrimary: boolean; @@ -3710,4 +3889,96 @@ export async function uploadQuotationAttachments( error: error instanceof Error ? error.message : '파일 업로드 중 오류가 발생했습니다.' }; } +} + +/** + * Update SHI Comment (revisionNote) for the current revision of a quotation. + * Only the revisionNote is updated in the tech_sales_vendor_quotation_revisions table. + */ +export async function updateSHIComment(revisionId: number, revisionNote: string) { + try { + const updatedRevision = await db + .update(techSalesVendorQuotationRevisions) + .set({ + revisionNote: revisionNote, + }) + .where(eq(techSalesVendorQuotationRevisions.id, revisionId)) + .returning(); + + if (updatedRevision.length === 0) { + return { data: null, error: "revision을 업데이트할 수 없습니다." }; + } + + return { data: updatedRevision[0], error: null }; + } catch (error) { + console.error("SHI Comment 업데이트 중 오류:", error); + return { data: null, error: "SHI Comment 업데이트 중 오류가 발생했습니다." }; + } +} + +// RFQ 단일 조회 함수 추가 +export async function getTechSalesRfqById(id: number) { + try { + const rfq = await db.query.techSalesRfqs.findFirst({ + where: eq(techSalesRfqs.id, id), + }); + const project = await db + .select({ + id: biddingProjects.id, + projectCode: biddingProjects.pspid, + projectName: biddingProjects.projNm, + pjtType: biddingProjects.pjtType, + ptypeNm: biddingProjects.ptypeNm, + projMsrm: biddingProjects.projMsrm, + }) + .from(biddingProjects) + .where(eq(biddingProjects.id, rfq?.biddingProjectId ?? 0)); + + if (!rfq) { + return { data: null, error: "RFQ를 찾을 수 없습니다." }; + } + + return { data: { ...rfq, project }, error: null }; + } catch (err) { + console.error("Error fetching RFQ:", err); + return { data: null, error: getErrorMessage(err) }; + } +} + +// RFQ 업데이트 함수 수정 (description으로 통일) +export async function updateTechSalesRfq(data: { + id: number; + description: string; + dueDate: Date; + updatedBy: number; +}) { + try { + return await db.transaction(async (tx) => { + const rfq = await tx.query.techSalesRfqs.findFirst({ + where: eq(techSalesRfqs.id, data.id), + }); + + if (!rfq) { + return { data: null, error: "RFQ를 찾을 수 없습니다." }; + } + + const [updatedRfq] = await tx + .update(techSalesRfqs) + .set({ + description: data.description, // description 필드로 업데이트 + dueDate: data.dueDate, + updatedAt: new Date(), + }) + .where(eq(techSalesRfqs.id, data.id)) + .returning(); + + revalidateTag("techSalesRfqs"); + revalidatePath(getTechSalesRevalidationPath(rfq.rfqType || "SHIP")); + + return { data: updatedRfq, error: null }; + }); + } catch (err) { + console.error("Error updating RFQ:", err); + return { data: null, error: getErrorMessage(err) }; + } } \ No newline at end of file diff --git a/lib/techsales-rfq/table/detail-table/quotation-contacts-view-dialog.tsx b/lib/techsales-rfq/table/detail-table/quotation-contacts-view-dialog.tsx index 3e793b62..61c97b1b 100644 --- a/lib/techsales-rfq/table/detail-table/quotation-contacts-view-dialog.tsx +++ b/lib/techsales-rfq/table/detail-table/quotation-contacts-view-dialog.tsx @@ -20,6 +20,7 @@ interface QuotationContact { contactId: number contactName: string contactPosition: string | null + contactTitle: string | null contactEmail: string contactPhone: string | null contactCountry: string | null @@ -129,6 +130,11 @@ export function QuotationContactsViewDialog({ {contact.contactPosition}

)} + {contact.contactTitle && ( +

+ {contact.contactTitle} +

+ )} {contact.contactCountry && (

{contact.contactCountry} diff --git a/lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx b/lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx index 0f5158d9..7d972b91 100644 --- a/lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx +++ b/lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx @@ -1,5 +1,4 @@ "use client" - import * as React from "react" import { useState, useEffect } from "react" import { @@ -16,6 +15,8 @@ import { Skeleton } from "@/components/ui/skeleton" import { Clock, User, AlertCircle, Paperclip } from "lucide-react" import { formatDate } from "@/lib/utils" import { toast } from "sonner" +import { Button } from "@/components/ui/button" +import { updateSHIComment } from "@/lib/techsales-rfq/service"; interface QuotationAttachment { id: number @@ -37,7 +38,7 @@ interface QuotationSnapshot { totalPrice: string | null validUntil: Date | null remark: string | null - status: string | null + status: string quotationVersion: number | null submittedAt: Date | null acceptedAt: Date | null @@ -93,7 +94,9 @@ function QuotationCard({ isCurrent = false, revisedBy, revisedAt, - attachments + attachments, + revisionId, + revisionNote, }: { data: QuotationSnapshot | QuotationHistoryData["current"] version: number @@ -101,9 +104,36 @@ function QuotationCard({ revisedBy?: string | null revisedAt?: Date attachments?: QuotationAttachment[] + revisionId?: number + revisionNote?: string | null }) { const statusInfo = statusConfig[data.status as keyof typeof statusConfig] || { label: data.status || "알 수 없음", color: "bg-gray-100 text-gray-800" } + + const [editValue, setEditValue] = React.useState(revisionNote || ""); + const [isSaving, setIsSaving] = React.useState(false); + + React.useEffect(() => { + setEditValue(revisionNote || ""); + }, [revisionNote]); + + const handleSave = async () => { + if (!revisionId) return; + + setIsSaving(true); + try { + const result = await updateSHIComment(revisionId, editValue); + if (result.error) { + toast.error(result.error); + } else { + toast.success("저장 완료"); + } + } catch (error) { + toast.error("저장 중 오류가 발생했습니다"); + } finally { + setIsSaving(false); + } + }; return ( @@ -117,12 +147,6 @@ function QuotationCard({ {statusInfo.label}

- {/* {changeReason && ( -
- - {changeReason} -
- )} */}
@@ -147,6 +171,21 @@ function QuotationCard({
)} + {revisionId && ( +
+

SHI Comment

+