From 6f22fc9ebc8d175041aa18cf0986592e57d03f63 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Thu, 3 Jul 2025 02:47:09 +0000 Subject: (최겸) 기술영업 벤더별 아이템 조회 기능 추가 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/tech-vendor-possible-items/repository.ts | 47 ++ lib/tech-vendor-possible-items/service.ts | 583 +++++++++++++++++++++ .../table/excel-export.tsx | 181 +++++++ .../table/excel-import.tsx | 220 ++++++++ .../table/excel-template.tsx | 137 +++++ .../table/possible-items-data-table.tsx | 90 ++++ .../table/possible-items-table-columns.tsx | 140 +++++ .../table/possible-items-table-toolbar-actions.tsx | 201 +++++++ lib/tech-vendor-possible-items/validations.ts | 45 ++ 9 files changed, 1644 insertions(+) create mode 100644 lib/tech-vendor-possible-items/repository.ts create mode 100644 lib/tech-vendor-possible-items/service.ts create mode 100644 lib/tech-vendor-possible-items/table/excel-export.tsx create mode 100644 lib/tech-vendor-possible-items/table/excel-import.tsx create mode 100644 lib/tech-vendor-possible-items/table/excel-template.tsx create mode 100644 lib/tech-vendor-possible-items/table/possible-items-data-table.tsx create mode 100644 lib/tech-vendor-possible-items/table/possible-items-table-columns.tsx create mode 100644 lib/tech-vendor-possible-items/table/possible-items-table-toolbar-actions.tsx create mode 100644 lib/tech-vendor-possible-items/validations.ts (limited to 'lib') diff --git a/lib/tech-vendor-possible-items/repository.ts b/lib/tech-vendor-possible-items/repository.ts new file mode 100644 index 00000000..b2588395 --- /dev/null +++ b/lib/tech-vendor-possible-items/repository.ts @@ -0,0 +1,47 @@ +import { eq, desc, count } from "drizzle-orm"; +import { + techVendors, + techVendorPossibleItems +} from "@/db/schema/techVendors"; + +/** + * 기술영업 벤더 가능 아이템 목록 조회 (조인 포함) + */ +export async function selectTechVendorPossibleItemsWithJoin( + tx: any, + where: any, + orderBy: any[], + offset: number, + limit: number +) { + return await tx + .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(where) + .orderBy(...(orderBy || [desc(techVendorPossibleItems.createdAt)])) + .limit(limit) + .offset(offset); +} + +/** + * 기술영업 벤더 가능 아이템 총 개수 조회 (조인 포함) + */ +export async function countTechVendorPossibleItemsWithJoin(tx: any, where?: any) { + const [result] = await tx + .select({ count: count() }) + .from(techVendorPossibleItems) + .innerJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id)) + .where(where); + + return result.count; +} \ No newline at end of file diff --git a/lib/tech-vendor-possible-items/service.ts b/lib/tech-vendor-possible-items/service.ts new file mode 100644 index 00000000..efe9be51 --- /dev/null +++ b/lib/tech-vendor-possible-items/service.ts @@ -0,0 +1,583 @@ +"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택) +import { eq, and, inArray, desc, asc, or, ilike } from "drizzle-orm"; +import db from "@/db/db"; +import { + techVendors, + techVendorPossibleItems +} 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; + vendorCode: string | null; + vendorName: string; + techVendorType: string; + itemCode: string; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateTechVendorPossibleItemData { + vendorId: number; + itemCode: string; +} + +export interface ImportTechVendorPossibleItemData { + vendorCode: string; + vendorEmail?: string; + itemCode: string; +} + +export interface ImportResult { + success: boolean; + totalRows: number; + successCount: number; + failedRows: { + row: number; + error: string; + vendorCode?: string; + vendorEmail?: string; + itemCode?: string; + }[]; +} + + + +/** + * 견적프로젝트 패턴에 맞는 메인 조회 함수 + */ +export async function getTechVendorPossibleItems(input: GetTechVendorPossibleItemsSchema) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + // 고급 필터링 (DataTableAdvancedToolbar용) + const advancedWhere = filterColumns({ + table: techVendorPossibleItems, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + // 전역 검색 (search box용) + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(techVendors.vendorCode, s), + ilike(techVendors.vendorName, s), + ilike(techVendorPossibleItems.itemCode, s), + ); + } + + // 기존 호환성을 위한 개별 필터들 + const legacyFilters = []; + if (input.vendorCode) { + legacyFilters.push(ilike(techVendors.vendorCode, `%${input.vendorCode}%`)); + } + if (input.vendorName) { + legacyFilters.push(ilike(techVendors.vendorName, `%${input.vendorName}%`)); + } + if (input.itemCode) { + legacyFilters.push(ilike(techVendorPossibleItems.itemCode, `%${input.itemCode}%`)); + } + + // 벤더 타입 필터링 + if (input.vendorType && input.vendorType !== "all") { + const vendorTypeMap = { + "ship": "조선", + "top": "해양TOP", + "hull": "해양HULL" + }; + + const actualVendorType = vendorTypeMap[input.vendorType as keyof typeof vendorTypeMap] || input.vendorType; + + if (actualVendorType) { + legacyFilters.push(ilike(techVendors.techVendorType, `%${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); + + const total = await countTechVendorPossibleItemsWithJoin(tx, finalWhere); + return { data, total }; + }); + + const pageCount = Math.ceil(total / input.perPage); + + return { data, pageCount, totalCount: total }; + } catch (error) { + console.error("Error fetching tech vendor possible items:", error); + return { data: [], pageCount: 0, totalCount: 0 }; + } + }, + [JSON.stringify(input)], + { + revalidate: 3600, + 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 생성 (간단 버전) + */ +export async function createTechVendorPossibleItem( + data: CreateTechVendorPossibleItemData +): Promise<{ success: boolean; error?: string }> { + try { + // 벤더 존재 여부만 확인 + 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) + ) + ) + .limit(1); + + if (existing.length > 0) { + return { success: false, error: "이미 존재하는 벤더-아이템 조합입니다." }; + } + + // 아이템 코드 검증 없이 바로 삽입 + await db.insert(techVendorPossibleItems).values({ + vendorId: data.vendorId, + itemCode: data.itemCode, + }); + + 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 { + 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.vendorCode && row.vendorCode.trim()) { + // 벤더 코드가 있으면 먼저 벤더 코드로 검색 + vendor = await getTechVendorByCode(row.vendorCode); + } else if (row.vendorEmail && row.vendorEmail.trim()) { + // 벤더 코드가 없으면 이메일로 검색 + vendor = await getTechVendorByEmail(row.vendorEmail); + } + + if (!vendor) { + const identifier = row.vendorCode ? `벤더 코드 '${row.vendorCode}'` : + row.vendorEmail ? `벤더 이메일 '${row.vendorEmail}'` : + '벤더 코드 또는 이메일'; + result.failedRows.push({ + row: rowNumber, + error: `${identifier}을(를) 찾을 수 없습니다.`, + vendorCode: row.vendorCode, + vendorEmail: row.vendorEmail, + itemCode: row.itemCode, + }); + continue; + } + + // 중복 체크 + const existing = await db + .select() + .from(techVendorPossibleItems) + .where( + and( + eq(techVendorPossibleItems.vendorId, vendor.id), + eq(techVendorPossibleItems.itemCode, row.itemCode) + ) + ) + .limit(1); + + if (existing.length > 0) { + result.failedRows.push({ + row: rowNumber, + error: `이미 존재하는 벤더-아이템 조합입니다.`, + vendorCode: row.vendorCode, + vendorEmail: row.vendorEmail, + itemCode: row.itemCode, + }); + continue; + } + + // 아이템 코드 검증 없이 바로 삽입 + await db.insert(techVendorPossibleItems).values({ + vendorId: vendor.id, + itemCode: row.itemCode, + }); + + result.successCount++; + } catch (error) { + result.failedRows.push({ + row: rowNumber, + error: error instanceof Error ? error.message : "알 수 없는 오류", + vendorCode: row.vendorCode, + vendorEmail: row.vendorEmail, + itemCode: row.itemCode, + }); + } + } + + 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 { + try { + const result = await db + .select({ + techVendorType: techVendors.techVendorType, + }) + .from(techVendors) + .where(eq(techVendors.status, "ACTIVE")); + + // techVendorType이 JSON 배열 형태로 저장된 경우를 고려 + const allTypes = new Set(); + + 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"]; + } +} \ No newline at end of file diff --git a/lib/tech-vendor-possible-items/table/excel-export.tsx b/lib/tech-vendor-possible-items/table/excel-export.tsx new file mode 100644 index 00000000..d3c4dea5 --- /dev/null +++ b/lib/tech-vendor-possible-items/table/excel-export.tsx @@ -0,0 +1,181 @@ +import * as ExcelJS from 'exceljs'; +import { saveAs } from "file-saver"; +import type { TechVendorPossibleItemsData } from '../service'; +import { format } from 'date-fns'; +import { ko } from 'date-fns/locale'; + +/** + * 기술영업 벤더 가능 아이템 데이터를 Excel 파일로 내보내기 + */ +export async function exportTechVendorPossibleItemsToExcel( + data: TechVendorPossibleItemsData[] +) { + try { + // 워크북 생성 + const workbook = new ExcelJS.Workbook(); + workbook.creator = 'Tech Vendor Possible Items Management System'; + workbook.created = new Date(); + + // 워크시트 생성 + const worksheet = workbook.addWorksheet('기술영업 벤더 가능 아이템'); + + // 컬럼 헤더 정의 및 스타일 적용 + worksheet.columns = [ + { header: '번호', key: 'id', width: 10 }, + { header: '벤더코드', key: 'vendorCode', width: 15 }, + { header: '벤더명', key: 'vendorName', width: 25 }, + { header: '벤더타입', key: 'techVendorType', width: 20 }, + { header: '아이템코드', key: 'itemCode', width: 20 }, + { header: '생성일시', key: 'createdAt', width: 20 }, + ]; + + // 헤더 스타일 적용 + const headerRow = worksheet.getRow(1); + headerRow.eachCell((cell) => { + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFE6F3FF' } + }; + cell.font = { + bold: true, + color: { argb: 'FF1F4E79' } + }; + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + }; + cell.alignment = { + vertical: 'middle', + horizontal: 'center' + }; + }); + + // 데이터 추가 + data.forEach((item, index) => { + // 벤더 타입 파싱 + let vendorTypes = ''; + try { + const parsed = JSON.parse(item.techVendorType || "[]"); + vendorTypes = Array.isArray(parsed) ? parsed.join(', ') : item.techVendorType; + } catch { + vendorTypes = item.techVendorType; + } + + const row = worksheet.addRow({ + id: item.id, + vendorCode: item.vendorCode || '-', + vendorName: item.vendorName, + techVendorType: vendorTypes, + itemCode: item.itemCode, + createdAt: format(item.createdAt, 'yyyy-MM-dd HH:mm', { locale: ko }), + }); + + // 데이터 행 스타일 + row.eachCell((cell, colNumber) => { + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + }; + + if (colNumber === 1) { + // ID 컬럼 가운데 정렬 + cell.alignment = { vertical: 'middle', horizontal: 'center' }; + } else { + // 나머지 컬럼 왼쪽 정렬 + cell.alignment = { vertical: 'middle', horizontal: 'left' }; + } + }); + + // 홀수 행 배경색 + if (index % 2 === 1) { + row.eachCell((cell) => { + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFF8F9FA' } + }; + }); + } + }); + + // 요약 정보 워크시트 생성 + const summarySheet = workbook.addWorksheet('요약 정보'); + + const summaryData = [ + ['기술영업 벤더 가능 아이템 현황', ''], + ['', ''], + ['총 항목 수:', data.length.toLocaleString()], + ['고유 벤더 수:', new Set(data.map(item => item.vendorId)).size.toLocaleString()], + ['고유 아이템 수:', new Set(data.map(item => item.itemCode)).size.toLocaleString()], + ['', ''], + ['벤더 타입별 분포:', ''], + ...getVendorTypeDistribution(data), + ['', ''], + ['내보내기 일시:', format(new Date(), 'yyyy-MM-dd HH:mm:ss', { locale: ko })], + ]; + + summaryData.forEach((rowData, index) => { + const row = summarySheet.addRow(rowData); + if (index === 0) { + // 제목 스타일 + row.getCell(1).font = { bold: true, size: 16, color: { argb: 'FF1F4E79' } }; + } else if (typeof rowData[0] === 'string' && rowData[0].includes(':') && rowData[1] === '') { + // 섹션 제목 스타일 + row.getCell(1).font = { bold: true, color: { argb: 'FF1F4E79' } }; + } + }); + + summarySheet.getColumn(1).width = 30; + summarySheet.getColumn(2).width = 20; + + // 파일 생성 및 다운로드 + const buffer = await workbook.xlsx.writeBuffer(); + const blob = new Blob([buffer], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + }); + + const fileName = `기술영업_벤더_가능_아이템_${format(new Date(), 'yyyyMMdd_HHmmss')}.xlsx`; + saveAs(blob, fileName); + + return { success: true }; + } catch (error) { + console.error("Excel 내보내기 중 오류:", error); + return { + success: false, + error: error instanceof Error ? error.message : "내보내기 중 오류가 발생했습니다." + }; + } +} + +/** + * 벤더 타입별 분포 계산 + */ +function getVendorTypeDistribution(data: TechVendorPossibleItemsData[]): [string, string][] { + const typeCount = new Map(); + + data.forEach(item => { + try { + const parsed = JSON.parse(item.techVendorType || "[]"); + const types = Array.isArray(parsed) ? parsed : [item.techVendorType]; + + types.forEach(type => { + if (type) { + typeCount.set(type, (typeCount.get(type) || 0) + 1); + } + }); + } catch { + if (item.techVendorType) { + typeCount.set(item.techVendorType, (typeCount.get(item.techVendorType) || 0) + 1); + } + } + }); + + return Array.from(typeCount.entries()) + .sort((a, b) => b[1] - a[1]) + .map(([type, count]) => [` - ${type}`, count.toLocaleString()]); +} \ No newline at end of file diff --git a/lib/tech-vendor-possible-items/table/excel-import.tsx b/lib/tech-vendor-possible-items/table/excel-import.tsx new file mode 100644 index 00000000..fbf984dd --- /dev/null +++ b/lib/tech-vendor-possible-items/table/excel-import.tsx @@ -0,0 +1,220 @@ +"use client"; + +import * as ExcelJS from 'exceljs'; +import { ImportTechVendorPossibleItemData, ImportResult, importTechVendorPossibleItems } from '../service'; +import { saveAs } from "file-saver"; + +export interface ExcelImportResult extends ImportResult { + errorFileUrl?: string; +} + +/** + * Excel 파일에서 tech vendor possible items 데이터를 읽고 import + */ +export async function importTechVendorPossibleItemsFromExcel( + file: File +): Promise { + try { + const buffer = await file.arrayBuffer(); + const workbook = new ExcelJS.Workbook(); + await workbook.xlsx.load(buffer); + + // 첫 번째 워크시트에서 데이터 읽기 + const worksheet = workbook.getWorksheet(1); + if (!worksheet) { + return { + success: false, + totalRows: 0, + successCount: 0, + failedRows: [{ row: 0, error: "워크시트를 찾을 수 없습니다." }], + }; + } + + const data: ImportTechVendorPossibleItemData[] = []; + + // 데이터 행 읽기 (헤더 제외) + worksheet.eachRow((row, rowNumber) => { + if (rowNumber === 1) return; // 헤더 건너뛰기 + + const itemCode = row.getCell(1).value?.toString()?.trim(); + const vendorCode = row.getCell(2).value?.toString()?.trim(); + const vendorEmail = row.getCell(3).value?.toString()?.trim(); + + // 빈 행 건너뛰기 + if (!itemCode && !vendorCode && !vendorEmail) return; + + // 벤더 코드 또는 이메일 중 하나는 있어야 함 + if (itemCode && (vendorCode || vendorEmail)) { + data.push({ + vendorCode: vendorCode || '', + vendorEmail: vendorEmail || '', + itemCode, + }); + } else { + // 불완전한 데이터 처리 + data.push({ + vendorCode: vendorCode || '', + vendorEmail: vendorEmail || '', + itemCode: itemCode || '', + }); + } + }); + + if (data.length === 0) { + return { + success: false, + totalRows: 0, + successCount: 0, + failedRows: [{ row: 0, error: "가져올 데이터가 없습니다. 템플릿 형식을 확인하세요." }], + }; + } + + // 서비스를 통해 import 실행 + const result = await importTechVendorPossibleItems(data); + + // 실패한 항목이 있으면 오류 파일 생성 + if (result.failedRows.length > 0) { + const errorFileUrl = await createErrorExcelFile(result.failedRows); + return { + ...result, + errorFileUrl, + }; + } + + return result; + } catch (error) { + console.error("Excel import 중 오류:", error); + return { + success: false, + totalRows: 0, + successCount: 0, + failedRows: [ + { + row: 0, + error: error instanceof Error ? error.message : "파일 처리 중 오류가 발생했습니다. 파일 형식을 확인하세요.", + }, + ], + }; + } +} + +/** + * 실패한 항목들을 포함한 오류 Excel 파일 생성 + */ +async function createErrorExcelFile( + failedRows: ImportResult['failedRows'] +): Promise { + try { + const workbook = new ExcelJS.Workbook(); + const worksheet = workbook.addWorksheet('Import 오류 목록'); + + // 헤더 설정 + worksheet.columns = [ + { header: '행 번호', key: 'row', width: 10 }, + { header: '아이템코드', key: 'itemCode', width: 20 }, + { header: '벤더코드', key: 'vendorCode', width: 15 }, + { header: '벤더이메일', key: 'vendorEmail', width: 30 }, + { header: '오류 내용', key: 'error', width: 60 }, + { header: '해결 방법', key: 'solution', width: 40 }, + ]; + + // 헤더 스타일 + const headerRow = worksheet.getRow(1); + headerRow.eachCell((cell) => { + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFFF6B6B' } + }; + cell.font = { + bold: true, + color: { argb: 'FFFFFFFF' } + }; + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + }; + }); + + // 오류 데이터 추가 + failedRows.forEach((item) => { + let solution = '시스템 관리자에게 문의하세요'; + + if (item.error.includes('벤더 코드') || item.error.includes('벤더 이메일')) { + solution = '등록된 벤더 코드 또는 이메일인지 확인하세요'; + } else if (item.error.includes('아이템 코드')) { + solution = '벤더 타입에 맞는 아이템 코드인지 확인하세요'; + } else if (item.error.includes('이미 존재')) { + solution = '중복된 조합입니다. 제거하거나 건너뛰세요'; + } + + const row = worksheet.addRow({ + row: item.row, + itemCode: item.itemCode || '누락', + vendorCode: item.vendorCode || '누락', + vendorEmail: item.vendorEmail || '누락', + error: item.error, + solution: solution, + }); + + row.eachCell((cell) => { + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + }; + }); + }); + + // 안내사항 추가 + const instructionSheet = workbook.addWorksheet('오류 해결 가이드'); + const instructions = [ + ['📋 오류 유형별 해결 방법', ''], + ['', ''], + ['1. 벤더 코드/이메일 오류:', ''], + [' • 시스템에 등록된 벤더 코드 또는 이메일인지 확인', ''], + [' • 벤더 관리 메뉴에서 등록 상태 확인', ''], + [' • 벤더 코드가 없으면 벤더 이메일로 대체 가능', ''], + ['', ''], + ['2. 아이템 코드 오류:', ''], + [' • 벤더 타입과 일치하는 아이템인지 확인', ''], + [' • 조선 벤더 → item_shipbuilding 테이블', ''], + [' • 해양TOP 벤더 → item_offshore_top 테이블', ''], + [' • 해양HULL 벤더 → item_offshore_hull 테이블', ''], + ['', ''], + ['3. 중복 오류:', ''], + [' • 이미 등록된 벤더-아이템 조합', ''], + [' • 기존 데이터 확인 후 중복 제거', ''], + ['', ''], + ['📞 추가 문의: 시스템 관리자', ''], + ]; + + instructions.forEach((rowData, index) => { + const row = instructionSheet.addRow(rowData); + if (index === 0) { + row.getCell(1).font = { bold: true, size: 14, color: { argb: 'FF1F4E79' } }; + } else if (rowData[0]?.includes(':')) { + row.getCell(1).font = { bold: true, color: { argb: 'FF1F4E79' } }; + } + }); + + instructionSheet.getColumn(1).width = 50; + + // 파일 생성 및 다운로드 + const buffer = await workbook.xlsx.writeBuffer(); + const blob = new Blob([buffer], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + }); + + const fileName = `Import_오류_${new Date().toISOString().split('T')[0]}_${Date.now()}.xlsx`; + saveAs(blob, fileName); + + return fileName; + } catch (error) { + console.error("오류 파일 생성 중 오류:", error); + return ''; + } +} \ No newline at end of file diff --git a/lib/tech-vendor-possible-items/table/excel-template.tsx b/lib/tech-vendor-possible-items/table/excel-template.tsx new file mode 100644 index 00000000..70a7eddf --- /dev/null +++ b/lib/tech-vendor-possible-items/table/excel-template.tsx @@ -0,0 +1,137 @@ +import * as ExcelJS from 'exceljs'; +import { saveAs } from "file-saver"; + +/** + * 기술영업 벤더 가능 아이템 Import를 위한 Excel 템플릿 파일 생성 및 다운로드 + */ +export async function exportTechVendorPossibleItemsTemplate() { + // 워크북 생성 + const workbook = new ExcelJS.Workbook(); + workbook.creator = 'Tech Vendor Possible Items Management System'; + workbook.created = new Date(); + + // 워크시트 생성 + const worksheet = workbook.addWorksheet('기술영업 벤더 가능 아이템'); + + // 컬럼 헤더 정의 및 스타일 적용 + worksheet.columns = [ + { header: '아이템코드', key: 'itemCode', width: 20 }, + { header: '벤더코드', key: 'vendorCode', width: 15 }, + { header: '벤더이메일', key: 'vendorEmail', width: 30 }, + ]; + + // 헤더 스타일 적용 + const headerRow = worksheet.getRow(1); + headerRow.eachCell((cell) => { + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFE6F3FF' } + }; + cell.font = { + bold: true, + color: { argb: 'FF1F4E79' } + }; + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + }; + cell.alignment = { + vertical: 'middle', + horizontal: 'center' + }; + }); + + // 샘플 데이터 추가 + const sampleData = [ + { itemCode: 'ITEM001', vendorCode: 'V001', vendorEmail: '' }, + { itemCode: 'ITEM001', vendorCode: 'V002', vendorEmail: '' }, + { itemCode: 'ITEM002', vendorCode: '', vendorEmail: 'vendor@example.com' }, + { itemCode: 'ITEM002', vendorCode: 'V002', vendorEmail: '' }, + { itemCode: 'ITEM004', vendorCode: '', vendorEmail: 'vendor2@example.com' }, + ]; + + sampleData.forEach((data) => { + const row = worksheet.addRow(data); + row.eachCell((cell) => { + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + }; + cell.alignment = { + vertical: 'middle', + horizontal: 'left' + }; + }); + }); + + // 안내사항 워크시트 생성 + const guideSheet = workbook.addWorksheet('사용 가이드'); + + const guideData = [ + ['기술영업 벤더 가능 아이템 Import 템플릿', ''], + ['', ''], + ['📋 사용 방법:', ''], + ['1. "기술영업 벤더 가능 아이템" 시트에 데이터를 입력하세요', ''], + ['2. 벤더 식별: 벤더코드 또는 벤더이메일 중 하나는 반드시 입력', ''], + [' • 벤더코드가 있으면 벤더코드를 우선 사용', ''], + [' • 벤더코드가 없으면 벤더이메일로 벤더 검색', ''], + ['3. 아이템코드는 실제 존재하는 아이템코드를 사용하세요', ''], + ['4. 한 아이템코드에 여러 벤더를 매핑할 수 있습니다 (1:N 관계)', ''], + ['5. 중복된 벤더-아이템 조합은 무시됩니다', ''], + ['6. 파일 저장 후 시스템에서 업로드하세요', ''], + ['', ''], + ['⚠️ 중요 사항:', ''], + ['- 벤더코드 또는 벤더이메일 중 하나는 반드시 필요', ''], + ['- 벤더코드가 우선, 없으면 벤더이메일로 검색', ''], + ['- 중복된 벤더-아이템 조합은 건너뜁니다', ''], + ['- 오류가 있는 항목은 별도 파일로 다운로드됩니다', ''], + ['- 빈 셀이 있으면 해당 행은 무시됩니다', ''], + ['', ''], + ['💡 팁:', ''], + ['- 벤더코드만 존재하면 어떤 아이템코드든 입력 가능합니다', ''], + ['- 아이템코드는 그대로 시스템에 저장됩니다', ''], + ['', ''], + ['📞 문의사항이 있으시면 시스템 관리자에게 연락하세요.', ''], + ]; + + guideData.forEach((rowData, index) => { + const row = guideSheet.addRow(rowData); + if (index === 0) { + // 제목 스타일 + row.getCell(1).font = { bold: true, size: 16, color: { argb: 'FF1F4E79' } }; + } else if (rowData[0]?.includes(':')) { + // 섹션 제목 스타일 + row.getCell(1).font = { bold: true, color: { argb: 'FF1F4E79' } }; + } else if (rowData[0]?.includes('•') || rowData[0]?.includes('-')) { + // 리스트 아이템 스타일 + row.getCell(1).font = { color: { argb: 'FF333333' } }; + } + }); + + guideSheet.getColumn(1).width = 70; + guideSheet.getColumn(2).width = 20; + + // 파일 생성 및 다운로드 + try { + const buffer = await workbook.xlsx.writeBuffer(); + const blob = new Blob([buffer], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + }); + + const fileName = `기술영업_벤더_가능_아이템_템플릿_${new Date().toISOString().split('T')[0]}.xlsx`; + saveAs(blob, fileName); + + return { success: true }; + } catch (error) { + console.error("Excel 템플릿 생성 중 오류:", 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/possible-items-data-table.tsx b/lib/tech-vendor-possible-items/table/possible-items-data-table.tsx new file mode 100644 index 00000000..5252684b --- /dev/null +++ b/lib/tech-vendor-possible-items/table/possible-items-data-table.tsx @@ -0,0 +1,90 @@ +"use client"; + +import * as React from "react"; + +import { useDataTable } from "@/hooks/use-data-table"; +import { DataTable } from "@/components/data-table/data-table"; +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"; + +import { 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; + createdAt: Date; + updatedAt: Date; +}; +import type { DataTableAdvancedFilterField } from "@/types/table"; + +interface PossibleItemsDataTableProps { + promises: Promise<[{ + data: TechVendorPossibleItemsData[]; + pageCount: number; + totalCount: number; + }, string[]]>; +} + +export function PossibleItemsDataTable({ promises }: PossibleItemsDataTableProps) { + const [{ data, pageCount }, vendorTypes] = React.use(promises); + + const columns = React.useMemo(() => getColumns(), []); + + const filterFields: DataTableAdvancedFilterField[] = [ + { + id: "vendorCode", + label: "벤더코드", + type: "text", + }, + { + id: "vendorName", + label: "벤더명", + type: "text", + }, + { + id: "itemCode", + label: "아이템코드", + type: "text", + }, + { + id: "techVendorType", + label: "벤더타입", + type: "multi-select", + options: Array.isArray(vendorTypes) ? vendorTypes.map((type: string) => ({ + label: type, + value: type, + count: 0, + })) : [], + }, + ]; + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + pagination: { pageIndex: 0, pageSize: 10 }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }); + + return ( + <> + + + + + + + ); +} \ No newline at end of file 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 new file mode 100644 index 00000000..520c089e --- /dev/null +++ b/lib/tech-vendor-possible-items/table/possible-items-table-columns.tsx @@ -0,0 +1,140 @@ +"use client"; + +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; + itemCode: string; + createdAt: Date; + updatedAt: Date; +}; +import { format } from "date-fns"; +import { ko } from "date-fns/locale"; +import { Badge } from "@/components/ui/badge"; + +export function getColumns(): ColumnDef[] { + return [ + { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="모두 선택" + className="translate-y-[2px]" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="행 선택" + className="translate-y-[2px]" + /> + ), + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "itemCode", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const itemCode = row.getValue("itemCode") as string; + return
{itemCode}
; + }, + }, + { + accessorKey: "vendorCode", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const vendorCode = row.getValue("vendorCode") as string; + return
{vendorCode || "-"}
; + }, + }, + { + accessorKey: "vendorName", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const vendorName = row.getValue("vendorName") as string; + const vendorId = row.original.vendorId; + return ( + + {vendorName} + + ); + }, + }, + { + accessorKey: "techVendorType", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const techVendorType = row.getValue("techVendorType") as string; + + // JSON 배열인지 확인하고 파싱 + let types: string[] = []; + try { + const parsed = JSON.parse(techVendorType || "[]"); + types = Array.isArray(parsed) ? parsed : [techVendorType]; + } catch { + types = [techVendorType]; + } + + return ( +
+ {types.map((type, index) => ( + + {type} + + ))} +
+ ); + }, + filterFn: (row, id, value) => { + const techVendorType = row.getValue(id) as string; + try { + const parsed = JSON.parse(techVendorType || "[]"); + const types = Array.isArray(parsed) ? parsed : [techVendorType]; + return types.some(type => type.includes(value)); + } catch { + return techVendorType?.includes(value) || false; + } + }, + }, + + { + accessorKey: "createdAt", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const createdAt = row.getValue("createdAt") as Date; + return ( +
+ {format(createdAt, "yyyy-MM-dd HH:mm", { locale: ko })} +
+ ); + }, + }, + ]; +} \ No newline at end of file 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 new file mode 100644 index 00000000..3628f87e --- /dev/null +++ b/lib/tech-vendor-possible-items/table/possible-items-table-toolbar-actions.tsx @@ -0,0 +1,201 @@ +"use client"; + +import * as React from "react"; +import { type Table } from "@tanstack/react-table"; +import { Download, Upload, FileSpreadsheet, Trash2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useToast } from "@/hooks/use-toast"; +import { deleteTechVendorPossibleItems } from "@/lib/tech-vendor-possible-items/service"; +// Excel 함수들을 동적 import로만 사용하기 위해 타입만 import +type TechVendorPossibleItemsData = { + id: number; + vendorId: number; + vendorCode: string | null; + vendorName: string; + techVendorType: string; + itemCode: string; + createdAt: Date; + updatedAt: Date; +}; + +interface PossibleItemsTableToolbarActionsProps { + table: Table; +} + +export function PossibleItemsTableToolbarActions({ + table, +}: PossibleItemsTableToolbarActionsProps) { + const { toast } = useToast(); + const [isPending, startTransition] = React.useTransition(); + + const selectedRows = table.getFilteredSelectedRowModel().rows; + const hasSelection = selectedRows.length > 0; + + const handleDelete = () => { + if (!hasSelection) return; + + startTransition(async () => { + const selectedIds = selectedRows.map((row) => row.original.id); + + try { + const result = await deleteTechVendorPossibleItems(selectedIds); + + if (result.success) { + toast({ + title: "성공", + description: `${selectedIds.length}개의 아이템이 삭제되었습니다.`, + }); + table.toggleAllRowsSelected(false); + // 페이지 새로고침이나 데이터 다시 로드 필요 + window.location.reload(); + } else { + toast({ + title: "오류", + description: result.error || "삭제 중 오류가 발생했습니다.", + variant: "destructive", + }); + } + } catch (error) { + console.error("Delete error:", error); + toast({ + title: "오류", + description: "삭제 중 오류가 발생했습니다.", + variant: "destructive", + }); + } + }); + }; + + const handleExport = async () => { + try { + const { exportTechVendorPossibleItemsToExcel } = await import("./excel-export"); + const result = await exportTechVendorPossibleItemsToExcel(table.getFilteredRowModel().rows.map(row => row.original)); + + if (result.success) { + toast({ + title: "성공", + description: "Excel 파일이 다운로드되었습니다.", + }); + } else { + toast({ + title: "오류", + description: result.error || "내보내기 중 오류가 발생했습니다.", + variant: "destructive", + }); + } + } catch (error) { + console.error("Export error:", error); + toast({ + title: "오류", + description: "내보내기 중 오류가 발생했습니다.", + variant: "destructive", + }); + } + }; + + const handleImport = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + 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 = ""; + }; + + const handleDownloadTemplate = async () => { + try { + const { exportTechVendorPossibleItemsTemplate } = await import("./excel-template"); + const result = await exportTechVendorPossibleItemsTemplate(); + if (result.success) { + toast({ + title: "성공", + description: "템플릿 파일이 다운로드되었습니다.", + }); + } else { + toast({ + title: "오류", + description: result.error || "템플릿 다운로드 중 오류가 발생했습니다.", + variant: "destructive", + }); + } + } catch (error) { + console.error("Template download error:", error); + toast({ + title: "오류", + description: "템플릿 다운로드 중 오류가 발생했습니다.", + variant: "destructive", + }); + } + }; + + return ( +
+ {hasSelection && ( + + )} + + + + + + + + +
+ ); +} \ No newline at end of file diff --git a/lib/tech-vendor-possible-items/validations.ts b/lib/tech-vendor-possible-items/validations.ts new file mode 100644 index 00000000..1e42264b --- /dev/null +++ b/lib/tech-vendor-possible-items/validations.ts @@ -0,0 +1,45 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server" +import * as z from "zod" + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { techVendorPossibleItems } from "@/db/schema/techVendors" + +export const searchParamsTechVendorPossibleItemsCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(20), + sort: getSortingStateParser().withDefault([ + { id: "createdAt", desc: true }, + ]), + + // advanced filter + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), + + // 추가 필터 (기존 호환성) + vendorCode: parseAsString.withDefault(""), + vendorName: parseAsString.withDefault(""), + itemCode: parseAsString.withDefault(""), + vendorType: parseAsString.withDefault(""), +}) + +export const createTechVendorPossibleItemSchema = z.object({ + vendorId: z.number().min(1, "벤더를 선택해주세요"), + itemCode: z.string().min(1, "아이템 코드를 입력해주세요"), +}) + +export const updateTechVendorPossibleItemSchema = createTechVendorPossibleItemSchema.extend({ + id: z.number(), +}) + +export type CreateTechVendorPossibleItemSchema = z.infer +export type UpdateTechVendorPossibleItemSchema = z.infer + +export type GetTechVendorPossibleItemsSchema = Awaited> \ No newline at end of file -- cgit v1.2.3