summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-07-25 07:51:15 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-07-25 07:51:15 +0000
commit2650b7c0bb0ea12b68a58c0439f72d61df04b2f1 (patch)
tree17156183fd74b69d78178065388ac61a18ac07b4 /lib
parentd32acea05915bd6c1ed4b95e56c41ef9204347bc (diff)
(대표님) 정기평가 대상, 미들웨어 수정, nextauth 토큰 처리 개선, GTC 등
(최겸) 기술영업
Diffstat (limited to 'lib')
-rw-r--r--lib/basic-contract/service.ts16
-rw-r--r--lib/basic-contract/template/add-basic-contract-template-dialog.tsx30
-rw-r--r--lib/basic-contract/validations.ts4
-rw-r--r--lib/contact-possible-items/service.ts99
-rw-r--r--lib/contact-possible-items/table/contact-possible-items-table-columns.tsx301
-rw-r--r--lib/evaluation-submit/table/evaluation-submissions-table-columns.tsx2
-rw-r--r--lib/evaluation-target-list/service.ts62
-rw-r--r--lib/evaluation-target-list/table/evaluation-target-table.tsx36
-rw-r--r--lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx1
-rw-r--r--lib/evaluation-target-list/table/manual-create-evaluation-target-dialog.tsx7
-rw-r--r--lib/evaluation-target-list/table/update-evaluation-target.tsx5
-rw-r--r--lib/evaluation-target-list/validation.ts1
-rw-r--r--lib/evaluation/service.ts2
-rw-r--r--lib/evaluation/table/evaluation-filter-sheet.tsx48
-rw-r--r--lib/file-stroage.ts107
-rw-r--r--lib/gtc-contract/service.ts105
-rw-r--r--lib/gtc-contract/status/create-gtc-document-dialog.tsx18
-rw-r--r--lib/gtc-contract/status/delete-gtc-documents-dialog.tsx8
-rw-r--r--lib/gtc-contract/status/gtc-contract-table.tsx8
-rw-r--r--lib/gtc-contract/status/gtc-documents-table-columns.tsx4
-rw-r--r--lib/gtc-contract/status/gtc-documents-table-floating-bar.tsx4
-rw-r--r--lib/gtc-contract/status/gtc-documents-table-toolbar-actions.tsx4
-rw-r--r--lib/gtc-contract/validations.ts62
-rw-r--r--lib/tech-vendor-possible-items/repository.ts158
-rw-r--r--lib/tech-vendor-possible-items/service.ts893
-rw-r--r--lib/tech-vendor-possible-items/table/add-possible-item-dialog.tsx822
-rw-r--r--lib/tech-vendor-possible-items/table/possible-items-data-table.tsx33
-rw-r--r--lib/tech-vendor-possible-items/table/possible-items-table-columns.tsx86
-rw-r--r--lib/tech-vendor-possible-items/table/possible-items-table-toolbar-actions.tsx247
-rw-r--r--lib/tech-vendors/contacts-table/add-contact-dialog.tsx15
-rw-r--r--lib/tech-vendors/contacts-table/contact-table.tsx55
-rw-r--r--lib/tech-vendors/contacts-table/update-contact-sheet.tsx16
-rw-r--r--lib/tech-vendors/possible-items/add-item-dialog.tsx29
-rw-r--r--lib/tech-vendors/possible-items/possible-items-columns.tsx470
-rw-r--r--lib/tech-vendors/possible-items/possible-items-table.tsx425
-rw-r--r--lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx235
-rw-r--r--lib/tech-vendors/repository.ts164
-rw-r--r--lib/tech-vendors/service.ts5391
-rw-r--r--lib/tech-vendors/table/tech-vendors-filter-sheet.tsx1
-rw-r--r--lib/tech-vendors/table/tech-vendors-table-columns.tsx4
-rw-r--r--lib/tech-vendors/table/tech-vendors-table.tsx3
-rw-r--r--lib/tech-vendors/validations.ts783
-rw-r--r--lib/techsales-rfq/repository.ts1
-rw-r--r--lib/techsales-rfq/service.ts493
-rw-r--r--lib/techsales-rfq/table/detail-table/quotation-contacts-view-dialog.tsx6
-rw-r--r--lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx163
-rw-r--r--lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx18
-rw-r--r--lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx4
-rw-r--r--lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx6
-rw-r--r--lib/techsales-rfq/table/detail-table/vendor-contact-selection-dialog.tsx6
-rw-r--r--lib/techsales-rfq/table/rfq-filter-sheet.tsx135
-rw-r--r--lib/techsales-rfq/table/rfq-table-column.tsx33
-rw-r--r--lib/techsales-rfq/table/rfq-table.tsx28
-rw-r--r--lib/techsales-rfq/table/update-rfq-sheet.tsx267
-rw-r--r--lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx4
-rw-r--r--lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx17
-rw-r--r--lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx38
-rw-r--r--lib/vendor-document-list/enhanced-document-service.ts41
58 files changed, 6316 insertions, 5708 deletions
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<Contact
}
// ----------------------------------------------------------------
- // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
+ // 3) config를 기반으로 컬럼 그룹들을 동적으로 생성
// ----------------------------------------------------------------
- const baseColumns: ColumnDef<ContactPossibleItemDetail>[] = [
- // 벤더 정보
- {
- id: "vendorInfo",
- header: "벤더 정보",
- columns: [
- {
- accessorKey: "vendorCode",
- enableResizing: true,
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="벤더 코드" />
- ),
- cell: ({ row }) => row.original.vendorCode ?? "",
- },
- {
- accessorKey: "vendorName",
- enableResizing: true,
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="벤더명" />
- ),
- cell: ({ row }) => row.original.vendorName ?? "",
- },
- {
- accessorKey: "vendorCountry",
- enableResizing: true,
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="벤더 국가" />
- ),
- cell: ({ row }) => {
- const country = row.original.vendorCountry
- return country || <span className="text-muted-foreground">-</span>
- },
- },
- {
- accessorKey: "techVendorType",
- enableResizing: true,
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="벤더 타입" />
- ),
- cell: ({ row }) => {
- const type = row.original.techVendorType
- return type || <span className="text-muted-foreground">-</span>
- },
- },
- ]
- },
- // 담당자 정보
- {
- id: "contactInfo",
- header: "담당자 정보",
- columns: [
- {
- accessorKey: "contactName",
- enableResizing: true,
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="담당자명" />
- ),
- cell: ({ row }) => {
- const contactName = row.original.contactName
- return contactName || <span className="text-muted-foreground">-</span>
- },
- },
- {
- accessorKey: "contactPosition",
- enableResizing: true,
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="직책" />
- ),
- cell: ({ row }) => {
- const position = row.original.contactPosition
- return position || <span className="text-muted-foreground">-</span>
- },
- },
- {
- accessorKey: "contactEmail",
- enableResizing: true,
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="담당자 이메일" />
- ),
- cell: ({ row }) => {
- const contactEmail = row.original.contactEmail
- return contactEmail || <span className="text-muted-foreground">-</span>
- },
- },
- {
- accessorKey: "contactPhone",
- enableResizing: true,
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="담당자 전화번호" />
- ),
- cell: ({ row }) => {
- const contactPhone = row.original.contactPhone
- return contactPhone || <span className="text-muted-foreground">-</span>
- },
- },
- {
- accessorKey: "contactCountry",
- enableResizing: true,
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="담당자 국가" />
- ),
- cell: ({ row }) => {
- const contactCountry = row.original.contactCountry
- return contactCountry || <span className="text-muted-foreground">-</span>
- },
- },
- {
- accessorKey: "isPrimary",
- enableResizing: true,
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="주담당자" />
- ),
- cell: ({ row }) => {
- const isPrimary = row.original.isPrimary
- return isPrimary ? "예" : "아니오"
- },
- },
- ]
- },
- // 아이템 정보
- {
- id: "itemInfo",
- header: "아이템 정보",
- columns: [
- {
- accessorKey: "itemCode",
- enableResizing: true,
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="아이템 코드" />
- ),
- cell: ({ row }) => row.original.itemCode ?? "",
- },
- {
- accessorKey: "itemList",
- enableResizing: true,
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="아이템 리스트" />
- ),
- cell: ({ row }) => row.original.itemList ?? "",
- },
- {
- accessorKey: "workType",
- enableResizing: true,
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="공종" />
- ),
- cell: ({ row }) => row.original.workType ?? "",
- },
- {
- accessorKey: "shipTypes",
- enableResizing: true,
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="선종" />
- ),
- cell: ({ row }) => row.original.shipTypes ?? "",
- },
- {
- accessorKey: "subItemList",
- enableResizing: true,
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="서브아이템 리스트" />
- ),
- cell: ({ row }) => row.original.subItemList ?? "",
- },
- ]
- },
-
- // 시스템 정보
- {
- id: "systemInfo",
- header: "시스템 정보",
- columns: [
- {
- accessorKey: "createdAt",
- enableResizing: true,
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="생성일" />
- ),
- cell: ({ row }) => {
- const dateVal = row.getValue("createdAt") as Date
- return formatDate(dateVal)
- },
- },
- {
- accessorKey: "updatedAt",
- enableResizing: true,
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="수정일" />
- ),
- cell: ({ row }) => {
- const dateVal = row.getValue("updatedAt") as Date
- return formatDate(dateVal)
- },
- },
- ]
- },
- ]
+
+ // 특수한 셀 렌더링이 필요한 컬럼들을 위한 헬퍼 함수
+ const getCellRenderer = (accessorKey: keyof ContactPossibleItemDetail) => {
+ switch (accessorKey) {
+ case 'createdAt':
+ case 'updatedAt':
+ return function DateCell({ row }: { row: Row<ContactPossibleItemDetail> }) {
+ const dateVal = row.getValue(accessorKey) as Date
+ return formatDate(dateVal, "ko-KR")
+ }
+ case 'isPrimary':
+ return function PrimaryCell({ row }: { row: Row<ContactPossibleItemDetail> }) {
+ const isPrimary = row.original.isPrimary
+ return isPrimary ? "Y" : "N"
+ }
+ case 'techVendorType':
+ return function VendorTypeCell({ row }: { row: Row<ContactPossibleItemDetail> }) {
+ 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 (
+ <div className="flex flex-wrap gap-1">
+ {types.length > 0 ? types.map((type, index) => (
+ <Badge key={`${type}-${index}`} variant="secondary" className="text-xs">
+ {type}
+ </Badge>
+ )) : (
+ <span className="text-muted-foreground">-</span>
+ )}
+ </div>
+ )
+ }
+ case 'vendorCountry':
+ case 'contactName':
+ case 'contactPosition':
+ case 'contactTitle':
+ case 'contactEmail':
+ case 'contactPhone':
+ case 'contactCountry':
+ return function OptionalCell({ row }: { row: Row<ContactPossibleItemDetail> }) {
+ const value = row.original[accessorKey]
+ return value || <span className="text-muted-foreground">-</span>
+ }
+ default:
+ return function DefaultCell({ row }: { row: Row<ContactPossibleItemDetail> }) {
+ return row.original[accessorKey] ?? ""
+ }
+ }
+ }
+
+ const baseColumns: ColumnDef<ContactPossibleItemDetail>[] = 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<ContactPossibleItemDetail> }) {
+ return <DataTableColumnHeaderSimple column={column} title={colConfig.title} />
+ },
+ 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 (
<div className="space-y-1">
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<any, any, any>,
@@ -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<HTMLDivElement>(null);
const [containerTop, setContainerTop] = React.useState(0);
@@ -597,7 +630,7 @@ export function EvaluationTargetsTable({ promises, evaluationYear, className }:
onRenamePreset={renamePreset}
/>
- <EvaluationTargetsTableToolbarActions table={table} />
+ <EvaluationTargetsTableToolbarActions table={table}onRefresh={refreshData} />
</div>
</DataTableAdvancedToolbar>
</DataTable>
@@ -607,6 +640,7 @@ export function EvaluationTargetsTable({ promises, evaluationYear, className }:
open={rowAction?.type === "update"}
onOpenChange={() => setRowAction(null)}
evaluationTarget={rowAction?.row.original ?? null}
+ onDataChange={refreshData}
/>
</div>
</div>
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<typeof createEvaluationTargetSch
setVendorSearch("")
setReviewerSearches({})
setReviewerOpens({})
+ onSuccess?.() // 기존 방식 (table.resetRowSelection, router.refresh 등)
+ onDataChange?.() // 새로운 방식 (클라이언트 상태 업데이트)
router.refresh()
} else {
toast.error(result.error || "평가 대상 생성에 실패했습니다.")
diff --git a/lib/evaluation-target-list/table/update-evaluation-target.tsx b/lib/evaluation-target-list/table/update-evaluation-target.tsx
index ef24aa9f..9b1f4868 100644
--- a/lib/evaluation-target-list/table/update-evaluation-target.tsx
+++ b/lib/evaluation-target-list/table/update-evaluation-target.tsx
@@ -88,6 +88,7 @@ interface EditEvaluationTargetSheetProps {
open: boolean
onOpenChange: (open: boolean) => 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"
>
<X className="size-3.5" />
@@ -317,7 +317,7 @@ export function PeriodicEvaluationFilterSheet({
<Select
value={field.value}
onValueChange={field.onChange}
- disabled={isInitializing}
+ disabled={isPending}
>
<FormControl>
<SelectTrigger className={cn(field.value && "pr-8", "bg-white")}>
@@ -333,7 +333,7 @@ export function PeriodicEvaluationFilterSheet({
e.stopPropagation();
form.setValue("division", "");
}}
- disabled={isInitializing}
+ disabled={isPending}
>
<X className="size-3" />
</Button>
@@ -364,7 +364,7 @@ export function PeriodicEvaluationFilterSheet({
<Select
value={field.value}
onValueChange={field.onChange}
- disabled={isInitializing}
+ disabled={isPending}
>
<FormControl>
<SelectTrigger className={cn(field.value && "pr-8", "bg-white")}>
@@ -380,7 +380,7 @@ export function PeriodicEvaluationFilterSheet({
e.stopPropagation();
form.setValue("status", "");
}}
- disabled={isInitializing}
+ disabled={isPending}
>
<X className="size-3" />
</Button>
@@ -411,7 +411,7 @@ export function PeriodicEvaluationFilterSheet({
<Select
value={field.value}
onValueChange={field.onChange}
- disabled={isInitializing}
+ disabled={isPending}
>
<FormControl>
<SelectTrigger className={cn(field.value && "pr-8", "bg-white")}>
@@ -427,7 +427,7 @@ export function PeriodicEvaluationFilterSheet({
e.stopPropagation();
form.setValue("domesticForeign", "");
}}
- disabled={isInitializing}
+ disabled={isPending}
>
<X className="size-3" />
</Button>
@@ -458,7 +458,7 @@ export function PeriodicEvaluationFilterSheet({
<Select
value={field.value}
onValueChange={field.onChange}
- disabled={isInitializing}
+ disabled={isPending}
>
<FormControl>
<SelectTrigger className={cn(field.value && "pr-8", "bg-white")}>
@@ -474,7 +474,7 @@ export function PeriodicEvaluationFilterSheet({
e.stopPropagation();
form.setValue("materialType", "");
}}
- disabled={isInitializing}
+ disabled={isPending}
>
<X className="size-3" />
</Button>
@@ -507,7 +507,7 @@ export function PeriodicEvaluationFilterSheet({
<Input
placeholder="벤더 코드 입력"
{...field}
- disabled={isInitializing}
+ disabled={isPending}
className={cn(field.value && "pr-8", "bg-white")}
/>
{field.value && (
@@ -520,7 +520,7 @@ export function PeriodicEvaluationFilterSheet({
e.stopPropagation();
form.setValue("vendorCode", "");
}}
- disabled={isInitializing}
+ disabled={isPending}
>
<X className="size-3.5" />
</Button>
@@ -544,7 +544,7 @@ export function PeriodicEvaluationFilterSheet({
<Input
placeholder="벤더명 입력"
{...field}
- disabled={isInitializing}
+ disabled={isPending}
className={cn(field.value && "pr-8", "bg-white")}
/>
{field.value && (
@@ -557,7 +557,7 @@ export function PeriodicEvaluationFilterSheet({
e.stopPropagation();
form.setValue("vendorName", "");
}}
- disabled={isInitializing}
+ disabled={isPending}
>
<X className="size-3.5" />
</Button>
@@ -579,7 +579,7 @@ export function PeriodicEvaluationFilterSheet({
<Select
value={field.value}
onValueChange={field.onChange}
- disabled={isInitializing}
+ disabled={isPending}
>
<FormControl>
<SelectTrigger className={cn(field.value && "pr-8", "bg-white")}>
@@ -595,7 +595,7 @@ export function PeriodicEvaluationFilterSheet({
e.stopPropagation();
form.setValue("documentsSubmitted", "");
}}
- disabled={isInitializing}
+ disabled={isPending}
>
<X className="size-3" />
</Button>
@@ -626,7 +626,7 @@ export function PeriodicEvaluationFilterSheet({
<Select
value={field.value}
onValueChange={field.onChange}
- disabled={isInitializing}
+ disabled={isPending}
>
<FormControl>
<SelectTrigger className={cn(field.value && "pr-8", "bg-white")}>
@@ -642,7 +642,7 @@ export function PeriodicEvaluationFilterSheet({
e.stopPropagation();
form.setValue("evaluationGrade", "");
}}
- disabled={isInitializing}
+ disabled={isPending}
>
<X className="size-3" />
</Button>
@@ -673,7 +673,7 @@ export function PeriodicEvaluationFilterSheet({
<Select
value={field.value}
onValueChange={field.onChange}
- disabled={isInitializing}
+ disabled={isPending}
>
<FormControl>
<SelectTrigger className={cn(field.value && "pr-8", "bg-white")}>
@@ -689,7 +689,7 @@ export function PeriodicEvaluationFilterSheet({
e.stopPropagation();
form.setValue("finalGrade", "");
}}
- disabled={isInitializing}
+ disabled={isPending}
>
<X className="size-3" />
</Button>
@@ -725,7 +725,7 @@ export function PeriodicEvaluationFilterSheet({
step="0.1"
placeholder="최소"
{...field}
- disabled={isInitializing}
+ disabled={isPending}
className={cn(field.value && "pr-8", "bg-white")}
/>
{field.value && (
@@ -738,7 +738,7 @@ export function PeriodicEvaluationFilterSheet({
e.stopPropagation();
form.setValue("minTotalScore", "");
}}
- disabled={isInitializing}
+ disabled={isPending}
>
<X className="size-3.5" />
</Button>
@@ -763,7 +763,7 @@ export function PeriodicEvaluationFilterSheet({
step="0.1"
placeholder="최대"
{...field}
- disabled={isInitializing}
+ disabled={isPending}
className={cn(field.value && "pr-8", "bg-white")}
/>
{field.value && (
@@ -776,7 +776,7 @@ export function PeriodicEvaluationFilterSheet({
e.stopPropagation();
form.setValue("maxTotalScore", "");
}}
- disabled={isInitializing}
+ disabled={isPending}
>
<X className="size-3.5" />
</Button>
diff --git a/lib/file-stroage.ts b/lib/file-stroage.ts
index eab52364..c347ffe3 100644
--- a/lib/file-stroage.ts
+++ b/lib/file-stroage.ts
@@ -1,9 +1,10 @@
// lib/file-storage.ts - 보안이 강화된 파일 저장 유틸리티
-import { promises as fs } from "fs";
+import { promises as fs, createWriteStream } from "fs";
import path from "path";
import crypto from "crypto";
import { createHash } from "crypto";
+import { Readable } from 'stream'
interface FileStorageConfig {
baseDir: string;
@@ -45,7 +46,7 @@ const SECURITY_CONFIG = {
]),
// 최대 파일 크기 (100MB)
- MAX_FILE_SIZE: 100 * 1024 * 1024,
+ MAX_FILE_SIZE: 1024 * 1024 * 1024,
// 파일명 최대 길이
MAX_FILENAME_LENGTH: 255,
@@ -756,4 +757,106 @@ export function getSecurityConfig() {
maxFileSizeFormatted: FileUploadLogger['formatFileSize'](SECURITY_CONFIG.MAX_FILE_SIZE),
maxFilenameLength: SECURITY_CONFIG.MAX_FILENAME_LENGTH,
};
+}
+
+export async function saveFileStream({
+ file,
+ directory,
+ originalName,
+ userId,
+}: SaveFileOptions): Promise<SaveFileResult> {
+ const finalFileName = originalName || file.name
+
+ try {
+ console.log(`🚀 스트리밍 저장 시작: ${finalFileName}`)
+
+ // 기본 보안 검증들 (확장자, 파일명 등)
+ const extValidation = FileSecurityValidator.validateExtension(finalFileName)
+ if (!extValidation.valid) {
+ return { success: false, error: extValidation.error }
+ }
+
+ const nameValidation = FileSecurityValidator.validateFileName(finalFileName)
+ if (!nameValidation.valid) {
+ return { success: false, error: nameValidation.error }
+ }
+
+ const sizeValidation = FileSecurityValidator.validateFileSize(file.size)
+ if (!sizeValidation.valid) {
+ return { success: false, error: sizeValidation.error }
+ }
+
+ const config = getStorageConfig()
+ const safeOriginalName = sanitizeFileNameForStorage(finalFileName)
+ const hashedFileName = generateHashedFileName(safeOriginalName)
+
+ const saveDir = path.join(config.baseDir, directory)
+ const filePath = path.join(saveDir, hashedFileName)
+
+ // 디렉토리 생성
+ await fs.mkdir(saveDir, { recursive: true })
+
+ // Node.js 스트림으로 변환하여 저장
+ const nodeStream = Readable.fromWeb(file.stream() as ReadableStream)
+ const writeStream = createWriteStream(filePath)
+
+ // 스트림 파이프라인으로 메모리 효율적 저장
+ await new Promise((resolve, reject) => {
+ nodeStream.pipe(writeStream)
+ writeStream.on('finish', resolve)
+ writeStream.on('error', reject)
+ nodeStream.on('error', reject)
+ })
+
+ console.log(`✅ 스트리밍 저장 완료: ${finalFileName}`)
+
+ // 저장 후 첫 부분만 샘플링하여 내용 검증
+ const contentValidation = await validateLargeFileContentSample(filePath, finalFileName)
+ if (!contentValidation.valid) {
+ await fs.unlink(filePath) // 검증 실패 시 파일 삭제
+ return { success: false, error: contentValidation.error }
+ }
+
+ const publicPath = config.isProduction
+ ? `${config.publicUrl}/${directory}/${hashedFileName}`
+ : `/${directory}/${hashedFileName}`
+
+ return {
+ success: true,
+ filePath,
+ publicPath,
+ fileName: hashedFileName,
+ originalName: finalFileName,
+ fileSize: file.size,
+ }
+
+ } catch (error) {
+ console.error(`❌ 스트리밍 저장 실패: ${finalFileName}`, error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '스트리밍 저장 실패'
+ }
+ }
+}
+
+// 4. 대용량 파일 샘플 검증
+async function validateLargeFileContentSample(
+ filePath: string,
+ fileName: string
+): Promise<{ valid: boolean; error?: string }> {
+ try {
+ const fileHandle = await fs.open(filePath, 'r')
+
+ // 파일 시작 부분 64KB만 읽어서 검증
+ const sampleSize = Math.min(64 * 1024, (await fileHandle.stat()).size)
+ const buffer = Buffer.allocUnsafe(sampleSize)
+ const { bytesRead } = await fileHandle.read(buffer, 0, sampleSize, 0)
+ await fileHandle.close()
+
+ const sampleBuffer = buffer.subarray(0, bytesRead)
+ return await FileSecurityValidator.validateFileContent(sampleBuffer, fileName)
+ } catch (error) {
+ console.error('파일 샘플 검증 실패:', error)
+ return { valid: false, error: '파일 내용 검증 실패' }
+ }
} \ No newline at end of file
diff --git a/lib/gtc-contract/service.ts b/lib/gtc-contract/service.ts
index 61e69995..23cdd422 100644
--- a/lib/gtc-contract/service.ts
+++ b/lib/gtc-contract/service.ts
@@ -1,7 +1,9 @@
+'use server'
+
import { unstable_cache } from "next/cache"
import { and, desc, asc, eq, or, ilike, count, max } from "drizzle-orm"
import db from "@/db/db"
-import { gtcDocuments, type GtcDocument, type GtcDocumentWithRelations } from "@/db/schema/gtc"
+import { gtcDocuments, gtcDocumentsView, type GtcDocument, type GtcDocumentWithRelations } from "@/db/schema/gtc"
import { projects } from "@/db/schema/projects"
import { users } from "@/db/schema/users"
import { filterColumns } from "@/lib/filter-columns"
@@ -20,44 +22,7 @@ export async function checkProjectExists(projectId: number): Promise<boolean> {
return result.length > 0
}
-/**
- * GTC 문서 관련 뷰/조인 쿼리를 위한 기본 select
- */
-function selectGtcDocumentsWithRelations() {
- return db
- .select({
- id: gtcDocuments.id,
- type: gtcDocuments.type,
- projectId: gtcDocuments.projectId,
- revision: gtcDocuments.revision,
- createdAt: gtcDocuments.createdAt,
- createdById: gtcDocuments.createdById,
- updatedAt: gtcDocuments.updatedAt,
- updatedById: gtcDocuments.updatedById,
- editReason: gtcDocuments.editReason,
- isActive: gtcDocuments.isActive,
- // 관계 데이터
- project: {
- id: projects.id,
- code: projects.code,
- name: projects.name,
- },
- createdBy: {
- id: users.id,
- name: users.name,
- email: users.email,
- },
- updatedBy: {
- id: users.id,
- name: users.name,
- email: users.email,
- },
- })
- .from(gtcDocuments)
- .leftJoin(projects, eq(gtcDocuments.projectId, projects.id))
- .leftJoin(users, eq(gtcDocuments.createdById, users.id))
- .leftJoin(users, eq(gtcDocuments.updatedById, users.id))
-}
+
/**
* GTC 문서 개수 조회
@@ -82,7 +47,7 @@ export async function getGtcDocuments(input: GetGtcDocumentsSchema) {
// (1) advancedWhere - 고급 필터
const advancedWhere = filterColumns({
- table: gtcDocuments,
+ table: gtcDocumentsView,
filters: input.filters,
joinOperator: input.joinOperator,
})
@@ -92,49 +57,37 @@ export async function getGtcDocuments(input: GetGtcDocumentsSchema) {
if (input.search) {
const s = `%${input.search}%`
globalWhere = or(
- ilike(gtcDocuments.editReason, s),
+ ilike(gtcDocumentsView.editReason, s),
ilike(projects.name, s),
ilike(projects.code, s)
)
}
- // (3) 기본 필터들
- const basicFilters = []
-
- if (input.type && input.type !== "") {
- basicFilters.push(eq(gtcDocuments.type, input.type))
- }
-
- if (input.projectId && input.projectId > 0) {
- basicFilters.push(eq(gtcDocuments.projectId, input.projectId))
- }
-
- // 활성 문서만 조회 (기본값)
- basicFilters.push(eq(gtcDocuments.isActive, true))
// (4) 최종 where 조건
const finalWhere = and(
advancedWhere,
globalWhere,
- ...basicFilters
)
// (5) 정렬
const orderBy =
input.sort.length > 0
? input.sort.map((item) => {
- const column = gtcDocuments[item.id as keyof typeof gtcDocuments]
+ const column = gtcDocumentsView[item.id as keyof typeof gtcDocumentsView]
return item.desc ? desc(column) : asc(column)
})
- : [desc(gtcDocuments.updatedAt)]
+ : [desc(gtcDocumentsView.updatedAt)]
// (6) 데이터 조회
const { data, total } = await db.transaction(async (tx) => {
- const data = await selectGtcDocumentsWithRelations()
- .where(finalWhere)
- .orderBy(...orderBy)
- .offset(offset)
- .limit(input.perPage)
+ const data =await db
+ .select()
+ .from(gtcDocumentsView)
+ .where(finalWhere)
+ .orderBy(...orderBy)
+ .limit(input.perPage)
+ .offset(offset);
const total = await countGtcDocuments(tx, finalWhere)
return { data, total }
@@ -155,17 +108,27 @@ export async function getGtcDocuments(input: GetGtcDocumentsSchema) {
)()
}
-/**
- * 특정 GTC 문서 조회
- */
-export async function getGtcDocumentById(id: number): Promise<GtcDocumentWithRelations | null> {
- const result = await selectGtcDocumentsWithRelations()
- .where(eq(gtcDocuments.id, id))
- .limit(1)
+// 성공한 ID들을 반환하는 버전
+export async function deleteGtcDocuments(
+ ids: number[],
+ updatedById: number
+): Promise<number[]> {
+ if (ids.length === 0) {
+ return [];
+ }
- return result[0] || null
-}
+ const updated = await db
+ .update(gtcDocuments)
+ .set({
+ isActive: false,
+ updatedById,
+ updatedAt: new Date(),
+ })
+ .where(inArray(gtcDocuments.id, ids))
+ .returning({ id: gtcDocuments.id });
+ return updated.map(doc => doc.id);
+}
/**
* 다음 리비전 번호 조회
*/
diff --git a/lib/gtc-contract/status/create-gtc-document-dialog.tsx b/lib/gtc-contract/status/create-gtc-document-dialog.tsx
index 6791adfa..98cd249f 100644
--- a/lib/gtc-contract/status/create-gtc-document-dialog.tsx
+++ b/lib/gtc-contract/status/create-gtc-document-dialog.tsx
@@ -42,11 +42,18 @@ import { toast } from "sonner"
import { createGtcDocumentSchema, type CreateGtcDocumentSchema } from "@/lib/gtc-contract/validations"
import { createGtcDocument, getProjectsForSelect } from "@/lib/gtc-contract/service"
import { type Project } from "@/db/schema/projects"
+import { useSession } from "next-auth/react"
export function CreateGtcDocumentDialog() {
const [open, setOpen] = React.useState(false)
const [projects, setProjects] = React.useState<Project[]>([])
const [isCreatePending, startCreateTransition] = React.useTransition()
+ const { data: session } = useSession()
+
+ const currentUserId =React.useMemo(() => {
+ return session?.user?.id ? Number(session.user.id) : null;
+ }, [session]);
+
React.useEffect(() => {
if (open) {
@@ -70,8 +77,17 @@ export function CreateGtcDocumentDialog() {
async function onSubmit(data: CreateGtcDocumentSchema) {
startCreateTransition(async () => {
+
+ if (!currentUserId) {
+ toast.error("로그인이 필요합니다")
+ return
+ }
+
try {
- const result = await createGtcDocument(data)
+ const result = await createGtcDocument({
+ ...data,
+ createdById: currentUserId
+ })
if (result.error) {
toast.error(`에러: ${result.error}`)
diff --git a/lib/gtc-contract/status/delete-gtc-documents-dialog.tsx b/lib/gtc-contract/status/delete-gtc-documents-dialog.tsx
index 5779a2b6..50c8d3f4 100644
--- a/lib/gtc-contract/status/delete-gtc-documents-dialog.tsx
+++ b/lib/gtc-contract/status/delete-gtc-documents-dialog.tsx
@@ -29,6 +29,7 @@ import {
} from "@/components/ui/drawer"
import { deleteGtcDocuments } from "@/lib/gtc-contract/service"
+import { useSession } from "next-auth/react"
import { type GtcDocumentWithRelations } from "@/db/schema/gtc"
interface DeleteGtcDocumentsDialogProps
@@ -46,11 +47,18 @@ export function DeleteGtcDocumentsDialog({
}: DeleteGtcDocumentsDialogProps) {
const [isDeletePending, startDeleteTransition] = React.useTransition()
const isDesktop = useMediaQuery("(min-width: 640px)")
+ const { data: session } = useSession()
function onDelete() {
+ if (!session?.user?.id) {
+ toast.error("로그인이 필요합니다.")
+ return
+ }
+
startDeleteTransition(async () => {
const { error } = await deleteGtcDocuments({
ids: gtcDocuments.map((doc) => doc.id),
+ updatedById: Number(session.user.id)
})
if (error) {
diff --git a/lib/gtc-contract/status/gtc-contract-table.tsx b/lib/gtc-contract/status/gtc-contract-table.tsx
index dd04fbc9..0fb637b6 100644
--- a/lib/gtc-contract/status/gtc-contract-table.tsx
+++ b/lib/gtc-contract/status/gtc-contract-table.tsx
@@ -26,6 +26,7 @@ import { GtcDocumentsTableFloatingBar } from "./gtc-documents-table-floating-bar
import { UpdateGtcDocumentSheet } from "./update-gtc-document-sheet"
import { CreateGtcDocumentDialog } from "./create-gtc-document-dialog"
import { CreateNewRevisionDialog } from "./create-new-revision-dialog"
+import { useRouter } from "next/navigation"
interface GtcDocumentsTableProps {
promises: Promise<
@@ -39,13 +40,14 @@ interface GtcDocumentsTableProps {
export function GtcDocumentsTable({ promises }: GtcDocumentsTableProps) {
const [{ data, pageCount }, projects, users] = React.use(promises)
+ const router = useRouter()
const [rowAction, setRowAction] =
React.useState<DataTableRowAction<GtcDocumentWithRelations> | null>(null)
const columns = React.useMemo(
- () => getColumns({ setRowAction }),
- [setRowAction]
+ () => getColumns({ setRowAction , router}),
+ [setRowAction, router]
)
/**
@@ -167,7 +169,7 @@ export function GtcDocumentsTable({ promises }: GtcDocumentsTableProps) {
originalDocument={rowAction?.row.original ?? null}
/>
- <CreateGtcDocumentDialog />
+ {/* <CreateGtcDocumentDialog /> */}
</>
)
} \ No newline at end of file
diff --git a/lib/gtc-contract/status/gtc-documents-table-columns.tsx b/lib/gtc-contract/status/gtc-documents-table-columns.tsx
index 2d5f08b9..f6eb81d0 100644
--- a/lib/gtc-contract/status/gtc-documents-table-columns.tsx
+++ b/lib/gtc-contract/status/gtc-documents-table-columns.tsx
@@ -20,7 +20,6 @@ import {
import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
import { type GtcDocumentWithRelations } from "@/db/schema/gtc"
-import { useRouter } from "next/navigation"
interface GetColumnsProps {
setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<GtcDocumentWithRelations> | null>>
@@ -29,8 +28,7 @@ interface GetColumnsProps {
/**
* GTC Documents 테이블 컬럼 정의
*/
-export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<GtcDocumentWithRelations>[] {
- const router = useRouter()
+export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef<GtcDocumentWithRelations>[] {
// ----------------------------------------------------------------
// 1) select 컬럼 (체크박스)
diff --git a/lib/gtc-contract/status/gtc-documents-table-floating-bar.tsx b/lib/gtc-contract/status/gtc-documents-table-floating-bar.tsx
index a9139ed2..8fac597e 100644
--- a/lib/gtc-contract/status/gtc-documents-table-floating-bar.tsx
+++ b/lib/gtc-contract/status/gtc-documents-table-floating-bar.tsx
@@ -8,9 +8,9 @@ import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
-import { exportTableToCSV } from "@/lib/export"
import { type GtcDocumentWithRelations } from "@/db/schema/gtc"
import { DeleteGtcDocumentsDialog } from "./delete-gtc-documents-dialog"
+import { exportTableToExcel } from "@/lib/export"
interface GtcDocumentsTableFloatingBarProps {
table: Table<GtcDocumentWithRelations>
@@ -68,7 +68,7 @@ export function GtcDocumentsTableFloatingBar({
variant="secondary"
size="sm"
onClick={() =>
- exportTableToCSV(table, {
+ exportTableToExcel(table, {
filename: "gtc-documents",
excludeColumns: ["select", "actions"],
})
diff --git a/lib/gtc-contract/status/gtc-documents-table-toolbar-actions.tsx b/lib/gtc-contract/status/gtc-documents-table-toolbar-actions.tsx
index cb52b2ed..90f2f8a8 100644
--- a/lib/gtc-contract/status/gtc-documents-table-toolbar-actions.tsx
+++ b/lib/gtc-contract/status/gtc-documents-table-toolbar-actions.tsx
@@ -3,11 +3,11 @@
import { type Table } from "@tanstack/react-table"
import { Download } from "lucide-react"
-import { exportTableToCSV } from "@/lib/export"
import { Button } from "@/components/ui/button"
import { type GtcDocumentWithRelations } from "@/db/schema/gtc"
import { CreateGtcDocumentDialog } from "./create-gtc-document-dialog"
+import { exportTableToExcel } from "@/lib/export"
interface GtcDocumentsTableToolbarActionsProps {
table: Table<GtcDocumentWithRelations>
@@ -23,7 +23,7 @@ export function GtcDocumentsTableToolbarActions({
variant="outline"
size="sm"
onClick={() =>
- exportTableToCSV(table, {
+ exportTableToExcel(table, {
filename: "gtc-documents",
excludeColumns: ["select", "actions"],
})
diff --git a/lib/gtc-contract/validations.ts b/lib/gtc-contract/validations.ts
index b79a8b08..671e25b7 100644
--- a/lib/gtc-contract/validations.ts
+++ b/lib/gtc-contract/validations.ts
@@ -8,7 +8,6 @@ import {
} from "nuqs/server"
import * as z from "zod"
import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
-import { checkProjectExists } from "./service"
export const searchParamsCache = createSearchParamsCache({
flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
@@ -30,47 +29,30 @@ export const searchParamsCache = createSearchParamsCache({
export const createGtcDocumentSchema = z.object({
type: z.enum(["standard", "project"]),
- projectId: z
- .number()
- .nullable()
- .optional()
- .refine(
- async (projectId, ctx) => {
- // 프로젝트 타입인 경우 projectId 필수
- if (ctx.parent.type === "project" && !projectId) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: "Project is required for project type GTC",
- })
- return false
- }
-
- // 표준 타입인 경우 projectId null이어야 함
- if (ctx.parent.type === "standard" && projectId) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: "Project should not be set for standard type GTC",
- })
- return false
- }
-
- // 프로젝트 ID가 유효한지 검사
- if (projectId) {
- const exists = await checkProjectExists(projectId)
- if (!exists) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: "Invalid project ID",
- })
- return false
- }
- }
-
- return true
- }
- ),
+ projectId: z.number().nullable().optional(),
revision: z.number().min(0).default(0),
editReason: z.string().optional(),
+}).superRefine(async (data, ctx) => {
+ // 프로젝트 타입인 경우 projectId 필수
+ if (data.type === "project" && !data.projectId) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Project is required for project type GTC",
+ path: ["projectId"],
+ })
+ return
+ }
+
+ // 표준 타입인 경우 projectId null이어야 함
+ if (data.type === "standard" && data.projectId) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Project should not be set for standard type GTC",
+ path: ["projectId"],
+ })
+ return
+ }
+
})
export const updateGtcDocumentSchema = z.object({
diff --git a/lib/tech-vendor-possible-items/repository.ts b/lib/tech-vendor-possible-items/repository.ts
index 5c1487b5..4d876643 100644
--- a/lib/tech-vendor-possible-items/repository.ts
+++ b/lib/tech-vendor-possible-items/repository.ts
@@ -1,12 +1,14 @@
-import { eq, desc, count, SQL, sql, and, or, ilike } from "drizzle-orm";
+import { eq, desc, count, SQL, sql } from "drizzle-orm";
import {
techVendors,
techVendorPossibleItems
} from "@/db/schema/techVendors";
+import { itemShipbuilding, itemOffshoreTop, itemOffshoreHull } from "@/db/schema/items";
import type { PgTransaction } from "drizzle-orm/pg-core";
/**
- * 기술영업 벤더 가능 아이템 목록 조회 (조인 포함)
+ * 새로운 스키마에 맞는 기술영업 벤더 가능 아이템 목록 조회 (조인 포함)
+ * techVendorPossibleItems는 shipbuildingItemId, offshoreTopItemId, offshoreHullItemId 중 하나만 가짐
*/
export async function selectTechVendorPossibleItemsWithJoin(
tx: PgTransaction<any, any, any>,
@@ -19,22 +21,38 @@ export async function selectTechVendorPossibleItemsWithJoin(
.select({
id: techVendorPossibleItems.id,
vendorId: techVendorPossibleItems.vendorId,
- vendorCode: techVendorPossibleItems.vendorCode, // 테이블에서 직접 조회
- vendorName: techVendors.vendorName,
- vendorEmail: techVendorPossibleItems.vendorEmail, // 테이블에서 직접 조회
- techVendorType: techVendors.techVendorType,
- vendorStatus: techVendors.status,
- itemCode: techVendorPossibleItems.itemCode,
- // 새로운 스키마: 테이블에서 직접 조회
- workType: techVendorPossibleItems.workType,
- shipTypes: techVendorPossibleItems.shipTypes,
- itemList: techVendorPossibleItems.itemList,
- subItemList: techVendorPossibleItems.subItemList,
+ shipbuildingItemId: techVendorPossibleItems.shipbuildingItemId,
+ offshoreTopItemId: techVendorPossibleItems.offshoreTopItemId,
+ offshoreHullItemId: techVendorPossibleItems.offshoreHullItemId,
createdAt: techVendorPossibleItems.createdAt,
updatedAt: techVendorPossibleItems.updatedAt,
+ // 벤더 정보
+ vendorCode: techVendors.vendorCode,
+ vendorName: techVendors.vendorName,
+ vendorEmail: techVendors.email,
+ vendorStatus: techVendors.status,
+ techVendorType: techVendors.techVendorType,
+ // 조선 아이템 정보 (shipbuildingItemId가 있을 때만)
+ shipItemCode: itemShipbuilding.itemCode,
+ shipWorkType: itemShipbuilding.workType,
+ shipItemList: itemShipbuilding.itemList,
+ shipTypes: itemShipbuilding.shipTypes,
+ // 해양 TOP 아이템 정보 (offshoreTopItemId가 있을 때만)
+ topItemCode: itemOffshoreTop.itemCode,
+ topWorkType: itemOffshoreTop.workType,
+ topItemList: itemOffshoreTop.itemList,
+ topSubItemList: itemOffshoreTop.subItemList,
+ // 해양 HULL 아이템 정보 (offshoreHullItemId가 있을 때만)
+ hullItemCode: itemOffshoreHull.itemCode,
+ hullWorkType: itemOffshoreHull.workType,
+ hullItemList: itemOffshoreHull.itemList,
+ hullSubItemList: itemOffshoreHull.subItemList,
})
.from(techVendorPossibleItems)
.innerJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id))
+ .leftJoin(itemShipbuilding, eq(techVendorPossibleItems.shipbuildingItemId, itemShipbuilding.id))
+ .leftJoin(itemOffshoreTop, eq(techVendorPossibleItems.offshoreTopItemId, itemOffshoreTop.id))
+ .leftJoin(itemOffshoreHull, eq(techVendorPossibleItems.offshoreHullItemId, itemOffshoreHull.id))
.where(where)
.orderBy(...(orderBy || [desc(techVendorPossibleItems.createdAt)]))
.limit(limit)
@@ -52,58 +70,70 @@ export async function countTechVendorPossibleItemsWithJoin(
.select({ count: count() })
.from(techVendorPossibleItems)
.innerJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id))
+ .leftJoin(itemShipbuilding, eq(techVendorPossibleItems.shipbuildingItemId, itemShipbuilding.id))
+ .leftJoin(itemOffshoreTop, eq(techVendorPossibleItems.offshoreTopItemId, itemOffshoreTop.id))
+ .leftJoin(itemOffshoreHull, eq(techVendorPossibleItems.offshoreHullItemId, itemOffshoreHull.id))
.where(where);
return result.count;
}
/**
- * 새로운 필드들을 위한 그룹별 통계 조회
+ * 공종별 통계 조회 (새로운 스키마 적용)
*/
-export async function getTechVendorPossibleItemsGroupStats(
+export async function getWorkTypeStats(
tx: PgTransaction<any, any, any>,
- groupBy: 'workType' | 'shipTypes' | 'vendorCode' | 'vendorEmail',
where?: SQL | undefined
) {
- const groupField = techVendorPossibleItems[groupBy];
-
- return await tx
+ // 각 아이템 타입별로 별도 쿼리 실행 후 통합
+ const shipStats = await tx
.select({
- groupValue: groupField,
+ workType: itemShipbuilding.workType,
count: count(),
vendorCount: sql<number>`COUNT(DISTINCT ${techVendorPossibleItems.vendorId})`.as('vendorCount'),
- itemCount: sql<number>`COUNT(DISTINCT ${techVendorPossibleItems.itemCode})`.as('itemCount'),
+ itemCount: sql<number>`COUNT(DISTINCT ${itemShipbuilding.itemCode})`.as('itemCount'),
})
.from(techVendorPossibleItems)
.innerJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id))
+ .innerJoin(itemShipbuilding, eq(techVendorPossibleItems.shipbuildingItemId, itemShipbuilding.id))
.where(where)
- .groupBy(groupField)
+ .groupBy(itemShipbuilding.workType)
.orderBy(desc(count()));
-}
-/**
- * 공종별 통계 조회
- */
-export async function getWorkTypeStats(
- tx: PgTransaction<any, any, any>,
- where?: SQL | undefined
-) {
- return await tx
+ const topStats = await tx
+ .select({
+ workType: itemOffshoreTop.workType,
+ count: count(),
+ vendorCount: sql<number>`COUNT(DISTINCT ${techVendorPossibleItems.vendorId})`.as('vendorCount'),
+ itemCount: sql<number>`COUNT(DISTINCT ${itemOffshoreTop.itemCode})`.as('itemCount'),
+ })
+ .from(techVendorPossibleItems)
+ .innerJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id))
+ .innerJoin(itemOffshoreTop, eq(techVendorPossibleItems.offshoreTopItemId, itemOffshoreTop.id))
+ .where(where)
+ .groupBy(itemOffshoreTop.workType)
+ .orderBy(desc(count()));
+
+ const hullStats = await tx
.select({
- workType: techVendorPossibleItems.workType,
+ workType: itemOffshoreHull.workType,
count: count(),
vendorCount: sql<number>`COUNT(DISTINCT ${techVendorPossibleItems.vendorId})`.as('vendorCount'),
- itemCount: sql<number>`COUNT(DISTINCT ${techVendorPossibleItems.itemCode})`.as('itemCount'),
+ itemCount: sql<number>`COUNT(DISTINCT ${itemOffshoreHull.itemCode})`.as('itemCount'),
})
.from(techVendorPossibleItems)
.innerJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id))
+ .innerJoin(itemOffshoreHull, eq(techVendorPossibleItems.offshoreHullItemId, itemOffshoreHull.id))
.where(where)
- .groupBy(techVendorPossibleItems.workType)
+ .groupBy(itemOffshoreHull.workType)
.orderBy(desc(count()));
+
+ // 결과 통합
+ return [...shipStats, ...topStats, ...hullStats];
}
/**
- * 선종별 통계 조회
+ * 선종별 통계 조회 (조선 아이템만 해당)
*/
export async function getShipTypeStats(
tx: PgTransaction<any, any, any>,
@@ -111,15 +141,16 @@ export async function getShipTypeStats(
) {
return await tx
.select({
- shipTypes: techVendorPossibleItems.shipTypes,
+ shipTypes: itemShipbuilding.shipTypes,
count: count(),
vendorCount: sql<number>`COUNT(DISTINCT ${techVendorPossibleItems.vendorId})`.as('vendorCount'),
- itemCount: sql<number>`COUNT(DISTINCT ${techVendorPossibleItems.itemCode})`.as('itemCount'),
+ itemCount: sql<number>`COUNT(DISTINCT ${itemShipbuilding.itemCode})`.as('itemCount'),
})
.from(techVendorPossibleItems)
.innerJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id))
+ .innerJoin(itemShipbuilding, eq(techVendorPossibleItems.shipbuildingItemId, itemShipbuilding.id))
.where(where)
- .groupBy(techVendorPossibleItems.shipTypes)
+ .groupBy(itemShipbuilding.shipTypes)
.orderBy(desc(count()));
}
@@ -133,24 +164,61 @@ export async function getVendorStats(
return await tx
.select({
vendorId: techVendorPossibleItems.vendorId,
- vendorCode: techVendorPossibleItems.vendorCode,
+ vendorCode: techVendors.vendorCode,
vendorName: techVendors.vendorName,
- vendorEmail: techVendorPossibleItems.vendorEmail,
+ vendorEmail: techVendors.email,
+ vendorStatus: techVendors.status,
itemCount: count(),
- distinctItemCount: sql<number>`COUNT(DISTINCT ${techVendorPossibleItems.itemCode})`.as('distinctItemCount'),
- workTypeCount: sql<number>`COUNT(DISTINCT ${techVendorPossibleItems.workType})`.as('workTypeCount'),
- shipTypeCount: sql<number>`COUNT(DISTINCT ${techVendorPossibleItems.shipTypes})`.as('shipTypeCount'),
latestUpdate: sql<Date>`MAX(${techVendorPossibleItems.updatedAt})`.as('latestUpdate'),
})
.from(techVendorPossibleItems)
.innerJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id))
+ .leftJoin(itemShipbuilding, eq(techVendorPossibleItems.shipbuildingItemId, itemShipbuilding.id))
+ .leftJoin(itemOffshoreTop, eq(techVendorPossibleItems.offshoreTopItemId, itemOffshoreTop.id))
+ .leftJoin(itemOffshoreHull, eq(techVendorPossibleItems.offshoreHullItemId, itemOffshoreHull.id))
.where(where)
.groupBy(
techVendorPossibleItems.vendorId,
- techVendorPossibleItems.vendorCode,
+ techVendors.vendorCode,
techVendors.vendorName,
- techVendorPossibleItems.vendorEmail
+ techVendors.email,
+ techVendors.status
)
.orderBy(desc(count()));
}
+/**
+ * 아이템 타입별 통계 조회 (조선, 해양TOP, 해양HULL)
+ */
+export async function getItemTypeStats(
+ tx: PgTransaction<any, any, any>,
+ where?: SQL | undefined
+) {
+ const [shipCount] = await tx
+ .select({ count: count() })
+ .from(techVendorPossibleItems)
+ .innerJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id))
+ .innerJoin(itemShipbuilding, eq(techVendorPossibleItems.shipbuildingItemId, itemShipbuilding.id))
+ .where(where);
+
+ const [topCount] = await tx
+ .select({ count: count() })
+ .from(techVendorPossibleItems)
+ .innerJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id))
+ .innerJoin(itemOffshoreTop, eq(techVendorPossibleItems.offshoreTopItemId, itemOffshoreTop.id))
+ .where(where);
+
+ const [hullCount] = await tx
+ .select({ count: count() })
+ .from(techVendorPossibleItems)
+ .innerJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id))
+ .innerJoin(itemOffshoreHull, eq(techVendorPossibleItems.offshoreHullItemId, itemOffshoreHull.id))
+ .where(where);
+
+ return [
+ { itemType: "조선", count: shipCount.count },
+ { itemType: "해양TOP", count: topCount.count },
+ { itemType: "해양HULL", count: hullCount.count },
+ ];
+}
+
diff --git a/lib/tech-vendor-possible-items/service.ts b/lib/tech-vendor-possible-items/service.ts
index c630e33a..48f9b869 100644
--- a/lib/tech-vendor-possible-items/service.ts
+++ b/lib/tech-vendor-possible-items/service.ts
@@ -1,5 +1,6 @@
-"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택)
-import { eq, and, inArray, desc, asc, or, ilike, isNull } from "drizzle-orm";
+"use server";
+
+import { eq } from "drizzle-orm";
import db from "@/db/db";
import {
techVendors,
@@ -7,69 +8,48 @@ import {
} from "@/db/schema/techVendors";
import { itemShipbuilding, itemOffshoreTop, itemOffshoreHull } from "@/db/schema/items";
import { unstable_cache } from "@/lib/unstable-cache";
-import { filterColumns } from "@/lib/filter-columns";
-import type { GetTechVendorPossibleItemsSchema } from "./validations";
-import {
- selectTechVendorPossibleItemsWithJoin,
- countTechVendorPossibleItemsWithJoin,
-} from "./repository";
+// 새로운 스키마에 맞는 데이터 인터페이스
export interface TechVendorPossibleItemsData {
id: number;
vendorId: number;
+ shipbuildingItemId: number | null;
+ offshoreTopItemId: number | null;
+ offshoreHullItemId: number | null;
+ createdAt: Date;
+ updatedAt: Date;
+ // 조인된 벤더 정보
vendorCode: string | null;
vendorName: string;
vendorEmail: string | null;
+ vendorStatus: string;
techVendorType: string;
+ // 조인된 아이템 정보
itemCode: string;
workType: string | null;
shipTypes: string | null;
itemList: string | null;
subItemList: string | null;
- createdAt: Date;
- updatedAt: Date;
}
-export interface CreateTechVendorPossibleItemData {
- vendorId: number; // 필수: 벤더 ID (Add Dialog에서 벤더 선택 시 사용)
- itemCode: string; // 필수: 아이템 코드
- workType?: string | null; // 공종 (아이템에서 가져온 정보)
- shipTypes?: string | null; // 선종 (아이템에서 가져온 정보)
- itemList?: string | null; // 아이템리스트 (아이템에서 가져온 정보)
- subItemList?: string | null; // 서브아이템리스트 (아이템에서 가져온 정보)
-}
-
-export interface ImportTechVendorPossibleItemData {
+export interface GetTechVendorPossibleItemsSchema {
+ page: number;
+ perPage: number;
+ search?: string;
+ filters?: Array<{ id: string; value: string | number | boolean; operator?: string }>;
+ joinOperator?: "and" | "or";
+ sort?: Array<{ id: string; desc: boolean }>;
vendorCode?: string;
- vendorEmail: string; // 필수: 벤더 이메일
- itemCode: string; // 필수: 아이템 코드
- workType?: string;
- shipTypes?: string;
- itemList?: string;
- subItemList?: string;
-}
-
-export interface ImportResult {
- success: boolean;
- totalRows: number;
- successCount: number;
- failedRows: {
- row: number;
- error: string;
- vendorCode?: string;
- vendorEmail?: string;
- itemCode?: string;
- workType?: string;
- shipTypes?: string;
- itemList?: string;
- subItemList?: string;
- }[];
+ vendorName?: string;
+ vendorEmail?: string;
+ vendorStatus?: string;
+ itemCode?: string;
+ vendorType?: string;
}
-
-
/**
- * 견적프로젝트 패턴에 맞는 메인 조회 함수
+ * 새로운 스키마에 맞는 tech vendor possible items 조회 함수
+ * 벤더 정보와 각 아이템 테이블별 정보를 조인해서 가져옴
*/
export async function getTechVendorPossibleItems(input: GetTechVendorPossibleItemsSchema) {
return unstable_cache(
@@ -77,39 +57,112 @@ export async function getTechVendorPossibleItems(input: GetTechVendorPossibleIte
try {
const offset = (input.page - 1) * input.perPage;
- // 고급 필터링 (DataTableAdvancedToolbar용)
- const advancedWhere = filterColumns({
- table: techVendorPossibleItems,
- filters: input.filters,
- joinOperator: input.joinOperator,
- });
-
- // 전역 검색 (search box용)
- let globalWhere;
+ // 한 쿼리로 모든 아이템 조회 (각 레코드는 3개 중 하나의 ID만 가짐)
+ const rawItems = await db
+ .select({
+ id: techVendorPossibleItems.id,
+ vendorId: techVendorPossibleItems.vendorId,
+ shipbuildingItemId: techVendorPossibleItems.shipbuildingItemId,
+ offshoreTopItemId: techVendorPossibleItems.offshoreTopItemId,
+ offshoreHullItemId: techVendorPossibleItems.offshoreHullItemId,
+ createdAt: techVendorPossibleItems.createdAt,
+ updatedAt: techVendorPossibleItems.updatedAt,
+ // 벤더 정보
+ vendorCode: techVendors.vendorCode,
+ vendorName: techVendors.vendorName,
+ vendorEmail: techVendors.email,
+ vendorStatus: techVendors.status,
+ techVendorType: techVendors.techVendorType,
+ // 조선 아이템 정보 (shipbuildingItemId가 있을 때만)
+ shipItemCode: itemShipbuilding.itemCode,
+ shipWorkType: itemShipbuilding.workType,
+ shipItemList: itemShipbuilding.itemList,
+ shipTypes: itemShipbuilding.shipTypes,
+ // 해양 TOP 아이템 정보 (offshoreTopItemId가 있을 때만)
+ topItemCode: itemOffshoreTop.itemCode,
+ topWorkType: itemOffshoreTop.workType,
+ topItemList: itemOffshoreTop.itemList,
+ topSubItemList: itemOffshoreTop.subItemList,
+ // 해양 HULL 아이템 정보 (offshoreHullItemId가 있을 때만)
+ hullItemCode: itemOffshoreHull.itemCode,
+ hullWorkType: itemOffshoreHull.workType,
+ hullItemList: itemOffshoreHull.itemList,
+ hullSubItemList: itemOffshoreHull.subItemList,
+ })
+ .from(techVendorPossibleItems)
+ .innerJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id))
+ .leftJoin(itemShipbuilding, eq(techVendorPossibleItems.shipbuildingItemId, itemShipbuilding.id))
+ .leftJoin(itemOffshoreTop, eq(techVendorPossibleItems.offshoreTopItemId, itemOffshoreTop.id))
+ .leftJoin(itemOffshoreHull, eq(techVendorPossibleItems.offshoreHullItemId, itemOffshoreHull.id));
+
+ // 결과를 통합된 형태로 변환
+ const allItems: TechVendorPossibleItemsData[] = rawItems.map((item: Record<string, any>) => ({
+ id: item.id,
+ vendorId: item.vendorId,
+ shipbuildingItemId: item.shipbuildingItemId,
+ offshoreTopItemId: item.offshoreTopItemId,
+ offshoreHullItemId: item.offshoreHullItemId,
+ createdAt: item.createdAt,
+ updatedAt: item.updatedAt,
+ vendorCode: item.vendorCode,
+ vendorName: item.vendorName,
+ vendorEmail: item.vendorEmail,
+ vendorStatus: item.vendorStatus,
+ techVendorType: item.techVendorType,
+ // 어떤 타입의 아이템인지에 따라 적절한 값 선택
+ itemCode: item.shipItemCode || item.topItemCode || item.hullItemCode || "",
+ workType: item.shipWorkType || item.topWorkType || item.hullWorkType,
+ itemList: item.shipItemList || item.topItemList || item.hullItemList,
+ shipTypes: item.shipTypes, // 조선에만 있음
+ subItemList: item.topSubItemList || item.hullSubItemList, // 해양에만 있음
+ }));
+
+ let filteredItems = allItems;
+
+ // 필터링 적용
if (input.search) {
- const s = `%${input.search}%`;
- globalWhere = or(
- ilike(techVendors.vendorCode, s),
- ilike(techVendors.vendorName, s),
- ilike(techVendorPossibleItems.vendorEmail, s),
- ilike(techVendorPossibleItems.itemCode, s),
- ilike(techVendorPossibleItems.workType, s),
- ilike(techVendorPossibleItems.shipTypes, s),
- ilike(techVendorPossibleItems.itemList, s),
- ilike(techVendorPossibleItems.subItemList, s),
+ const s = input.search.toLowerCase();
+ filteredItems = filteredItems.filter(item =>
+ item.vendorCode?.toLowerCase().includes(s) ||
+ item.vendorName?.toLowerCase().includes(s) ||
+ item.vendorEmail?.toLowerCase().includes(s) ||
+ 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)
);
}
- // 기존 호환성을 위한 개별 필터들
- const legacyFilters = [];
+ // 개별 필터들
if (input.vendorCode) {
- legacyFilters.push(ilike(techVendorPossibleItems.vendorCode, `%${input.vendorCode}%`));
+ filteredItems = filteredItems.filter(item =>
+ item.vendorCode?.toLowerCase().includes(input.vendorCode!.toLowerCase())
+ );
}
+
if (input.vendorName) {
- legacyFilters.push(ilike(techVendors.vendorName, `%${input.vendorName}%`));
+ filteredItems = filteredItems.filter(item =>
+ item.vendorName?.toLowerCase().includes(input.vendorName!.toLowerCase())
+ );
+ }
+
+ if (input.vendorEmail) {
+ filteredItems = filteredItems.filter(item =>
+ item.vendorEmail?.toLowerCase().includes(input.vendorEmail!.toLowerCase())
+ );
}
+
+ if (input.vendorStatus) {
+ filteredItems = filteredItems.filter(item =>
+ item.vendorStatus === input.vendorStatus
+ );
+ }
+
if (input.itemCode) {
- legacyFilters.push(ilike(techVendorPossibleItems.itemCode, `%${input.itemCode}%`));
+ filteredItems = filteredItems.filter(item =>
+ item.itemCode?.toLowerCase().includes(input.itemCode!.toLowerCase())
+ );
}
// 벤더 타입 필터링
@@ -123,31 +176,36 @@ export async function getTechVendorPossibleItems(input: GetTechVendorPossibleIte
const actualVendorType = vendorTypeMap[input.vendorType as keyof typeof vendorTypeMap] || input.vendorType;
if (actualVendorType) {
- legacyFilters.push(ilike(techVendors.techVendorType, `%${actualVendorType}%`));
+ filteredItems = filteredItems.filter(item =>
+ item.techVendorType?.includes(actualVendorType)
+ );
}
}
- // 모든 조건 결합
- const finalWhere = and(
- advancedWhere,
- globalWhere,
- ...(legacyFilters.length > 0 ? [and(...legacyFilters)] : [])
- );
-
- // 정렬 조건
- const orderBy = [desc(techVendorPossibleItems.createdAt)];
-
- // 트랜잭션 내에서 Repository 호출
- const { data, total } = await db.transaction(async (tx) => {
- const data = await selectTechVendorPossibleItemsWithJoin(tx, finalWhere, orderBy, offset, input.perPage);
+ // 정렬
+ if (input.sort && input.sort.length > 0) {
+ filteredItems.sort((a, b) => {
+ for (const sortItem of input.sort!) {
+ const aVal = (a as Record<string, any>)[sortItem.id] || "";
+ const bVal = (b as Record<string, any>)[sortItem.id] || "";
+
+ 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 = await countTechVendorPossibleItemsWithJoin(tx, finalWhere);
- return { data, total };
- });
+ const totalCount = filteredItems.length;
+ const pageCount = Math.ceil(totalCount / input.perPage);
- const pageCount = Math.ceil(total / input.perPage);
+ // 페이지네이션 적용
+ const data = filteredItems.slice(offset, offset + input.perPage);
- return { data, pageCount, totalCount: total };
+ return { data, pageCount, totalCount };
} catch (error) {
console.error("Error fetching tech vendor possible items:", error);
return { data: [], pageCount: 0, totalCount: 0 };
@@ -159,659 +217,4 @@ export async function getTechVendorPossibleItems(input: GetTechVendorPossibleIte
tags: ["tech-vendor-possible-items"],
}
)();
-}
-
-
-
-/**
- * 페이지네이션을 포함한 tech vendor possible items 조회
- */
-// export async function getTechVendorPossibleItemsWithPagination(
-// page: number = 1,
-// pageSize: number = 50,
-// searchTerm?: string,
-// vendorType?: string
-// ): Promise<{
-// data: TechVendorPossibleItemsData[];
-// totalCount: number;
-// totalPages: number;
-// }> {
-// const whereConditions = [];
-
-// if (searchTerm) {
-// whereConditions.push(
-// sql`(
-// ${techVendors.vendorName} ILIKE ${`%${searchTerm}%`} OR
-// ${techVendors.vendorCode} ILIKE ${`%${searchTerm}%`} OR
-// ${techVendorPossibleItems.itemCode} ILIKE ${`%${searchTerm}%`}
-// )`
-// );
-// }
-
-// // 벤더 타입 필터링 로직 추가
-// if (vendorType && vendorType !== "all") {
-// // URL의 vendorType 파라미터를 실제 벤더 타입으로 매핑
-// const vendorTypeMap = {
-// "ship": "조선",
-// "top": "해양TOP",
-// "hull": "해양HULL"
-// };
-
-// const actualVendorType = vendorType in vendorTypeMap
-// ? vendorTypeMap[vendorType as keyof typeof vendorTypeMap]
-// : vendorType; // 매핑되지 않는 경우 원본 값 사용
-
-// if (actualVendorType) {
-// // techVendorType 필드는 콤마로 구분된 문자열이므로 LIKE 사용
-// whereConditions.push(sql`${techVendors.techVendorType} ILIKE ${`%${actualVendorType}%`}`);
-// }
-// }
-
-// const whereClause = whereConditions.length > 0 ? and(...whereConditions) : undefined;
-
-// // 총 개수 조회
-// const [totalCountResult] = await db
-// .select({ count: count() })
-// .from(techVendorPossibleItems)
-// .innerJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id))
-// .where(whereClause);
-
-// const totalCount = totalCountResult.count;
-// const totalPages = Math.ceil(totalCount / pageSize);
-// const offset = (page - 1) * pageSize;
-
-// // 데이터 조회
-// const data = await db
-// .select({
-// id: techVendorPossibleItems.id,
-// vendorId: techVendorPossibleItems.vendorId,
-// vendorCode: techVendors.vendorCode,
-// vendorName: techVendors.vendorName,
-// techVendorType: techVendors.techVendorType,
-// itemCode: techVendorPossibleItems.itemCode,
-// createdAt: techVendorPossibleItems.createdAt,
-// updatedAt: techVendorPossibleItems.updatedAt,
-// })
-// .from(techVendorPossibleItems)
-// .innerJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id))
-// .where(whereClause)
-// .orderBy(desc(techVendorPossibleItems.createdAt))
-// .limit(pageSize)
-// .offset(offset);
-
-// return {
-// data,
-// totalCount,
-// totalPages,
-// };
-// }
-
-/**
- * tech vendor possible item 생성 (Add Dialog용 - vendorId 기반)
- */
-export async function createTechVendorPossibleItem(
- data: CreateTechVendorPossibleItemData
-): Promise<{ success: boolean; error?: string }> {
- try {
- // 벤더 ID로 벤더 조회
- const vendor = await db
- .select()
- .from(techVendors)
- .where(eq(techVendors.id, data.vendorId))
- .limit(1);
-
- if (!vendor[0]) {
- return { success: false, error: "벤더를 찾을 수 없습니다." };
- }
-
- // 중복 체크 (벤더 + 아이템코드 + 공종 + 선종 조합)
- const existing = await db
- .select()
- .from(techVendorPossibleItems)
- .where(
- and(
- eq(techVendorPossibleItems.vendorId, data.vendorId),
- eq(techVendorPossibleItems.itemCode, data.itemCode),
- data.workType
- ? eq(techVendorPossibleItems.workType, data.workType)
- : isNull(techVendorPossibleItems.workType),
- data.shipTypes
- ? eq(techVendorPossibleItems.shipTypes, data.shipTypes)
- : isNull(techVendorPossibleItems.shipTypes)
- )
- )
- .limit(1);
-
- if (existing.length > 0) {
- return { success: false, error: "이미 존재하는 벤더-아이템 조합입니다." };
- }
-
- // 새로운 아이템 생성 (선택한 아이템의 정보를 그대로 저장)
- await db.insert(techVendorPossibleItems).values({
- vendorId: vendor[0].id,
- vendorCode: vendor[0].vendorCode,
- vendorEmail: vendor[0].email,
- itemCode: data.itemCode,
- workType: data.workType,
- shipTypes: data.shipTypes,
- itemList: data.itemList,
- subItemList: data.subItemList,
- });
-
- return { success: true };
- } catch (error) {
- console.error("Failed to create tech vendor possible item:", error);
- return {
- success: false,
- error: error instanceof Error ? error.message : "생성 중 오류가 발생했습니다."
- };
- }
-}
-
-/**
- * tech vendor possible items 삭제
- */
-export async function deleteTechVendorPossibleItems(
- ids: number[]
-): Promise<{ success: boolean; error?: string }> {
- try {
- await db
- .delete(techVendorPossibleItems)
- .where(inArray(techVendorPossibleItems.id, ids));
-
- return { success: true };
- } catch (error) {
- console.error("Failed to delete tech vendor possible items:", error);
- return {
- success: false,
- error: error instanceof Error ? error.message : "삭제 중 오류가 발생했습니다."
- };
- }
-}
-
-/**
- * 벤더 코드로 벤더 정보 조회
- */
-export async function getTechVendorByCode(vendorCode: string) {
- const result = await db
- .select()
- .from(techVendors)
- .where(eq(techVendors.vendorCode, vendorCode))
- .limit(1);
-
- return result[0] || null;
-}
-
-/**
- * 벤더 이메일로 벤더 정보 조회
- */
-export async function getTechVendorByEmail(vendorEmail: string) {
- const result = await db
- .select()
- .from(techVendors)
- .where(eq(techVendors.email, vendorEmail))
- .limit(1);
-
- return result[0] || null;
-}
-
-/**
- * 벤더 타입에 따라 적절한 아이템 테이블에서 아이템 조회
- */
-export async function getItemByCodeAndVendorType(itemCode: string, vendorType: string) {
- try {
- switch (vendorType) {
- case "조선":
- const shipItem = await db
- .select()
- .from(itemShipbuilding)
- .where(eq(itemShipbuilding.itemCode, itemCode))
- .limit(1);
- return shipItem[0] ? {
- itemCode: shipItem[0].itemCode,
- workType: shipItem[0].workType
- } : null;
-
- case "해양TOP":
- const topItem = await db
- .select()
- .from(itemOffshoreTop)
- .where(eq(itemOffshoreTop.itemCode, itemCode))
- .limit(1);
- return topItem[0] ? {
- itemCode: topItem[0].itemCode,
- workType: topItem[0].workType
- } : null;
-
- case "해양HULL":
- const hullItem = await db
- .select()
- .from(itemOffshoreHull)
- .where(eq(itemOffshoreHull.itemCode, itemCode))
- .limit(1);
- return hullItem[0] ? {
- itemCode: hullItem[0].itemCode,
- workType: hullItem[0].workType
- } : null;
-
- default:
- return null;
- }
- } catch (error) {
- console.error("Error fetching item by code and vendor type:", error);
- return null;
- }
-}
-
-/**
- * 아이템 코드로 아이템 정보 조회 (기존 함수 - 호환성 유지)
- */
-export async function getItemByCode(itemCode: string) {
- // 기존 items 테이블 대신 조선 테이블에서 먼저 조회 시도
- try {
- const shipItem = await db
- .select()
- .from(itemShipbuilding)
- .where(eq(itemShipbuilding.itemCode, itemCode))
- .limit(1);
-
- if (shipItem[0]) {
- return {
- itemCode: shipItem[0].itemCode,
- };
- }
-
- const topItem = await db
- .select()
- .from(itemOffshoreTop)
- .where(eq(itemOffshoreTop.itemCode, itemCode))
- .limit(1);
-
- if (topItem[0]) {
- return {
- itemCode: topItem[0].itemCode,
- };
- }
-
- const hullItem = await db
- .select()
- .from(itemOffshoreHull)
- .where(eq(itemOffshoreHull.itemCode, itemCode))
- .limit(1);
-
- if (hullItem[0]) {
- return {
- itemCode: hullItem[0].itemCode,
- };
- }
-
- return null;
- } catch (error) {
- console.error("Error fetching item by code:", error);
- return null;
- }
-}
-
-/**
- * Import 기능: 벤더이메일과 아이템정보를 통한 batch insert (새로운 스키마 버전)
- */
-export async function importTechVendorPossibleItems(
- data: ImportTechVendorPossibleItemData[]
-): Promise<ImportResult> {
- const result: ImportResult = {
- success: true,
- totalRows: data.length,
- successCount: 0,
- failedRows: [],
- };
-
- for (let i = 0; i < data.length; i++) {
- const row = data[i];
- const rowNumber = i + 1;
-
- try {
- // 벤더 이메일로 벤더 찾기 (필수)
- let vendor = null;
-
- if (row.vendorEmail && row.vendorEmail.trim()) {
- vendor = await getTechVendorByEmail(row.vendorEmail);
- } else {
- result.failedRows.push({
- row: rowNumber,
- error: "벤더 이메일은 필수입니다.",
- vendorCode: row.vendorCode,
- vendorEmail: row.vendorEmail,
- itemCode: row.itemCode,
- workType: row.workType,
- shipTypes: row.shipTypes,
- itemList: row.itemList,
- subItemList: row.subItemList,
- });
- continue;
- }
-
- if (!vendor) {
- result.failedRows.push({
- row: rowNumber,
- error: `벤더 이메일 '${row.vendorEmail}'을(를) 찾을 수 없습니다.`,
- vendorCode: row.vendorCode,
- vendorEmail: row.vendorEmail,
- itemCode: row.itemCode,
- workType: row.workType,
- shipTypes: row.shipTypes,
- itemList: row.itemList,
- subItemList: row.subItemList,
- });
- continue;
- }
-
- // 중복 체크 (벤더 + 아이템코드 + 공종 + 선종 조합)
- const existing = await db
- .select()
- .from(techVendorPossibleItems)
- .where(
- and(
- eq(techVendorPossibleItems.vendorId, vendor.id),
- eq(techVendorPossibleItems.itemCode, row.itemCode),
- row.workType
- ? eq(techVendorPossibleItems.workType, row.workType)
- : isNull(techVendorPossibleItems.workType),
- row.shipTypes
- ? eq(techVendorPossibleItems.shipTypes, row.shipTypes)
- : isNull(techVendorPossibleItems.shipTypes)
- )
- )
- .limit(1);
-
- if (existing.length > 0) {
- result.failedRows.push({
- row: rowNumber,
- error: `이미 존재하는 벤더-아이템 조합입니다.`,
- vendorCode: row.vendorCode,
- vendorEmail: row.vendorEmail,
- itemCode: row.itemCode,
- workType: row.workType,
- shipTypes: row.shipTypes,
- itemList: row.itemList,
- subItemList: row.subItemList,
- });
- continue;
- }
-
- // 새로운 아이템 생성
- await db.insert(techVendorPossibleItems).values({
- vendorId: vendor.id,
- vendorCode: vendor.vendorCode,
- vendorEmail: vendor.email,
- itemCode: row.itemCode,
- workType: row.workType || null,
- shipTypes: row.shipTypes || null,
- itemList: row.itemList || null,
- subItemList: row.subItemList || null,
- });
-
- result.successCount++;
- } catch (error) {
- result.failedRows.push({
- row: rowNumber,
- error: error instanceof Error ? error.message : "알 수 없는 오류",
- vendorCode: row.vendorCode,
- vendorEmail: row.vendorEmail,
- itemCode: row.itemCode,
- workType: row.workType,
- shipTypes: row.shipTypes,
- itemList: row.itemList,
- subItemList: row.subItemList,
- });
- }
- }
-
- if (result.failedRows.length > 0) {
- result.success = false;
- }
-
- return result;
-}
-
-/**
- * 모든 기술영업 벤더 조회 (드롭다운용)
- */
-export async function getAllTechVendors() {
- return await db
- .select({
- id: techVendors.id,
- vendorCode: techVendors.vendorCode,
- vendorName: techVendors.vendorName,
- techVendorType: techVendors.techVendorType,
- })
- .from(techVendors)
- .where(eq(techVendors.status, "ACTIVE"))
- .orderBy(asc(techVendors.vendorName));
-}
-
-/**
- * 고유한 벤더 타입 목록 조회 (필터용)
- */
-export async function getUniqueTechVendorTypes(): Promise<string[]> {
- try {
- const result = await db
- .select({
- techVendorType: techVendors.techVendorType,
- })
- .from(techVendors)
- .where(eq(techVendors.status, "ACTIVE"));
-
- // techVendorType이 JSON 배열 형태로 저장된 경우를 고려
- const allTypes = new Set<string>();
-
- result.forEach(row => {
- try {
- // techVendorType이 JSON 문자열인지 확인
- if (row.techVendorType && row.techVendorType.startsWith('[')) {
- const types = JSON.parse(row.techVendorType);
- if (Array.isArray(types)) {
- types.forEach(type => {
- if (type && typeof type === 'string') {
- allTypes.add(type.trim());
- }
- });
- }
- } else if (row.techVendorType) {
- // 단순 문자열인 경우
- row.techVendorType.split(',').forEach(type => {
- const trimmedType = type.trim();
- if (trimmedType) {
- allTypes.add(trimmedType);
- }
- });
- }
- } catch {
- // JSON 파싱 실패시 문자열로 처리
- if (row.techVendorType) {
- row.techVendorType.split(',').forEach(type => {
- const trimmedType = type.trim();
- if (trimmedType) {
- allTypes.add(trimmedType);
- }
- });
- }
- }
- });
-
- return Array.from(allTypes).sort();
- } catch (error) {
- console.error("Error fetching unique tech vendor types:", error);
- // 오류 발생시 기본 벤더 타입 반환
- return ["조선", "해양TOP", "해양HULL"];
- }
-}
-
-/**
- * 벤더 타입에 따른 아이템 목록 조회
- */
-export async function getItemsByVendorType(vendorTypes: string): Promise<{
- itemCode: string;
- itemList: string | null;
- workType: string | null;
- shipTypes?: string | null;
- subItemList?: string | null;
-}[]> {
- try {
- // 벤더 타입 파싱 개선
- let types: string[] = [];
- if (!vendorTypes) {
- return [];
- }
-
- if (vendorTypes.startsWith('[') && vendorTypes.endsWith(']')) {
- // JSON 배열 형태
- try {
- const parsed = JSON.parse(vendorTypes);
- types = Array.isArray(parsed) ? parsed.filter(Boolean) : [vendorTypes];
- } catch {
- types = [vendorTypes];
- }
- } else if (vendorTypes.includes(',')) {
- // 콤마로 구분된 문자열
- types = vendorTypes.split(',').map(t => t.trim()).filter(Boolean);
- } else {
- // 단일 문자열
- types = [vendorTypes.trim()].filter(Boolean);
- }
- // 벤더 타입 정렬 - 조선 > 해양TOP > 해양HULL 순
- const typeOrder = ["조선", "해양TOP", "해양HULL"];
- types.sort((a, b) => {
- const indexA = typeOrder.indexOf(a);
- const indexB = typeOrder.indexOf(b);
-
- // 정의된 순서에 있는 경우 우선순위 적용
- if (indexA !== -1 && indexB !== -1) {
- return indexA - indexB;
- }
- // 정의된 순서에 없는 경우 마지막에 배치하고 알파벳 순으로 정렬
- if (indexA !== -1) return -1;
- if (indexB !== -1) return 1;
- return a.localeCompare(b);
- });
-
- const allItems: any[] = [];
-
- // 각 벤더 타입에 따라 해당 아이템 테이블에서 조회
- for (const type of types) {
- switch (type) {
- case "조선":
- const shipItems = await db
- .select({
- itemCode: itemShipbuilding.itemCode,
- itemList: itemShipbuilding.itemList,
- workType: itemShipbuilding.workType,
- shipTypes: itemShipbuilding.shipTypes,
- })
- .from(itemShipbuilding);
- allItems.push(...shipItems);
- break;
-
- case "해양TOP":
- const topItems = await db
- .select({
- itemCode: itemOffshoreTop.itemCode,
- itemList: itemOffshoreTop.itemList,
- workType: itemOffshoreTop.workType,
- subItemList: itemOffshoreTop.subItemList,
- })
- .from(itemOffshoreTop);
- allItems.push(...topItems);
- break;
-
- case "해양HULL":
- const hullItems = await db
- .select({
- itemCode: itemOffshoreHull.itemCode,
- itemList: itemOffshoreHull.itemList,
- workType: itemOffshoreHull.workType,
- subItemList: itemOffshoreHull.subItemList,
- })
- .from(itemOffshoreHull);
- allItems.push(...hullItems);
- break;
- }
- }
- // // 중복 제거 (itemCode 기준)
- // const uniqueItems = allItems.filter((item, index, self) =>
- // index === self.findIndex(i => i.itemCode === item.itemCode)
- // );
-
- // const finalItems = uniqueItems.filter(item => item.itemCode); // itemCode가 있는 것만 반환
- // console.log("Final items after deduplication and filtering:", finalItems.length);
-
- return allItems;
- } catch (error) {
- console.error("Error fetching items by vendor type:", error);
- return [];
- }
-}
-
-/**
- * Excel Export 기능: 기술영업 벤더 가능 아이템 목록 내보내기
- */
-export async function exportTechVendorPossibleItemsToExcel(): Promise<{
- success: boolean;
- data?: Array<{
- 벤더코드: string | null;
- 벤더명: string;
- 벤더이메일: string | null;
- 벤더타입: string;
- 아이템코드: string;
- 공종: string | null;
- 선종: string | null;
- 아이템리스트: string | null;
- 서브아이템리스트: string | null;
- 생성일: string;
- }>;
- error?: string;
-}> {
- try {
- // 모든 데이터 조회 (페이지네이션 없이)
- const allData = await db
- .select({
- vendorCode: techVendorPossibleItems.vendorCode,
- vendorName: techVendors.vendorName,
- vendorEmail: techVendorPossibleItems.vendorEmail,
- techVendorType: techVendors.techVendorType,
- itemCode: techVendorPossibleItems.itemCode,
- workType: techVendorPossibleItems.workType,
- shipTypes: techVendorPossibleItems.shipTypes,
- itemList: techVendorPossibleItems.itemList,
- subItemList: techVendorPossibleItems.subItemList,
- createdAt: techVendorPossibleItems.createdAt,
- })
- .from(techVendorPossibleItems)
- .innerJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id))
- .orderBy(desc(techVendorPossibleItems.createdAt));
-
- // Excel 형태로 변환
- const excelData = allData.map(item => ({
- 벤더코드: item.vendorCode,
- 벤더명: item.vendorName,
- 벤더이메일: item.vendorEmail,
- 벤더타입: item.techVendorType,
- 아이템코드: item.itemCode,
- 공종: item.workType,
- 선종: item.shipTypes,
- 아이템리스트: item.itemList,
- 서브아이템리스트: item.subItemList,
- 생성일: item.createdAt.toISOString().split('T')[0], // YYYY-MM-DD 형식
- }));
-
- return {
- success: true,
- data: excelData,
- };
- } catch (error) {
- console.error("Error exporting tech vendor possible items:", error);
- return {
- success: false,
- error: error instanceof Error ? error.message : "내보내기 중 오류가 발생했습니다.",
- };
- }
-}
+} \ No newline at end of file
diff --git a/lib/tech-vendor-possible-items/table/add-possible-item-dialog.tsx b/lib/tech-vendor-possible-items/table/add-possible-item-dialog.tsx
index cdce60af..85a551ea 100644
--- a/lib/tech-vendor-possible-items/table/add-possible-item-dialog.tsx
+++ b/lib/tech-vendor-possible-items/table/add-possible-item-dialog.tsx
@@ -1,450 +1,450 @@
-"use client";
+// "use client";
-import * as React from "react";
-import { Search, Plus, X } from "lucide-react";
+// import * as React from "react";
+// import { Search, Plus, X } from "lucide-react";
-import { Button } from "@/components/ui/button";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import { Badge } from "@/components/ui/badge";
-import { useToast } from "@/hooks/use-toast";
-import {
- getAllTechVendors,
- createTechVendorPossibleItem,
- getItemsByVendorType
-} from "@/lib/tech-vendor-possible-items/service";
+// import { Button } from "@/components/ui/button";
+// import {
+// Dialog,
+// DialogContent,
+// DialogDescription,
+// DialogHeader,
+// DialogTitle,
+// DialogTrigger,
+// } from "@/components/ui/dialog";
+// import { Input } from "@/components/ui/input";
+// import { Label } from "@/components/ui/label";
+// import { Badge } from "@/components/ui/badge";
+// import { useToast } from "@/hooks/use-toast";
+// import {
+// getAllTechVendors,
+// createTechVendorPossibleItem,
+// getItemsByVendorType
+// } from "@/lib/tech-vendor-possible-items/service";
-interface TechVendor {
- id: number;
- vendorCode: string | null;
- vendorName: string;
- techVendorType: string;
-}
+// interface TechVendor {
+// id: number;
+// vendorCode: string | null;
+// vendorName: string;
+// techVendorType: string;
+// }
-interface ItemData {
- itemCode: string;
- itemList: string | null;
- workType: string | null;
- shipTypes?: string | null;
- subItemList?: string | null;
-}
+// interface ItemData {
+// itemCode: string;
+// itemList: string | null;
+// workType: string | null;
+// shipTypes?: string | null;
+// subItemList?: string | null;
+// }
-interface AddPossibleItemDialogProps {
- children?: React.ReactNode;
- onSuccess?: () => void;
-}
+// interface AddPossibleItemDialogProps {
+// children?: React.ReactNode;
+// onSuccess?: () => void;
+// }
-export function AddPossibleItemDialog({
- children,
- onSuccess
-}: AddPossibleItemDialogProps) {
- const { toast } = useToast();
- const [open, setOpen] = React.useState(false);
+// export function AddPossibleItemDialog({
+// children,
+// onSuccess
+// }: AddPossibleItemDialogProps) {
+// const { toast } = useToast();
+// const [open, setOpen] = React.useState(false);
- // 벤더 관련 상태
- const [vendors, setVendors] = React.useState<TechVendor[]>([]);
- const [filteredVendors, setFilteredVendors] = React.useState<TechVendor[]>([]);
- const [vendorSearch, setVendorSearch] = React.useState("");
- const [selectedVendor, setSelectedVendor] = React.useState<TechVendor | null>(null);
+// // 벤더 관련 상태
+// const [vendors, setVendors] = React.useState<TechVendor[]>([]);
+// const [filteredVendors, setFilteredVendors] = React.useState<TechVendor[]>([]);
+// const [vendorSearch, setVendorSearch] = React.useState("");
+// const [selectedVendor, setSelectedVendor] = React.useState<TechVendor | null>(null);
- // 아이템 관련 상태
- const [items, setItems] = React.useState<ItemData[]>([]);
- const [filteredItems, setFilteredItems] = React.useState<ItemData[]>([]);
- const [itemSearch, setItemSearch] = React.useState("");
- const [selectedItems, setSelectedItems] = React.useState<ItemData[]>([]);
+// // 아이템 관련 상태
+// const [items, setItems] = React.useState<ItemData[]>([]);
+// const [filteredItems, setFilteredItems] = React.useState<ItemData[]>([]);
+// const [itemSearch, setItemSearch] = React.useState("");
+// const [selectedItems, setSelectedItems] = React.useState<ItemData[]>([]);
- const [isLoading, setIsLoading] = React.useState(false);
+// const [isLoading, setIsLoading] = React.useState(false);
- // 벤더 목록 로드
- React.useEffect(() => {
- if (open) {
- loadVendors();
- }
- }, [open]);
+// // 벤더 목록 로드
+// React.useEffect(() => {
+// if (open) {
+// loadVendors();
+// }
+// }, [open]);
- // 벤더 검색 필터링
- React.useEffect(() => {
- if (!vendorSearch) {
- setFilteredVendors(vendors);
- } else {
- const filtered = vendors.filter(vendor =>
- vendor.vendorName.toLowerCase().includes(vendorSearch.toLowerCase()) ||
- vendor.vendorCode?.toLowerCase().includes(vendorSearch.toLowerCase())
- );
- setFilteredVendors(filtered);
- }
- }, [vendors, vendorSearch]);
+// // 벤더 검색 필터링
+// React.useEffect(() => {
+// if (!vendorSearch) {
+// setFilteredVendors(vendors);
+// } else {
+// const filtered = vendors.filter(vendor =>
+// vendor.vendorName.toLowerCase().includes(vendorSearch.toLowerCase()) ||
+// vendor.vendorCode?.toLowerCase().includes(vendorSearch.toLowerCase())
+// );
+// setFilteredVendors(filtered);
+// }
+// }, [vendors, vendorSearch]);
- // 아이템 검색 필터링
- React.useEffect(() => {
- if (!itemSearch) {
- setFilteredItems(items);
- } else {
- const filtered = items.filter(item =>
- item.itemCode.toLowerCase().includes(itemSearch.toLowerCase()) ||
- item.itemList?.toLowerCase().includes(itemSearch.toLowerCase()) ||
- item.workType?.toLowerCase().includes(itemSearch.toLowerCase())
- );
- setFilteredItems(filtered);
- }
- }, [items, itemSearch]);
+// // 아이템 검색 필터링
+// React.useEffect(() => {
+// if (!itemSearch) {
+// setFilteredItems(items);
+// } else {
+// const filtered = items.filter(item =>
+// item.itemCode.toLowerCase().includes(itemSearch.toLowerCase()) ||
+// item.itemList?.toLowerCase().includes(itemSearch.toLowerCase()) ||
+// item.workType?.toLowerCase().includes(itemSearch.toLowerCase())
+// );
+// setFilteredItems(filtered);
+// }
+// }, [items, itemSearch]);
- const loadVendors = async () => {
- try {
- setIsLoading(true);
- const vendorData = await getAllTechVendors();
- setVendors(vendorData);
- } catch (error) {
- console.error("Failed to load vendors:", error);
- toast({
- title: "오류",
- description: "벤더 목록을 불러오는데 실패했습니다.",
- variant: "destructive",
- });
- } finally {
- setIsLoading(false);
- }
- };
+// const loadVendors = async () => {
+// try {
+// setIsLoading(true);
+// const vendorData = await getAllTechVendors();
+// setVendors(vendorData);
+// } catch (error) {
+// console.error("Failed to load vendors:", error);
+// toast({
+// title: "오류",
+// description: "벤더 목록을 불러오는데 실패했습니다.",
+// variant: "destructive",
+// });
+// } finally {
+// setIsLoading(false);
+// }
+// };
- const loadItemsByVendorType = async (vendorTypes: string) => {
- try {
- setIsLoading(true);
- console.log("Loading items for vendor types:", vendorTypes);
- const itemData = await getItemsByVendorType(vendorTypes);
- console.log("Loaded items:", itemData.length, itemData);
- setItems(itemData);
- } catch (error) {
- console.error("Failed to load items:", error);
- toast({
- title: "오류",
- description: "아이템 목록을 불러오는데 실패했습니다.",
- variant: "destructive",
- });
- } finally {
- setIsLoading(false);
- }
- };
+// const loadItemsByVendorType = async (vendorTypes: string) => {
+// try {
+// setIsLoading(true);
+// console.log("Loading items for vendor types:", vendorTypes);
+// const itemData = await getItemsByVendorType(vendorTypes);
+// console.log("Loaded items:", itemData.length, itemData);
+// setItems(itemData);
+// } catch (error) {
+// console.error("Failed to load items:", error);
+// toast({
+// title: "오류",
+// description: "아이템 목록을 불러오는데 실패했습니다.",
+// variant: "destructive",
+// });
+// } finally {
+// setIsLoading(false);
+// }
+// };
- const handleVendorSelect = (vendor: TechVendor) => {
- setSelectedVendor(vendor);
- setSelectedItems([]); // 벤더 변경시 선택된 아이템 초기화
- loadItemsByVendorType(vendor.techVendorType);
- };
+// const handleVendorSelect = (vendor: TechVendor) => {
+// setSelectedVendor(vendor);
+// setSelectedItems([]); // 벤더 변경시 선택된 아이템 초기화
+// loadItemsByVendorType(vendor.techVendorType);
+// };
- const handleItemToggle = (item: ItemData) => {
- setSelectedItems(prev => {
- const isSelected = prev.some(i => i.itemCode === item.itemCode);
- if (isSelected) {
- return prev.filter(i => i.itemCode !== item.itemCode);
- } else {
- return [...prev, item];
- }
- });
- };
+// const handleItemToggle = (item: ItemData) => {
+// setSelectedItems(prev => {
+// const isSelected = prev.some(i => i.itemCode === item.itemCode);
+// if (isSelected) {
+// return prev.filter(i => i.itemCode !== item.itemCode);
+// } else {
+// return [...prev, item];
+// }
+// });
+// };
- const handleSubmit = async () => {
- if (!selectedVendor || selectedItems.length === 0) return;
+// const handleSubmit = async () => {
+// if (!selectedVendor || selectedItems.length === 0) return;
- try {
- setIsLoading(true);
- let successCount = 0;
- let errorCount = 0;
+// try {
+// setIsLoading(true);
+// let successCount = 0;
+// let errorCount = 0;
- for (const item of selectedItems) {
- const result = await createTechVendorPossibleItem({
- vendorId: selectedVendor.id,
- itemCode: item.itemCode,
- workType: item.workType,
- shipTypes: item.shipTypes,
- itemList: item.itemList,
- subItemList: item.subItemList,
- });
+// for (const item of selectedItems) {
+// const result = await createTechVendorPossibleItem({
+// vendorId: selectedVendor.id,
+// itemCode: item.itemCode,
+// workType: item.workType,
+// shipTypes: item.shipTypes,
+// itemList: item.itemList,
+// subItemList: item.subItemList,
+// });
- if (result.success) {
- successCount++;
- } else {
- errorCount++;
- }
- }
+// if (result.success) {
+// successCount++;
+// } else {
+// errorCount++;
+// }
+// }
- if (successCount > 0) {
- toast({
- title: "성공",
- description: `${successCount}개의 아이템이 추가되었습니다.${errorCount > 0 ? ` (${errorCount}개 실패)` : ""}`,
- });
+// if (successCount > 0) {
+// toast({
+// title: "성공",
+// description: `${successCount}개의 아이템이 추가되었습니다.${errorCount > 0 ? ` (${errorCount}개 실패)` : ""}`,
+// });
- handleClose();
- onSuccess?.();
- } else {
- toast({
- title: "오류",
- description: "아이템 추가에 실패했습니다.",
- variant: "destructive",
- });
- }
- } catch (error) {
- console.error("Failed to add items:", error);
- toast({
- title: "오류",
- description: "아이템 추가 중 오류가 발생했습니다.",
- variant: "destructive",
- });
- } finally {
- setIsLoading(false);
- }
- };
+// handleClose();
+// onSuccess?.();
+// } else {
+// toast({
+// title: "오류",
+// description: "아이템 추가에 실패했습니다.",
+// variant: "destructive",
+// });
+// }
+// } catch (error) {
+// console.error("Failed to add items:", error);
+// toast({
+// title: "오류",
+// description: "아이템 추가 중 오류가 발생했습니다.",
+// variant: "destructive",
+// });
+// } finally {
+// setIsLoading(false);
+// }
+// };
- const handleClose = () => {
- setOpen(false);
- setTimeout(() => {
- setSelectedVendor(null);
- setSelectedItems([]);
- setVendorSearch("");
- setItemSearch("");
- setVendors([]);
- setItems([]);
- setFilteredVendors([]);
- setFilteredItems([]);
- }, 200);
- };
+// const handleClose = () => {
+// setOpen(false);
+// setTimeout(() => {
+// setSelectedVendor(null);
+// setSelectedItems([]);
+// setVendorSearch("");
+// setItemSearch("");
+// setVendors([]);
+// setItems([]);
+// setFilteredVendors([]);
+// setFilteredItems([]);
+// }, 200);
+// };
- const parseVendorTypes = (vendorType: string): string[] => {
- if (!vendorType) return [];
+// const parseVendorTypes = (vendorType: string): string[] => {
+// if (!vendorType) return [];
- // JSON 배열 형태인지 확인
- if (vendorType.startsWith('[') && vendorType.endsWith(']')) {
- try {
- const parsed = JSON.parse(vendorType);
- return Array.isArray(parsed) ? parsed.filter(Boolean) : [vendorType];
- } catch {
- return [vendorType];
- }
- }
+// // JSON 배열 형태인지 확인
+// if (vendorType.startsWith('[') && vendorType.endsWith(']')) {
+// try {
+// const parsed = JSON.parse(vendorType);
+// return Array.isArray(parsed) ? parsed.filter(Boolean) : [vendorType];
+// } catch {
+// return [vendorType];
+// }
+// }
- // 콤마로 구분된 문자열인지 확인
- if (vendorType.includes(',')) {
- return vendorType.split(',').map(t => t.trim()).filter(Boolean);
- }
+// // 콤마로 구분된 문자열인지 확인
+// if (vendorType.includes(',')) {
+// return vendorType.split(',').map(t => t.trim()).filter(Boolean);
+// }
- // 단일 문자열
- return [vendorType.trim()].filter(Boolean);
- };
+// // 단일 문자열
+// return [vendorType.trim()].filter(Boolean);
+// };
- return (
- <Dialog open={open} onOpenChange={setOpen}>
- <DialogTrigger asChild>
- {children || (
- <Button size="sm">
- <Plus className="mr-2 h-4 w-4" />
- 추가
- </Button>
- )}
- </DialogTrigger>
- <DialogContent className="max-w-6xl max-h-[90vh] flex flex-col">
- <DialogHeader>
- <DialogTitle>
- 벤더별 아이템 추가
- </DialogTitle>
- <DialogDescription>
- 왼쪽에서 벤더를 선택하고, 오른쪽에서 아이템을 선택하세요.
- </DialogDescription>
- </DialogHeader>
+// return (
+// <Dialog open={open} onOpenChange={setOpen}>
+// <DialogTrigger asChild>
+// {children || (
+// <Button size="sm">
+// <Plus className="mr-2 h-4 w-4" />
+// 추가
+// </Button>
+// )}
+// </DialogTrigger>
+// <DialogContent className="max-w-6xl max-h-[90vh] flex flex-col">
+// <DialogHeader>
+// <DialogTitle>
+// 벤더별 아이템 추가
+// </DialogTitle>
+// <DialogDescription>
+// 왼쪽에서 벤더를 선택하고, 오른쪽에서 아이템을 선택하세요.
+// </DialogDescription>
+// </DialogHeader>
- <div className="flex-1 min-h-0">
- <div className="grid grid-cols-2 gap-4 h-[500px]">
- {/* 왼쪽: 벤더 선택/표시 */}
- <div className="space-y-4 h-full flex flex-col">
- {!selectedVendor ? (
- <>
- <div className="space-y-2">
- <Label htmlFor="vendor-search">벤더 검색</Label>
- <div className="relative">
- <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" /> <Input
- id="vendor-search"
- placeholder="벤더명 또는 벤더코드로 검색..."
- value={vendorSearch}
- onChange={(e) => setVendorSearch(e.target.value)}
- className="pl-10"
- />
- </div>
- </div>
+// <div className="flex-1 min-h-0">
+// <div className="grid grid-cols-2 gap-4 h-[500px]">
+// {/* 왼쪽: 벤더 선택/표시 */}
+// <div className="space-y-4 h-full flex flex-col">
+// {!selectedVendor ? (
+// <>
+// <div className="space-y-2">
+// <Label htmlFor="vendor-search">벤더 검색</Label>
+// <div className="relative">
+// <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" /> <Input
+// id="vendor-search"
+// placeholder="벤더명 또는 벤더코드로 검색..."
+// value={vendorSearch}
+// onChange={(e) => setVendorSearch(e.target.value)}
+// className="pl-10"
+// />
+// </div>
+// </div>
- <div className="max-h-96 overflow-y-auto border rounded-lg bg-gray-50 p-2">
- <div className="space-y-2">
- {isLoading ? (
- <div className="text-center py-4">로딩 중...</div>
- ) : filteredVendors.length === 0 ? (
- <div className="text-center py-4 text-muted-foreground">
- 검색 결과가 없습니다.
- </div>
- ) : (
- filteredVendors.map((vendor) => (
- <div
- key={vendor.id}
- className="p-3 bg-white border rounded-lg cursor-pointer transition-colors hover:bg-gray-50"
- onClick={() => handleVendorSelect(vendor)}
- >
- <div className="font-medium">{vendor.vendorName}</div>
- <div className="text-sm text-muted-foreground">
- {vendor.vendorCode}
- </div>
- <div className="flex flex-wrap gap-1 mt-2">
- {parseVendorTypes(vendor.techVendorType).map((type, index) => (
- <Badge key={`${vendor.id}-${type}-${index}`} variant="secondary" className="text-xs">
- {type}
- </Badge>
- ))}
- </div>
- </div>
- ))
- )}
- </div>
- </div>
- </>
- ) : (
- <div className="space-y-4">
- <div className="flex items-center justify-between">
- <Label>선택된 벤더</Label>
- <Button
- variant="outline"
- size="sm"
- onClick={() => {
- setSelectedVendor(null);
- setSelectedItems([]);
- setItems([]);
- setFilteredItems([]);
- }}
- >
- 변경
- </Button>
- </div>
- <div className="p-4 border rounded-md bg-muted/20">
- <div className="font-medium">{selectedVendor?.vendorName}</div>
- <div className="text-sm text-muted-foreground">
- {selectedVendor?.vendorCode}
- </div>
- <div className="flex flex-wrap gap-1 mt-2">
- {selectedVendor && parseVendorTypes(selectedVendor.techVendorType).map((type, index) => (
- <Badge key={`selected-${type}-${index}`} variant="outline" className="text-xs">
- {type}
- </Badge>
- ))}
- </div>
- </div>
- </div>
- )}
- </div>
+// <div className="max-h-96 overflow-y-auto border rounded-lg bg-gray-50 p-2">
+// <div className="space-y-2">
+// {isLoading ? (
+// <div className="text-center py-4">로딩 중...</div>
+// ) : filteredVendors.length === 0 ? (
+// <div className="text-center py-4 text-muted-foreground">
+// 검색 결과가 없습니다.
+// </div>
+// ) : (
+// filteredVendors.map((vendor) => (
+// <div
+// key={vendor.id}
+// className="p-3 bg-white border rounded-lg cursor-pointer transition-colors hover:bg-gray-50"
+// onClick={() => handleVendorSelect(vendor)}
+// >
+// <div className="font-medium">{vendor.vendorName}</div>
+// <div className="text-sm text-muted-foreground">
+// {vendor.vendorCode}
+// </div>
+// <div className="flex flex-wrap gap-1 mt-2">
+// {parseVendorTypes(vendor.techVendorType).map((type, index) => (
+// <Badge key={`${vendor.id}-${type}-${index}`} variant="secondary" className="text-xs">
+// {type}
+// </Badge>
+// ))}
+// </div>
+// </div>
+// ))
+// )}
+// </div>
+// </div>
+// </>
+// ) : (
+// <div className="space-y-4">
+// <div className="flex items-center justify-between">
+// <Label>선택된 벤더</Label>
+// <Button
+// variant="outline"
+// size="sm"
+// onClick={() => {
+// setSelectedVendor(null);
+// setSelectedItems([]);
+// setItems([]);
+// setFilteredItems([]);
+// }}
+// >
+// 변경
+// </Button>
+// </div>
+// <div className="p-4 border rounded-md bg-muted/20">
+// <div className="font-medium">{selectedVendor?.vendorName}</div>
+// <div className="text-sm text-muted-foreground">
+// {selectedVendor?.vendorCode}
+// </div>
+// <div className="flex flex-wrap gap-1 mt-2">
+// {selectedVendor && parseVendorTypes(selectedVendor.techVendorType).map((type, index) => (
+// <Badge key={`selected-${type}-${index}`} variant="outline" className="text-xs">
+// {type}
+// </Badge>
+// ))}
+// </div>
+// </div>
+// </div>
+// )}
+// </div>
- {/* 오른쪽: 아이템 선택 */}
- <div className="space-y-4 h-full flex flex-col">
- {selectedVendor ? (
- <>
+// {/* 오른쪽: 아이템 선택 */}
+// <div className="space-y-4 h-full flex flex-col">
+// {selectedVendor ? (
+// <>
- <Label htmlFor="item-search">아이템 검색</Label>
- <div className="relative">
- <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" /> <Input
- id="item-search"
- placeholder="아이템코드, 아이템리스트, 공종으로 검색..."
- value={itemSearch}
- onChange={(e) => setItemSearch(e.target.value)}
- className="pl-10"
- />
- </div>
+// <Label htmlFor="item-search">아이템 검색</Label>
+// <div className="relative">
+// <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" /> <Input
+// id="item-search"
+// placeholder="아이템코드, 아이템리스트, 공종으로 검색..."
+// value={itemSearch}
+// onChange={(e) => setItemSearch(e.target.value)}
+// className="pl-10"
+// />
+// </div>
- {selectedItems.length > 0 && (
- <div className="space-y-2">
- <Label>선택된 아이템 ({selectedItems.length}개)</Label>
- <div className="flex flex-wrap gap-1 p-2 border rounded-md bg-muted/50 max-h-20 overflow-y-auto">
- {selectedItems.map((item) => (
- <Badge key={`selected-${item.itemCode}`} variant="default" className="text-xs">
- {item.itemCode}
- <X
- className="ml-1 h-3 w-3 cursor-pointer"
- onClick={(e) => {
- e.stopPropagation();
- handleItemToggle(item);
- }}
- />
- </Badge>
- ))}
- </div>
- </div>
- )}
+// {selectedItems.length > 0 && (
+// <div className="space-y-2">
+// <Label>선택된 아이템 ({selectedItems.length}개)</Label>
+// <div className="flex flex-wrap gap-1 p-2 border rounded-md bg-muted/50 max-h-20 overflow-y-auto">
+// {selectedItems.map((item) => (
+// <Badge key={`selected-${item.itemCode}`} variant="default" className="text-xs">
+// {item.itemCode}
+// <X
+// className="ml-1 h-3 w-3 cursor-pointer"
+// onClick={(e) => {
+// e.stopPropagation();
+// handleItemToggle(item);
+// }}
+// />
+// </Badge>
+// ))}
+// </div>
+// </div>
+// )}
- <div className="max-h-80 overflow-y-auto border rounded-lg bg-gray-50 p-2">
- <div className="space-y-2">
- {isLoading ? (
- <div className="text-center py-4">아이템 로딩 중...</div>
- ) : filteredItems.length === 0 && items.length === 0 ? (
- <div className="text-center py-4 text-muted-foreground">
- 해당 벤더 타입에 대한 아이템이 없습니다.
- </div>
- ) : filteredItems.length === 0 ? (
- <div className="text-center py-4 text-muted-foreground">
- 검색 결과가 없습니다.
- </div>
- ) : (
- filteredItems.map((item) => {
- const isSelected = selectedItems.some(i => i.itemCode === item.itemCode);
- return (
- <div
- key={`item-${item.itemCode}`}
- className={`p-3 bg-white border rounded-lg cursor-pointer transition-colors ${
- isSelected
- ? "bg-primary/10 border-primary hover:bg-primary/20"
- : "hover:bg-gray-50"
- }`}
- onClick={() => handleItemToggle(item)}
- >
- <div className="font-medium">{item.itemCode}</div>
- <div className="text-sm text-muted-foreground">
- {item.itemList || "-"}
- </div>
- <div className="flex gap-2 mt-1 text-xs">
- <span>공종: {item.workType || "-"}</span>
- {item.shipTypes && <span>선종: {item.shipTypes}</span>}
- {item.subItemList && <span>서브아이템: {item.subItemList}</span>}
- </div>
- </div>
- );
- })
- )}
- </div>
- </div>
- </>
- ) : (
- <div className="flex-1 flex items-center justify-center text-muted-foreground">
- 왼쪽에서 벤더를 선택하세요.
- </div>
- )}
- </div>
- </div>
- </div>
+// <div className="max-h-80 overflow-y-auto border rounded-lg bg-gray-50 p-2">
+// <div className="space-y-2">
+// {isLoading ? (
+// <div className="text-center py-4">아이템 로딩 중...</div>
+// ) : filteredItems.length === 0 && items.length === 0 ? (
+// <div className="text-center py-4 text-muted-foreground">
+// 해당 벤더 타입에 대한 아이템이 없습니다.
+// </div>
+// ) : filteredItems.length === 0 ? (
+// <div className="text-center py-4 text-muted-foreground">
+// 검색 결과가 없습니다.
+// </div>
+// ) : (
+// filteredItems.map((item) => {
+// const isSelected = selectedItems.some(i => i.itemCode === item.itemCode);
+// return (
+// <div
+// key={`item-${item.itemCode}`}
+// className={`p-3 bg-white border rounded-lg cursor-pointer transition-colors ${
+// isSelected
+// ? "bg-primary/10 border-primary hover:bg-primary/20"
+// : "hover:bg-gray-50"
+// }`}
+// onClick={() => handleItemToggle(item)}
+// >
+// <div className="font-medium">{item.itemCode}</div>
+// <div className="text-sm text-muted-foreground">
+// {item.itemList || "-"}
+// </div>
+// <div className="flex gap-2 mt-1 text-xs">
+// <span>공종: {item.workType || "-"}</span>
+// {item.shipTypes && <span>선종: {item.shipTypes}</span>}
+// {item.subItemList && <span>서브아이템: {item.subItemList}</span>}
+// </div>
+// </div>
+// );
+// })
+// )}
+// </div>
+// </div>
+// </>
+// ) : (
+// <div className="flex-1 flex items-center justify-center text-muted-foreground">
+// 왼쪽에서 벤더를 선택하세요.
+// </div>
+// )}
+// </div>
+// </div>
+// </div>
- <div className="flex justify-end gap-2 pt-4 border-t">
- <Button variant="outline" onClick={handleClose}>
- 취소
- </Button>
- <Button
- onClick={handleSubmit}
- disabled={!selectedVendor || selectedItems.length === 0 || isLoading}
- >
- {isLoading ? "추가 중..." : `추가 (${selectedItems.length})`}
- </Button>
- </div>
- </DialogContent>
- </Dialog>
- );
-} \ No newline at end of file
+// <div className="flex justify-end gap-2 pt-4 border-t">
+// <Button variant="outline" onClick={handleClose}>
+// 취소
+// </Button>
+// <Button
+// onClick={handleSubmit}
+// disabled={!selectedVendor || selectedItems.length === 0 || isLoading}
+// >
+// {isLoading ? "추가 중..." : `추가 (${selectedItems.length})`}
+// </Button>
+// </div>
+// </DialogContent>
+// </Dialog>
+// );
+// } \ No newline at end of file
diff --git a/lib/tech-vendor-possible-items/table/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 {
@@ -51,6 +38,22 @@ export function PossibleItemsDataTable({ promises }: PossibleItemsDataTableProps
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: "아이템코드",
type: "text",
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";
@@ -153,6 +139,22 @@ export function getColumns(): ColumnDef<TechVendorPossibleItemsData>[] {
},
},
{
+ accessorKey: "vendorEmail",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="벤더이메일" />
+ ),
+ cell: ({ row }) => {
+ const vendorEmail = row.getValue("vendorEmail") as string | null;
+ return <div className="max-w-[200px] truncate">{vendorEmail || "-"}</div>;
+ },
+ 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 }) => (
<DataTableColumnHeaderSimple column={column} title="벤더타입" />
@@ -216,29 +218,35 @@ export function getColumns(): ColumnDef<TechVendorPossibleItemsData>[] {
},
},
- {
- accessorKey: "vendorStatus",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="벤더상태" />
- ),
- cell: ({ row }) => {
- const vendorStatus = row.getValue("vendorStatus") as string;
- const getStatusColor = (status: string) => {
- switch (status) {
- case "ACTIVE": return "bg-green-100 text-green-800";
- case "PENDING_INVITE": return "bg-yellow-100 text-yellow-800";
- case "PENDING_REVIEW": return "bg-blue-100 text-blue-800";
- case "INACTIVE": return "bg-gray-100 text-gray-800";
- default: return "bg-gray-100 text-gray-800";
- }
- };
- return (
- <Badge className={getStatusColor(vendorStatus)}>
- {vendorStatus}
- </Badge>
- );
- },
- },
+ // {
+ // accessorKey: "vendorStatus",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="벤더상태" />
+ // ),
+ // cell: ({ row }) => {
+ // const vendorStatus = row.getValue("vendorStatus") as string;
+ // const getStatusColor = (status: string) => {
+ // switch (status) {
+ // case "ACTIVE": return "bg-green-100 text-green-800";
+ // case "PENDING_INVITE": return "bg-yellow-100 text-yellow-800";
+ // case "PENDING_REVIEW": return "bg-blue-100 text-blue-800";
+ // case "INACTIVE": return "bg-gray-100 text-gray-800";
+ // default: return "bg-gray-100 text-gray-800";
+ // }
+ // };
+ // return (
+ // <Badge className={getStatusColor(vendorStatus)}>
+ // {vendorStatus}
+ // </Badge>
+ // );
+ // },
+ // 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<TechVendorPossibleItemsData>;
}
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<HTMLInputElement>) => {
- const file = event.target.files?.[0];
- if (!file) return;
+ // Excel Import 함수 주석처리 (새 스키마에서 사용하지 않음)
+ // const handleImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
+ // 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 (
<div className="flex items-center gap-2">
- {hasSelection && (
- <DeletePossibleItemsDialog
- selectedItems={selectedItems}
- onSuccess={handleSuccess}
- />
- )}
- <AddPossibleItemDialog onSuccess={handleSuccess}>
- <Button size="sm">
- <Plus className="mr-2 h-4 w-4" />
- 추가
- </Button>
- </AddPossibleItemDialog>
+ {/* {hasSelection && ( */}
+ {/* // <DeletePo ssibleItemsDialog
+ // selectedItems={selectedItems}
+ // onSuccess={handleSuccess}
+ // />
+ // )}
+ // <AddPossibleItemDialog onSuccess={handleSuccess}>
+ // <Button size="sm">
+ // <Plus className="mr-2 h-4 w-4" />
+ // 추가
+ // </Button>
+ // </AddPossibleItemDialog>
- <Button
+ {/* Excel 관련 버튼들 주석처리 (새 스키마에서 사용하지 않음) */}
+ {/* <Button
variant="outline"
size="sm"
onClick={() => document.getElementById("import-file")?.click()}
@@ -173,7 +164,7 @@ export function PossibleItemsTableToolbarActions({
<Button variant="outline" size="sm" onClick={handleDownloadTemplate}>
<FileSpreadsheet className="mr-2 h-4 w-4" />
Download Template
- </Button>
+ </Button> */}
</div>
);
} \ 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: "",
@@ -120,6 +121,20 @@ export function AddContactDialog({ vendorId }: AddContactDialogProps) {
<FormField
control={form.control}
+ name="contactTitle"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Contact Title</FormLabel>
+ <FormControl>
+ <Input placeholder="예: 기술영업 담당자" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
name="contactEmail"
render={({ field }) => (
<FormItem>
diff --git a/lib/tech-vendors/contacts-table/contact-table.tsx b/lib/tech-vendors/contacts-table/contact-table.tsx
index 6029fe16..5a49cf8d 100644
--- a/lib/tech-vendors/contacts-table/contact-table.tsx
+++ b/lib/tech-vendors/contacts-table/contact-table.tsx
@@ -15,6 +15,9 @@ import { getTechVendorContacts } from "../service"
import { TechVendorContact } from "@/db/schema/techVendors"
import { TechVendorContactsTableToolbarActions } from "./contact-table-toolbar-actions"
import { UpdateContactSheet } from "./update-contact-sheet"
+import { toast } from "sonner"
+import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction } from "@/components/ui/alert-dialog"
+import { deleteTechVendorContact } from "../service"
interface TechVendorContactsTableProps {
promises: Promise<
@@ -31,6 +34,32 @@ export function TechVendorContactsTable({ promises , vendorId}: TechVendorContac
const [{ data, pageCount }] = React.use(promises)
const [rowAction, setRowAction] = React.useState<DataTableRowAction<TechVendorContact> | 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 */}
+ <AlertDialog open={showDeleteAlert} onOpenChange={setShowDeleteAlert}>
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle>연락처 삭제</AlertDialogTitle>
+ <AlertDialogDescription>
+ 이 연락처를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel onClick={() => setRowAction(null)}>
+ 취소
+ </AlertDialogCancel>
+ <AlertDialogAction
+ onClick={handleDeleteContact}
+ disabled={isDeleting}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ >
+ {isDeleting ? "삭제 중..." : "삭제"}
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
</>
)
} \ No newline at end of file
diff --git a/lib/tech-vendors/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,
})
}
@@ -128,6 +130,20 @@ export function UpdateContactSheet({ contact, vendorId, ...props }: UpdateContac
<FormField
control={form.control}
+ name="contactTitle"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>직책</FormLabel>
+ <FormControl>
+ <Input placeholder="직책을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
name="contactEmail"
render={({ field }) => (
<FormItem>
diff --git a/lib/tech-vendors/possible-items/add-item-dialog.tsx b/lib/tech-vendors/possible-items/add-item-dialog.tsx
index ef15a5ce..0e6edd19 100644
--- a/lib/tech-vendors/possible-items/add-item-dialog.tsx
+++ b/lib/tech-vendors/possible-items/add-item-dialog.tsx
@@ -27,6 +27,7 @@ interface ItemData {
workType: string | null;
shipTypes?: string | null;
subItemList?: string | null;
+ itemType: "SHIP" | "TOP" | "HULL";
createdAt: Date;
updatedAt: Date;
}
@@ -80,7 +81,7 @@ export function AddItemDialog({ open, onOpenChange, vendorId }: AddItemDialogPro
console.log("Loaded items:", result.data.length, result.data);
// itemCode가 null이 아닌 항목만 필터링
const validItems = result.data.filter(item => 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
<div className="flex flex-wrap gap-1 p-2 border rounded-md bg-muted/50 max-h-20 overflow-y-auto">
{selectedItems.map((item) => {
if (!item.itemCode) return null;
- const itemKey = `${item.itemCode}${item.shipTypes ? `-${item.shipTypes}` : ''}`;
+ const itemKey = `${item.itemType}-${item.id}-${item.itemCode}${item.shipTypes ? `-${item.shipTypes}` : ''}`;
+ const displayText = `[${item.itemType}] ${item.itemCode}${item.shipTypes ? `-${item.shipTypes}` : ''}`;
return (
<Badge key={`selected-${itemKey}`} variant="default" className="text-xs">
- {itemKey}
+ {displayText}
<X
className="ml-1 h-3 w-3 cursor-pointer"
onClick={(e) => {
@@ -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 (
<div
@@ -249,7 +248,7 @@ export function AddItemDialog({ open, onOpenChange, vendorId }: AddItemDialogPro
onClick={() => handleItemToggle(item)}
>
<div className="font-medium">
- {itemKey}
+ {`[${item.itemType}] ${item.itemCode}${item.shipTypes ? `-${item.shipTypes}` : ''}`}
</div>
<div className="text-sm text-muted-foreground">
{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<React.SetStateAction<DataTableRowAction<TechVendorPossibleItem> | null>>;
-}
-
-export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<TechVendorPossibleItem>[] {
- return [
- // 선택 체크박스
- {
- id: "select",
- header: ({ table }) => (
- <Checkbox
- checked={
- table.getIsAllPageRowsSelected() ||
- (table.getIsSomePageRowsSelected() && "indeterminate")
- }
- onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
- aria-label="Select all"
- className="translate-y-0.5"
- />
- ),
- cell: ({ row }) => (
- <Checkbox
- checked={row.getIsSelected()}
- onCheckedChange={(value) => row.toggleSelected(!!value)}
- aria-label="Select row"
- className="translate-y-0.5"
- />
- ),
- size: 40,
- enableSorting: false,
- enableHiding: false,
- },
-
- // 아이템 코드
- {
- accessorKey: "itemCode",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="자재 그룹" />
- ),
- cell: ({ row }) => (
- <div className="font-mono text-sm">
- {row.getValue("itemCode")}
- </div>
- ),
- size: 150,
- },
-
- // 공종
- {
- accessorKey: "workType",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="공종" />
- ),
- cell: ({ row }) => {
- const workType = row.getValue("workType") as string | null
- return workType ? (
- <Badge variant="secondary" className="text-xs">
- {workType}
- </Badge>
- ) : (
- <span className="text-muted-foreground">-</span>
- )
- },
- size: 100,
- },
-
- // 아이템명
- {
- accessorKey: "itemList",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="자재명" />
- ),
- cell: ({ row }) => {
- const itemList = row.getValue("itemList") as string | null
- return (
- <div className="max-w-[300px]">
- {itemList || <span className="text-muted-foreground">-</span>}
- </div>
- )
- },
- size: 300,
- },
-
- // 선종 (조선용)
- {
- accessorKey: "shipTypes",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="선종" />
- ),
- cell: ({ row }) => {
- const shipTypes = row.getValue("shipTypes") as string | null
- return shipTypes ? (
- <Badge variant="outline" className="text-xs">
- {shipTypes}
- </Badge>
- ) : (
- <span className="text-muted-foreground">-</span>
- )
- },
- size: 120,
- },
-
- // 서브아이템 (해양용)
- {
- accessorKey: "subItemList",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="자재명(상세)" />
- ),
- cell: ({ row }) => {
- const subItemList = row.getValue("subItemList") as string | null
- return (
- <div className="max-w-[200px]">
- {subItemList || <span className="text-muted-foreground">-</span>}
- </div>
- )
- },
- size: 200,
- },
-
- // 등록일
- {
- accessorKey: "createdAt",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="등록일" />
- ),
- cell: ({ row }) => {
- const date = row.getValue("createdAt") as Date
- return (
- <div className="text-sm text-muted-foreground">
- {formatDate(date)}
- </div>
- )
- },
- size: 120,
- },
-
- // 수정일
- {
- accessorKey: "updatedAt",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="수정일" />
- ),
- cell: ({ row }) => {
- const date = row.getValue("updatedAt") as Date
- return (
- <div className="text-sm text-muted-foreground">
- {formatDate(date)}
- </div>
- )
- },
- size: 120,
- },
-
- // 액션 메뉴
- {
- id: "actions",
- enableHiding: false,
- cell: function Cell({ row }) {
- return (
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- aria-label="Open menu"
- variant="ghost"
- className="flex size-8 p-0 data-[state=open]:bg-muted"
- >
- <Ellipsis className="size-4" aria-hidden="true" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end" className="w-40">
- <DropdownMenuItem
- onSelect={() => setRowAction({ row, type: "delete" })}
- >
- 삭제
- <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
- )
- },
- size: 40,
- },
- ]
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Ellipsis } from "lucide-react"
+
+import { formatDate } from "@/lib/utils"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuShortcut,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import type { TechVendorPossibleItem } from "../validations"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<TechVendorPossibleItem> | null>>;
+}
+
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<TechVendorPossibleItem>[] {
+ return [
+ // 선택 체크박스
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ },
+
+ // 아이템 코드
+ {
+ accessorKey: "itemCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="아이템 코드" />
+ ),
+ cell: ({ row }) => {
+ const itemCode = row.getValue("itemCode") as string | undefined
+ return (
+ <div className="font-mono text-sm">
+ {itemCode || <span className="text-muted-foreground">-</span>}
+ </div>
+ )
+ },
+ size: 150,
+ },
+
+ // // 타입
+ // {
+ // accessorKey: "techVendorType",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="타입" />
+ // ),
+ // 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 (
+ // <div className="flex flex-wrap gap-1">
+ // {types.length > 0 ? types.map((type, index) => (
+ // <Badge key={`${type}-${index}`} variant="secondary" className="text-xs">
+ // {type}
+ // </Badge>
+ // )) : (
+ // <span className="text-muted-foreground">-</span>
+ // )}
+ // </div>
+ // )
+ // },
+ // size: 120,
+ // },
+
+ // 공종
+ {
+ accessorKey: "workType",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="공종" />
+ ),
+ cell: ({ row }) => {
+ const workType = row.getValue("workType") as string | null
+ return workType ? (
+ <Badge variant="secondary" className="text-xs">
+ {workType}
+ </Badge>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )
+ },
+ size: 100,
+ },
+
+ // 아이템명
+ {
+ accessorKey: "itemList",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="아이템명" />
+ ),
+ cell: ({ row }) => {
+ const itemList = row.getValue("itemList") as string | null
+ return (
+ <div className="max-w-[300px]">
+ {itemList || <span className="text-muted-foreground">-</span>}
+ </div>
+ )
+ },
+ size: 300,
+ },
+
+ // 선종 (조선용)
+ {
+ accessorKey: "shipTypes",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="선종" />
+ ),
+ cell: ({ row }) => {
+ const shipTypes = row.getValue("shipTypes") as string | null
+ return shipTypes ? (
+ <Badge variant="outline" className="text-xs">
+ {shipTypes}
+ </Badge>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )
+ },
+ size: 120,
+ },
+
+ // 서브아이템 (해양용)
+ {
+ accessorKey: "subItemList",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="서브아이템" />
+ ),
+ cell: ({ row }) => {
+ const subItemList = row.getValue("subItemList") as string | null
+ return (
+ <div className="max-w-[200px]">
+ {subItemList || <span className="text-muted-foreground">-</span>}
+ </div>
+ )
+ },
+ size: 200,
+ },
+
+ // 등록일
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="등록일" />
+ ),
+ cell: ({ row }) => {
+ const date = row.getValue("createdAt") as Date
+ return (
+ <div className="text-sm text-muted-foreground">
+ {formatDate(date, "ko-KR")}
+ </div>
+ )
+ },
+ size: 120,
+ },
+
+ // 수정일
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="수정일" />
+ ),
+ cell: ({ row }) => {
+ const date = row.getValue("updatedAt") as Date
+ return (
+ <div className="text-sm text-muted-foreground">
+ {formatDate(date, "ko-KR")}
+ </div>
+ )
+ },
+ size: 120,
+ },
+
+ // 액션 메뉴
+ {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-40">
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "delete" })}
+ >
+ 삭제
+ <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 40,
+ },
+ ]
} \ No newline at end of file
diff --git a/lib/tech-vendors/possible-items/possible-items-table.tsx b/lib/tech-vendors/possible-items/possible-items-table.tsx
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<ReturnType<typeof getTechVendorPossibleItems>>,
- ]
- >
- vendorId: number
-}
-
-export function TechVendorPossibleItemsTable({
- promises,
- vendorId,
-}: TechVendorPossibleItemsTableProps) {
- // Suspense로 받아온 데이터
- const [{ data, pageCount }] = React.use(promises)
- const [rowAction, setRowAction] = React.useState<DataTableRowAction<TechVendorPossibleItem> | null>(null)
- const [showAddDialog, setShowAddDialog] = React.useState(false)
- const [showDeleteAlert, setShowDeleteAlert] = React.useState(false)
- const [isDeleting, setIsDeleting] = React.useState(false)
-
- // getColumns() 호출 시, setRowAction을 주입
- const columns = React.useMemo(
- () => getColumns({ setRowAction }),
- [setRowAction]
- )
-
- // 단일 아이템 삭제 핸들러
- async function handleDeleteItem() {
- if (!rowAction || rowAction.type !== "delete") return
-
- setIsDeleting(true)
- try {
- const { success, error } = await deleteTechVendorPossibleItem(
- rowAction.row.original.id,
- vendorId
- )
-
- if (!success) {
- throw new Error(error)
- }
-
- toast.success("아이템이 삭제되었습니다")
- setShowDeleteAlert(false)
- setRowAction(null)
- } catch (err) {
- toast.error(err instanceof Error ? err.message : "아이템 삭제 중 오류가 발생했습니다")
- } finally {
- setIsDeleting(false)
- }
- }
-
- const filterFields: DataTableFilterField<TechVendorPossibleItem>[] = [
- { id: "itemCode", label: "아이템 코드" },
- { id: "workType", label: "공종" },
- ]
-
- const advancedFilterFields: DataTableAdvancedFilterField<TechVendorPossibleItem>[] = [
- { id: "itemCode", label: "아이템 코드", type: "text" },
- { id: "workType", label: "공종", type: "text" },
- { id: "itemList", label: "아이템명", type: "text" },
- { id: "shipTypes", label: "선종", type: "text" },
- { id: "subItemList", label: "서브아이템", type: "text" },
- { id: "createdAt", label: "등록일", type: "date" },
- { id: "updatedAt", label: "수정일", type: "date" },
- ]
-
- const { table } = useDataTable({
- data,
- columns,
- pageCount,
- filterFields,
- enablePinning: true,
- enableAdvancedFilter: true,
- initialState: {
- sorting: [{ id: "createdAt", desc: true }],
- columnPinning: { right: ["actions"] },
- },
- getRowId: (originalRow) => String(originalRow.id),
- shallow: false,
- clearOnDefault: true,
- })
-
- // rowAction 상태 변경 감지
- React.useEffect(() => {
- if (rowAction?.type === "delete") {
- setShowDeleteAlert(true)
- }
- }, [rowAction])
-
- return (
- <>
- <DataTable table={table}>
- <DataTableAdvancedToolbar
- table={table}
- filterFields={advancedFilterFields}
- shallow={false}
- >
- <PossibleItemsTableToolbarActions
- table={table}
- vendorId={vendorId}
- onAdd={() => setShowAddDialog(true)}
- />
- </DataTableAdvancedToolbar>
- </DataTable>
-
- {/* Add Item Dialog */}
- <AddItemDialog
- open={showAddDialog}
- onOpenChange={setShowAddDialog}
- vendorId={vendorId}
- />
-
- {/* Delete Confirmation Dialog */}
- <AlertDialog open={showDeleteAlert} onOpenChange={setShowDeleteAlert}>
- <AlertDialogContent>
- <AlertDialogHeader>
- <AlertDialogTitle>아이템 삭제</AlertDialogTitle>
- <AlertDialogDescription>
- 이 아이템을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
- </AlertDialogDescription>
- </AlertDialogHeader>
- <AlertDialogFooter>
- <AlertDialogCancel onClick={() => setRowAction(null)}>
- 취소
- </AlertDialogCancel>
- <AlertDialogAction
- onClick={handleDeleteItem}
- disabled={isDeleting}
- className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
- >
- {isDeleting ? "삭제 중..." : "삭제"}
- </AlertDialogAction>
- </AlertDialogFooter>
- </AlertDialogContent>
- </AlertDialog>
- </>
- )
+"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<ReturnType<typeof getTechVendorPossibleItems>>,
+ ]
+ >
+ vendorId: number
+}
+
+export function TechVendorPossibleItemsTable({
+ promises,
+ vendorId,
+}: TechVendorPossibleItemsTableProps) {
+ // Suspense로 받아온 데이터
+ const [{ data, pageCount }] = React.use(promises)
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<TechVendorPossibleItem> | null>(null)
+ const [showAddDialog, setShowAddDialog] = React.useState(false) // 주석처리
+ const [showDeleteAlert, setShowDeleteAlert] = React.useState(false)
+ const [isDeleting, setIsDeleting] = React.useState(false)
+
+ // 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<TechVendorPossibleItem>[] = [
+ { id: "vendorId", label: "벤더 ID" },
+ { id: "techVendorType", label: "아이템 타입" },
+ ]
+
+ const advancedFilterFields: DataTableAdvancedFilterField<TechVendorPossibleItem>[] = [
+ { 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 (
+ <>
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <div className="flex items-center gap-2">
+ {/* 견적비교용 벤더일 때만 items 버튼 표시 */}
+ {isQuoteComparisonVendor && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setShowItemsDialog(true)}
+ disabled={isLoadingVendor}
+ className="flex items-center gap-2"
+ >
+ <Badge variant="secondary" className="text-xs">
+ 수기입력된 자재 항목
+ </Badge>
+ </Button>
+ )}
+
+ <PossibleItemsTableToolbarActions
+ table={table}
+ vendorId={vendorId}
+ onAdd={() => setShowAddDialog(true)} // 주석처리
+ />
+ </div>
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ {/* Add Item Dialog */}
+ <AddItemDialog
+ open={showAddDialog}
+ onOpenChange={setShowAddDialog}
+ vendorId={vendorId}
+ />
+
+ {/* Vendor Items Dialog */}
+ <Dialog open={showItemsDialog} onOpenChange={setShowItemsDialog}>
+ <DialogContent className="max-w-2xl max-h-[80vh]">
+ <DialogHeader>
+ <DialogTitle>벤더 수기입력 자재 항목</DialogTitle>
+ <DialogDescription>
+ {vendorInfo?.vendorName} 벤더가 직접 입력한 자재 항목입니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <ScrollArea className="max-h-[60vh]">
+ {vendorInfo?.items && vendorInfo.items.length > 0 ? (
+ <div className="space-y-3">
+ <p className="text-sm text-muted-foreground mt-1">
+ {vendorInfo.items}
+ </p>
+ </div>
+ ) : (
+ <div className="text-center py-8 text-muted-foreground">
+ <p>등록된 수기입력 자재 항목이 없습니다.</p>
+ </div>
+ )}
+ </ScrollArea>
+ </DialogContent>
+ </Dialog>
+
+ {/* Delete Confirmation Dialog */}
+ <AlertDialog open={showDeleteAlert} onOpenChange={setShowDeleteAlert}>
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle>아이템 삭제</AlertDialogTitle>
+ <AlertDialogDescription>
+ 이 아이템을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel onClick={() => setRowAction(null)}>
+ 취소
+ </AlertDialogCancel>
+ <AlertDialogAction
+ onClick={handleDeleteItem}
+ disabled={isDeleting}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ >
+ {isDeleting ? "삭제 중..." : "삭제"}
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+ </>
+ )
} \ No newline at end of file
diff --git a/lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx b/lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx
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<TechVendorPossibleItem>
- vendorId: number
- onAdd: () => void
-}
-
-export function PossibleItemsTableToolbarActions({
- table,
- vendorId,
- onAdd,
-}: PossibleItemsTableToolbarActionsProps) {
- const [showDeleteAlert, setShowDeleteAlert] = React.useState(false)
- const [isDeleting, setIsDeleting] = React.useState(false)
-
- const selectedRows = table.getFilteredSelectedRowModel().rows
-
- async function handleDelete() {
- setIsDeleting(true)
- try {
- const ids = selectedRows.map((row) => row.original.id)
- const { error } = await deleteTechVendorPossibleItemsNew(ids, vendorId)
-
- if (error) {
- throw new Error(error)
- }
-
- toast.success(`${ids.length}개의 아이템이 삭제되었습니다`)
- table.resetRowSelection()
- setShowDeleteAlert(false)
- } catch {
- toast.error("아이템 삭제 중 오류가 발생했습니다")
- } finally {
- setIsDeleting(false)
- }
- }
-
- return (
- <>
- <div className="flex items-center gap-2">
- <Button
- variant="outline"
- size="sm"
- onClick={onAdd}
- >
- <Plus className="mr-2 h-4 w-4" />
- 자재 연결하기
- </Button>
-
- {selectedRows.length > 0 && (
- <>
- <Separator orientation="vertical" className="mx-2 h-4" />
- <Tooltip>
- <TooltipTrigger asChild>
- <Button
- variant="outline"
- size="sm"
- onClick={() => setShowDeleteAlert(true)}
- disabled={selectedRows.length === 0}
- >
- <Trash2 className="mr-2 h-4 w-4" />
- 삭제 ({selectedRows.length})
- </Button>
- </TooltipTrigger>
- <TooltipContent>
- 선택된 {selectedRows.length}개 아이템을 삭제합니다
- </TooltipContent>
- </Tooltip>
- </>
- )}
- </div>
-
- <AlertDialog open={showDeleteAlert} onOpenChange={setShowDeleteAlert}>
- <AlertDialogContent>
- <AlertDialogHeader>
- <AlertDialogTitle>아이템 삭제</AlertDialogTitle>
- <AlertDialogDescription>
- 선택된 {selectedRows.length}개의 아이템을 삭제하시겠습니까?
- 이 작업은 되돌릴 수 없습니다.
- </AlertDialogDescription>
- </AlertDialogHeader>
- <AlertDialogFooter>
- <AlertDialogCancel>취소</AlertDialogCancel>
- <AlertDialogAction
- onClick={handleDelete}
- disabled={isDeleting}
- className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
- >
- {isDeleting ? "삭제 중..." : "삭제"}
- </AlertDialogAction>
- </AlertDialogFooter>
- </AlertDialogContent>
- </AlertDialog>
- </>
- )
+"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<TechVendorPossibleItem>
+ vendorId: number
+ onAdd: () => void // 주석처리
+}
+
+export function PossibleItemsTableToolbarActions({
+ table,
+ vendorId,
+ onAdd, // 주석처리
+}: PossibleItemsTableToolbarActionsProps) {
+ const [showDeleteAlert, setShowDeleteAlert] = React.useState(false)
+ const [isDeleting, setIsDeleting] = React.useState(false)
+
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+
+ async function handleDelete() {
+ setIsDeleting(true)
+ try {
+ const ids = selectedRows.map((row) => row.original.id)
+ const { error } = await deleteTechVendorPossibleItemsNew(ids, vendorId)
+
+ if (error) {
+ throw new Error(error)
+ }
+
+ toast.success(`${ids.length}개의 아이템이 삭제되었습니다`)
+ table.resetRowSelection()
+ setShowDeleteAlert(false)
+ } catch {
+ toast.error("아이템 삭제 중 오류가 발생했습니다")
+ } finally {
+ setIsDeleting(false)
+ }
+ }
+
+ return (
+ <>
+ <div className="flex items-center gap-2">
+ {/* 아이템 추가 버튼 주석처리 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={onAdd}
+ >
+ <Plus className="mr-2 h-4 w-4" />
+ 아이템 추가
+ </Button>
+
+ {selectedRows.length > 0 && (
+ <>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setShowDeleteAlert(true)}
+ disabled={selectedRows.length === 0}
+ >
+ <Trash2 className="mr-2 h-4 w-4" />
+ 삭제 ({selectedRows.length})
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ 선택된 {selectedRows.length}개 아이템을 삭제합니다
+ </TooltipContent>
+ </Tooltip>
+ </>
+ )}
+ </div>
+
+ <AlertDialog open={showDeleteAlert} onOpenChange={setShowDeleteAlert}>
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle>아이템 삭제</AlertDialogTitle>
+ <AlertDialogDescription>
+ 선택된 {selectedRows.length}개의 아이템을 삭제하시겠습니까?
+ 이 작업은 되돌릴 수 없습니다.
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel>취소</AlertDialogCancel>
+ <AlertDialogAction
+ onClick={handleDelete}
+ disabled={isDeleting}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ >
+ {isDeleting ? "삭제 중..." : "삭제"}
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+ </>
+ )
} \ No newline at end of file
diff --git a/lib/tech-vendors/repository.ts b/lib/tech-vendors/repository.ts
index 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<unknown>;
orderBy?: SQL<unknown>[];
} & 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<unknown>) {
- const query = tx.select({ count: count() }).from(techVendorItemsView);
+// 아이템 수 카운트 (새 스키마용)
+export async function countTechVendorPossibleItems(tx: any, where?: SQL<unknown>) {
+ 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<unknown>) {
// 아이템 생성
export async function insertTechVendorItem(
tx: any,
- data: Omit<TechVendorItem, "id" | "createdAt" | "updatedAt">
+ data: Omit<NewTechVendorItem, "id" | "createdAt" | "updatedAt">
) {
return tx
.insert(techVendorPossibleItems)
@@ -338,118 +346,52 @@ export async function getVendorWorkTypes(
vendorType: string
): Promise<string[]> {
try {
- // 벤더의 possible items 조회 - 모든 필드 가져오기
- const possibleItems = await tx
- .select({
- itemCode: techVendorPossibleItems.itemCode,
- shipTypes: techVendorPossibleItems.shipTypes,
- itemList: techVendorPossibleItems.itemList,
- subItemList: techVendorPossibleItems.subItemList,
- workType: techVendorPossibleItems.workType
- })
- .from(techVendorPossibleItems)
- .where(eq(techVendorPossibleItems.vendorId, vendorId));
- 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<TechVendor["status"], number> = {
- "PENDING_INVITE": 0,
- "INVITED": 0,
- "QUOTE_COMPARISON": 0,
- "ACTIVE": 0,
- "INACTIVE": 0,
- "BLACKLISTED": 0,
- };
-
- const result = await db.transaction(async (tx) => {
- const rows = await groupByTechVendorStatus(tx);
- type StatusCountRow = { status: TechVendor["status"]; count: number };
- return (rows as StatusCountRow[]).reduce<Record<TechVendor["status"], number>>((acc, { status, count }) => {
- acc[status] = count;
- return acc;
- }, initial);
- });
-
- return result;
- } catch (err) {
- return {} as Record<TechVendor["status"], number>;
- }
- },
- ["tech-vendor-status-counts"], // 캐싱 키
- {
- revalidate: 3600,
- }
- )();
-}
-
-/**
- * 벤더 상세 정보 조회
- */
-export async function getTechVendorById(id: number) {
- return unstable_cache(
- async () => {
- try {
- const result = await getTechVendorDetailById(id);
- return { data: result };
- } catch (err) {
- console.error("기술영업 벤더 상세 조회 오류:", err);
- return { data: null };
- }
- },
- [`tech-vendor-${id}`],
- {
- revalidate: 3600,
- tags: ["tech-vendors", `tech-vendor-${id}`],
- }
- )();
-}
-
-/* -----------------------------------------------------
- 2) 생성(Create)
------------------------------------------------------ */
-
-/**
- * 첨부파일 저장 헬퍼 함수
- */
-async function storeTechVendorFiles(
- tx: any,
- vendorId: number,
- files: File[],
- attachmentType: string
-) {
-
- for (const file of files) {
-
- const saveResult = await saveDRMFile(file, decryptWithServerAction, `tech-vendors/${vendorId}`)
-
- // Insert attachment record
- await tx.insert(techVendorAttachments).values({
- vendorId,
- fileName: file.name,
- filePath: saveResult.publicPath,
- attachmentType,
- });
- }
-}
-
-/**
- * 신규 기술영업 벤더 생성
- */
-export async function createTechVendor(input: CreateTechVendorSchema) {
- unstable_noStore();
-
- try {
- // 이메일 중복 검사
- const existingVendorByEmail = await db
- .select({ id: techVendors.id, vendorName: techVendors.vendorName })
- .from(techVendors)
- .where(eq(techVendors.email, input.email))
- .limit(1);
-
- // 이미 동일한 이메일을 가진 업체가 존재하면 에러 반환
- if (existingVendorByEmail.length > 0) {
- return {
- success: false,
- data: null,
- error: `이미 등록된 이메일입니다. (업체명: ${existingVendorByEmail[0].vendorName})`
- };
- }
-
- // taxId 중복 검사
- const existingVendorByTaxId = await db
- .select({ id: techVendors.id })
- .from(techVendors)
- .where(eq(techVendors.taxId, input.taxId))
- .limit(1);
-
- // 이미 동일한 taxId를 가진 업체가 존재하면 에러 반환
- if (existingVendorByTaxId.length > 0) {
- return {
- success: false,
- data: null,
- error: `이미 등록된 사업자등록번호입니다. (Tax ID ${input.taxId} already exists in the system)`
- };
- }
-
- const result = await db.transaction(async (tx) => {
- // 1. 벤더 생성
- const [newVendor] = await insertTechVendor(tx, {
- vendorName: input.vendorName,
- vendorCode: input.vendorCode || null,
- taxId: input.taxId,
- address: input.address || null,
- country: input.country,
- countryEng: null,
- countryFab: null,
- agentName: null,
- agentPhone: null,
- agentEmail: null,
- phone: input.phone || null,
- email: input.email,
- website: input.website || null,
- techVendorType: Array.isArray(input.techVendorType) ? input.techVendorType.join(',') : input.techVendorType,
- representativeName: input.representativeName || null,
- representativeBirth: input.representativeBirth || null,
- representativeEmail: input.representativeEmail || null,
- representativePhone: input.representativePhone || null,
- items: input.items || null,
- status: "ACTIVE",
- isQuoteComparison: false,
- });
-
- // 2. 연락처 정보 등록
- for (const contact of input.contacts) {
- await insertTechVendorContact(tx, {
- vendorId: newVendor.id,
- contactName: contact.contactName,
- contactPosition: contact.contactPosition || null,
- contactEmail: contact.contactEmail,
- contactPhone: contact.contactPhone || null,
- isPrimary: contact.isPrimary ?? false,
- contactCountry: contact.contactCountry || null,
- });
- }
-
- // 3. 첨부파일 저장
- if (input.files && input.files.length > 0) {
- await storeTechVendorFiles(tx, newVendor.id, input.files, "GENERAL");
- }
-
- return newVendor;
- });
-
- revalidateTag("tech-vendors");
-
- return {
- success: true,
- data: result,
- error: null
- };
- } catch (err) {
- console.error("기술영업 벤더 생성 오류:", err);
-
- return {
- success: false,
- data: null,
- error: getErrorMessage(err)
- };
- }
-}
-
-/* -----------------------------------------------------
- 3) 업데이트 (단건/복수)
------------------------------------------------------ */
-
-/** 단건 업데이트 */
-export async function modifyTechVendor(
- input: UpdateTechVendorSchema & { id: string; }
-) {
- unstable_noStore();
- try {
- const updated = await db.transaction(async (tx) => {
- // 벤더 정보 업데이트
- const [res] = await updateTechVendor(tx, input.id, {
- vendorName: input.vendorName,
- vendorCode: input.vendorCode,
- address: input.address,
- country: input.country,
- countryEng: input.countryEng,
- countryFab: input.countryFab,
- phone: input.phone,
- email: input.email,
- website: input.website,
- status: input.status,
- // 에이전트 정보 추가
- agentName: input.agentName,
- agentEmail: input.agentEmail,
- agentPhone: input.agentPhone,
- // 대표자 정보 추가
- representativeName: input.representativeName,
- representativeEmail: input.representativeEmail,
- representativePhone: input.representativePhone,
- representativeBirth: input.representativeBirth,
- // techVendorType 처리
- techVendorType: Array.isArray(input.techVendorType) ? input.techVendorType.join(',') : input.techVendorType,
- });
-
- return res;
- });
-
- // 캐시 무효화
- revalidateTag("tech-vendors");
- revalidateTag(`tech-vendor-${input.id}`);
-
- return { data: updated, error: null };
- } catch (err) {
- return { data: null, error: getErrorMessage(err) };
- }
-}
-
-/** 복수 업데이트 */
-export async function modifyTechVendors(input: {
- ids: string[];
- status?: TechVendor["status"];
-}) {
- unstable_noStore();
- try {
- const data = await db.transaction(async (tx) => {
- // 여러 협력업체 일괄 업데이트
- const [updated] = await updateTechVendors(tx, input.ids, {
- // 예: 상태만 일괄 변경
- status: input.status,
- });
- return updated;
- });
-
- // 캐시 무효화
- revalidateTag("tech-vendors");
- revalidateTag("tech-vendor-status-counts");
-
- return { data: null, error: null };
- } catch (err) {
- return { data: null, error: getErrorMessage(err) };
- }
-}
-
-/* -----------------------------------------------------
- 4) 연락처 관리
------------------------------------------------------ */
-
-export async function getTechVendorContacts(input: GetTechVendorContactsSchema, id: number) {
- return unstable_cache(
- async () => {
- try {
- const offset = (input.page - 1) * input.perPage;
-
- // 필터링 설정
- const advancedWhere = filterColumns({
- table: techVendorContacts,
- filters: input.filters,
- joinOperator: input.joinOperator,
- });
-
- // 검색 조건
- let globalWhere;
- if (input.search) {
- const s = `%${input.search}%`;
- globalWhere = or(
- ilike(techVendorContacts.contactName, s),
- ilike(techVendorContacts.contactPosition, s),
- ilike(techVendorContacts.contactEmail, s),
- ilike(techVendorContacts.contactPhone, s)
- );
- }
-
- // 해당 벤더 조건
- const vendorWhere = eq(techVendorContacts.vendorId, id);
-
- // 최종 조건 결합
- const finalWhere = and(advancedWhere, globalWhere, vendorWhere);
-
- // 정렬 조건
- const orderBy =
- input.sort.length > 0
- ? input.sort.map((item) =>
- item.desc ? desc(techVendorContacts[item.id]) : asc(techVendorContacts[item.id])
- )
- : [asc(techVendorContacts.createdAt)];
-
- // 트랜잭션 내부에서 Repository 호출
- const { data, total } = await db.transaction(async (tx) => {
- const data = await selectTechVendorContacts(tx, {
- where: finalWhere,
- orderBy,
- offset,
- limit: input.perPage,
- });
- const total = await countTechVendorContacts(tx, finalWhere);
- return { data, total };
- });
-
- const pageCount = Math.ceil(total / input.perPage);
-
- return { data, pageCount };
- } catch (err) {
- // 에러 발생 시 디폴트
- return { data: [], pageCount: 0 };
- }
- },
- [JSON.stringify(input), String(id)], // 캐싱 키
- {
- revalidate: 3600,
- tags: [`tech-vendor-contacts-${id}`],
- }
- )();
-}
-
-export async function createTechVendorContact(input: CreateTechVendorContactSchema) {
- unstable_noStore();
- try {
- await db.transaction(async (tx) => {
- // DB Insert
- const [newContact] = await insertTechVendorContact(tx, {
- vendorId: input.vendorId,
- contactName: input.contactName,
- contactPosition: input.contactPosition || "",
- contactEmail: input.contactEmail,
- contactPhone: input.contactPhone || "",
- contactCountry: input.contactCountry || "",
- isPrimary: input.isPrimary || false,
- });
-
- return newContact;
- });
-
- // 캐시 무효화
- revalidateTag(`tech-vendor-contacts-${input.vendorId}`);
- revalidateTag("users");
-
- return { data: null, error: null };
- } catch (err) {
- return { data: null, error: getErrorMessage(err) };
- }
-}
-
-export async function updateTechVendorContact(input: UpdateTechVendorContactSchema & { id: number; vendorId: number }) {
- unstable_noStore();
- try {
- const [updatedContact] = await db
- .update(techVendorContacts)
- .set({
- contactName: input.contactName,
- contactPosition: input.contactPosition || null,
- contactEmail: input.contactEmail,
- contactPhone: input.contactPhone || null,
- contactCountry: input.contactCountry || null,
- isPrimary: input.isPrimary || false,
- updatedAt: new Date(),
- })
- .where(eq(techVendorContacts.id, input.id))
- .returning();
-
- // 캐시 무효화
- revalidateTag(`tech-vendor-contacts-${input.vendorId}`);
- revalidateTag("users");
-
- return { data: updatedContact, error: null };
- } catch (err) {
- return { data: null, error: getErrorMessage(err) };
- }
-}
-
-export async function deleteTechVendorContact(contactId: number, vendorId: number) {
- unstable_noStore();
- try {
- const [deletedContact] = await db
- .delete(techVendorContacts)
- .where(eq(techVendorContacts.id, contactId))
- .returning();
-
- // 캐시 무효화
- revalidateTag(`tech-vendor-contacts-${contactId}`);
- revalidateTag(`tech-vendor-contacts-${vendorId}`);
-
- return { data: deletedContact, error: null };
- } catch (err) {
- return { data: null, error: getErrorMessage(err) };
- }
-}
-
-/* -----------------------------------------------------
- 5) 아이템 관리
------------------------------------------------------ */
-
-export async function getTechVendorItems(input: GetTechVendorItemsSchema, id: number) {
- return unstable_cache(
- async () => {
- try {
- const offset = (input.page - 1) * input.perPage;
-
- // 필터링 설정
- const advancedWhere = filterColumns({
- table: techVendorItemsView,
- filters: input.filters,
- joinOperator: input.joinOperator,
- });
-
- // 검색 조건
- let globalWhere;
- if (input.search) {
- const s = `%${input.search}%`;
- globalWhere = or(
- ilike(techVendorItemsView.itemCode, s)
- );
- }
-
- // 해당 벤더 조건
- const vendorWhere = eq(techVendorItemsView.vendorId, id);
-
- // 최종 조건 결합
- const finalWhere = and(advancedWhere, globalWhere, vendorWhere);
-
- // 정렬 조건
- const orderBy =
- input.sort.length > 0
- ? input.sort.map((item) =>
- item.desc ? desc(techVendorItemsView[item.id]) : asc(techVendorItemsView[item.id])
- )
- : [asc(techVendorItemsView.createdAt)];
-
- // 트랜잭션 내부에서 Repository 호출
- const { data, total } = await db.transaction(async (tx) => {
- const data = await selectTechVendorItems(tx, {
- where: finalWhere,
- orderBy,
- offset,
- limit: input.perPage,
- });
- const total = await countTechVendorItems(tx, finalWhere);
- return { data, total };
- });
-
- const pageCount = Math.ceil(total / input.perPage);
-
- return { data, pageCount };
- } catch (err) {
- // 에러 발생 시 디폴트
- return { data: [], pageCount: 0 };
- }
- },
- [JSON.stringify(input), String(id)], // 캐싱 키
- {
- revalidate: 3600,
- tags: [`tech-vendor-items-${id}`],
- }
- )();
-}
-
-export interface ItemDropdownOption {
- itemCode: string;
- itemList: string;
- workType: string | null;
- shipTypes: string | null;
- subItemList: string | null;
-}
-
-/**
- * Vendor Item 추가 시 사용할 아이템 목록 조회 (전체 목록 반환)
- * 아이템 코드, 이름, 설명만 간소화해서 반환
- */
-export async function getItemsForTechVendor(vendorId: number) {
- return unstable_cache(
- async () => {
- try {
- // 1. 벤더 정보 조회로 벤더 타입 확인
- const vendor = await db.query.techVendors.findFirst({
- where: eq(techVendors.id, vendorId),
- columns: {
- techVendorType: true
- }
- });
-
- if (!vendor) {
- return {
- data: [],
- error: "벤더를 찾을 수 없습니다.",
- };
- }
-
- // 2. 해당 벤더가 이미 가지고 있는 itemCode 목록 조회
- const existingItems = await db
- .select({
- itemCode: techVendorPossibleItems.itemCode,
- })
- .from(techVendorPossibleItems)
- .where(eq(techVendorPossibleItems.vendorId, vendorId));
-
- const existingItemCodes = existingItems.map(item => item.itemCode);
-
- // 3. 벤더 타입에 따라 해당 타입의 아이템만 조회
- // let availableItems: ItemDropdownOption[] = [];
- let availableItems: (typeof itemShipbuilding.$inferSelect | typeof itemOffshoreTop.$inferSelect | typeof itemOffshoreHull.$inferSelect)[] = [];
- switch (vendor.techVendorType) {
- case "조선":
- const shipbuildingItems = await db
- .select({
- id: itemShipbuilding.id,
- createdAt: itemShipbuilding.createdAt,
- updatedAt: itemShipbuilding.updatedAt,
- itemCode: itemShipbuilding.itemCode,
- itemList: itemShipbuilding.itemList,
- workType: itemShipbuilding.workType,
- shipTypes: itemShipbuilding.shipTypes,
- })
- .from(itemShipbuilding)
- .where(
- existingItemCodes.length > 0
- ? not(inArray(itemShipbuilding.itemCode, existingItemCodes))
- : undefined
- )
- .orderBy(asc(itemShipbuilding.itemCode));
-
- availableItems = shipbuildingItems
- .filter(item => item.itemCode != null)
- .map(item => ({
- id: item.id,
- createdAt: item.createdAt,
- updatedAt: item.updatedAt,
- itemCode: item.itemCode!,
- itemList: item.itemList || "조선 아이템",
- workType: item.workType || "조선 관련 아이템",
- shipTypes: item.shipTypes || "조선 관련 아이템"
- }));
- break;
-
- case "해양TOP":
- const offshoreTopItems = await db
- .select({
- id: itemOffshoreTop.id,
- createdAt: itemOffshoreTop.createdAt,
- updatedAt: itemOffshoreTop.updatedAt,
- itemCode: itemOffshoreTop.itemCode,
- itemList: itemOffshoreTop.itemList,
- workType: itemOffshoreTop.workType,
- subItemList: itemOffshoreTop.subItemList,
- })
- .from(itemOffshoreTop)
- .where(
- existingItemCodes.length > 0
- ? not(inArray(itemOffshoreTop.itemCode, existingItemCodes))
- : undefined
- )
- .orderBy(asc(itemOffshoreTop.itemCode));
-
- availableItems = offshoreTopItems
- .filter(item => item.itemCode != null)
- .map(item => ({
- id: item.id,
- createdAt: item.createdAt,
- updatedAt: item.updatedAt,
- itemCode: item.itemCode!,
- itemList: item.itemList || "해양TOP 아이템",
- workType: item.workType || "해양TOP 관련 아이템",
- subItemList: item.subItemList || "해양TOP 관련 아이템"
- }));
- break;
-
- case "해양HULL":
- const offshoreHullItems = await db
- .select({
- id: itemOffshoreHull.id,
- createdAt: itemOffshoreHull.createdAt,
- updatedAt: itemOffshoreHull.updatedAt,
- itemCode: itemOffshoreHull.itemCode,
- itemList: itemOffshoreHull.itemList,
- workType: itemOffshoreHull.workType,
- subItemList: itemOffshoreHull.subItemList,
- })
- .from(itemOffshoreHull)
- .where(
- existingItemCodes.length > 0
- ? not(inArray(itemOffshoreHull.itemCode, existingItemCodes))
- : undefined
- )
- .orderBy(asc(itemOffshoreHull.itemCode));
-
- availableItems = offshoreHullItems
- .filter(item => item.itemCode != null)
- .map(item => ({
- id: item.id,
- createdAt: item.createdAt,
- updatedAt: item.updatedAt,
- itemCode: item.itemCode!,
- itemList: item.itemList || "해양HULL 아이템",
- workType: item.workType || "해양HULL 관련 아이템",
- subItemList: item.subItemList || "해양HULL 관련 아이템"
- }));
- break;
-
- default:
- return {
- data: [],
- error: `지원하지 않는 벤더 타입입니다: ${vendor.techVendorType}`,
- };
- }
-
- return {
- data: availableItems,
- error: null
- };
- } catch (err) {
- console.error("Failed to fetch items for tech vendor dropdown:", err);
- return {
- data: [],
- error: "아이템 목록을 불러오는데 실패했습니다.",
- };
- }
- },
- // 캐시 키를 vendorId 별로 달리 해야 한다.
- ["items-for-tech-vendor", String(vendorId)],
- {
- revalidate: 3600, // 1시간 캐싱
- tags: ["items"], // revalidateTag("items") 호출 시 무효화
- }
- )();
-}
-
-/**
- * 벤더 타입과 아이템 코드에 따른 아이템 조회
- */
-export async function getItemsByVendorType(vendorType: string, itemCode: string) {
- try {
- let items: (typeof itemShipbuilding.$inferSelect | typeof itemOffshoreTop.$inferSelect | typeof itemOffshoreHull.$inferSelect)[] = [];
-
- switch (vendorType) {
- case "조선":
- const shipbuildingResults = await db
- .select({
- id: itemShipbuilding.id,
- itemCode: itemShipbuilding.itemCode,
- workType: itemShipbuilding.workType,
- shipTypes: itemShipbuilding.shipTypes,
- itemList: itemShipbuilding.itemList,
- createdAt: itemShipbuilding.createdAt,
- updatedAt: itemShipbuilding.updatedAt,
- })
- .from(itemShipbuilding)
- .where(itemCode ? eq(itemShipbuilding.itemCode, itemCode) : undefined);
- items = shipbuildingResults;
- break;
-
- case "해양TOP":
- const offshoreTopResults = await db
- .select({
- id: itemOffshoreTop.id,
- itemCode: itemOffshoreTop.itemCode,
- workType: itemOffshoreTop.workType,
- itemList: itemOffshoreTop.itemList,
- subItemList: itemOffshoreTop.subItemList,
- createdAt: itemOffshoreTop.createdAt,
- updatedAt: itemOffshoreTop.updatedAt,
- })
- .from(itemOffshoreTop)
- .where(itemCode ? eq(itemOffshoreTop.itemCode, itemCode) : undefined);
- items = offshoreTopResults;
- break;
-
- case "해양HULL":
- const offshoreHullResults = await db
- .select({
- id: itemOffshoreHull.id,
- itemCode: itemOffshoreHull.itemCode,
- workType: itemOffshoreHull.workType,
- itemList: itemOffshoreHull.itemList,
- subItemList: itemOffshoreHull.subItemList,
- createdAt: itemOffshoreHull.createdAt,
- updatedAt: itemOffshoreHull.updatedAt,
- })
- .from(itemOffshoreHull)
- .where(itemCode ? eq(itemOffshoreHull.itemCode, itemCode) : undefined);
- items = offshoreHullResults;
- break;
-
- default:
- items = [];
- }
-
- const result = items.map(item => ({
- ...item,
- techVendorType: vendorType
- }));
-
- return { data: result, error: null };
- } catch (err) {
- console.error("Error fetching items by vendor type:", err);
- return { data: [], error: "Failed to fetch items" };
- }
-}
-
-/**
- * 벤더의 possible_items를 조회하고 해당 아이템 코드로 각 타입별 테이블을 조회
- * 벤더 타입이 콤마로 구분된 경우 (예: "조선,해양TOP,해양HULL") 모든 타입의 아이템을 조회
- */
-export async function getVendorItemsByType(vendorId: number, vendorType: string) {
- try {
- // 벤더의 possible_items 조회
- const possibleItems = await db.query.techVendorPossibleItems.findMany({
- where: eq(techVendorPossibleItems.vendorId, vendorId),
- columns: {
- itemCode: true
- }
- })
-
- const itemCodes = possibleItems.map(item => item.itemCode)
-
- if (itemCodes.length === 0) {
- return { data: [] }
- }
-
- // 벤더 타입을 콤마로 분리
- const vendorTypes = vendorType.split(',').map(type => type.trim())
- const allItems: Array<Record<string, any> & { techVendorType: "조선" | "해양TOP" | "해양HULL" }> = []
-
- // 각 벤더 타입에 따라 해당하는 테이블에서 아이템 조회
- for (const singleType of vendorTypes) {
- switch (singleType) {
- case "조선":
- const shipbuildingItems = await db.query.itemShipbuilding.findMany({
- where: inArray(itemShipbuilding.itemCode, itemCodes)
- })
- allItems.push(...shipbuildingItems.map(item => ({
- ...item,
- techVendorType: "조선" as const
- })))
- break
-
- case "해양TOP":
- const offshoreTopItems = await db.query.itemOffshoreTop.findMany({
- where: inArray(itemOffshoreTop.itemCode, itemCodes)
- })
- allItems.push(...offshoreTopItems.map(item => ({
- ...item,
- techVendorType: "해양TOP" as const
- })))
- break
-
- case "해양HULL":
- const offshoreHullItems = await db.query.itemOffshoreHull.findMany({
- where: inArray(itemOffshoreHull.itemCode, itemCodes)
- })
- allItems.push(...offshoreHullItems.map(item => ({
- ...item,
- techVendorType: "해양HULL" as const
- })))
- break
-
- default:
- console.warn(`Unknown vendor type: ${singleType}`)
- break
- }
- }
-
- // 중복 허용 - 모든 아이템을 그대로 반환
- return {
- data: allItems.sort((a, b) => a.itemCode.localeCompare(b.itemCode))
- }
- } catch (err) {
- console.error("Error getting vendor items by type:", err)
- return { data: [] }
- }
-}
-
-export async function createTechVendorItem(input: CreateTechVendorItemSchema) {
- unstable_noStore();
- try {
- // DB에 이미 존재하는지 확인
- const existingItem = await db
- .select({ id: techVendorPossibleItems.id })
- .from(techVendorPossibleItems)
- .where(
- and(
- eq(techVendorPossibleItems.vendorId, input.vendorId),
- eq(techVendorPossibleItems.itemCode, input.itemCode)
- )
- )
- .limit(1);
-
- if (existingItem.length > 0) {
- return { data: null, error: "이미 추가된 아이템입니다." };
- }
-
- await db.transaction(async (tx) => {
- // DB Insert
- const [newItem] = await tx
- .insert(techVendorPossibleItems)
- .values({
- vendorId: input.vendorId,
- itemCode: input.itemCode,
- })
- .returning();
- return newItem;
- });
-
- // 캐시 무효화
- revalidateTag(`tech-vendor-items-${input.vendorId}`);
-
- return { data: null, error: null };
- } catch (err) {
- return { data: null, error: getErrorMessage(err) };
- }
-}
-
-/* -----------------------------------------------------
- 6) 기술영업 벤더 승인/거부
------------------------------------------------------ */
-
-interface ApproveTechVendorsInput {
- ids: string[];
-}
-
-/**
- * 기술영업 벤더 승인 (상태를 ACTIVE로 변경)
- */
-export async function approveTechVendors(input: ApproveTechVendorsInput) {
- unstable_noStore();
-
- try {
- // 트랜잭션 내에서 협력업체 상태 업데이트
- const result = await db.transaction(async (tx) => {
- // 협력업체 상태 업데이트
- const [updated] = await tx
- .update(techVendors)
- .set({
- status: "ACTIVE",
- updatedAt: new Date()
- })
- .where(inArray(techVendors.id, input.ids.map(id => parseInt(id))))
- .returning();
-
- return updated;
- });
-
- // 캐시 무효화
- revalidateTag("tech-vendors");
- revalidateTag("tech-vendor-status-counts");
-
- return { data: result, error: null };
- } catch (err) {
- console.error("Error approving tech vendors:", err);
- return { data: null, error: getErrorMessage(err) };
- }
-}
-
-/**
- * 기술영업 벤더 거부 (상태를 REJECTED로 변경)
- */
-export async function rejectTechVendors(input: ApproveTechVendorsInput) {
- unstable_noStore();
-
- try {
- // 트랜잭션 내에서 협력업체 상태 업데이트
- const result = await db.transaction(async (tx) => {
- // 협력업체 상태 업데이트
- const [updated] = await tx
- .update(techVendors)
- .set({
- status: "INACTIVE",
- updatedAt: new Date()
- })
- .where(inArray(techVendors.id, input.ids.map(id => parseInt(id))))
- .returning();
-
- return updated;
- });
-
- // 캐시 무효화
- revalidateTag("tech-vendors");
- revalidateTag("tech-vendor-status-counts");
-
- return { data: result, error: null };
- } catch (err) {
- console.error("Error rejecting tech vendors:", err);
- return { data: null, error: getErrorMessage(err) };
- }
-}
-
-/* -----------------------------------------------------
- 7) 엑셀 내보내기
------------------------------------------------------ */
-
-/**
- * 벤더 연락처 목록 엑셀 내보내기
- */
-export async function exportTechVendorContacts(vendorId: number) {
- try {
- const contacts = await db
- .select()
- .from(techVendorContacts)
- .where(eq(techVendorContacts.vendorId, vendorId))
- .orderBy(techVendorContacts.isPrimary, techVendorContacts.contactName);
-
- return contacts;
- } catch (err) {
- console.error("기술영업 벤더 연락처 내보내기 오류:", err);
- return [];
- }
-}
-
-/**
- * 벤더 아이템 목록 엑셀 내보내기
- */
-export async function exportTechVendorItems(vendorId: number) {
- try {
- const items = await db
- .select({
- id: techVendorItemsView.vendorItemId,
- vendorId: techVendorItemsView.vendorId,
- itemCode: techVendorItemsView.itemCode,
- createdAt: techVendorItemsView.createdAt,
- updatedAt: techVendorItemsView.updatedAt,
- })
- .from(techVendorItemsView)
- .where(eq(techVendorItemsView.vendorId, vendorId))
-
- return items;
- } catch (err) {
- console.error("기술영업 벤더 아이템 내보내기 오류:", err);
- return [];
- }
-}
-
-/**
- * 벤더 정보 엑셀 내보내기
- */
-export async function exportTechVendorDetails(vendorIds: number[]) {
- try {
- if (!vendorIds.length) return [];
-
- // 벤더 기본 정보 조회
- const vendorsData = await db
- .select({
- id: techVendors.id,
- vendorName: techVendors.vendorName,
- vendorCode: techVendors.vendorCode,
- taxId: techVendors.taxId,
- address: techVendors.address,
- country: techVendors.country,
- phone: techVendors.phone,
- email: techVendors.email,
- website: techVendors.website,
- status: techVendors.status,
- representativeName: techVendors.representativeName,
- representativeEmail: techVendors.representativeEmail,
- representativePhone: techVendors.representativePhone,
- representativeBirth: techVendors.representativeBirth,
- items: techVendors.items,
- createdAt: techVendors.createdAt,
- updatedAt: techVendors.updatedAt,
- })
- .from(techVendors)
- .where(
- vendorIds.length === 1
- ? eq(techVendors.id, vendorIds[0])
- : inArray(techVendors.id, vendorIds)
- );
-
- // 벤더별 상세 정보를 포함하여 반환
- const vendorsWithDetails = await Promise.all(
- vendorsData.map(async (vendor) => {
- // 연락처 조회
- const contacts = await exportTechVendorContacts(vendor.id);
-
- // 아이템 조회
- const items = await exportTechVendorItems(vendor.id);
-
- return {
- ...vendor,
- vendorContacts: contacts,
- vendorItems: items,
- };
- })
- );
-
- return vendorsWithDetails;
- } catch (err) {
- console.error("기술영업 벤더 상세 내보내기 오류:", err);
- return [];
- }
-}
-
-/**
- * 기술영업 벤더 상세 정보 조회 (연락처, 첨부파일 포함)
- */
-export async function getTechVendorDetailById(id: number) {
- try {
- const vendor = await db.select().from(techVendors).where(eq(techVendors.id, id)).limit(1);
-
- if (!vendor || vendor.length === 0) {
- console.error(`Vendor not found with id: ${id}`);
- return null;
- }
-
- const contacts = await db.select().from(techVendorContacts).where(eq(techVendorContacts.vendorId, id));
- const attachments = await db.select().from(techVendorAttachments).where(eq(techVendorAttachments.vendorId, id));
- const possibleItems = await db.select().from(techVendorPossibleItems).where(eq(techVendorPossibleItems.vendorId, id));
-
- return {
- ...vendor[0],
- contacts,
- attachments,
- possibleItems
- };
- } catch (error) {
- console.error("Error fetching tech vendor detail:", error);
- return null;
- }
-}
-
-/**
- * 기술영업 벤더 첨부파일 다운로드를 위한 서버 액션
- * @param vendorId 기술영업 벤더 ID
- * @param fileId 특정 파일 ID (단일 파일 다운로드시)
- * @returns 다운로드할 수 있는 임시 URL
- */
-export async function downloadTechVendorAttachments(vendorId:number, fileId?:number) {
- try {
- // API 경로 생성 (단일 파일 또는 모든 파일)
- const url = fileId
- ? `/api/tech-vendors/attachments/download?id=${fileId}&vendorId=${vendorId}`
- : `/api/tech-vendors/attachments/download-all?vendorId=${vendorId}`;
-
- // fetch 요청 (기본적으로 Blob으로 응답 받기)
- const response = await fetch(url, {
- method: 'GET',
- headers: {
- 'Content-Type': 'application/json',
- },
- });
-
- if (!response.ok) {
- throw new Error(`Server responded with ${response.status}: ${response.statusText}`);
- }
-
- // 파일명 가져오기 (Content-Disposition 헤더에서)
- const contentDisposition = response.headers.get('content-disposition');
- let fileName = fileId ? `file-${fileId}.zip` : `tech-vendor-${vendorId}-files.zip`;
-
- if (contentDisposition) {
- const matches = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/.exec(contentDisposition);
- if (matches && matches[1]) {
- fileName = matches[1].replace(/['"]/g, '');
- }
- }
-
- // Blob으로 응답 변환
- const blob = await response.blob();
-
- // Blob URL 생성
- const blobUrl = window.URL.createObjectURL(blob);
-
- return {
- url: blobUrl,
- fileName,
- blob
- };
- } catch (error) {
- console.error('Download API error:', error);
- throw error;
- }
-}
-
-/**
- * 임시 ZIP 파일 정리를 위한 서버 액션
- * @param fileName 정리할 파일명
- */
-export async function cleanupTechTempFiles(fileName: string) {
- 'use server';
-
- try {
-
- await deleteFile(`tmp/${fileName}`)
-
- return { success: true };
- } catch (error) {
- console.error('임시 파일 정리 오류:', error);
- return { success: false, error: '임시 파일 정리 중 오류가 발생했습니다.' };
- }
-}
-
-export const findVendorById = async (id: number): Promise<TechVendor | null> => {
- try {
- // 직접 DB에서 조회
- const vendor = await db
- .select()
- .from(techVendors)
- .where(eq(techVendors.id, id))
- .limit(1)
- .then(rows => rows[0] || null);
-
- if (!vendor) {
- console.error(`Vendor not found with id: ${id}`);
- return null;
- }
-
- return vendor;
- } catch (error) {
- console.error('Error fetching vendor:', error);
- return null;
- }
-};
-
-/* -----------------------------------------------------
- 8) 기술영업 벤더 RFQ 히스토리 조회
------------------------------------------------------ */
-
-/**
- * 기술영업 벤더의 RFQ 히스토리 조회 (간단한 버전)
- */
-export async function getTechVendorRfqHistory(input: GetTechVendorRfqHistorySchema, id:number) {
- try {
-
- // 먼저 해당 벤더의 견적서가 있는지 확인
- const { techSalesVendorQuotations } = await import("@/db/schema/techSales");
-
- const quotationCheck = await db
- .select({ count: sql<number>`count(*)`.as("count") })
- .from(techSalesVendorQuotations)
- .where(eq(techSalesVendorQuotations.vendorId, id));
-
- console.log(`벤더 ${id}의 견적서 개수:`, quotationCheck[0]?.count);
-
- if (quotationCheck[0]?.count === 0) {
- console.log("해당 벤더의 견적서가 없습니다.");
- return { data: [], pageCount: 0 };
- }
-
- const offset = (input.page - 1) * input.perPage;
- const { techSalesRfqs } = await import("@/db/schema/techSales");
- const { biddingProjects } = await import("@/db/schema/projects");
-
- // 간단한 조회
- let whereCondition = eq(techSalesVendorQuotations.vendorId, id);
-
- // 검색이 있다면 추가
- if (input.search) {
- const s = `%${input.search}%`;
- const searchCondition = and(
- whereCondition,
- or(
- ilike(techSalesRfqs.rfqCode, s),
- ilike(techSalesRfqs.description, s),
- ilike(biddingProjects.pspid, s),
- ilike(biddingProjects.projNm, s)
- )
- );
- whereCondition = searchCondition || whereCondition;
- }
-
- // 데이터 조회 - 테이블에 필요한 필드들 (프로젝트 타입 추가)
- const data = await db
- .select({
- id: techSalesRfqs.id,
- rfqCode: techSalesRfqs.rfqCode,
- description: techSalesRfqs.description,
- projectCode: biddingProjects.pspid,
- projectName: biddingProjects.projNm,
- projectType: biddingProjects.pjtType, // 프로젝트 타입 추가
- status: techSalesRfqs.status,
- totalAmount: techSalesVendorQuotations.totalPrice,
- currency: techSalesVendorQuotations.currency,
- dueDate: techSalesRfqs.dueDate,
- createdAt: techSalesRfqs.createdAt,
- quotationCode: techSalesVendorQuotations.quotationCode,
- submittedAt: techSalesVendorQuotations.submittedAt,
- })
- .from(techSalesVendorQuotations)
- .innerJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id))
- .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id))
- .where(whereCondition)
- .orderBy(desc(techSalesRfqs.createdAt))
- .limit(input.perPage)
- .offset(offset);
-
- console.log("조회된 데이터:", data.length, "개");
-
- // 전체 개수 조회
- const totalResult = await db
- .select({ count: sql<number>`count(*)`.as("count") })
- .from(techSalesVendorQuotations)
- .innerJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id))
- .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id))
- .where(whereCondition);
-
- const total = totalResult[0]?.count || 0;
- const pageCount = Math.ceil(total / input.perPage);
-
- console.log("기술영업 벤더 RFQ 히스토리 조회 완료", {
- id,
- dataLength: data.length,
- total,
- pageCount
- });
-
- return { data, pageCount };
- } catch (err) {
- console.error("기술영업 벤더 RFQ 히스토리 조회 오류:", {
- err,
- id,
- stack: err instanceof Error ? err.stack : undefined
- });
- return { data: [], pageCount: 0 };
- }
-}
-
-/**
- * 기술영업 벤더 엑셀 import 시 유저 생성 및 담당자 등록
- */
-export async function importTechVendorsFromExcel(
- vendors: Array<{
- vendorName: string;
- vendorCode?: string | null;
- email: string;
- taxId: string;
- country?: string | null;
- countryEng?: string | null;
- countryFab?: string | null;
- agentName?: string | null;
- agentPhone?: string | null;
- agentEmail?: string | null;
- address?: string | null;
- phone?: string | null;
- website?: string | null;
- techVendorType: string;
- representativeName?: string | null;
- representativeEmail?: string | null;
- representativePhone?: string | null;
- representativeBirth?: string | null;
- items: string;
- contacts?: Array<{
- contactName: string;
- contactPosition?: string;
- contactEmail: string;
- contactPhone?: string;
- contactCountry?: string | null;
- isPrimary?: boolean;
- }>;
- }>,
-) {
- unstable_noStore();
-
- try {
- console.log("Import 시작 - 벤더 수:", vendors.length);
- console.log("첫 번째 벤더 데이터:", vendors[0]);
-
- const result = await db.transaction(async (tx) => {
- const createdVendors = [];
- const skippedVendors = [];
- const errors = [];
-
- for (const vendor of vendors) {
- console.log("벤더 처리 시작:", vendor.vendorName);
-
- try {
- // 0. 이메일 타입 검사
- // - 문자열이 아니거나, '@' 미포함, 혹은 객체(예: 하이퍼링크 등)인 경우 모두 거절
- const isEmailString = typeof vendor.email === "string";
- const isEmailContainsAt = isEmailString && vendor.email.includes("@");
- // 하이퍼링크 등 객체로 넘어온 경우 (예: { href: "...", ... } 등) 방지
- const isEmailPlainString = isEmailString && Object.prototype.toString.call(vendor.email) === "[object String]";
-
- if (!isEmailPlainString || !isEmailContainsAt) {
- console.log("이메일 형식이 올바르지 않습니다:", vendor.email);
- errors.push({
- vendorName: vendor.vendorName,
- email: vendor.email,
- error: "이메일 형식이 올바르지 않습니다"
- });
- continue;
- }
- // 1. 이메일로 기존 벤더 중복 체크
- const existingVendor = await tx.query.techVendors.findFirst({
- where: eq(techVendors.email, vendor.email),
- columns: { id: true, vendorName: true, email: true }
- });
-
- if (existingVendor) {
- console.log("이미 존재하는 벤더 스킵:", vendor.vendorName, vendor.email);
- skippedVendors.push({
- vendorName: vendor.vendorName,
- email: vendor.email,
- reason: `이미 등록된 이메일입니다 (기존 업체: ${existingVendor.vendorName})`
- });
- continue;
- }
-
- // 2. 벤더 생성
- console.log("벤더 생성 시도:", {
- vendorName: vendor.vendorName,
- email: vendor.email,
- techVendorType: vendor.techVendorType
- });
-
- const [newVendor] = await tx.insert(techVendors).values({
- vendorName: vendor.vendorName,
- vendorCode: vendor.vendorCode || null,
- taxId: vendor.taxId,
- country: vendor.country || null,
- countryEng: vendor.countryEng || null,
- countryFab: vendor.countryFab || null,
- agentName: vendor.agentName || null,
- agentPhone: vendor.agentPhone || null,
- agentEmail: vendor.agentEmail || null,
- address: vendor.address || null,
- phone: vendor.phone || null,
- email: vendor.email,
- website: vendor.website || null,
- techVendorType: vendor.techVendorType,
- status: "ACTIVE",
- representativeName: vendor.representativeName || null,
- representativeEmail: vendor.representativeEmail || null,
- representativePhone: vendor.representativePhone || null,
- representativeBirth: vendor.representativeBirth || null,
- }).returning();
-
- console.log("벤더 생성 성공:", newVendor.id);
-
- // 2. 담당자 생성 (최소 1명 이상 등록)
- if (vendor.contacts && vendor.contacts.length > 0) {
- console.log("담당자 생성 시도:", vendor.contacts.length, "명");
-
- for (const contact of vendor.contacts) {
- await tx.insert(techVendorContacts).values({
- vendorId: newVendor.id,
- contactName: contact.contactName,
- contactPosition: contact.contactPosition || null,
- contactEmail: contact.contactEmail,
- contactPhone: contact.contactPhone || null,
- contactCountry: contact.contactCountry || null,
- isPrimary: contact.isPrimary || false,
- });
- console.log("담당자 생성 성공:", contact.contactName, contact.contactEmail);
- }
-
- // // 벤더 이메일을 주 담당자의 이메일로 업데이트
- // const primaryContact = vendor.contacts.find(c => c.isPrimary) || vendor.contacts[0];
- // if (primaryContact && primaryContact.contactEmail !== vendor.email) {
- // await tx.update(techVendors)
- // .set({ email: primaryContact.contactEmail })
- // .where(eq(techVendors.id, newVendor.id));
- // console.log("벤더 이메일 업데이트:", primaryContact.contactEmail);
- // }
- }
- // else {
- // // 담당자 정보가 없는 경우 벤더 정보로 기본 담당자 생성
- // console.log("기본 담당자 생성");
- // await tx.insert(techVendorContacts).values({
- // vendorId: newVendor.id,
- // contactName: vendor.representativeName || vendor.vendorName || "기본 담당자",
- // contactPosition: null,
- // contactEmail: vendor.email,
- // contactPhone: vendor.representativePhone || vendor.phone || null,
- // contactCountry: vendor.country || null,
- // isPrimary: true,
- // });
- // console.log("기본 담당자 생성 성공:", vendor.email);
- // }
-
- // 3. 유저 생성 (이메일이 있는 경우)
- if (vendor.email) {
- console.log("유저 생성 시도:", vendor.email);
-
- // 이미 존재하는 유저인지 확인
- const existingUser = await tx.query.users.findFirst({
- where: eq(users.email, vendor.email),
- columns: { id: true }
- });
-
- if (!existingUser) {
- // 유저가 존재하지 않는 경우 생성
- await tx.insert(users).values({
- name: vendor.vendorName,
- email: vendor.email,
- techCompanyId: newVendor.id,
- domain: "partners",
- });
- console.log("유저 생성 성공");
- } else {
- // 이미 존재하는 유저라면 techCompanyId 업데이트
- await tx.update(users)
- .set({ techCompanyId: newVendor.id })
- .where(eq(users.id, existingUser.id));
- console.log("이미 존재하는 유저, techCompanyId 업데이트:", existingUser.id);
- }
- }
-
- createdVendors.push(newVendor);
- console.log("벤더 처리 완료:", vendor.vendorName);
- } catch (error) {
- console.error("벤더 처리 중 오류 발생:", vendor.vendorName, error);
- errors.push({
- vendorName: vendor.vendorName,
- email: vendor.email,
- error: error instanceof Error ? error.message : "알 수 없는 오류"
- });
- // 개별 벤더 오류는 전체 트랜잭션을 롤백하지 않도록 continue
- continue;
- }
- }
-
- console.log("모든 벤더 처리 완료:", {
- 생성됨: createdVendors.length,
- 스킵됨: skippedVendors.length,
- 오류: errors.length
- });
-
- return {
- createdVendors,
- skippedVendors,
- errors,
- totalProcessed: vendors.length,
- successCount: createdVendors.length,
- skipCount: skippedVendors.length,
- errorCount: errors.length
- };
- });
-
- // 캐시 무효화
- revalidateTag("tech-vendors");
- revalidateTag("tech-vendor-contacts");
- revalidateTag("users");
-
- console.log("Import 완료 - 결과:", result);
-
- // 결과 메시지 생성
- const messages = [];
- if (result.successCount > 0) {
- messages.push(`${result.successCount}개 벤더 생성 성공`);
- }
- if (result.skipCount > 0) {
- messages.push(`${result.skipCount}개 벤더 중복으로 스킵`);
- }
- if (result.errorCount > 0) {
- messages.push(`${result.errorCount}개 벤더 처리 중 오류`);
- }
-
- return {
- success: true,
- data: result,
- message: messages.join(", "),
- details: {
- created: result.createdVendors,
- skipped: result.skippedVendors,
- errors: result.errors
- }
- };
- } catch (error) {
- console.error("Import 실패:", error);
- return { success: false, error: getErrorMessage(error) };
- }
-}
-
-export async function findTechVendorById(id: number): Promise<TechVendor | null> {
- const result = await db
- .select()
- .from(techVendors)
- .where(eq(techVendors.id, id))
- .limit(1)
-
- return result[0] || null
-}
-
-/**
- * 회원가입 폼을 통한 기술영업 벤더 생성 (초대 토큰 기반)
- */
-export async function createTechVendorFromSignup(params: {
- vendorData: {
- vendorName: string
- vendorCode?: string
- items: string
- website?: string
- taxId: string
- address?: string
- email: string
- phone?: string
- country: string
- techVendorType: "조선" | "해양TOP" | "해양HULL" | ("조선" | "해양TOP" | "해양HULL")[]
- representativeName?: string
- representativeBirth?: string
- representativeEmail?: string
- representativePhone?: string
- }
- files?: File[]
- contacts: {
- contactName: string
- contactPosition?: string
- contactEmail: string
- contactPhone?: string
- isPrimary?: boolean
- }[]
- selectedItemCodes?: string[] // 선택된 아이템 코드들
- invitationToken?: string // 초대 토큰
-}) {
- unstable_noStore();
-
- try {
- console.log("기술영업 벤더 회원가입 시작:", params.vendorData.vendorName);
-
- // 초대 토큰 검증
- let existingVendorId: number | null = null;
- if (params.invitationToken) {
- const { verifyTechVendorInvitationToken } = await import("@/lib/tech-vendor-invitation-token");
- const tokenPayload = await verifyTechVendorInvitationToken(params.invitationToken);
-
- if (!tokenPayload) {
- throw new Error("유효하지 않은 초대 토큰입니다.");
- }
-
- existingVendorId = tokenPayload.vendorId;
- console.log("초대 토큰 검증 성공, 벤더 ID:", existingVendorId);
- }
-
- const result = await db.transaction(async (tx) => {
- let vendorResult;
-
- if (existingVendorId) {
- // 기존 벤더 정보 업데이트
- const [updatedVendor] = await tx.update(techVendors)
- .set({
- vendorName: params.vendorData.vendorName,
- vendorCode: params.vendorData.vendorCode || null,
- taxId: params.vendorData.taxId,
- country: params.vendorData.country,
- address: params.vendorData.address || null,
- phone: params.vendorData.phone || null,
- email: params.vendorData.email,
- website: params.vendorData.website || null,
- techVendorType: Array.isArray(params.vendorData.techVendorType)
- ? params.vendorData.techVendorType[0]
- : params.vendorData.techVendorType,
- status: "QUOTE_COMPARISON", // 가입 완료 시 QUOTE_COMPARISON으로 변경
- representativeName: params.vendorData.representativeName || null,
- representativeEmail: params.vendorData.representativeEmail || null,
- representativePhone: params.vendorData.representativePhone || null,
- representativeBirth: params.vendorData.representativeBirth || null,
- items: params.vendorData.items,
- updatedAt: new Date(),
- })
- .where(eq(techVendors.id, existingVendorId))
- .returning();
-
- vendorResult = updatedVendor;
- console.log("기존 벤더 정보 업데이트 완료:", vendorResult.id);
- } else {
- // 1. 이메일 중복 체크 (새 벤더인 경우)
- const existingVendor = await tx.query.techVendors.findFirst({
- where: eq(techVendors.email, params.vendorData.email),
- columns: { id: true, vendorName: true }
- });
-
- if (existingVendor) {
- throw new Error(`이미 등록된 이메일입니다: ${params.vendorData.email} (기존 업체: ${existingVendor.vendorName})`);
- }
-
- // 2. 새 벤더 생성
- const [newVendor] = await tx.insert(techVendors).values({
- vendorName: params.vendorData.vendorName,
- vendorCode: params.vendorData.vendorCode || null,
- taxId: params.vendorData.taxId,
- country: params.vendorData.country,
- address: params.vendorData.address || null,
- phone: params.vendorData.phone || null,
- email: params.vendorData.email,
- website: params.vendorData.website || null,
- techVendorType: Array.isArray(params.vendorData.techVendorType)
- ? params.vendorData.techVendorType[0]
- : params.vendorData.techVendorType,
- status: "QUOTE_COMPARISON",
- isQuoteComparison: false,
- representativeName: params.vendorData.representativeName || null,
- representativeEmail: params.vendorData.representativeEmail || null,
- representativePhone: params.vendorData.representativePhone || null,
- representativeBirth: params.vendorData.representativeBirth || null,
- items: params.vendorData.items,
- }).returning();
-
- vendorResult = newVendor;
- console.log("새 벤더 생성 완료:", vendorResult.id);
- }
-
- // 이 부분은 위에서 이미 처리되었으므로 주석 처리
-
- // 3. 연락처 생성
- if (params.contacts && params.contacts.length > 0) {
- for (const [index, contact] of params.contacts.entries()) {
- await tx.insert(techVendorContacts).values({
- vendorId: vendorResult.id,
- contactName: contact.contactName,
- contactPosition: contact.contactPosition || null,
- contactEmail: contact.contactEmail,
- contactPhone: contact.contactPhone || null,
- isPrimary: index === 0, // 첫 번째 연락처를 primary로 설정
- });
- }
- console.log("연락처 생성 완료:", params.contacts.length, "개");
- }
-
- // 4. 선택된 아이템들을 tech_vendor_possible_items에 저장
- if (params.selectedItemCodes && params.selectedItemCodes.length > 0) {
- for (const itemCode of params.selectedItemCodes) {
- await tx.insert(techVendorPossibleItems).values({
- vendorId: vendorResult.id,
- vendorCode: vendorResult.vendorCode,
- vendorEmail: vendorResult.email,
- itemCode: itemCode,
- workType: null,
- shipTypes: null,
- itemList: null,
- subItemList: null,
- });
- }
- console.log("선택된 아이템 저장 완료:", params.selectedItemCodes.length, "개");
- }
-
- // 4. 첨부파일 처리
- if (params.files && params.files.length > 0) {
- await storeTechVendorFiles(tx, vendorResult.id, params.files, "GENERAL");
- console.log("첨부파일 저장 완료:", params.files.length, "개");
- }
-
- // 5. 유저 생성 (techCompanyId 설정)
- console.log("유저 생성 시도:", params.vendorData.email);
-
- const existingUser = await tx.query.users.findFirst({
- where: eq(users.email, params.vendorData.email),
- columns: { id: true, techCompanyId: true }
- });
-
- let userId = null;
- if (!existingUser) {
- const [newUser] = await tx.insert(users).values({
- name: params.vendorData.vendorName,
- email: params.vendorData.email,
- techCompanyId: vendorResult.id, // 중요: techCompanyId 설정
- domain: "partners",
- }).returning();
- userId = newUser.id;
- console.log("유저 생성 성공:", userId);
- } else {
- // 기존 유저의 techCompanyId 업데이트
- if (!existingUser.techCompanyId) {
- await tx.update(users)
- .set({ techCompanyId: vendorResult.id })
- .where(eq(users.id, existingUser.id));
- console.log("기존 유저의 techCompanyId 업데이트:", existingUser.id);
- }
- userId = existingUser.id;
- }
-
- 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<number> {
- try {
- const result = await db
- .select({ count: sql<number>`count(*)`.as("count") })
- .from(techVendorPossibleItems)
- .where(eq(techVendorPossibleItems.vendorId, vendorId));
-
- return result[0]?.count || 0;
- } catch (err) {
- console.error("Error getting tech vendor possible items count:", err);
- return 0;
- }
-}
-
-/**
- * 기술영업 벤더 초대 메일 발송
- */
-export async function inviteTechVendor(params: {
- vendorId: number;
- subject: string;
- message: string;
- recipientEmail: string;
-}) {
- unstable_noStore();
-
- try {
- console.log("기술영업 벤더 초대 메일 발송 시작:", params.vendorId);
-
- const result = await db.transaction(async (tx) => {
- // 벤더 정보 조회
- const vendor = await tx.query.techVendors.findFirst({
- where: eq(techVendors.id, params.vendorId),
- });
-
- if (!vendor) {
- throw new Error("벤더를 찾을 수 없습니다.");
- }
-
- // 벤더 상태를 INVITED로 변경 (PENDING_INVITE에서)
- if (vendor.status !== "PENDING_INVITE") {
- throw new Error("초대 가능한 상태가 아닙니다. (PENDING_INVITE 상태만 초대 가능)");
- }
-
- await tx.update(techVendors)
- .set({
- status: "INVITED",
- updatedAt: new Date(),
- })
- .where(eq(techVendors.id, params.vendorId));
-
- // 초대 토큰 생성
- const { createTechVendorInvitationToken, createTechVendorSignupUrl } = await import("@/lib/tech-vendor-invitation-token");
- const { sendEmail } = await import("@/lib/mail/sendEmail");
-
- const invitationToken = await createTechVendorInvitationToken({
- vendorType: vendor.techVendorType as "조선" | "해양TOP" | "해양HULL" | ("조선" | "해양TOP" | "해양HULL")[],
- vendorId: vendor.id,
- vendorName: vendor.vendorName,
- email: params.recipientEmail,
- });
-
- const signupUrl = await createTechVendorSignupUrl(invitationToken);
-
- // 초대 메일 발송
- await sendEmail({
- to: params.recipientEmail,
- subject: params.subject,
- template: "tech-vendor-invitation",
- context: {
- companyName: vendor.vendorName,
- language: "ko",
- registrationLink: signupUrl,
- customMessage: params.message,
- }
- });
-
- console.log("초대 메일 발송 완료:", params.recipientEmail);
-
- return { vendor, invitationToken, signupUrl };
- });
-
- // 캐시 무효화
- revalidateTag("tech-vendors");
-
- console.log("기술영업 벤더 초대 완료:", result);
- return { success: true, data: result };
- } catch (error) {
- console.error("기술영업 벤더 초대 실패:", error);
- return { success: false, error: getErrorMessage(error) };
- }
-}
-
-/* -----------------------------------------------------
- Possible Items 관련 함수들
------------------------------------------------------ */
-
-/**
- * 특정 벤더의 possible items 조회 (페이지네이션 포함)
- */
-export async function getTechVendorPossibleItems(input: GetTechVendorPossibleItemsSchema, vendorId: number) {
- return unstable_cache(
- async () => {
- try {
- const offset = (input.page - 1) * input.perPage
-
- // 고급 필터 처리
- const advancedWhere = filterColumns({
- table: techVendorPossibleItems,
- filters: input.filters,
- joinOperator: input.joinOperator,
- })
-
- // 글로벌 검색
- let globalWhere;
- if (input.search) {
- const s = `%${input.search}%`;
- globalWhere = or(
- ilike(techVendorPossibleItems.itemCode, s),
- ilike(techVendorPossibleItems.workType, s),
- ilike(techVendorPossibleItems.itemList, s),
- ilike(techVendorPossibleItems.shipTypes, s),
- ilike(techVendorPossibleItems.subItemList, s)
- );
- }
-
- // 벤더 ID 조건
- const vendorWhere = eq(techVendorPossibleItems.vendorId, vendorId)
-
- // 개별 필터들
- const individualFilters = []
- if (input.itemCode) {
- individualFilters.push(ilike(techVendorPossibleItems.itemCode, `%${input.itemCode}%`))
- }
- if (input.workType) {
- individualFilters.push(ilike(techVendorPossibleItems.workType, `%${input.workType}%`))
- }
- if (input.itemList) {
- individualFilters.push(ilike(techVendorPossibleItems.itemList, `%${input.itemList}%`))
- }
- if (input.shipTypes) {
- individualFilters.push(ilike(techVendorPossibleItems.shipTypes, `%${input.shipTypes}%`))
- }
- if (input.subItemList) {
- individualFilters.push(ilike(techVendorPossibleItems.subItemList, `%${input.subItemList}%`))
- }
-
- // 최종 where 조건
- const finalWhere = and(
- vendorWhere,
- advancedWhere,
- globalWhere,
- ...(individualFilters.length > 0 ? individualFilters : [])
- )
-
- // 정렬
- const orderBy =
- input.sort.length > 0
- ? input.sort.map((item) => {
- // techVendorType은 실제 테이블 컬럼이 아니므로 제외
- if (item.id === 'techVendorType') return desc(techVendorPossibleItems.createdAt)
- const column = (techVendorPossibleItems as any)[item.id]
- return item.desc ? desc(column) : asc(column)
- })
- : [desc(techVendorPossibleItems.createdAt)]
-
- // 데이터 조회
- const data = await db
- .select()
- .from(techVendorPossibleItems)
- .where(finalWhere)
- .orderBy(...orderBy)
- .limit(input.perPage)
- .offset(offset)
-
- // 전체 개수 조회
- const totalResult = await db
- .select({ count: sql<number>`count(*)`.as("count") })
- .from(techVendorPossibleItems)
- .where(finalWhere)
-
- const total = totalResult[0]?.count || 0
- const pageCount = Math.ceil(total / input.perPage)
-
- return { data, pageCount }
- } catch (err) {
- console.error("Error fetching tech vendor possible items:", err)
- return { data: [], pageCount: 0 }
- }
- },
- [JSON.stringify(input), String(vendorId)],
- {
- revalidate: 3600,
- tags: [`tech-vendor-possible-items-${vendorId}`],
- }
- )()
-}
-
-export async function createTechVendorPossibleItemNew(input: CreateTechVendorPossibleItemSchema) {
- unstable_noStore()
-
- try {
- // 중복 체크
- const existing = await db
- .select({ id: techVendorPossibleItems.id })
- .from(techVendorPossibleItems)
- .where(
- and(
- eq(techVendorPossibleItems.vendorId, input.vendorId),
- eq(techVendorPossibleItems.itemCode, input.itemCode)
- )
- )
- .limit(1)
-
- if (existing.length > 0) {
- return { data: null, error: "이미 등록된 아이템입니다." }
- }
-
- const [newItem] = await db
- .insert(techVendorPossibleItems)
- .values({
- vendorId: input.vendorId,
- itemCode: input.itemCode,
- workType: input.workType,
- shipTypes: input.shipTypes,
- itemList: input.itemList,
- subItemList: input.subItemList,
- })
- .returning()
-
- revalidateTag(`tech-vendor-possible-items-${input.vendorId}`)
- return { data: newItem, error: null }
- } catch (err) {
- console.error("Error creating tech vendor possible item:", err)
- return { data: null, error: getErrorMessage(err) }
- }
-}
-
-export async function updateTechVendorPossibleItemNew(input: UpdateTechVendorPossibleItemSchema) {
- unstable_noStore()
-
- try {
- const [updatedItem] = await db
- .update(techVendorPossibleItems)
- .set({
- itemCode: input.itemCode,
- workType: input.workType,
- shipTypes: input.shipTypes,
- itemList: input.itemList,
- subItemList: input.subItemList,
- updatedAt: new Date(),
- })
- .where(eq(techVendorPossibleItems.id, input.id))
- .returning()
-
- revalidateTag(`tech-vendor-possible-items-${input.vendorId}`)
- return { data: updatedItem, error: null }
- } catch (err) {
- console.error("Error updating tech vendor possible item:", err)
- return { data: null, error: getErrorMessage(err) }
- }
-}
-
-export async function deleteTechVendorPossibleItemsNew(ids: number[], vendorId: number) {
- unstable_noStore()
-
- try {
- await db
- .delete(techVendorPossibleItems)
- .where(inArray(techVendorPossibleItems.id, ids))
-
- revalidateTag(`tech-vendor-possible-items-${vendorId}`)
- return { data: null, error: null }
- } catch (err) {
- return { data: null, error: getErrorMessage(err) }
- }
-}
-
-export async function addTechVendorPossibleItem(input: {
- vendorId: number;
- itemCode?: string;
- workType?: string;
- shipTypes?: string;
- itemList?: string;
- subItemList?: string;
-}) {
- unstable_noStore();
- try {
- if (!input.itemCode) {
- return { success: false, error: "아이템 코드는 필수입니다." };
- }
-
- const [newItem] = await db
- .insert(techVendorPossibleItems)
- .values({
- vendorId: input.vendorId,
- itemCode: input.itemCode,
- workType: input.workType || null,
- shipTypes: input.shipTypes || null,
- itemList: input.itemList || null,
- subItemList: input.subItemList || null,
- createdAt: new Date(),
- updatedAt: new Date(),
- })
- .returning();
-
- revalidateTag(`tech-vendor-possible-items-${input.vendorId}`);
-
- return { success: true, data: newItem };
- } catch (err) {
- return { success: false, error: getErrorMessage(err) };
- }
-}
-
-export async function deleteTechVendorPossibleItem(itemId: number, vendorId: number) {
- unstable_noStore();
- try {
- const [deletedItem] = await db
- .delete(techVendorPossibleItems)
- .where(eq(techVendorPossibleItems.id, itemId))
- .returning();
-
- revalidateTag(`tech-vendor-possible-items-${vendorId}`);
-
- return { success: true, data: deletedItem };
- } catch (err) {
- return { success: false, error: getErrorMessage(err) };
- }
-}
-
-
-
-//기술영업 담당자 연락처 관련 함수들
-
-export interface ImportContactData {
- vendorEmail: string // 벤더 대표이메일 (유니크)
- contactName: string
- contactPosition?: string
- contactEmail: string
- contactPhone?: string
- contactCountry?: string
- isPrimary?: boolean
-}
-
-export interface ImportResult {
- success: boolean
- totalRows: number
- successCount: number
- failedRows: Array<{
- row: number
- error: string
- vendorEmail: string
- contactName: string
- contactEmail: string
- }>
-}
-
-/**
- * 벤더 대표이메일로 벤더 찾기
- */
-async function getTechVendorByEmail(email: string) {
- const vendor = await db
- .select({
- id: techVendors.id,
- vendorName: techVendors.vendorName,
- email: techVendors.email,
- })
- .from(techVendors)
- .where(eq(techVendors.email, email))
- .limit(1)
-
- return vendor[0] || null
-}
-
-/**
- * 연락처 이메일 중복 체크
- */
-async function checkContactEmailExists(vendorId: number, contactEmail: string) {
- const existing = await db
- .select()
- .from(techVendorContacts)
- .where(
- and(
- eq(techVendorContacts.vendorId, vendorId),
- eq(techVendorContacts.contactEmail, contactEmail)
- )
- )
- .limit(1)
-
- return existing.length > 0
-}
-
-/**
- * 벤더 연락처 일괄 import
- */
-export async function importTechVendorContacts(
- data: ImportContactData[]
-): Promise<ImportResult> {
- const result: ImportResult = {
- success: true,
- totalRows: data.length,
- successCount: 0,
- failedRows: [],
- }
-
- for (let i = 0; i < data.length; i++) {
- const row = data[i]
- const rowNumber = i + 1
-
- try {
- // 1. 벤더 이메일로 벤더 찾기
- if (!row.vendorEmail || !row.vendorEmail.trim()) {
- result.failedRows.push({
- row: rowNumber,
- error: "벤더 대표이메일은 필수입니다.",
- vendorEmail: row.vendorEmail,
- contactName: row.contactName,
- contactEmail: row.contactEmail,
- })
- continue
- }
-
- const vendor = await getTechVendorByEmail(row.vendorEmail.trim())
- if (!vendor) {
- result.failedRows.push({
- row: rowNumber,
- error: `벤더 대표이메일 '${row.vendorEmail}'을(를) 찾을 수 없습니다.`,
- vendorEmail: row.vendorEmail,
- contactName: row.contactName,
- contactEmail: row.contactEmail,
- })
- continue
- }
-
- // 2. 연락처 이메일 중복 체크
- const isDuplicate = await checkContactEmailExists(vendor.id, row.contactEmail)
- if (isDuplicate) {
- result.failedRows.push({
- row: rowNumber,
- error: `이미 존재하는 연락처 이메일입니다: ${row.contactEmail}`,
- vendorEmail: row.vendorEmail,
- contactName: row.contactName,
- contactEmail: row.contactEmail,
- })
- continue
- }
-
- // 3. 연락처 생성
- await db.insert(techVendorContacts).values({
- vendorId: vendor.id,
- contactName: row.contactName,
- contactPosition: row.contactPosition || null,
- contactEmail: row.contactEmail,
- contactPhone: row.contactPhone || null,
- contactCountry: row.contactCountry || null,
- isPrimary: row.isPrimary || false,
- })
-
- result.successCount++
- } catch (error) {
- result.failedRows.push({
- row: rowNumber,
- error: error instanceof Error ? error.message : "알 수 없는 오류",
- vendorEmail: row.vendorEmail,
- contactName: row.contactName,
- contactEmail: row.contactEmail,
- })
- }
- }
-
- // 캐시 무효화
- revalidateTag("tech-vendor-contacts")
-
- return result
-}
-
-/**
- * 벤더 연락처 import 템플릿 생성
- */
-export async function generateContactImportTemplate(): Promise<Blob> {
- const workbook = new ExcelJS.Workbook()
- const worksheet = workbook.addWorksheet("벤더연락처_템플릿")
-
- // 헤더 설정
- worksheet.columns = [
- { header: "벤더대표이메일*", key: "vendorEmail", width: 25 },
- { header: "담당자명*", key: "contactName", width: 20 },
- { header: "직책", key: "contactPosition", width: 15 },
- { header: "담당자이메일*", key: "contactEmail", width: 25 },
- { header: "담당자연락처", key: "contactPhone", width: 15 },
- { header: "담당자국가", key: "contactCountry", width: 15 },
- { header: "주담당자여부", key: "isPrimary", width: 12 },
- ]
-
- // 헤더 스타일 설정
- const headerRow = worksheet.getRow(1)
- headerRow.font = { bold: true }
- headerRow.fill = {
- type: "pattern",
- pattern: "solid",
- fgColor: { argb: "FFE0E0E0" },
- }
-
- // 예시 데이터 추가
- worksheet.addRow({
- vendorEmail: "example@company.com",
- contactName: "홍길동",
- contactPosition: "대표",
- contactEmail: "hong@company.com",
- contactPhone: "010-1234-5678",
- contactCountry: "대한민국",
- isPrimary: "Y",
- })
-
- worksheet.addRow({
- vendorEmail: "example@company.com",
- contactName: "김철수",
- contactPosition: "과장",
- contactEmail: "kim@company.com",
- contactPhone: "010-9876-5432",
- contactCountry: "대한민국",
- isPrimary: "N",
- })
-
- const buffer = await workbook.xlsx.writeBuffer()
- return new Blob([buffer], {
- type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
- })
-}
-
-/**
- * Excel 파일에서 연락처 데이터 파싱
- */
-export async function parseContactImportFile(file: File): Promise<ImportContactData[]> {
- const arrayBuffer = await file.arrayBuffer()
- const workbook = new ExcelJS.Workbook()
- await workbook.xlsx.load(arrayBuffer)
-
- const worksheet = workbook.worksheets[0]
- if (!worksheet) {
- throw new Error("Excel 파일에 워크시트가 없습니다.")
- }
-
- const data: ImportContactData[] = []
-
- worksheet.eachRow((row, index) => {
- console.log(`행 ${index} 처리 중:`, row.values)
- // 헤더 행 건너뛰기 (1행)
- if (index === 1) return
-
- const values = row.values as (string | null)[]
- if (!values || values.length < 4) return
-
- const vendorEmail = values[1]?.toString().trim()
- const contactName = values[2]?.toString().trim()
- const contactPosition = values[3]?.toString().trim()
- const contactEmail = values[4]?.toString().trim()
- const contactPhone = values[5]?.toString().trim()
- const contactCountry = values[6]?.toString().trim()
- const isPrimary = values[7]?.toString().trim()
-
- // 필수 필드 검증
- if (!vendorEmail || !contactName || !contactEmail) {
- return
- }
-
- data.push({
- vendorEmail,
- contactName,
- contactPosition: contactPosition || undefined,
- contactEmail,
- contactPhone: contactPhone || undefined,
- contactCountry: contactCountry || undefined,
- isPrimary: isPrimary === "Y" || isPrimary === "y",
- })
-
- // rowNumber++
- })
-
- return data
+"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<TechVendor["status"], number> = {
+ "PENDING_INVITE": 0,
+ "INVITED": 0,
+ "QUOTE_COMPARISON": 0,
+ "ACTIVE": 0,
+ "INACTIVE": 0,
+ "BLACKLISTED": 0,
+ };
+
+ const result = await db.transaction(async (tx) => {
+ const rows = await groupByTechVendorStatus(tx);
+ type StatusCountRow = { status: TechVendor["status"]; count: number };
+ return (rows as StatusCountRow[]).reduce<Record<TechVendor["status"], number>>((acc, { status, count }) => {
+ acc[status] = count;
+ return acc;
+ }, initial);
+ });
+
+ return result;
+ } catch (err) {
+ return {} as Record<TechVendor["status"], number>;
+ }
+ },
+ ["tech-vendor-status-counts"], // 캐싱 키
+ {
+ revalidate: 3600,
+ }
+ )();
+}
+
+/**
+ * 벤더 상세 정보 조회
+ */
+export async function getTechVendorById(id: number) {
+ return unstable_cache(
+ async () => {
+ try {
+ const result = await getTechVendorDetailById(id);
+ return { data: result };
+ } catch (err) {
+ console.error("기술영업 벤더 상세 조회 오류:", err);
+ return { data: null };
+ }
+ },
+ [`tech-vendor-${id}`],
+ {
+ revalidate: 3600,
+ tags: ["tech-vendors", `tech-vendor-${id}`],
+ }
+ )();
+}
+
+/* -----------------------------------------------------
+ 2) 생성(Create)
+----------------------------------------------------- */
+
+/**
+ * 첨부파일 저장 헬퍼 함수
+ */
+async function storeTechVendorFiles(
+ tx: any,
+ vendorId: number,
+ files: File[],
+ attachmentType: string
+) {
+
+ for (const file of files) {
+
+ const saveResult = await saveDRMFile(file, decryptWithServerAction, `tech-vendors/${vendorId}`)
+
+ // Insert attachment record
+ await tx.insert(techVendorAttachments).values({
+ vendorId,
+ fileName: file.name,
+ filePath: saveResult.publicPath,
+ attachmentType,
+ });
+ }
+}
+
+/**
+ * 신규 기술영업 벤더 생성
+ */
+export async function createTechVendor(input: CreateTechVendorSchema) {
+ unstable_noStore();
+
+ try {
+ // 이메일 중복 검사
+ const existingVendorByEmail = await db
+ .select({ id: techVendors.id, vendorName: techVendors.vendorName })
+ .from(techVendors)
+ .where(eq(techVendors.email, input.email))
+ .limit(1);
+
+ // 이미 동일한 이메일을 가진 업체가 존재하면 에러 반환
+ if (existingVendorByEmail.length > 0) {
+ return {
+ success: false,
+ data: null,
+ error: `이미 등록된 이메일입니다. (업체명: ${existingVendorByEmail[0].vendorName})`
+ };
+ }
+
+ // taxId 중복 검사
+ const existingVendorByTaxId = await db
+ .select({ id: techVendors.id })
+ .from(techVendors)
+ .where(eq(techVendors.taxId, input.taxId))
+ .limit(1);
+
+ // 이미 동일한 taxId를 가진 업체가 존재하면 에러 반환
+ if (existingVendorByTaxId.length > 0) {
+ return {
+ success: false,
+ data: null,
+ error: `이미 등록된 사업자등록번호입니다. (Tax ID ${input.taxId} already exists in the system)`
+ };
+ }
+
+ const result = await db.transaction(async (tx) => {
+ // 1. 벤더 생성
+ const [newVendor] = await insertTechVendor(tx, {
+ vendorName: input.vendorName,
+ vendorCode: input.vendorCode || null,
+ taxId: input.taxId,
+ address: input.address || null,
+ country: input.country,
+ countryEng: null,
+ countryFab: null,
+ agentName: null,
+ agentPhone: null,
+ agentEmail: null,
+ phone: input.phone || null,
+ email: input.email,
+ website: input.website || null,
+ techVendorType: Array.isArray(input.techVendorType) ? input.techVendorType.join(',') : input.techVendorType,
+ representativeName: input.representativeName || null,
+ representativeBirth: input.representativeBirth || null,
+ representativeEmail: input.representativeEmail || null,
+ representativePhone: input.representativePhone || null,
+ items: input.items || null,
+ status: "ACTIVE",
+ isQuoteComparison: false,
+ });
+
+ // 2. 연락처 정보 등록
+ for (const contact of input.contacts) {
+ await insertTechVendorContact(tx, {
+ vendorId: newVendor.id,
+ contactName: contact.contactName,
+ contactPosition: contact.contactPosition || null,
+ contactEmail: contact.contactEmail,
+ contactPhone: contact.contactPhone || null,
+ isPrimary: contact.isPrimary ?? false,
+ contactCountry: contact.contactCountry || null,
+ 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<string>`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<string>`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<string>`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<TechVendor | null> => {
+ try {
+ // 직접 DB에서 조회
+ const vendor = await db
+ .select()
+ .from(techVendors)
+ .where(eq(techVendors.id, id))
+ .limit(1)
+ .then(rows => rows[0] || null);
+
+ if (!vendor) {
+ console.error(`Vendor not found with id: ${id}`);
+ return null;
+ }
+
+ return vendor;
+ } catch (error) {
+ console.error('Error fetching vendor:', error);
+ return null;
+ }
+};
+
+/* -----------------------------------------------------
+ 8) 기술영업 벤더 RFQ 히스토리 조회
+----------------------------------------------------- */
+
+/**
+ * 기술영업 벤더의 RFQ 히스토리 조회 (간단한 버전)
+ */
+export async function getTechVendorRfqHistory(input: GetTechVendorRfqHistorySchema, id:number) {
+ try {
+
+ // 먼저 해당 벤더의 견적서가 있는지 확인
+ const { techSalesVendorQuotations } = await import("@/db/schema/techSales");
+
+ const quotationCheck = await db
+ .select({ count: sql<number>`count(*)`.as("count") })
+ .from(techSalesVendorQuotations)
+ .where(eq(techSalesVendorQuotations.vendorId, id));
+
+ console.log(`벤더 ${id}의 견적서 개수:`, quotationCheck[0]?.count);
+
+ if (quotationCheck[0]?.count === 0) {
+ console.log("해당 벤더의 견적서가 없습니다.");
+ return { data: [], pageCount: 0 };
+ }
+
+ const offset = (input.page - 1) * input.perPage;
+ const { techSalesRfqs } = await import("@/db/schema/techSales");
+ const { biddingProjects } = await import("@/db/schema/projects");
+
+ // 간단한 조회
+ let whereCondition = eq(techSalesVendorQuotations.vendorId, id);
+
+ // 검색이 있다면 추가
+ if (input.search) {
+ const s = `%${input.search}%`;
+ const searchCondition = and(
+ whereCondition,
+ or(
+ ilike(techSalesRfqs.rfqCode, s),
+ ilike(techSalesRfqs.description, s),
+ ilike(biddingProjects.pspid, s),
+ ilike(biddingProjects.projNm, s)
+ )
+ );
+ whereCondition = searchCondition || whereCondition;
+ }
+
+ // 데이터 조회 - 테이블에 필요한 필드들 (프로젝트 타입 추가)
+ const data = await db
+ .select({
+ id: techSalesRfqs.id,
+ rfqCode: techSalesRfqs.rfqCode,
+ description: techSalesRfqs.description,
+ projectCode: biddingProjects.pspid,
+ projectName: biddingProjects.projNm,
+ projectType: biddingProjects.pjtType, // 프로젝트 타입 추가
+ status: techSalesRfqs.status,
+ totalAmount: techSalesVendorQuotations.totalPrice,
+ currency: techSalesVendorQuotations.currency,
+ dueDate: techSalesRfqs.dueDate,
+ createdAt: techSalesRfqs.createdAt,
+ quotationCode: techSalesVendorQuotations.quotationCode,
+ submittedAt: techSalesVendorQuotations.submittedAt,
+ })
+ .from(techSalesVendorQuotations)
+ .innerJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id))
+ .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id))
+ .where(whereCondition)
+ .orderBy(desc(techSalesRfqs.createdAt))
+ .limit(input.perPage)
+ .offset(offset);
+
+ console.log("조회된 데이터:", data.length, "개");
+
+ // 전체 개수 조회
+ const totalResult = await db
+ .select({ count: sql<number>`count(*)`.as("count") })
+ .from(techSalesVendorQuotations)
+ .innerJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id))
+ .leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id))
+ .where(whereCondition);
+
+ const total = totalResult[0]?.count || 0;
+ const pageCount = Math.ceil(total / input.perPage);
+
+ console.log("기술영업 벤더 RFQ 히스토리 조회 완료", {
+ id,
+ dataLength: data.length,
+ total,
+ pageCount
+ });
+
+ return { data, pageCount };
+ } catch (err) {
+ console.error("기술영업 벤더 RFQ 히스토리 조회 오류:", {
+ err,
+ id,
+ stack: err instanceof Error ? err.stack : undefined
+ });
+ return { data: [], pageCount: 0 };
+ }
+}
+
+/**
+ * 기술영업 벤더 엑셀 import 시 유저 생성 및 담당자 등록
+ */
+export async function importTechVendorsFromExcel(
+ vendors: Array<{
+ vendorName: string;
+ vendorCode?: string | null;
+ email: string;
+ taxId: string;
+ country?: string | null;
+ countryEng?: string | null;
+ countryFab?: string | null;
+ agentName?: string | null;
+ agentPhone?: string | null;
+ agentEmail?: string | null;
+ address?: string | null;
+ phone?: string | null;
+ website?: string | null;
+ techVendorType: string;
+ representativeName?: string | null;
+ representativeEmail?: string | null;
+ representativePhone?: string | null;
+ representativeBirth?: string | null;
+ items: string;
+ contacts?: Array<{
+ contactName: string;
+ contactPosition?: string;
+ contactEmail: string;
+ contactPhone?: string;
+ contactCountry?: string | null;
+ isPrimary?: boolean;
+ }>;
+ }>,
+) {
+ unstable_noStore();
+
+ try {
+ console.log("Import 시작 - 벤더 수:", vendors.length);
+ console.log("첫 번째 벤더 데이터:", vendors[0]);
+
+ const result = await db.transaction(async (tx) => {
+ const createdVendors = [];
+ const skippedVendors = [];
+ const errors = [];
+
+ for (const vendor of vendors) {
+ console.log("벤더 처리 시작:", vendor.vendorName);
+
+ try {
+ // 0. 이메일 타입 검사
+ // - 문자열이 아니거나, '@' 미포함, 혹은 객체(예: 하이퍼링크 등)인 경우 모두 거절
+ const isEmailString = typeof vendor.email === "string";
+ const isEmailContainsAt = isEmailString && vendor.email.includes("@");
+ // 하이퍼링크 등 객체로 넘어온 경우 (예: { href: "...", ... } 등) 방지
+ const isEmailPlainString = isEmailString && Object.prototype.toString.call(vendor.email) === "[object String]";
+
+ if (!isEmailPlainString || !isEmailContainsAt) {
+ console.log("이메일 형식이 올바르지 않습니다:", vendor.email);
+ errors.push({
+ vendorName: vendor.vendorName,
+ email: vendor.email,
+ error: "이메일 형식이 올바르지 않습니다"
+ });
+ continue;
+ }
+ // 1. 이메일로 기존 벤더 중복 체크
+ const existingVendor = await tx.query.techVendors.findFirst({
+ where: eq(techVendors.email, vendor.email),
+ columns: { id: true, vendorName: true, email: true }
+ });
+
+ if (existingVendor) {
+ console.log("이미 존재하는 벤더 스킵:", vendor.vendorName, vendor.email);
+ skippedVendors.push({
+ vendorName: vendor.vendorName,
+ email: vendor.email,
+ reason: `이미 등록된 이메일입니다 (기존 업체: ${existingVendor.vendorName})`
+ });
+ continue;
+ }
+
+ // 2. 벤더 생성
+ console.log("벤더 생성 시도:", {
+ vendorName: vendor.vendorName,
+ email: vendor.email,
+ techVendorType: vendor.techVendorType
+ });
+
+ const [newVendor] = await tx.insert(techVendors).values({
+ vendorName: vendor.vendorName,
+ vendorCode: vendor.vendorCode || null,
+ taxId: vendor.taxId,
+ country: vendor.country || null,
+ countryEng: vendor.countryEng || null,
+ countryFab: vendor.countryFab || null,
+ agentName: vendor.agentName || null,
+ agentPhone: vendor.agentPhone || null,
+ agentEmail: vendor.agentEmail || null,
+ address: vendor.address || null,
+ phone: vendor.phone || null,
+ email: vendor.email,
+ website: vendor.website || null,
+ techVendorType: vendor.techVendorType,
+ status: "ACTIVE",
+ representativeName: vendor.representativeName || null,
+ representativeEmail: vendor.representativeEmail || null,
+ representativePhone: vendor.representativePhone || null,
+ representativeBirth: vendor.representativeBirth || null,
+ }).returning();
+
+ console.log("벤더 생성 성공:", newVendor.id);
+
+ // 2. 담당자 생성 (최소 1명 이상 등록)
+ if (vendor.contacts && vendor.contacts.length > 0) {
+ console.log("담당자 생성 시도:", vendor.contacts.length, "명");
+
+ for (const contact of vendor.contacts) {
+ await tx.insert(techVendorContacts).values({
+ vendorId: newVendor.id,
+ contactName: contact.contactName,
+ contactPosition: contact.contactPosition || null,
+ contactEmail: contact.contactEmail,
+ contactPhone: contact.contactPhone || null,
+ contactCountry: contact.contactCountry || null,
+ isPrimary: contact.isPrimary || false,
+ });
+ console.log("담당자 생성 성공:", contact.contactName, contact.contactEmail);
+ }
+
+ // // 벤더 이메일을 주 담당자의 이메일로 업데이트
+ // const primaryContact = vendor.contacts.find(c => c.isPrimary) || vendor.contacts[0];
+ // if (primaryContact && primaryContact.contactEmail !== vendor.email) {
+ // await tx.update(techVendors)
+ // .set({ email: primaryContact.contactEmail })
+ // .where(eq(techVendors.id, newVendor.id));
+ // console.log("벤더 이메일 업데이트:", primaryContact.contactEmail);
+ // }
+ }
+ // else {
+ // // 담당자 정보가 없는 경우 벤더 정보로 기본 담당자 생성
+ // console.log("기본 담당자 생성");
+ // await tx.insert(techVendorContacts).values({
+ // vendorId: newVendor.id,
+ // contactName: vendor.representativeName || vendor.vendorName || "기본 담당자",
+ // contactPosition: null,
+ // contactEmail: vendor.email,
+ // contactPhone: vendor.representativePhone || vendor.phone || null,
+ // contactCountry: vendor.country || null,
+ // isPrimary: true,
+ // });
+ // console.log("기본 담당자 생성 성공:", vendor.email);
+ // }
+
+ // 3. 유저 생성 (이메일이 있는 경우)
+ if (vendor.email) {
+ console.log("유저 생성 시도:", vendor.email);
+
+ // 이미 존재하는 유저인지 확인
+ const existingUser = await tx.query.users.findFirst({
+ where: eq(users.email, vendor.email),
+ columns: { id: true }
+ });
+
+ if (!existingUser) {
+ // 유저가 존재하지 않는 경우 생성
+ await tx.insert(users).values({
+ name: vendor.vendorName,
+ email: vendor.email,
+ techCompanyId: newVendor.id,
+ domain: "partners",
+ });
+ console.log("유저 생성 성공");
+ } else {
+ // 이미 존재하는 유저라면 techCompanyId 업데이트
+ await tx.update(users)
+ .set({ techCompanyId: newVendor.id })
+ .where(eq(users.id, existingUser.id));
+ console.log("이미 존재하는 유저, techCompanyId 업데이트:", existingUser.id);
+ }
+ }
+
+ createdVendors.push(newVendor);
+ console.log("벤더 처리 완료:", vendor.vendorName);
+ } catch (error) {
+ console.error("벤더 처리 중 오류 발생:", vendor.vendorName, error);
+ errors.push({
+ vendorName: vendor.vendorName,
+ email: vendor.email,
+ error: error instanceof Error ? error.message : "알 수 없는 오류"
+ });
+ // 개별 벤더 오류는 전체 트랜잭션을 롤백하지 않도록 continue
+ continue;
+ }
+ }
+
+ console.log("모든 벤더 처리 완료:", {
+ 생성됨: createdVendors.length,
+ 스킵됨: skippedVendors.length,
+ 오류: errors.length
+ });
+
+ return {
+ createdVendors,
+ skippedVendors,
+ errors,
+ totalProcessed: vendors.length,
+ successCount: createdVendors.length,
+ skipCount: skippedVendors.length,
+ errorCount: errors.length
+ };
+ });
+
+ // 캐시 무효화
+ revalidateTag("tech-vendors");
+ revalidateTag("tech-vendor-contacts");
+ revalidateTag("users");
+
+ console.log("Import 완료 - 결과:", result);
+
+ // 결과 메시지 생성
+ const messages = [];
+ if (result.successCount > 0) {
+ messages.push(`${result.successCount}개 벤더 생성 성공`);
+ }
+ if (result.skipCount > 0) {
+ messages.push(`${result.skipCount}개 벤더 중복으로 스킵`);
+ }
+ if (result.errorCount > 0) {
+ messages.push(`${result.errorCount}개 벤더 처리 중 오류`);
+ }
+
+ return {
+ success: true,
+ data: result,
+ message: messages.join(", "),
+ details: {
+ created: result.createdVendors,
+ skipped: result.skippedVendors,
+ errors: result.errors
+ }
+ };
+ } catch (error) {
+ console.error("Import 실패:", error);
+ return { success: false, error: getErrorMessage(error) };
+ }
+}
+
+export async function findTechVendorById(id: number): Promise<TechVendor | null> {
+ const result = await db
+ .select()
+ .from(techVendors)
+ .where(eq(techVendors.id, id))
+ .limit(1)
+
+ return result[0] || null
+}
+
+/**
+ * 회원가입 폼을 통한 기술영업 벤더 생성 (초대 토큰 기반)
+ */
+export async function createTechVendorFromSignup(params: {
+ vendorData: {
+ vendorName: string
+ vendorCode?: string
+ items: string
+ website?: string
+ taxId: string
+ address?: string
+ email: string
+ phone?: string
+ country: string
+ techVendorType: "조선" | "해양TOP" | "해양HULL" | ("조선" | "해양TOP" | "해양HULL")[]
+ representativeName?: string
+ representativeBirth?: string
+ representativeEmail?: string
+ representativePhone?: string
+ }
+ files?: File[]
+ contacts: {
+ contactName: string
+ contactPosition?: string
+ contactEmail: string
+ contactPhone?: string
+ isPrimary?: boolean
+ }[]
+ selectedItemCodes?: string[] // 선택된 아이템 코드들
+ invitationToken?: string // 초대 토큰
+}) {
+ unstable_noStore();
+
+ try {
+ console.log("기술영업 벤더 회원가입 시작:", params.vendorData.vendorName);
+
+ // 초대 토큰 검증
+ let existingVendorId: number | null = null;
+ if (params.invitationToken) {
+ const { verifyTechVendorInvitationToken } = await import("@/lib/tech-vendor-invitation-token");
+ const tokenPayload = await verifyTechVendorInvitationToken(params.invitationToken);
+
+ if (!tokenPayload) {
+ throw new Error("유효하지 않은 초대 토큰입니다.");
+ }
+
+ existingVendorId = tokenPayload.vendorId;
+ console.log("초대 토큰 검증 성공, 벤더 ID:", existingVendorId);
+ }
+
+ const result = await db.transaction(async (tx) => {
+ let vendorResult;
+
+ if (existingVendorId) {
+ // 기존 벤더 정보 업데이트
+ const [updatedVendor] = await tx.update(techVendors)
+ .set({
+ vendorName: params.vendorData.vendorName,
+ vendorCode: params.vendorData.vendorCode || null,
+ taxId: params.vendorData.taxId,
+ country: params.vendorData.country,
+ address: params.vendorData.address || null,
+ phone: params.vendorData.phone || null,
+ email: params.vendorData.email,
+ website: params.vendorData.website || null,
+ techVendorType: Array.isArray(params.vendorData.techVendorType)
+ ? params.vendorData.techVendorType[0]
+ : params.vendorData.techVendorType,
+ status: "QUOTE_COMPARISON", // 가입 완료 시 QUOTE_COMPARISON으로 변경
+ representativeName: params.vendorData.representativeName || null,
+ representativeEmail: params.vendorData.representativeEmail || null,
+ representativePhone: params.vendorData.representativePhone || null,
+ representativeBirth: params.vendorData.representativeBirth || null,
+ items: params.vendorData.items,
+ updatedAt: new Date(),
+ })
+ .where(eq(techVendors.id, existingVendorId))
+ .returning();
+
+ vendorResult = updatedVendor;
+ console.log("기존 벤더 정보 업데이트 완료:", vendorResult.id);
+ } else {
+ // 1. 이메일 중복 체크 (새 벤더인 경우)
+ const existingVendor = await tx.query.techVendors.findFirst({
+ where: eq(techVendors.email, params.vendorData.email),
+ columns: { id: true, vendorName: true }
+ });
+
+ if (existingVendor) {
+ throw new Error(`이미 등록된 이메일입니다: ${params.vendorData.email} (기존 업체: ${existingVendor.vendorName})`);
+ }
+
+ // 2. 새 벤더 생성
+ const [newVendor] = await tx.insert(techVendors).values({
+ vendorName: params.vendorData.vendorName,
+ vendorCode: params.vendorData.vendorCode || null,
+ taxId: params.vendorData.taxId,
+ country: params.vendorData.country,
+ address: params.vendorData.address || null,
+ phone: params.vendorData.phone || null,
+ email: params.vendorData.email,
+ website: params.vendorData.website || null,
+ techVendorType: Array.isArray(params.vendorData.techVendorType)
+ ? params.vendorData.techVendorType[0]
+ : params.vendorData.techVendorType,
+ status: "QUOTE_COMPARISON",
+ isQuoteComparison: false,
+ representativeName: params.vendorData.representativeName || null,
+ representativeEmail: params.vendorData.representativeEmail || null,
+ representativePhone: params.vendorData.representativePhone || null,
+ representativeBirth: params.vendorData.representativeBirth || null,
+ items: params.vendorData.items,
+ }).returning();
+
+ vendorResult = newVendor;
+ console.log("새 벤더 생성 완료:", vendorResult.id);
+ }
+
+ // 이 부분은 위에서 이미 처리되었으므로 주석 처리
+
+ // 3. 연락처 생성
+ if (params.contacts && params.contacts.length > 0) {
+ for (const [index, contact] of params.contacts.entries()) {
+ await tx.insert(techVendorContacts).values({
+ vendorId: vendorResult.id,
+ contactName: contact.contactName,
+ contactPosition: contact.contactPosition || null,
+ contactEmail: contact.contactEmail,
+ contactPhone: contact.contactPhone || null,
+ isPrimary: index === 0, // 첫 번째 연락처를 primary로 설정
+ });
+ }
+ console.log("연락처 생성 완료:", params.contacts.length, "개");
+ }
+
+ // 4. 선택된 아이템들을 tech_vendor_possible_items에 저장
+ if (params.selectedItemCodes && params.selectedItemCodes.length > 0) {
+ for (const itemCode of params.selectedItemCodes) {
+ // 아이템 코드로 각 테이블에서 찾기
+ 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<number> {
+ try {
+ const result = await db
+ .select({ count: sql<number>`count(*)`.as("count") })
+ .from(techVendorPossibleItems)
+ .where(eq(techVendorPossibleItems.vendorId, vendorId));
+
+ return result[0]?.count || 0;
+ } catch (err) {
+ console.error("Error getting tech vendor possible items count:", err);
+ return 0;
+ }
+}
+
+/**
+ * 기술영업 벤더 초대 메일 발송
+ */
+export async function inviteTechVendor(params: {
+ vendorId: number;
+ subject: string;
+ message: string;
+ recipientEmail: string;
+}) {
+ unstable_noStore();
+
+ try {
+ console.log("기술영업 벤더 초대 메일 발송 시작:", params.vendorId);
+
+ const result = await db.transaction(async (tx) => {
+ // 벤더 정보 조회
+ const vendor = await tx.query.techVendors.findFirst({
+ where: eq(techVendors.id, params.vendorId),
+ });
+
+ if (!vendor) {
+ throw new Error("벤더를 찾을 수 없습니다.");
+ }
+
+ // 벤더 상태를 INVITED로 변경 (PENDING_INVITE에서)
+ if (vendor.status !== "PENDING_INVITE") {
+ throw new Error("초대 가능한 상태가 아닙니다. (PENDING_INVITE 상태만 초대 가능)");
+ }
+
+ await tx.update(techVendors)
+ .set({
+ status: "INVITED",
+ updatedAt: new Date(),
+ })
+ .where(eq(techVendors.id, params.vendorId));
+
+ // 초대 토큰 생성
+ const { createTechVendorInvitationToken, createTechVendorSignupUrl } = await import("@/lib/tech-vendor-invitation-token");
+ const { sendEmail } = await import("@/lib/mail/sendEmail");
+
+ const invitationToken = await createTechVendorInvitationToken({
+ vendorType: vendor.techVendorType as "조선" | "해양TOP" | "해양HULL" | ("조선" | "해양TOP" | "해양HULL")[],
+ vendorId: vendor.id,
+ vendorName: vendor.vendorName,
+ email: params.recipientEmail,
+ });
+
+ const signupUrl = await createTechVendorSignupUrl(invitationToken);
+
+ // 초대 메일 발송
+ await sendEmail({
+ to: params.recipientEmail,
+ subject: params.subject,
+ template: "tech-vendor-invitation",
+ context: {
+ companyName: vendor.vendorName,
+ language: "ko",
+ registrationLink: signupUrl,
+ customMessage: params.message,
+ }
+ });
+
+ console.log("초대 메일 발송 완료:", params.recipientEmail);
+
+ return { vendor, invitationToken, signupUrl };
+ });
+
+ // 캐시 무효화
+ revalidateTag("tech-vendors");
+
+ console.log("기술영업 벤더 초대 완료:", result);
+ return { success: true, data: result };
+ } catch (error) {
+ console.error("기술영업 벤더 초대 실패:", error);
+ return { success: false, error: getErrorMessage(error) };
+ }
+}
+
+/* -----------------------------------------------------
+ Possible Items 관련 함수들
+----------------------------------------------------- */
+
+/**
+ * 특정 벤더의 possible items 조회 (페이지네이션 포함) - 새 스키마에 맞게 수정
+ */
+export async function getTechVendorPossibleItems(input: GetTechVendorPossibleItemsSchema, vendorId: number) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage
+
+ // 벤더 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<string>`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<string>`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<string>`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<ImportResult> {
+ const result: ImportResult = {
+ success: true,
+ totalRows: data.length,
+ successCount: 0,
+ failedRows: [],
+ }
+
+ for (let i = 0; i < data.length; i++) {
+ const row = data[i]
+ const rowNumber = i + 1
+
+ try {
+ // 1. 벤더 이메일로 벤더 찾기
+ if (!row.vendorEmail || !row.vendorEmail.trim()) {
+ result.failedRows.push({
+ row: rowNumber,
+ error: "벤더 대표이메일은 필수입니다.",
+ vendorEmail: row.vendorEmail,
+ contactName: row.contactName,
+ contactEmail: row.contactEmail,
+ })
+ continue
+ }
+
+ const vendor = await getTechVendorByEmail(row.vendorEmail.trim())
+ if (!vendor) {
+ result.failedRows.push({
+ row: rowNumber,
+ error: `벤더 대표이메일 '${row.vendorEmail}'을(를) 찾을 수 없습니다.`,
+ vendorEmail: row.vendorEmail,
+ contactName: row.contactName,
+ contactEmail: row.contactEmail,
+ })
+ continue
+ }
+
+ // 2. 연락처 이메일 중복 체크
+ const isDuplicate = await checkContactEmailExists(vendor.id, row.contactEmail)
+ if (isDuplicate) {
+ result.failedRows.push({
+ row: rowNumber,
+ error: `이미 존재하는 연락처 이메일입니다: ${row.contactEmail}`,
+ vendorEmail: row.vendorEmail,
+ contactName: row.contactName,
+ contactEmail: row.contactEmail,
+ })
+ continue
+ }
+
+ // 3. 연락처 생성
+ await db.insert(techVendorContacts).values({
+ vendorId: vendor.id,
+ contactName: row.contactName,
+ contactPosition: row.contactPosition || null,
+ contactEmail: row.contactEmail,
+ contactPhone: row.contactPhone || null,
+ contactCountry: row.contactCountry || null,
+ isPrimary: row.isPrimary || false,
+ })
+
+ result.successCount++
+ } catch (error) {
+ result.failedRows.push({
+ row: rowNumber,
+ error: error instanceof Error ? error.message : "알 수 없는 오류",
+ vendorEmail: row.vendorEmail,
+ contactName: row.contactName,
+ contactEmail: row.contactEmail,
+ })
+ }
+ }
+
+ // 캐시 무효화
+ revalidateTag("tech-vendor-contacts")
+
+ return result
+}
+
+/**
+ * 벤더 연락처 import 템플릿 생성
+ */
+export async function generateContactImportTemplate(): Promise<Blob> {
+ const workbook = new ExcelJS.Workbook()
+ const worksheet = workbook.addWorksheet("벤더연락처_템플릿")
+
+ // 헤더 설정
+ worksheet.columns = [
+ { header: "벤더대표이메일*", key: "vendorEmail", width: 25 },
+ { header: "담당자명*", key: "contactName", width: 20 },
+ { header: "직책", key: "contactPosition", width: 15 },
+ { header: "담당자이메일*", key: "contactEmail", width: 25 },
+ { header: "담당자연락처", key: "contactPhone", width: 15 },
+ { header: "담당자국가", key: "contactCountry", width: 15 },
+ { header: "주담당자여부", key: "isPrimary", width: 12 },
+ ]
+
+ // 헤더 스타일 설정
+ const headerRow = worksheet.getRow(1)
+ headerRow.font = { bold: true }
+ headerRow.fill = {
+ type: "pattern",
+ pattern: "solid",
+ fgColor: { argb: "FFE0E0E0" },
+ }
+
+ // 예시 데이터 추가
+ worksheet.addRow({
+ vendorEmail: "example@company.com",
+ contactName: "홍길동",
+ contactPosition: "대표",
+ contactEmail: "hong@company.com",
+ contactPhone: "010-1234-5678",
+ contactCountry: "대한민국",
+ isPrimary: "Y",
+ })
+
+ worksheet.addRow({
+ vendorEmail: "example@company.com",
+ contactName: "김철수",
+ contactPosition: "과장",
+ contactEmail: "kim@company.com",
+ contactPhone: "010-9876-5432",
+ contactCountry: "대한민국",
+ isPrimary: "N",
+ })
+
+ const buffer = await workbook.xlsx.writeBuffer()
+ return new Blob([buffer], {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ })
+}
+
+/**
+ * Excel 파일에서 연락처 데이터 파싱
+ */
+export async function parseContactImportFile(file: File): Promise<ImportContactData[]> {
+ const arrayBuffer = await file.arrayBuffer()
+ const workbook = new ExcelJS.Workbook()
+ await workbook.xlsx.load(arrayBuffer)
+
+ const worksheet = workbook.worksheets[0]
+ if (!worksheet) {
+ throw new Error("Excel 파일에 워크시트가 없습니다.")
+ }
+
+ const data: ImportContactData[] = []
+
+ worksheet.eachRow((row, index) => {
+ console.log(`행 ${index} 처리 중:`, row.values)
+ // 헤더 행 건너뛰기 (1행)
+ if (index === 1) return
+
+ const values = row.values as (string | null)[]
+ if (!values || values.length < 4) return
+
+ const vendorEmail = values[1]?.toString().trim()
+ const contactName = values[2]?.toString().trim()
+ const contactPosition = values[3]?.toString().trim()
+ const contactEmail = values[4]?.toString().trim()
+ const contactPhone = values[5]?.toString().trim()
+ const contactCountry = values[6]?.toString().trim()
+ const isPrimary = values[7]?.toString().trim()
+
+ // 필수 필드 검증
+ if (!vendorEmail || !contactName || !contactEmail) {
+ return
+ }
+
+ data.push({
+ vendorEmail,
+ contactName,
+ contactPosition: contactPosition || undefined,
+ contactEmail,
+ contactPhone: contactPhone || undefined,
+ contactCountry: contactCountry || undefined,
+ isPrimary: isPrimary === "Y" || isPrimary === "y",
+ })
+
+ // rowNumber++
+ })
+
+ return data
} \ No newline at end of file
diff --git a/lib/tech-vendors/table/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<TechVendor>().withDefault([
- { id: "createdAt", desc: true }, // createdAt 기준 내림차순
- ]),
-
- // 고급 필터
- filters: getFiltersStateParser().withDefault([]),
- joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- // 검색 키워드
- search: parseAsString.withDefault(""),
-
- // -----------------------------------------------------------------
- // 기술영업 협력업체에 특화된 검색 필드
- // -----------------------------------------------------------------
- // 상태 (ACTIVE, INACTIVE, BLACKLISTED 등) 중에서 선택
- status: parseAsStringEnum(["ACTIVE", "INACTIVE", "BLACKLISTED", "PENDING_REVIEW"]),
-
- // 협력업체명 검색
- vendorName: parseAsString.withDefault(""),
-
- // 국가 검색
- country: parseAsString.withDefault(""),
-
- // 예) 코드 검색
- vendorCode: parseAsString.withDefault(""),
-
- // 벤더 타입 필터링 (다중 선택 가능)
- vendorType: parseAsStringEnum(["ship", "top", "hull"]),
-
- // workTypes 필터링 (다중 선택 가능)
- workTypes: parseAsArrayOf(parseAsStringEnum([
- // 조선 workTypes
- "기장", "전장", "선실", "배관", "철의", "선체",
- // 해양TOP workTypes
- "TM", "TS", "TE", "TP",
- // 해양HULL workTypes
- "HA", "HE", "HH", "HM", "NC", "HO", "HP"
- ])).withDefault([]),
-
- // 필요하다면 이메일 검색 / 웹사이트 검색 등 추가 가능
- email: parseAsString.withDefault(""),
- website: parseAsString.withDefault(""),
-});
-
-export const searchParamsContactCache = createSearchParamsCache({
- // 공통 플래그
- flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
- []
- ),
-
- // 페이징
- page: parseAsInteger.withDefault(1),
- perPage: parseAsInteger.withDefault(10),
-
- // 정렬
- sort: getSortingStateParser<TechVendorContact>().withDefault([
- { id: "createdAt", desc: true }, // createdAt 기준 내림차순
- ]),
-
- // 고급 필터
- filters: getFiltersStateParser().withDefault([]),
- joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- // 검색 키워드
- search: parseAsString.withDefault(""),
-
- // 특정 필드 검색
- contactName: parseAsString.withDefault(""),
- contactPosition: parseAsString.withDefault(""),
- contactEmail: parseAsString.withDefault(""),
- contactPhone: parseAsString.withDefault(""),
-});
-
-export const searchParamsItemCache = createSearchParamsCache({
- // 공통 플래그
- flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
- []
- ),
-
- // 페이징
- page: parseAsInteger.withDefault(1),
- perPage: parseAsInteger.withDefault(10),
-
- // 정렬
- sort: getSortingStateParser<TechVendorItemsView>().withDefault([
- { id: "createdAt", desc: true }, // createdAt 기준 내림차순
- ]),
-
- // 고급 필터
- filters: getFiltersStateParser().withDefault([]),
- joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- // 검색 키워드
- search: parseAsString.withDefault(""),
-
- // 특정 필드 검색
- itemName: parseAsString.withDefault(""),
- itemCode: parseAsString.withDefault(""),
-});
-
-export const searchParamsPossibleItemsCache = createSearchParamsCache({
- // 공통 플래그
- flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
- []
- ),
-
- // 페이징
- page: parseAsInteger.withDefault(1),
- perPage: parseAsInteger.withDefault(10),
-
- // 정렬
- sort: getSortingStateParser<TechVendorPossibleItem>().withDefault([
- { id: "createdAt", desc: true },
- ]),
-
- // 고급 필터
- filters: getFiltersStateParser().withDefault([]),
- joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- // 검색 키워드
- search: parseAsString.withDefault(""),
-
- // 개별 필터 필드들
- itemCode: parseAsString.withDefault(""),
- workType: parseAsString.withDefault(""),
- itemList: parseAsString.withDefault(""),
- shipTypes: parseAsString.withDefault(""),
- subItemList: parseAsString.withDefault(""),
-});
-
-// 기술영업 벤더 기본 정보 업데이트 스키마
-export const updateTechVendorSchema = z.object({
- vendorName: z.string().min(1, "업체명은 필수 입력사항입니다"),
- vendorCode: z.string().optional(),
- address: z.string().optional(),
- country: z.string().optional(),
- countryEng: z.string().optional(),
- countryFab: z.string().optional(),
- phone: z.string().optional(),
- email: z.string().email("유효한 이메일 주소를 입력해주세요").optional(),
- website: z.string().optional(),
- techVendorType: z.union([
- z.array(z.enum(VENDOR_TYPES)).min(1, "최소 하나의 벤더 타입을 선택해주세요"),
- z.string().min(1, "벤더 타입을 선택해주세요")
- ]).optional(),
- status: z.enum(techVendors.status.enumValues).optional(),
- // 에이전트 정보
- agentName: z.string().optional(),
- agentEmail: z.string().email("유효한 이메일 주소를 입력해주세요").optional().or(z.literal("")),
- agentPhone: z.string().optional(),
- // 대표자 정보
- representativeName: z.string().optional(),
- representativeEmail: z.string().email("유효한 이메일 주소를 입력해주세요").optional().or(z.literal("")),
- representativePhone: z.string().optional(),
- representativeBirth: z.string().optional(),
- userId: z.number().optional(),
- comment: z.string().optional(),
-});
-
-// 연락처 스키마
-const contactSchema = z.object({
- id: z.number().optional(),
- contactName: z
- .string()
- .min(1, "Contact name is required")
- .max(255, "Max length 255"),
- contactPosition: z.string().max(100).optional(),
- contactEmail: z.string().email("Invalid email").max(255),
- contactCountry: z.string().max(100).optional(),
- contactPhone: z.string().max(50).optional(),
- isPrimary: z.boolean().default(false).optional()
-});
-
-// 기술영업 벤더 생성 스키마
-export const createTechVendorSchema = z
- .object({
- vendorName: z
- .string()
- .min(1, "Vendor name is required")
- .max(255, "Max length 255"),
-
- email: z.string().email("Invalid email").max(255),
- // 나머지 optional
- vendorCode: z.string().max(100, "Max length 100").optional(),
- address: z.string().optional(),
- country: z.string()
- .min(1, "국가 선택은 필수입니다.")
- .max(100, "Max length 100"),
- phone: z.string().max(50, "Max length 50").optional(),
- website: z.string().max(255).optional(),
-
- files: z.any().optional(),
- status: z.enum(techVendors.status.enumValues).default("ACTIVE"),
- techVendorType: z.union([
- z.array(z.enum(VENDOR_TYPES)).min(1, "최소 하나의 벤더 타입을 선택해주세요"),
- z.string().min(1, "벤더 타입을 선택해주세요")
- ]).default(["조선"]),
-
- representativeName: z.union([z.string().max(255), z.literal("")]).optional(),
- representativeBirth: z.union([z.string().max(20), z.literal("")]).optional(),
- representativeEmail: z.union([z.string().email("Invalid email").max(255), z.literal("")]).optional(),
- representativePhone: z.union([z.string().max(50), z.literal("")]).optional(),
- taxId: z.string().min(1, { message: "사업자등록번호를 입력해주세요" }),
-
- items: z.string().min(1, { message: "공급품목을 입력해주세요" }),
-
- contacts: z
- .array(contactSchema)
- .nonempty("At least one contact is required.")
- })
- .superRefine((data, ctx) => {
- if (data.country === "KR") {
- // 1) 대표자 정보가 누락되면 각각 에러 발생
- if (!data.representativeName) {
- ctx.addIssue({
- code: "custom",
- path: ["representativeName"],
- message: "대표자 이름은 한국(KR) 업체일 경우 필수입니다.",
- })
- }
- if (!data.representativeBirth) {
- ctx.addIssue({
- code: "custom",
- path: ["representativeBirth"],
- message: "대표자 생년월일은 한국(KR) 업체일 경우 필수입니다.",
- })
- }
- if (!data.representativeEmail) {
- ctx.addIssue({
- code: "custom",
- path: ["representativeEmail"],
- message: "대표자 이메일은 한국(KR) 업체일 경우 필수입니다.",
- })
- }
- if (!data.representativePhone) {
- ctx.addIssue({
- code: "custom",
- path: ["representativePhone"],
- message: "대표자 전화번호는 한국(KR) 업체일 경우 필수입니다.",
- })
- }
-
- }
- });
-
-// 연락처 생성 스키마
-export const createTechVendorContactSchema = z.object({
- vendorId: z.number(),
- contactName: z.string()
- .min(1, "Contact name is required")
- .max(255, "Max length 255"),
- contactPosition: z.string().max(100, "Max length 100"),
- contactEmail: z.string().email(),
- contactPhone: z.string().max(50, "Max length 50").optional(),
- contactCountry: z.string().max(100, "Max length 100").optional(),
- isPrimary: z.boolean(),
-});
-
-// 연락처 업데이트 스키마
-export const updateTechVendorContactSchema = z.object({
- contactName: z.string()
- .min(1, "Contact name is required")
- .max(255, "Max length 255"),
- contactPosition: z.string().max(100, "Max length 100").optional(),
- contactEmail: z.string().email().optional(),
- contactPhone: z.string().max(50, "Max length 50").optional(),
- contactCountry: z.string().max(100, "Max length 100").optional(),
- isPrimary: z.boolean().optional(),
-});
-
-// 아이템 생성 스키마
-export const createTechVendorItemSchema = z.object({
- vendorId: z.number(),
- itemCode: z.string().max(100, "Max length 100"),
- itemList: z.string().min(1, "Item list is required").max(255, "Max length 255"),
-});
-
-// 아이템 업데이트 스키마
-export const updateTechVendorItemSchema = z.object({
- itemList: z.string().optional(),
- itemCode: z.string().max(100, "Max length 100"),
-});
-
-// Possible Items 생성 스키마
-export const createTechVendorPossibleItemSchema = z.object({
- vendorId: z.number(),
- itemCode: z.string().min(1, "아이템 코드는 필수입니다"),
- workType: z.string().optional(),
- shipTypes: z.string().optional(),
- itemList: z.string().optional(),
- subItemList: z.string().optional(),
-});
-
-// Possible Items 업데이트 스키마
-export const updateTechVendorPossibleItemSchema = createTechVendorPossibleItemSchema.extend({
- id: z.number(),
-});
-
-export const searchParamsRfqHistoryCache = createSearchParamsCache({
- // 공통 플래그
- flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
- []
- ),
-
- // 페이징
- page: parseAsInteger.withDefault(1),
- perPage: parseAsInteger.withDefault(10),
-
- // 정렬 (RFQ 히스토리에 맞춰)
- sort: getSortingStateParser<{
- id: number;
- rfqCode: string | null;
- description: string | null;
- projectCode: string | null;
- projectName: string | null;
- projectType: string | null; // 프로젝트 타입 추가
- status: string;
- totalAmount: string | null;
- currency: string | null;
- dueDate: Date | null;
- createdAt: Date;
- quotationCode: string | null;
- submittedAt: Date | null;
- }>().withDefault([
- { id: "createdAt", desc: true },
- ]),
-
- // 고급 필터
- filters: getFiltersStateParser().withDefault([]),
- joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
-
- // 검색 키워드
- search: parseAsString.withDefault(""),
-
- // RFQ 히스토리 특화 필드
- rfqCode: parseAsString.withDefault(""),
- description: parseAsString.withDefault(""),
- projectCode: parseAsString.withDefault(""),
- projectName: parseAsString.withDefault(""),
- projectType: parseAsStringEnum(["SHIP", "TOP", "HULL"]), // 프로젝트 타입 필터 추가
- status: parseAsStringEnum(["DRAFT", "PUBLISHED", "EVALUATION", "AWARDED"]),
-});
-
-// 타입 내보내기
-export type GetTechVendorsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>
-export type GetTechVendorContactsSchema = Awaited<ReturnType<typeof searchParamsContactCache.parse>>
-export type GetTechVendorItemsSchema = Awaited<ReturnType<typeof searchParamsItemCache.parse>>
-export type GetTechVendorPossibleItemsSchema = Awaited<ReturnType<typeof searchParamsPossibleItemsCache.parse>>
-export type GetTechVendorRfqHistorySchema = Awaited<ReturnType<typeof searchParamsRfqHistoryCache.parse>>
-
-export type UpdateTechVendorSchema = z.infer<typeof updateTechVendorSchema>
-export type CreateTechVendorSchema = z.infer<typeof createTechVendorSchema>
-export type CreateTechVendorContactSchema = z.infer<typeof createTechVendorContactSchema>
-export type UpdateTechVendorContactSchema = z.infer<typeof updateTechVendorContactSchema>
-export type CreateTechVendorItemSchema = z.infer<typeof createTechVendorItemSchema>
-export type UpdateTechVendorItemSchema = z.infer<typeof updateTechVendorItemSchema>
-export type CreateTechVendorPossibleItemSchema = z.infer<typeof createTechVendorPossibleItemSchema>
+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<TechVendor>().withDefault([
+ { id: "createdAt", desc: true }, // createdAt 기준 내림차순
+ ]),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ // 검색 키워드
+ search: parseAsString.withDefault(""),
+
+ // -----------------------------------------------------------------
+ // 기술영업 협력업체에 특화된 검색 필드
+ // -----------------------------------------------------------------
+ // 상태 (ACTIVE, INACTIVE, BLACKLISTED 등) 중에서 선택
+ status: parseAsStringEnum(["ACTIVE", "INACTIVE", "BLACKLISTED", "PENDING_REVIEW"]),
+
+ // 협력업체명 검색
+ vendorName: parseAsString.withDefault(""),
+
+ // 국가 검색
+ country: parseAsString.withDefault(""),
+
+ // 예) 코드 검색
+ vendorCode: parseAsString.withDefault(""),
+
+ // 벤더 타입 필터링 (다중 선택 가능)
+ vendorType: parseAsStringEnum(["ship", "top", "hull"]),
+
+ // workTypes 필터링 (다중 선택 가능)
+ workTypes: parseAsArrayOf(parseAsStringEnum([
+ // 조선 workTypes
+ "기장", "전장", "선실", "배관", "철의", "선체",
+ // 해양TOP workTypes
+ "TM", "TS", "TE", "TP",
+ // 해양HULL workTypes
+ "HA", "HE", "HH", "HM", "NC", "HO", "HP"
+ ])).withDefault([]),
+
+ // 필요하다면 이메일 검색 / 웹사이트 검색 등 추가 가능
+ email: parseAsString.withDefault(""),
+ website: parseAsString.withDefault(""),
+});
+
+export const searchParamsContactCache = createSearchParamsCache({
+ // 공통 플래그
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+
+ // 페이징
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+
+ // 정렬
+ sort: getSortingStateParser<TechVendorContact>().withDefault([
+ { id: "createdAt", desc: true }, // createdAt 기준 내림차순
+ ]),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ // 검색 키워드
+ search: parseAsString.withDefault(""),
+
+ // 특정 필드 검색
+ contactName: parseAsString.withDefault(""),
+ contactPosition: parseAsString.withDefault(""),
+ 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<TechVendorPossibleItem>().withDefault([
+ { id: "createdAt", desc: true }, // createdAt 기준 내림차순
+ ]),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ // 검색 키워드
+ search: parseAsString.withDefault(""),
+
+ // 특정 필드 검색
+ itemName: parseAsString.withDefault(""),
+ itemCode: parseAsString.withDefault(""),
+});
+
+export const searchParamsPossibleItemsCache = createSearchParamsCache({
+ // 공통 플래그
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+
+ // 페이징
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+
+ // 정렬
+ sort: getSortingStateParser<TechVendorPossibleItem>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ // 검색 키워드
+ search: parseAsString.withDefault(""),
+
+ // 개별 필터 필드들
+ itemCode: parseAsString.withDefault(""),
+ workType: parseAsString.withDefault(""),
+ itemList: parseAsString.withDefault(""),
+ shipTypes: parseAsString.withDefault(""),
+ subItemList: parseAsString.withDefault(""),
+});
+
+// 기술영업 벤더 기본 정보 업데이트 스키마
+export const updateTechVendorSchema = z.object({
+ vendorName: z.string().min(1, "업체명은 필수 입력사항입니다"),
+ vendorCode: z.string().optional(),
+ address: z.string().optional(),
+ country: z.string().optional(),
+ countryEng: z.string().optional(),
+ countryFab: z.string().optional(),
+ phone: z.string().optional(),
+ email: z.string().email("유효한 이메일 주소를 입력해주세요").optional(),
+ website: z.string().optional(),
+ techVendorType: z.union([
+ z.array(z.enum(VENDOR_TYPES)).min(1, "최소 하나의 벤더 타입을 선택해주세요"),
+ z.string().min(1, "벤더 타입을 선택해주세요")
+ ]).optional(),
+ status: z.enum(techVendors.status.enumValues).optional(),
+ // 에이전트 정보
+ agentName: z.string().optional(),
+ agentEmail: z.string().email("유효한 이메일 주소를 입력해주세요").optional().or(z.literal("")),
+ agentPhone: z.string().optional(),
+ // 대표자 정보
+ representativeName: z.string().optional(),
+ representativeEmail: z.string().email("유효한 이메일 주소를 입력해주세요").optional().or(z.literal("")),
+ representativePhone: z.string().optional(),
+ representativeBirth: z.string().optional(),
+ userId: z.number().optional(),
+ comment: z.string().optional(),
+});
+
+// 연락처 스키마
+const contactSchema = z.object({
+ id: z.number().optional(),
+ contactName: z
+ .string()
+ .min(1, "Contact name is required")
+ .max(255, "Max length 255"),
+ contactPosition: z.string().max(100).optional(),
+ contactEmail: z.string().email("Invalid email").max(255),
+ contactCountry: z.string().max(100).optional(),
+ contactPhone: z.string().max(50).optional(),
+ 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<ReturnType<typeof searchParamsCache.parse>>
+export type GetTechVendorContactsSchema = Awaited<ReturnType<typeof searchParamsContactCache.parse>>
+export type GetTechVendorItemsSchema = Awaited<ReturnType<typeof searchParamsItemCache.parse>>
+export type GetTechVendorPossibleItemsSchema = Awaited<ReturnType<typeof searchParamsPossibleItemsCache.parse>>
+export type GetTechVendorRfqHistorySchema = Awaited<ReturnType<typeof searchParamsRfqHistoryCache.parse>>
+
+export type UpdateTechVendorSchema = z.infer<typeof updateTechVendorSchema>
+export type CreateTechVendorSchema = z.infer<typeof createTechVendorSchema>
+export type CreateTechVendorContactSchema = z.infer<typeof createTechVendorContactSchema>
+export type UpdateTechVendorContactSchema = z.infer<typeof updateTechVendorContactSchema>
+export type CreateTechVendorPossibleItemSchema = z.infer<typeof createTechVendorPossibleItemSchema>
export type UpdateTechVendorPossibleItemSchema = z.infer<typeof updateTechVendorPossibleItemSchema> \ 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<string[]>`
- array_agg(DISTINCT ${techVendorPossibleItems.itemCode})
- `,
- matchedItemCount: sql<number>`
- count(DISTINCT ${techVendorPossibleItems.itemCode})
- `,
- })
- .from(techVendorPossibleItems)
- .innerJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id))
- .where(
- and(
- inArray(techVendorPossibleItems.itemCode, itemCodes),
- or(
- eq(techVendors.status, "ACTIVE"),
- eq(techVendors.status, "QUOTE_COMPARISON") // 견적비교용 벤더도 RFQ 초대 가능
+ // 해양 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,
+ });
+ }
+ }
}
}
}
@@ -3368,11 +3549,6 @@ export async function getAcceptedTechSalesVendorQuotations(input: {
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'),
eq(techSalesVendorQuotations.status, 'Accepted')
@@ -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}
</p>
)}
+ {contact.contactTitle && (
+ <p className="text-sm text-muted-foreground">
+ {contact.contactTitle}
+ </p>
+ )}
{contact.contactCountry && (
<p className="text-xs text-muted-foreground">
{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 (
<Card className={`${isCurrent ? "border-blue-500 shadow-md" : "border-gray-200"}`}>
@@ -117,12 +147,6 @@ function QuotationCard({
{statusInfo.label}
</Badge>
</div>
- {/* {changeReason && (
- <div className="flex items-center gap-2 text-sm text-muted-foreground">
- <FileText className="size-4" />
- <span>{changeReason}</span>
- </div>
- )} */}
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-2 gap-4">
@@ -147,6 +171,21 @@ function QuotationCard({
</div>
)}
+ {revisionId && (
+ <div>
+ <p className="text-sm font-medium text-muted-foreground mt-2">SHI Comment</p>
+ <textarea
+ className="w-full min-h-[60px] p-2 border rounded bg-gray-50 text-sm"
+ value={editValue}
+ onChange={e => setEditValue(e.target.value)}
+ disabled={isSaving}
+ />
+ <Button size="sm" onClick={handleSave} disabled={isSaving} className="mt-2">
+ 저장
+ </Button>
+ </div>
+)}
+
{/* 첨부파일 섹션 */}
{attachments && attachments.length > 0 && (
<div>
@@ -209,16 +248,15 @@ export function QuotationHistoryDialog({
}: QuotationHistoryDialogProps) {
const [data, setData] = useState<QuotationHistoryData | null>(null)
const [isLoading, setIsLoading] = useState(false)
-
+
useEffect(() => {
if (open && quotationId) {
loadQuotationHistory()
}
}, [open, quotationId])
-
+
const loadQuotationHistory = async () => {
if (!quotationId) return
-
try {
setIsLoading(true)
const { getTechSalesVendorQuotationWithRevisions } = await import("@/lib/techsales-rfq/service")
@@ -238,14 +276,14 @@ export function QuotationHistoryDialog({
setIsLoading(false)
}
}
-
+
const handleOpenChange = (newOpen: boolean) => {
onOpenChange(newOpen)
if (!newOpen) {
setData(null) // 다이얼로그 닫을 때 데이터 초기화
}
}
-
+
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="w-[80vw] max-h-[90vh] overflow-y-auto">
@@ -257,54 +295,55 @@ export function QuotationHistoryDialog({
</DialogHeader>
<div className="space-y-4 overflow-x-auto">
- {isLoading ? (
- <div className="space-y-4">
- {[1, 2, 3].map((i) => (
- <div key={i} className="space-y-3">
- <Skeleton className="h-6 w-32" />
- <Skeleton className="h-32 w-full" />
- </div>
- ))}
- </div>
- ) : data ? (
- <>
- {/* 현재 버전 */}
- <QuotationCard
- data={data.current}
- version={data.current.quotationVersion || 1}
- isCurrent={true}
- attachments={data.current.attachments}
- />
-
- {/* 이전 버전들 */}
- {data.revisions.length > 0 ? (
- data.revisions.map((revision) => (
- <QuotationCard
- key={revision.id}
- data={revision.snapshot}
- version={revision.version}
- changeReason={revision.changeReason}
- revisedBy={revision.revisedByName}
- revisedAt={revision.revisedAt}
- attachments={revision.attachments}
- />
- ))
- ) : (
- <div className="text-center py-8 text-muted-foreground">
- <AlertCircle className="size-12 mx-auto mb-2 opacity-50" />
- <p>수정 이력이 없습니다.</p>
- <p className="text-sm">이 견적서는 아직 수정되지 않았습니다.</p>
- </div>
- )}
- </>
- ) : (
- <div className="text-center py-8 text-muted-foreground">
- <AlertCircle className="size-12 mx-auto mb-2 opacity-50" />
- <p>견적서 정보를 불러올 수 없습니다.</p>
- </div>
- )}
- </div>
+ {isLoading ? (
+ <div className="space-y-4">
+ {[1, 2, 3].map((i) => (
+ <div key={i} className="space-y-3">
+ <Skeleton className="h-6 w-32" />
+ <Skeleton className="h-32 w-full" />
+ </div>
+ ))}
+ </div>
+ ) : data ? (
+ <>
+ {/* 현재 버전 - SHI Comment 없이 표시 */}
+ <QuotationCard
+ data={data.current}
+ version={data.current.quotationVersion || 1}
+ isCurrent={true}
+ attachments={data.current.attachments}
+ />
+
+ {/* 이전 버전들 (스냅샷) - SHI Comment 포함 */}
+ {data.revisions.length > 0 ? (
+ data.revisions.map((revision) => (
+ <QuotationCard
+ key={revision.id}
+ data={revision.snapshot}
+ version={revision.version}
+ revisedBy={revision.revisedByName}
+ revisedAt={revision.revisedAt}
+ attachments={revision.attachments}
+ revisionId={revision.id}
+ revisionNote={revision.revisionNote}
+ />
+ ))
+ ) : (
+ <div className="text-center py-8 text-muted-foreground">
+ <AlertCircle className="size-12 mx-auto mb-2 opacity-50" />
+ <p>수정 이력이 없습니다.</p>
+ <p className="text-sm">이 견적서는 아직 수정되지 않았습니다.</p>
+ </div>
+ )}
+ </>
+ ) : (
+ <div className="text-center py-8 text-muted-foreground">
+ <AlertCircle className="size-12 mx-auto mb-2 opacity-50" />
+ <p>견적서 정보를 불러올 수 없습니다.</p>
+ </div>
+ )}
+ </div>
</DialogContent>
</Dialog>
)
-} \ No newline at end of file
+} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx b/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx
index e4141520..7ece2406 100644
--- a/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx
+++ b/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx
@@ -46,6 +46,7 @@ export interface RfqDetailView {
createdByName: string | null
quotationCode?: string | null
rfqCode?: string | null
+ quotationVersion?: number | null
quotationAttachments?: Array<{
id: number
revisionId: number
@@ -185,6 +186,22 @@ export function getRfqDetailColumns({
enableResizing: true,
size: 160,
},
+ // [Rev 컬럼 추가]
+ {
+ id: "rev",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Rev" />
+ ),
+ cell: ({ row }) => {
+ const version = row.original.quotationVersion ?? 0;
+ return <div className="text-center font-mono">{version}</div>;
+ },
+ meta: {
+ excelHeader: "Rev"
+ },
+ enableResizing: false,
+ size: 60,
+ },
{
accessorKey: "totalPrice",
header: ({ column }) => (
@@ -227,6 +244,7 @@ export function getRfqDetailColumns({
enableResizing: true,
size: 140,
},
+
{
accessorKey: "quotationAttachments",
header: ({ column }) => (
diff --git a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx
index acf67497..8bfb8299 100644
--- a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx
+++ b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx
@@ -730,8 +730,8 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
onSuccess={handleRefreshData}
/>
- {/* 다중 벤더 삭제 확인 다이얼로그
- <DeleteVendorDialog
+ {/* 다중 벤더 삭제 확인 다이얼로그 */}
+ {/* <DeleteVendorDialog
open={deleteConfirmDialogOpen}
onOpenChange={setDeleteConfirmDialogOpen}
vendors={selectedRows}
diff --git a/lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx b/lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx
index e6cd32a9..5b60ef0f 100644
--- a/lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx
+++ b/lib/techsales-rfq/table/detail-table/vendor-communication-drawer.tsx
@@ -320,9 +320,9 @@ export function VendorCommunicationDrawer({
};
// 첨부파일 다운로드
- const handleAttachmentDownload = async (attachment: Attachment) => {
- const { downloadFile } = await import("@/lib/file-download");
- await downloadFile(attachment.filePath, attachment.originalFileName);
+ const handleAttachmentDownload = (attachment: Attachment) => {
+ // TODO: 실제 다운로드 구현
+ window.open(attachment.filePath, '_blank');
};
// 파일 아이콘 선택
diff --git a/lib/techsales-rfq/table/detail-table/vendor-contact-selection-dialog.tsx b/lib/techsales-rfq/table/detail-table/vendor-contact-selection-dialog.tsx
index aa6f6c2f..031f4aa2 100644
--- a/lib/techsales-rfq/table/detail-table/vendor-contact-selection-dialog.tsx
+++ b/lib/techsales-rfq/table/detail-table/vendor-contact-selection-dialog.tsx
@@ -21,6 +21,7 @@ interface VendorContact {
id: number
contactName: string
contactPosition: string | null
+ contactTitle: string | null
contactEmail: string
contactPhone: string | null
isPrimary: boolean
@@ -283,6 +284,11 @@ export function VendorContactSelectionDialog({
{contact.contactPosition}
</p>
)}
+ {contact.contactTitle && (
+ <p className="text-sm text-muted-foreground">
+ {contact.contactTitle}
+ </p>
+ )}
</div>
</div>
</div>
diff --git a/lib/techsales-rfq/table/rfq-filter-sheet.tsx b/lib/techsales-rfq/table/rfq-filter-sheet.tsx
index a03e6167..7db8305d 100644
--- a/lib/techsales-rfq/table/rfq-filter-sheet.tsx
+++ b/lib/techsales-rfq/table/rfq-filter-sheet.tsx
@@ -48,7 +48,32 @@ const filterSchema = z.object({
from: z.date().optional(),
to: z.date().optional(),
}).optional(),
+ workTypes: z.array(z.string()).optional(),
})
+// 공종 옵션 정의 (tech-vendors와 동일)
+const workTypeOptions = [
+ // 조선 workTypes
+ { value: "기장", label: "기장" },
+ { value: "전장", label: "전장" },
+ { value: "선실", label: "선실" },
+ { value: "배관", label: "배관" },
+ { value: "철의", label: "철의" },
+ { value: "선체", label: "선체" },
+ // 해양TOP workTypes
+ { value: "TM", label: "TM" },
+ { value: "TS", label: "TS" },
+ { value: "TE", label: "TE" },
+ { value: "TP", label: "TP" },
+ { value: "TA", label: "TA" },
+ // 해양HULL workTypes
+ { value: "HA", label: "HA" },
+ { value: "HE", label: "HE" },
+ { value: "HH", label: "HH" },
+ { value: "HM", label: "HM" },
+ { value: "NC", label: "NC" },
+ { value: "HO", label: "HO" },
+ { value: "HP", label: "HP" },
+];
// 상태 옵션 정의 (TechSales RFQ 상태에 맞게 수정)
const statusOptions = [
@@ -89,13 +114,13 @@ export function RFQFilterSheet({
// nuqs로 URL 상태 관리 - 파라미터명을 'basicFilters'로 변경
const [filters, setFilters] = useQueryState(
- "basicFilters",
+ "filters",
getFiltersStateParser().withDefault([])
)
// joinOperator 설정
const [joinOperator, setJoinOperator] = useQueryState(
- "basicJoinOperator",
+ "joinOperator",
parseAsStringEnum(["and", "or"]).withDefault("and")
)
@@ -118,6 +143,7 @@ export function RFQFilterSheet({
from: undefined,
to: undefined,
},
+ workTypes: [],
},
})
@@ -274,6 +300,16 @@ export function RFQFilterSheet({
})
}
+ if (data.workTypes && data.workTypes.length > 0) {
+ newFilters.push({
+ id: "workTypes",
+ value: data.workTypes,
+ type: "multi-select" as const,
+ operator: "eq" as const,
+ rowId: generateId()
+ })
+ }
+
console.log("기본 필터 적용:", newFilters);
// 마지막 적용된 필터 업데이트
@@ -313,6 +349,7 @@ export function RFQFilterSheet({
createdByName: "",
status: "",
dateRange: { from: undefined, to: undefined },
+ workTypes: [],
});
// 필터와 조인 연산자를 초기화
@@ -446,6 +483,7 @@ export function RFQFilterSheet({
</FormItem>
)}
/>
+
{/* 자재명 */}
<FormField
@@ -561,44 +599,6 @@ export function RFQFilterSheet({
)}
/>
- {/* 선종명 */}
- <FormField
- control={form.control}
- name="ptypeNm"
- render={({ field }) => (
- <FormItem>
- <FormLabel>{t("선종명")}</FormLabel>
- <FormControl>
- <div className="relative">
- <Input
- placeholder={t("선종명 입력")}
- {...field}
- className={cn(field.value && "pr-8", "bg-white")}
- disabled={isInitializing}
- />
- {field.value && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="absolute right-0 top-0 h-full px-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("ptypeNm", "");
- }}
- disabled={isInitializing}
- >
- <X className="size-3.5" />
- <span className="sr-only">Clear</span>
- </Button>
- )}
- </div>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
{/* 요청자 */}
<FormField
control={form.control}
@@ -685,43 +685,38 @@ export function RFQFilterSheet({
)}
/>
- {/* RFQ 전송일 */}
+ {/* 공종 */}
<FormField
control={form.control}
- name="dateRange"
+ name="workTypes"
render={({ field }) => (
<FormItem>
- <FormLabel>{t("RFQ 전송일")}</FormLabel>
- <FormControl>
- <div className="relative">
- <DateRangePicker
- triggerSize="default"
- triggerClassName="w-full bg-white"
- align="start"
- showClearButton={true}
- placeholder={t("RFQ 전송일 범위를 고르세요")}
- date={field.value || undefined}
- onDateChange={field.onChange}
- disabled={isInitializing}
- />
- {(field.value?.from || field.value?.to) && (
- <Button
- type="button"
- variant="ghost"
- size="icon"
- className="absolute right-10 top-0 h-full px-2"
- onClick={(e) => {
- e.stopPropagation();
- form.setValue("dateRange", { from: undefined, to: undefined });
+ <FormLabel>공종</FormLabel>
+ <div className="grid grid-cols-2 gap-2">
+ {workTypeOptions.map((option) => (
+ <div key={option.value} className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id={`workType-${option.value}`}
+ checked={field.value?.includes(option.value) || false}
+ onChange={(e) => {
+ const checked = e.target.checked;
+ const updatedValue = checked
+ ? [...(field.value || []), option.value]
+ : (field.value || []).filter((value) => value !== option.value);
+ field.onChange(updatedValue);
}}
disabled={isInitializing}
+ />
+ <label
+ htmlFor={`workType-${option.value}`}
+ className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
- <X className="size-3.5" />
- <span className="sr-only">Clear</span>
- </Button>
- )}
- </div>
- </FormControl>
+ {option.label}
+ </label>
+ </div>
+ ))}
+ </div>
<FormMessage />
</FormItem>
)}
diff --git a/lib/techsales-rfq/table/rfq-table-column.tsx b/lib/techsales-rfq/table/rfq-table-column.tsx
index f41857cd..2bc5b5b4 100644
--- a/lib/techsales-rfq/table/rfq-table-column.tsx
+++ b/lib/techsales-rfq/table/rfq-table-column.tsx
@@ -9,6 +9,9 @@ import { DataTableRowAction } from "@/types/table"
import { Paperclip, Package, FileText, BarChart3 } from "lucide-react"
import { Button } from "@/components/ui/button"
import { TechSalesRfq } from "./rfq-table"
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
+import { MoreHorizontal } from "lucide-react"
+import { Edit } from "lucide-react"
interface GetColumnsProps {
setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<TechSalesRfq> | null>>;
@@ -409,5 +412,35 @@ export function getColumns({
excelHeader: "CBE 결과"
},
},
+ // getColumns 함수 내 컬럼 배열 마지막에 추가
+{
+ id: "actions",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="" />
+ ),
+ cell: ({ row }) => {
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-8 w-8 p-0"
+ >
+ <MoreHorizontal className="h-4 w-4" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuItem onClick={() => setRowAction({ row, type: "update" })}>
+ <span>수정하기 </span>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ enableSorting: false,
+ enableResizing: false,
+ size: 60,
+}
]
} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/rfq-table.tsx b/lib/techsales-rfq/table/rfq-table.tsx
index e3551625..e1e511c8 100644
--- a/lib/techsales-rfq/table/rfq-table.tsx
+++ b/lib/techsales-rfq/table/rfq-table.tsx
@@ -17,7 +17,7 @@ import {
import { useDataTable } from "@/hooks/use-data-table"
import { DataTable } from "@/components/data-table/data-table"
import { getColumns } from "./rfq-table-column"
-import { useEffect, useMemo } from "react"
+import { useEffect, useMemo, useState } from "react"
import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
import { RFQTableToolbarActions } from "./rfq-table-toolbar-actions"
import { getTechSalesRfqsWithJoin, getTechSalesRfqAttachments } from "@/lib/techsales-rfq/service"
@@ -29,6 +29,7 @@ import { ProjectDetailDialog } from "./project-detail-dialog"
import { RFQFilterSheet } from "./rfq-filter-sheet"
import { TechSalesRfqAttachmentsSheet, ExistingTechSalesAttachment } from "./tech-sales-rfq-attachments-sheet"
import { RfqItemsViewDialog } from "./rfq-items-view-dialog"
+import UpdateSheet from "./update-rfq-sheet"
// 기본적인 RFQ 타입 정의 (repository selectTechSalesRfqsWithJoin 반환 타입에 맞춤)
export interface TechSalesRfq {
id: number
@@ -103,7 +104,10 @@ export function RFQListTable({
// 패널 collapse 상태
const [panelHeight, setPanelHeight] = React.useState<number>(55)
-
+ // RFQListTable 컴포넌트 내부의 rowAction 처리 부분 수정
+ const [updateSheetOpen, setUpdateSheetOpen] = useState(false);
+ const [selectedRfqIdForUpdate, setSelectedRfqIdForUpdate] = useState<number | null>(null);
+
// 고정 높이 설정을 위한 상수 (실제 측정값으로 조정 필요)
const LAYOUT_HEADER_HEIGHT = 64 // Layout Header 높이
const LAYOUT_FOOTER_HEIGHT = 60 // Layout Footer 높이 (있다면 실제 값)
@@ -246,8 +250,10 @@ export function RFQListTable({
setIsProjectDetailOpen(true);
break;
case "update":
- console.log("Update rfq:", rowAction.row.original)
- break;
+ // RFQ 수정 시트 열기
+ setSelectedRfqIdForUpdate(rowAction.row.original.id);
+ setUpdateSheetOpen(true);
+ break;
case "delete":
console.log("Delete rfq:", rowAction.row.original)
break;
@@ -391,6 +397,7 @@ export function RFQListTable({
{ 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" },
@@ -450,8 +457,6 @@ export function RFQListTable({
}
}
- console.log(panelHeight)
-
return (
<div
className={cn("flex flex-col relative", className)}
@@ -631,6 +636,17 @@ export function RFQListTable({
rfqType: selectedRfqForItems.rfqType
} : null}
/>
+ {updateSheetOpen && selectedRfqIdForUpdate && (
+ <UpdateSheet
+ open={updateSheetOpen}
+ onOpenChange={setUpdateSheetOpen}
+ rfqId={selectedRfqIdForUpdate}
+ onUpdated={() => {
+ // 테이블 새로고침 로직
+ // 필요한 경우 여기에 추가
+ }}
+ />
+ )}
</div>
)
} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/update-rfq-sheet.tsx b/lib/techsales-rfq/table/update-rfq-sheet.tsx
new file mode 100644
index 00000000..7dcc0e0e
--- /dev/null
+++ b/lib/techsales-rfq/table/update-rfq-sheet.tsx
@@ -0,0 +1,267 @@
+"use client";
+import * as React from "react";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { z } from "zod";
+import { format } from "date-fns";
+import { ko } from "date-fns/locale/ko";
+import { toast } from "sonner";
+import { Loader2, CalendarIcon } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter, SheetClose } from "@/components/ui/sheet";
+import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
+import { Calendar } from "@/components/ui/calendar";
+import { cn } from "@/lib/utils";
+import { updateTechSalesRfq, getTechSalesRfqById } from "@/lib/techsales-rfq/service";
+
+// Zod schema for form validation
+const updateRfqSchema = z.object({
+ rfqId: z.number().min(1, "RFQ ID is required"),
+ description: z.string(),
+ dueDate: z.string(),
+});
+
+type UpdateRfqSchema = z.infer<typeof updateRfqSchema>;
+
+interface UpdateSheetProps {
+ open: boolean;
+ onOpenChange?: (open: boolean) => void;
+ rfqId: number;
+ onUpdated?: () => void;
+}
+
+export default function UpdateSheet({ open, onOpenChange, rfqId, onUpdated }: UpdateSheetProps) {
+ const [isPending, startTransition] = React.useTransition();
+ const [projectInfo, setProjectInfo] = React.useState({
+ projNm: "",
+ sector: "",
+ projMsrm: "",
+ ptypeNm: "",
+ rfqNo: "",
+ });
+ const [isLoading, setIsLoading] = React.useState(false);
+
+ // Initialize form with React Hook Form and Zod
+ const form = useForm<UpdateRfqSchema>({
+ resolver: zodResolver(updateRfqSchema),
+ defaultValues: {
+ rfqId,
+ description: "",
+ dueDate: "",
+ },
+ });
+
+ // Load RFQ data when sheet opens
+ React.useEffect(() => {
+ if (open && rfqId) {
+ loadRfqData();
+ }
+ }, [open, rfqId]);
+
+ const loadRfqData = async () => {
+ try {
+ setIsLoading(true);
+ const result = await getTechSalesRfqById(rfqId);
+ if (result.error) {
+ toast.error(result.error);
+ onOpenChange?.(false);
+ return;
+ }
+ if (result.data) {
+ form.reset({
+ rfqId,
+ description: result.data.description || "",
+ dueDate: result.data.dueDate ? new Date(result.data.dueDate).toISOString().slice(0, 10) : "",
+ });
+ setProjectInfo({
+ projNm: result.data.project[0].projectName || "",
+ sector: result.data.project[0].pjtType || "",
+ projMsrm: result.data.project[0].projMsrm || "",
+ ptypeNm: result.data.project[0].ptypeNm || "",
+ rfqNo: result.data.rfqCode || "",
+ });
+ }
+ } catch (error: any) {
+ toast.error("RFQ 정보를 불러오는 중 오류가 발생했습니다: " + error.message);
+ onOpenChange?.(false);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ // Form submission handler with debug logs
+ async function onSubmit(values: UpdateRfqSchema) {
+ console.log("Form submitted with values:", values);
+ startTransition(async () => {
+ try {
+ console.log("Submitting RFQ update for ID:", values.rfqId);
+ const result = await updateTechSalesRfq({
+ id: values.rfqId,
+ description: values.description,
+ dueDate: new Date(values.dueDate),
+ updatedBy: 1, // Replace with actual user ID
+ });
+ if (result.error) {
+ console.error("Update error:", result.error);
+ toast.error(result.error);
+ } else {
+ console.log("RFQ updated successfully");
+ toast.success("RFQ가 성공적으로 업데이트되었습니다!");
+ onUpdated?.();
+ onOpenChange?.(false);
+ form.reset();
+ }
+ } catch (error: any) {
+ console.error("Update failed with error:", error.message);
+ toast.error("업데이트 중 오류 발생: " + error.message);
+ }
+ });
+ }
+
+ // Debug form errors on change
+ React.useEffect(() => {
+ const subscription = form.watch(() => {
+ console.log("Form values changed:", form.getValues());
+ console.log("Form errors:", form.formState.errors);
+ });
+ return () => subscription.unsubscribe();
+ }, [form]);
+
+ return (
+ <Sheet open={open} onOpenChange={onOpenChange}>
+ <SheetContent className="flex flex-col h-full sm:max-w-xl bg-gray-50">
+ <SheetHeader className="text-left flex-shrink-0">
+ <SheetTitle className="text-2xl font-bold">RFQ 수정</SheetTitle>
+ <SheetDescription className="">
+ RFQ 정보를 수정합니다. 모든 필드를 입력한 후 저장 버튼을 클릭하세요.
+ </SheetDescription>
+ </SheetHeader>
+
+ <div className="flex-1 overflow-y-auto py-4">
+ {isLoading ? (
+ <div className="flex justify-center items-center py-12">
+ <Loader2 className="h-10 w-10 animate-spin" />
+ </div>
+ ) : (
+ <div className="space-y-6">
+ <div className="bg-white shadow-sm rounded-lg p-5 border border-gray-200">
+ <div className="grid grid-cols-2 gap-4 text-sm">
+ <div>
+ <span className="font-semibold text-gray-700">프로젝트명:</span> {projectInfo.projNm}
+ </div>
+ <div>
+ <span className="font-semibold text-gray-700">섹터:</span> {projectInfo.sector}
+ </div>
+ <div>
+ <span className="font-semibold text-gray-700">척수:</span> {projectInfo.projMsrm}
+ </div>
+ <div>
+ <span className="font-semibold text-gray-700">선종:</span> {projectInfo.ptypeNm}
+ </div>
+ <div>
+ <span className="font-semibold text-gray-700">RFQ No:</span> {projectInfo.rfqNo}
+ </div>
+ </div>
+ </div>
+
+ <Form {...form}>
+ <form id="update-rfq-form" onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="text-sm font-medium text-gray-700">RFQ Title</FormLabel>
+ <FormControl>
+ <Input
+ {...field}
+ placeholder="RFQ Title을 입력하세요"
+ className="border-gray-300 rounded-md"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="dueDate"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>마감일</FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ className={cn(
+ "w-full pl-3 text-left font-normal",
+ !field.value && "text-muted-foreground"
+ )}
+ >
+ {field.value ? (
+ format(new Date(field.value), "PPP", { locale: ko })
+ ) : (
+ <span>마감일을 선택하세요</span>
+ )}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={field.value ? new Date(field.value) : undefined}
+ onSelect={(date) => {
+ // date-fns format을 사용해 yyyy-MM-dd로 변환하여 string 저장
+ if (date) {
+ field.onChange(format(date, "yyyy-MM-dd"));
+ }
+ }}
+ disabled={(date) =>
+ date < new Date() || date < new Date("1900-01-01")
+ }
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </form>
+ </Form>
+ </div>
+ )}
+ </div>
+
+ <SheetFooter className="gap-2 pt-2 sm:space-x-0 flex-shrink-0">
+ <SheetClose asChild>
+ <Button
+ type="button"
+ variant="outline"
+ disabled={isPending}
+ className="border-gray-300 text-gray-700 hover:bg-gray-100 rounded-md"
+ >
+ 취소
+ </Button>
+ </SheetClose>
+ <Button
+ type="submit"
+ form="update-rfq-form"
+ disabled={isPending}
+ className="bg-blue-600 hover:bg-blue-700 text-white rounded-md"
+ onClick={() => console.log("Save button clicked")}
+ >
+ {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />}
+ 저장
+ </Button>
+ </SheetFooter>
+ </SheetContent>
+ </Sheet>
+ );
+} \ No newline at end of file
diff --git a/lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx b/lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx
index 771db896..8a45f529 100644
--- a/lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx
+++ b/lib/techsales-rfq/vendor-response/detail/project-info-tab.tsx
@@ -80,12 +80,12 @@ export function ProjectInfoTab({ quotation }: ProjectInfoTabProps) {
<div className="text-sm font-medium text-muted-foreground">자재 그룹</div>
<div className="text-sm">{rfq.materialCode || "N/A"}</div>
</div>
- <div className="space-y-2">
+ {/* <div className="space-y-2">
<div className="text-sm font-medium text-muted-foreground">마감일</div>
<div className="text-sm">
{rfq.dueDate ? formatDate(rfq.dueDate) : "N/A"}
</div>
- </div>
+ </div> */}
<div className="space-y-2">
<div className="text-sm font-medium text-muted-foreground">RFQ 상태</div>
<div className="text-sm">{rfq.status || "N/A"}</div>
diff --git a/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx b/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx
index 9411ed02..087e2a4d 100644
--- a/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx
+++ b/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx
@@ -96,10 +96,9 @@ export function QuotationResponseTab({ quotation }: QuotationResponseTabProps) {
const rfq = quotation.rfq
const isDueDatePassed = rfq?.dueDate ? new Date(rfq.dueDate) < new Date() : false
- // const canSubmit = !["Accepted", "Rejected"].includes(quotation.status) && !isDueDatePassed
- // const canEdit = !["Accepted", "Rejected"].includes(quotation.status) && !isDueDatePassed
const canSubmit = !["Accepted", "Rejected"].includes(quotation.status)
const canEdit = !["Accepted", "Rejected"].includes(quotation.status)
+
// 파일 업로드 핸들러
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files
@@ -264,13 +263,13 @@ export function QuotationResponseTab({ quotation }: QuotationResponseTabProps) {
<div className="text-sm font-medium text-muted-foreground">견적서 상태</div>
<div className="text-sm">{getStatusLabel(quotation.status)}</div>
</div>
- <div className="space-y-2">
+ {/* <div className="space-y-2">
<div className="text-sm font-medium text-muted-foreground">RFQ 마감일</div>
<div className="text-sm">
{rfq?.dueDate ? formatDate(rfq.dueDate) : "N/A"}
</div>
- </div>
- <div className="space-y-2">
+ </div> */}
+ {/* <div className="space-y-2">
<div className="text-sm font-medium text-muted-foreground">남은 시간</div>
<div className="text-sm">
{isDueDatePassed ? (
@@ -283,19 +282,19 @@ export function QuotationResponseTab({ quotation }: QuotationResponseTabProps) {
"N/A"
)}
</div>
- </div>
+ </div> */}
</div>
- {isDueDatePassed && (
+ {/* {isDueDatePassed && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
RFQ 마감일이 지났습니다. 견적서를 수정하거나 제출할 수 없습니다.
</AlertDescription>
</Alert>
- )}
+ )} */}
- {!canEdit && !isDueDatePassed && (
+ {!canEdit && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
diff --git a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx
index 328def80..46b14f46 100644
--- a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx
+++ b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx
@@ -508,26 +508,26 @@ export function getColumns({ router, openAttachmentsSheet, openItemsDialog, open
// enableSorting: true,
// enableHiding: true,
// },
- {
- accessorKey: "dueDate",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="마감일" />
- ),
- cell: ({ row }) => {
- const dueDate = row.getValue("dueDate") as Date;
- const isOverdue = dueDate && new Date() > new Date(dueDate);
+ // {
+ // accessorKey: "dueDate",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="마감일" />
+ // ),
+ // cell: ({ row }) => {
+ // const dueDate = row.getValue("dueDate") as Date;
+ // const isOverdue = dueDate && new Date() > new Date(dueDate);
- return (
- <div className="w-28">
- <span className={`text-sm ${isOverdue ? "text-red-600 font-medium" : ""}`}>
- {dueDate ? formatDate(dueDate) : "N/A"}
- </span>
- </div>
- );
- },
- enableSorting: true,
- enableHiding: true,
- },
+ // return (
+ // <div className="w-28">
+ // <span className={`text-sm ${isOverdue ? "text-red-600 font-medium" : ""}`}>
+ // {dueDate ? formatDate(dueDate) : "N/A"}
+ // </span>
+ // </div>
+ // );
+ // },
+ // enableSorting: true,
+ // enableHiding: true,
+ // },
// {
// accessorKey: "rejectionReason",
// header: ({ column }) => (
diff --git a/lib/vendor-document-list/enhanced-document-service.ts b/lib/vendor-document-list/enhanced-document-service.ts
index 6fe8feb7..b78d0fc3 100644
--- a/lib/vendor-document-list/enhanced-document-service.ts
+++ b/lib/vendor-document-list/enhanced-document-service.ts
@@ -21,6 +21,8 @@ import type {
} from "@/types/enhanced-documents"
import { GetVendorShipDcoumentsSchema } from "./validations"
import { contracts, users, vendors } from "@/db/schema"
+import { getServerSession } from "next-auth/next"
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
// 스키마 타입 정의
export interface GetEnhancedDocumentsSchema {
@@ -1008,16 +1010,17 @@ export async function getDocumentDetails(documentId: number) {
input: GetVendorShipDcoumentsSchema
) {
try {
- const offset = (input.page - 1) * input.perPage
+
+ const session = await getServerSession(authOptions)
+ if (!session?.user?.id) {
+ throw new Error("인증이 필요합니다.")
+ }
- // 1. 사용자의 벤더(회사) ID 조회
- const [user] = await db
- .select({ companyId: users.companyId })
- .from(users)
- .where(eq(users.id, userId))
- .limit(1)
+ const companyId = session?.user?.companyId;
+
+ const offset = (input.page - 1) * input.perPage
- if (!user?.companyId) {
+ if (!companyId) {
return { data: [], pageCount: 0, total: 0, drawingKind: null, vendorInfo: null }
}
@@ -1025,7 +1028,7 @@ export async function getDocumentDetails(documentId: number) {
const vendorContracts = await db
.select({ id: contracts.id })
.from(contracts)
- .where(eq(contracts.vendorId, user.companyId))
+ .where(eq(contracts.vendorId, companyId))
const contractIds = vendorContracts.map(c => c.id)
@@ -1123,14 +1126,16 @@ export async function getDocumentDetails(documentId: number) {
*/
export async function getUserVendorDocumentStats(userId: number) {
try {
- // 사용자의 벤더 ID 조회
- const [user] = await db
- .select({ companyId: users.companyId })
- .from(users)
- .where(eq(users.id, userId))
- .limit(1)
-
- if (!user?.companyId) {
+
+ const session = await getServerSession(authOptions)
+ if (!session?.user?.id) {
+ throw new Error("인증이 필요합니다.")
+ }
+
+ const companyId = session?.user?.companyId;
+
+
+ if (!companyId) {
return { stats: {}, totalDocuments: 0, primaryDrawingKind: null }
}
@@ -1138,7 +1143,7 @@ export async function getDocumentDetails(documentId: number) {
const vendorContracts = await db
.select({ id: contracts.id })
.from(contracts)
- .where(eq(contracts.vendorId, user.companyId))
+ .where(eq(contracts.vendorId, companyId))
const contractIds = vendorContracts.map(c => c.id)