From 14f61e24947fb92dd71ec0a7196a6e815f8e66da Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 21 Jul 2025 07:54:26 +0000 Subject: (최겸)기술영업 RFQ 담당자 초대, 요구사항 반영 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/tech-vendor-possible-items/repository.ts | 123 +++++- lib/tech-vendor-possible-items/service.ts | 298 ++++++++++++-- .../table/add-possible-item-dialog.tsx | 450 +++++++++++++++++++++ .../table/delete-possible-items-dialog.tsx | 175 ++++++++ .../table/excel-export.tsx | 106 ++++- .../table/excel-import.tsx | 130 ++++-- .../table/excel-template.tsx | 151 +++++-- .../table/possible-items-data-table.tsx | 26 +- .../table/possible-items-table-columns.tsx | 147 ++++++- .../table/possible-items-table-toolbar-actions.tsx | 86 ++-- lib/tech-vendor-possible-items/validations.ts | 16 +- 11 files changed, 1521 insertions(+), 187 deletions(-) create mode 100644 lib/tech-vendor-possible-items/table/add-possible-item-dialog.tsx create mode 100644 lib/tech-vendor-possible-items/table/delete-possible-items-dialog.tsx (limited to 'lib/tech-vendor-possible-items') diff --git a/lib/tech-vendor-possible-items/repository.ts b/lib/tech-vendor-possible-items/repository.ts index b2588395..5c1487b5 100644 --- a/lib/tech-vendor-possible-items/repository.ts +++ b/lib/tech-vendor-possible-items/repository.ts @@ -1,16 +1,17 @@ -import { eq, desc, count } from "drizzle-orm"; +import { eq, desc, count, SQL, sql, and, or, ilike } from "drizzle-orm"; import { techVendors, techVendorPossibleItems } from "@/db/schema/techVendors"; +import type { PgTransaction } from "drizzle-orm/pg-core"; /** * 기술영업 벤더 가능 아이템 목록 조회 (조인 포함) */ export async function selectTechVendorPossibleItemsWithJoin( - tx: any, - where: any, - orderBy: any[], + tx: PgTransaction, + where: SQL | undefined, + orderBy: SQL[], offset: number, limit: number ) { @@ -18,10 +19,17 @@ export async function selectTechVendorPossibleItemsWithJoin( .select({ id: techVendorPossibleItems.id, vendorId: techVendorPossibleItems.vendorId, - vendorCode: techVendors.vendorCode, + vendorCode: techVendorPossibleItems.vendorCode, // 테이블에서 직접 조회 vendorName: techVendors.vendorName, + vendorEmail: techVendorPossibleItems.vendorEmail, // 테이블에서 직접 조회 techVendorType: techVendors.techVendorType, + vendorStatus: techVendors.status, itemCode: techVendorPossibleItems.itemCode, + // 새로운 스키마: 테이블에서 직접 조회 + workType: techVendorPossibleItems.workType, + shipTypes: techVendorPossibleItems.shipTypes, + itemList: techVendorPossibleItems.itemList, + subItemList: techVendorPossibleItems.subItemList, createdAt: techVendorPossibleItems.createdAt, updatedAt: techVendorPossibleItems.updatedAt, }) @@ -36,7 +44,10 @@ export async function selectTechVendorPossibleItemsWithJoin( /** * 기술영업 벤더 가능 아이템 총 개수 조회 (조인 포함) */ -export async function countTechVendorPossibleItemsWithJoin(tx: any, where?: any) { +export async function countTechVendorPossibleItemsWithJoin( + tx: PgTransaction, + where?: SQL | undefined +) { const [result] = await tx .select({ count: count() }) .from(techVendorPossibleItems) @@ -44,4 +55,102 @@ export async function countTechVendorPossibleItemsWithJoin(tx: any, where?: any) .where(where); return result.count; -} \ No newline at end of file +} + +/** + * 새로운 필드들을 위한 그룹별 통계 조회 + */ +export async function getTechVendorPossibleItemsGroupStats( + tx: PgTransaction, + groupBy: 'workType' | 'shipTypes' | 'vendorCode' | 'vendorEmail', + where?: SQL | undefined +) { + const groupField = techVendorPossibleItems[groupBy]; + + return await tx + .select({ + groupValue: groupField, + count: count(), + vendorCount: sql`COUNT(DISTINCT ${techVendorPossibleItems.vendorId})`.as('vendorCount'), + itemCount: sql`COUNT(DISTINCT ${techVendorPossibleItems.itemCode})`.as('itemCount'), + }) + .from(techVendorPossibleItems) + .innerJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id)) + .where(where) + .groupBy(groupField) + .orderBy(desc(count())); +} + +/** + * 공종별 통계 조회 + */ +export async function getWorkTypeStats( + tx: PgTransaction, + where?: SQL | undefined +) { + return await tx + .select({ + workType: techVendorPossibleItems.workType, + count: count(), + vendorCount: sql`COUNT(DISTINCT ${techVendorPossibleItems.vendorId})`.as('vendorCount'), + itemCount: sql`COUNT(DISTINCT ${techVendorPossibleItems.itemCode})`.as('itemCount'), + }) + .from(techVendorPossibleItems) + .innerJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id)) + .where(where) + .groupBy(techVendorPossibleItems.workType) + .orderBy(desc(count())); +} + +/** + * 선종별 통계 조회 + */ +export async function getShipTypeStats( + tx: PgTransaction, + where?: SQL | undefined +) { + return await tx + .select({ + shipTypes: techVendorPossibleItems.shipTypes, + count: count(), + vendorCount: sql`COUNT(DISTINCT ${techVendorPossibleItems.vendorId})`.as('vendorCount'), + itemCount: sql`COUNT(DISTINCT ${techVendorPossibleItems.itemCode})`.as('itemCount'), + }) + .from(techVendorPossibleItems) + .innerJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id)) + .where(where) + .groupBy(techVendorPossibleItems.shipTypes) + .orderBy(desc(count())); +} + +/** + * 벤더별 통계 조회 + */ +export async function getVendorStats( + tx: PgTransaction, + where?: SQL | undefined +) { + return await tx + .select({ + vendorId: techVendorPossibleItems.vendorId, + vendorCode: techVendorPossibleItems.vendorCode, + vendorName: techVendors.vendorName, + vendorEmail: techVendorPossibleItems.vendorEmail, + itemCount: count(), + distinctItemCount: sql`COUNT(DISTINCT ${techVendorPossibleItems.itemCode})`.as('distinctItemCount'), + workTypeCount: sql`COUNT(DISTINCT ${techVendorPossibleItems.workType})`.as('workTypeCount'), + shipTypeCount: sql`COUNT(DISTINCT ${techVendorPossibleItems.shipTypes})`.as('shipTypeCount'), + latestUpdate: sql`MAX(${techVendorPossibleItems.updatedAt})`.as('latestUpdate'), + }) + .from(techVendorPossibleItems) + .innerJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id)) + .where(where) + .groupBy( + techVendorPossibleItems.vendorId, + techVendorPossibleItems.vendorCode, + techVendors.vendorName, + techVendorPossibleItems.vendorEmail + ) + .orderBy(desc(count())); +} + diff --git a/lib/tech-vendor-possible-items/service.ts b/lib/tech-vendor-possible-items/service.ts index efe9be51..c630e33a 100644 --- a/lib/tech-vendor-possible-items/service.ts +++ b/lib/tech-vendor-possible-items/service.ts @@ -1,5 +1,5 @@ "use server"; // Next.js 서버 액션에서 직접 import하려면 (선택) -import { eq, and, inArray, desc, asc, or, ilike } from "drizzle-orm"; +import { eq, and, inArray, desc, asc, or, ilike, isNull } from "drizzle-orm"; import db from "@/db/db"; import { techVendors, @@ -9,9 +9,9 @@ import { itemShipbuilding, itemOffshoreTop, itemOffshoreHull } from "@/db/schema import { unstable_cache } from "@/lib/unstable-cache"; import { filterColumns } from "@/lib/filter-columns"; import type { GetTechVendorPossibleItemsSchema } from "./validations"; -import { - selectTechVendorPossibleItemsWithJoin, - countTechVendorPossibleItemsWithJoin +import { + selectTechVendorPossibleItemsWithJoin, + countTechVendorPossibleItemsWithJoin, } from "./repository"; export interface TechVendorPossibleItemsData { @@ -19,21 +19,34 @@ export interface TechVendorPossibleItemsData { vendorId: number; vendorCode: string | null; vendorName: string; + vendorEmail: string | null; techVendorType: string; itemCode: string; + workType: string | null; + shipTypes: string | null; + itemList: string | null; + subItemList: string | null; createdAt: Date; updatedAt: Date; } export interface CreateTechVendorPossibleItemData { - vendorId: number; - itemCode: string; + vendorId: number; // 필수: 벤더 ID (Add Dialog에서 벤더 선택 시 사용) + itemCode: string; // 필수: 아이템 코드 + workType?: string | null; // 공종 (아이템에서 가져온 정보) + shipTypes?: string | null; // 선종 (아이템에서 가져온 정보) + itemList?: string | null; // 아이템리스트 (아이템에서 가져온 정보) + subItemList?: string | null; // 서브아이템리스트 (아이템에서 가져온 정보) } export interface ImportTechVendorPossibleItemData { - vendorCode: string; - vendorEmail?: string; - itemCode: string; + vendorCode?: string; + vendorEmail: string; // 필수: 벤더 이메일 + itemCode: string; // 필수: 아이템 코드 + workType?: string; + shipTypes?: string; + itemList?: string; + subItemList?: string; } export interface ImportResult { @@ -45,7 +58,11 @@ export interface ImportResult { error: string; vendorCode?: string; vendorEmail?: string; - itemCode?: string; + itemCode?: string; + workType?: string; + shipTypes?: string; + itemList?: string; + subItemList?: string; }[]; } @@ -74,14 +91,19 @@ export async function getTechVendorPossibleItems(input: GetTechVendorPossibleIte globalWhere = or( ilike(techVendors.vendorCode, s), ilike(techVendors.vendorName, s), + ilike(techVendorPossibleItems.vendorEmail, s), ilike(techVendorPossibleItems.itemCode, s), + ilike(techVendorPossibleItems.workType, s), + ilike(techVendorPossibleItems.shipTypes, s), + ilike(techVendorPossibleItems.itemList, s), + ilike(techVendorPossibleItems.subItemList, s), ); } // 기존 호환성을 위한 개별 필터들 const legacyFilters = []; if (input.vendorCode) { - legacyFilters.push(ilike(techVendors.vendorCode, `%${input.vendorCode}%`)); + legacyFilters.push(ilike(techVendorPossibleItems.vendorCode, `%${input.vendorCode}%`)); } if (input.vendorName) { legacyFilters.push(ilike(techVendors.vendorName, `%${input.vendorName}%`)); @@ -225,13 +247,13 @@ export async function getTechVendorPossibleItems(input: GetTechVendorPossibleIte // } /** - * tech vendor possible item 생성 (간단 버전) + * tech vendor possible item 생성 (Add Dialog용 - vendorId 기반) */ export async function createTechVendorPossibleItem( data: CreateTechVendorPossibleItemData ): Promise<{ success: boolean; error?: string }> { try { - // 벤더 존재 여부만 확인 + // 벤더 ID로 벤더 조회 const vendor = await db .select() .from(techVendors) @@ -242,14 +264,20 @@ export async function createTechVendorPossibleItem( return { success: false, error: "벤더를 찾을 수 없습니다." }; } - // 중복 체크 + // 중복 체크 (벤더 + 아이템코드 + 공종 + 선종 조합) const existing = await db .select() .from(techVendorPossibleItems) .where( and( eq(techVendorPossibleItems.vendorId, data.vendorId), - eq(techVendorPossibleItems.itemCode, data.itemCode) + eq(techVendorPossibleItems.itemCode, data.itemCode), + data.workType + ? eq(techVendorPossibleItems.workType, data.workType) + : isNull(techVendorPossibleItems.workType), + data.shipTypes + ? eq(techVendorPossibleItems.shipTypes, data.shipTypes) + : isNull(techVendorPossibleItems.shipTypes) ) ) .limit(1); @@ -258,10 +286,16 @@ export async function createTechVendorPossibleItem( return { success: false, error: "이미 존재하는 벤더-아이템 조합입니다." }; } - // 아이템 코드 검증 없이 바로 삽입 + // 새로운 아이템 생성 (선택한 아이템의 정보를 그대로 저장) await db.insert(techVendorPossibleItems).values({ - vendorId: data.vendorId, + vendorId: vendor[0].id, + vendorCode: vendor[0].vendorCode, + vendorEmail: vendor[0].email, itemCode: data.itemCode, + workType: data.workType, + shipTypes: data.shipTypes, + itemList: data.itemList, + subItemList: data.subItemList, }); return { success: true }; @@ -419,7 +453,7 @@ export async function getItemByCode(itemCode: string) { } /** - * Import 기능: 벤더코드와 아이템코드를 통한 batch insert (간단 버전) + * Import 기능: 벤더이메일과 아이템정보를 통한 batch insert (새로운 스키마 버전) */ export async function importTechVendorPossibleItems( data: ImportTechVendorPossibleItemData[] @@ -436,39 +470,55 @@ export async function importTechVendorPossibleItems( const rowNumber = i + 1; try { - // 벤더 코드 또는 이메일로 벤더 찾기 + // 벤더 이메일로 벤더 찾기 (필수) let vendor = null; - if (row.vendorCode && row.vendorCode.trim()) { - // 벤더 코드가 있으면 먼저 벤더 코드로 검색 - vendor = await getTechVendorByCode(row.vendorCode); - } else if (row.vendorEmail && row.vendorEmail.trim()) { - // 벤더 코드가 없으면 이메일로 검색 + if (row.vendorEmail && row.vendorEmail.trim()) { vendor = await getTechVendorByEmail(row.vendorEmail); + } else { + result.failedRows.push({ + row: rowNumber, + error: "벤더 이메일은 필수입니다.", + vendorCode: row.vendorCode, + vendorEmail: row.vendorEmail, + itemCode: row.itemCode, + workType: row.workType, + shipTypes: row.shipTypes, + itemList: row.itemList, + subItemList: row.subItemList, + }); + continue; } if (!vendor) { - const identifier = row.vendorCode ? `벤더 코드 '${row.vendorCode}'` : - row.vendorEmail ? `벤더 이메일 '${row.vendorEmail}'` : - '벤더 코드 또는 이메일'; result.failedRows.push({ row: rowNumber, - error: `${identifier}을(를) 찾을 수 없습니다.`, + error: `벤더 이메일 '${row.vendorEmail}'을(를) 찾을 수 없습니다.`, vendorCode: row.vendorCode, vendorEmail: row.vendorEmail, itemCode: row.itemCode, + workType: row.workType, + shipTypes: row.shipTypes, + itemList: row.itemList, + subItemList: row.subItemList, }); continue; } - // 중복 체크 + // 중복 체크 (벤더 + 아이템코드 + 공종 + 선종 조합) const existing = await db .select() .from(techVendorPossibleItems) .where( and( eq(techVendorPossibleItems.vendorId, vendor.id), - eq(techVendorPossibleItems.itemCode, row.itemCode) + eq(techVendorPossibleItems.itemCode, row.itemCode), + row.workType + ? eq(techVendorPossibleItems.workType, row.workType) + : isNull(techVendorPossibleItems.workType), + row.shipTypes + ? eq(techVendorPossibleItems.shipTypes, row.shipTypes) + : isNull(techVendorPossibleItems.shipTypes) ) ) .limit(1); @@ -480,14 +530,24 @@ export async function importTechVendorPossibleItems( vendorCode: row.vendorCode, vendorEmail: row.vendorEmail, itemCode: row.itemCode, + workType: row.workType, + shipTypes: row.shipTypes, + itemList: row.itemList, + subItemList: row.subItemList, }); continue; } - // 아이템 코드 검증 없이 바로 삽입 + // 새로운 아이템 생성 await db.insert(techVendorPossibleItems).values({ vendorId: vendor.id, + vendorCode: vendor.vendorCode, + vendorEmail: vendor.email, itemCode: row.itemCode, + workType: row.workType || null, + shipTypes: row.shipTypes || null, + itemList: row.itemList || null, + subItemList: row.subItemList || null, }); result.successCount++; @@ -498,6 +558,10 @@ export async function importTechVendorPossibleItems( vendorCode: row.vendorCode, vendorEmail: row.vendorEmail, itemCode: row.itemCode, + workType: row.workType, + shipTypes: row.shipTypes, + itemList: row.itemList, + subItemList: row.subItemList, }); } } @@ -580,4 +644,174 @@ export async function getUniqueTechVendorTypes(): Promise { // 오류 발생시 기본 벤더 타입 반환 return ["조선", "해양TOP", "해양HULL"]; } -} \ No newline at end of file +} + +/** + * 벤더 타입에 따른 아이템 목록 조회 + */ +export async function getItemsByVendorType(vendorTypes: string): Promise<{ + itemCode: string; + itemList: string | null; + workType: string | null; + shipTypes?: string | null; + subItemList?: string | null; +}[]> { + try { + // 벤더 타입 파싱 개선 + let types: string[] = []; + if (!vendorTypes) { + return []; + } + + if (vendorTypes.startsWith('[') && vendorTypes.endsWith(']')) { + // JSON 배열 형태 + try { + const parsed = JSON.parse(vendorTypes); + types = Array.isArray(parsed) ? parsed.filter(Boolean) : [vendorTypes]; + } catch { + types = [vendorTypes]; + } + } else if (vendorTypes.includes(',')) { + // 콤마로 구분된 문자열 + types = vendorTypes.split(',').map(t => t.trim()).filter(Boolean); + } else { + // 단일 문자열 + types = [vendorTypes.trim()].filter(Boolean); + } + // 벤더 타입 정렬 - 조선 > 해양TOP > 해양HULL 순 + const typeOrder = ["조선", "해양TOP", "해양HULL"]; + types.sort((a, b) => { + const indexA = typeOrder.indexOf(a); + const indexB = typeOrder.indexOf(b); + + // 정의된 순서에 있는 경우 우선순위 적용 + if (indexA !== -1 && indexB !== -1) { + return indexA - indexB; + } + // 정의된 순서에 없는 경우 마지막에 배치하고 알파벳 순으로 정렬 + if (indexA !== -1) return -1; + if (indexB !== -1) return 1; + return a.localeCompare(b); + }); + + const allItems: any[] = []; + + // 각 벤더 타입에 따라 해당 아이템 테이블에서 조회 + for (const type of types) { + switch (type) { + case "조선": + const shipItems = await db + .select({ + itemCode: itemShipbuilding.itemCode, + itemList: itemShipbuilding.itemList, + workType: itemShipbuilding.workType, + shipTypes: itemShipbuilding.shipTypes, + }) + .from(itemShipbuilding); + allItems.push(...shipItems); + break; + + case "해양TOP": + const topItems = await db + .select({ + itemCode: itemOffshoreTop.itemCode, + itemList: itemOffshoreTop.itemList, + workType: itemOffshoreTop.workType, + subItemList: itemOffshoreTop.subItemList, + }) + .from(itemOffshoreTop); + allItems.push(...topItems); + break; + + case "해양HULL": + const hullItems = await db + .select({ + itemCode: itemOffshoreHull.itemCode, + itemList: itemOffshoreHull.itemList, + workType: itemOffshoreHull.workType, + subItemList: itemOffshoreHull.subItemList, + }) + .from(itemOffshoreHull); + allItems.push(...hullItems); + break; + } + } + // // 중복 제거 (itemCode 기준) + // const uniqueItems = allItems.filter((item, index, self) => + // index === self.findIndex(i => i.itemCode === item.itemCode) + // ); + + // const finalItems = uniqueItems.filter(item => item.itemCode); // itemCode가 있는 것만 반환 + // console.log("Final items after deduplication and filtering:", finalItems.length); + + return allItems; + } catch (error) { + console.error("Error fetching items by vendor type:", error); + return []; + } +} + +/** + * Excel Export 기능: 기술영업 벤더 가능 아이템 목록 내보내기 + */ +export async function exportTechVendorPossibleItemsToExcel(): Promise<{ + success: boolean; + data?: Array<{ + 벤더코드: string | null; + 벤더명: string; + 벤더이메일: string | null; + 벤더타입: string; + 아이템코드: string; + 공종: string | null; + 선종: string | null; + 아이템리스트: string | null; + 서브아이템리스트: string | null; + 생성일: string; + }>; + error?: string; +}> { + try { + // 모든 데이터 조회 (페이지네이션 없이) + const allData = await db + .select({ + vendorCode: techVendorPossibleItems.vendorCode, + vendorName: techVendors.vendorName, + vendorEmail: techVendorPossibleItems.vendorEmail, + techVendorType: techVendors.techVendorType, + itemCode: techVendorPossibleItems.itemCode, + workType: techVendorPossibleItems.workType, + shipTypes: techVendorPossibleItems.shipTypes, + itemList: techVendorPossibleItems.itemList, + subItemList: techVendorPossibleItems.subItemList, + createdAt: techVendorPossibleItems.createdAt, + }) + .from(techVendorPossibleItems) + .innerJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id)) + .orderBy(desc(techVendorPossibleItems.createdAt)); + + // Excel 형태로 변환 + const excelData = allData.map(item => ({ + 벤더코드: item.vendorCode, + 벤더명: item.vendorName, + 벤더이메일: item.vendorEmail, + 벤더타입: item.techVendorType, + 아이템코드: item.itemCode, + 공종: item.workType, + 선종: item.shipTypes, + 아이템리스트: item.itemList, + 서브아이템리스트: item.subItemList, + 생성일: item.createdAt.toISOString().split('T')[0], // YYYY-MM-DD 형식 + })); + + return { + success: true, + data: excelData, + }; + } catch (error) { + console.error("Error exporting tech vendor possible items:", error); + return { + success: false, + error: error instanceof Error ? error.message : "내보내기 중 오류가 발생했습니다.", + }; + } +} diff --git a/lib/tech-vendor-possible-items/table/add-possible-item-dialog.tsx b/lib/tech-vendor-possible-items/table/add-possible-item-dialog.tsx new file mode 100644 index 00000000..cdce60af --- /dev/null +++ b/lib/tech-vendor-possible-items/table/add-possible-item-dialog.tsx @@ -0,0 +1,450 @@ +"use client"; + +import * as React from "react"; +import { Search, Plus, X } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { useToast } from "@/hooks/use-toast"; +import { + getAllTechVendors, + createTechVendorPossibleItem, + getItemsByVendorType +} from "@/lib/tech-vendor-possible-items/service"; + +interface TechVendor { + id: number; + vendorCode: string | null; + vendorName: string; + techVendorType: string; +} + +interface ItemData { + itemCode: string; + itemList: string | null; + workType: string | null; + shipTypes?: string | null; + subItemList?: string | null; +} + +interface AddPossibleItemDialogProps { + children?: React.ReactNode; + onSuccess?: () => void; +} + +export function AddPossibleItemDialog({ + children, + onSuccess +}: AddPossibleItemDialogProps) { + const { toast } = useToast(); + const [open, setOpen] = React.useState(false); + + // 벤더 관련 상태 + const [vendors, setVendors] = React.useState([]); + const [filteredVendors, setFilteredVendors] = React.useState([]); + const [vendorSearch, setVendorSearch] = React.useState(""); + const [selectedVendor, setSelectedVendor] = React.useState(null); + + // 아이템 관련 상태 + const [items, setItems] = React.useState([]); + const [filteredItems, setFilteredItems] = React.useState([]); + const [itemSearch, setItemSearch] = React.useState(""); + const [selectedItems, setSelectedItems] = React.useState([]); + + const [isLoading, setIsLoading] = React.useState(false); + + // 벤더 목록 로드 + React.useEffect(() => { + if (open) { + loadVendors(); + } + }, [open]); + + // 벤더 검색 필터링 + React.useEffect(() => { + if (!vendorSearch) { + setFilteredVendors(vendors); + } else { + const filtered = vendors.filter(vendor => + vendor.vendorName.toLowerCase().includes(vendorSearch.toLowerCase()) || + vendor.vendorCode?.toLowerCase().includes(vendorSearch.toLowerCase()) + ); + setFilteredVendors(filtered); + } + }, [vendors, vendorSearch]); + + // 아이템 검색 필터링 + React.useEffect(() => { + if (!itemSearch) { + setFilteredItems(items); + } else { + const filtered = items.filter(item => + item.itemCode.toLowerCase().includes(itemSearch.toLowerCase()) || + item.itemList?.toLowerCase().includes(itemSearch.toLowerCase()) || + item.workType?.toLowerCase().includes(itemSearch.toLowerCase()) + ); + setFilteredItems(filtered); + } + }, [items, itemSearch]); + + const loadVendors = async () => { + try { + setIsLoading(true); + const vendorData = await getAllTechVendors(); + setVendors(vendorData); + } catch (error) { + console.error("Failed to load vendors:", error); + toast({ + title: "오류", + description: "벤더 목록을 불러오는데 실패했습니다.", + variant: "destructive", + }); + } finally { + setIsLoading(false); + } + }; + + const loadItemsByVendorType = async (vendorTypes: string) => { + try { + setIsLoading(true); + console.log("Loading items for vendor types:", vendorTypes); + const itemData = await getItemsByVendorType(vendorTypes); + console.log("Loaded items:", itemData.length, itemData); + setItems(itemData); + } catch (error) { + console.error("Failed to load items:", error); + toast({ + title: "오류", + description: "아이템 목록을 불러오는데 실패했습니다.", + variant: "destructive", + }); + } finally { + setIsLoading(false); + } + }; + + const handleVendorSelect = (vendor: TechVendor) => { + setSelectedVendor(vendor); + setSelectedItems([]); // 벤더 변경시 선택된 아이템 초기화 + loadItemsByVendorType(vendor.techVendorType); + }; + + const handleItemToggle = (item: ItemData) => { + setSelectedItems(prev => { + const isSelected = prev.some(i => i.itemCode === item.itemCode); + if (isSelected) { + return prev.filter(i => i.itemCode !== item.itemCode); + } else { + return [...prev, item]; + } + }); + }; + + const handleSubmit = async () => { + if (!selectedVendor || selectedItems.length === 0) return; + + try { + setIsLoading(true); + let successCount = 0; + let errorCount = 0; + + for (const item of selectedItems) { + const result = await createTechVendorPossibleItem({ + vendorId: selectedVendor.id, + itemCode: item.itemCode, + workType: item.workType, + shipTypes: item.shipTypes, + itemList: item.itemList, + subItemList: item.subItemList, + }); + + if (result.success) { + successCount++; + } else { + errorCount++; + } + } + + if (successCount > 0) { + toast({ + title: "성공", + description: `${successCount}개의 아이템이 추가되었습니다.${errorCount > 0 ? ` (${errorCount}개 실패)` : ""}`, + }); + + handleClose(); + onSuccess?.(); + } else { + toast({ + title: "오류", + description: "아이템 추가에 실패했습니다.", + variant: "destructive", + }); + } + } catch (error) { + console.error("Failed to add items:", error); + toast({ + title: "오류", + description: "아이템 추가 중 오류가 발생했습니다.", + variant: "destructive", + }); + } finally { + setIsLoading(false); + } + }; + + const handleClose = () => { + setOpen(false); + setTimeout(() => { + setSelectedVendor(null); + setSelectedItems([]); + setVendorSearch(""); + setItemSearch(""); + setVendors([]); + setItems([]); + setFilteredVendors([]); + setFilteredItems([]); + }, 200); + }; + + const parseVendorTypes = (vendorType: string): string[] => { + if (!vendorType) return []; + + // JSON 배열 형태인지 확인 + if (vendorType.startsWith('[') && vendorType.endsWith(']')) { + try { + const parsed = JSON.parse(vendorType); + return Array.isArray(parsed) ? parsed.filter(Boolean) : [vendorType]; + } catch { + return [vendorType]; + } + } + + // 콤마로 구분된 문자열인지 확인 + if (vendorType.includes(',')) { + return vendorType.split(',').map(t => t.trim()).filter(Boolean); + } + + // 단일 문자열 + return [vendorType.trim()].filter(Boolean); + }; + + return ( + + + {children || ( + + )} + + + + + 벤더별 아이템 추가 + + + 왼쪽에서 벤더를 선택하고, 오른쪽에서 아이템을 선택하세요. + + + +
+
+ {/* 왼쪽: 벤더 선택/표시 */} +
+ {!selectedVendor ? ( + <> +
+ +
+ setVendorSearch(e.target.value)} + className="pl-10" + /> +
+
+ +
+
+ {isLoading ? ( +
로딩 중...
+ ) : filteredVendors.length === 0 ? ( +
+ 검색 결과가 없습니다. +
+ ) : ( + filteredVendors.map((vendor) => ( +
handleVendorSelect(vendor)} + > +
{vendor.vendorName}
+
+ {vendor.vendorCode} +
+
+ {parseVendorTypes(vendor.techVendorType).map((type, index) => ( + + {type} + + ))} +
+
+ )) + )} +
+
+ + ) : ( +
+
+ + +
+
+
{selectedVendor?.vendorName}
+
+ {selectedVendor?.vendorCode} +
+
+ {selectedVendor && parseVendorTypes(selectedVendor.techVendorType).map((type, index) => ( + + {type} + + ))} +
+
+
+ )} +
+ + + + {/* 오른쪽: 아이템 선택 */} +
+ {selectedVendor ? ( + <> + + + +
+ setItemSearch(e.target.value)} + className="pl-10" + /> +
+ + + {selectedItems.length > 0 && ( +
+ +
+ {selectedItems.map((item) => ( + + {item.itemCode} + { + e.stopPropagation(); + handleItemToggle(item); + }} + /> + + ))} +
+
+ )} + +
+
+ {isLoading ? ( +
아이템 로딩 중...
+ ) : filteredItems.length === 0 && items.length === 0 ? ( +
+ 해당 벤더 타입에 대한 아이템이 없습니다. +
+ ) : filteredItems.length === 0 ? ( +
+ 검색 결과가 없습니다. +
+ ) : ( + filteredItems.map((item) => { + const isSelected = selectedItems.some(i => i.itemCode === item.itemCode); + return ( +
handleItemToggle(item)} + > +
{item.itemCode}
+
+ {item.itemList || "-"} +
+
+ 공종: {item.workType || "-"} + {item.shipTypes && 선종: {item.shipTypes}} + {item.subItemList && 서브아이템: {item.subItemList}} +
+
+ ); + }) + )} +
+
+ + ) : ( +
+ 왼쪽에서 벤더를 선택하세요. +
+ )} +
+
+
+ +
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/lib/tech-vendor-possible-items/table/delete-possible-items-dialog.tsx b/lib/tech-vendor-possible-items/table/delete-possible-items-dialog.tsx new file mode 100644 index 00000000..6b1c7775 --- /dev/null +++ b/lib/tech-vendor-possible-items/table/delete-possible-items-dialog.tsx @@ -0,0 +1,175 @@ +"use client"; + +import * as React from "react"; +import { Trash2, AlertTriangle } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { useToast } from "@/hooks/use-toast"; +import { deleteTechVendorPossibleItems } from "@/lib/tech-vendor-possible-items/service"; + +interface TechVendorPossibleItemsData { + id: number; + vendorId: number; + vendorCode: string | null; + vendorName: string; + techVendorType: string; + itemCode: string; + itemList: string | null; + workType: string | null; + shipTypes: string | null; + subItemList: string | null; + createdAt: Date; + updatedAt: Date; +} + +interface DeletePossibleItemsDialogProps { + selectedItems: TechVendorPossibleItemsData[]; + children?: React.ReactNode; + onSuccess?: () => void; +} + +export function DeletePossibleItemsDialog({ + selectedItems, + children, + onSuccess +}: DeletePossibleItemsDialogProps) { + const { toast } = useToast(); + const [open, setOpen] = React.useState(false); + const [isLoading, setIsLoading] = React.useState(false); + + const handleDelete = async () => { + if (selectedItems.length === 0) return; + + try { + setIsLoading(true); + const selectedIds = selectedItems.map(item => item.id); + + const result = await deleteTechVendorPossibleItems(selectedIds); + + if (result.success) { + toast({ + title: "성공", + description: `${selectedIds.length}개의 아이템이 삭제되었습니다.`, + }); + + setOpen(false); + onSuccess?.(); + } else { + toast({ + title: "오류", + description: result.error || "삭제 중 오류가 발생했습니다.", + variant: "destructive", + }); + } + } catch (error) { + console.error("Delete error:", error); + toast({ + title: "오류", + description: "삭제 중 오류가 발생했습니다.", + variant: "destructive", + }); + } finally { + setIsLoading(false); + } + }; + + const parseVendorTypes = (vendorType: string): string[] => { + try { + return JSON.parse(vendorType); + } catch { + return vendorType.split(',').map(t => t.trim()); + } + }; + + return ( + + + {children || ( + + )} + + + + + + 아이템 삭제 확인 + + + 선택한 {selectedItems.length}개의 벤더-아이템 조합을 삭제하시겠습니까? + 이 작업은 되돌릴 수 없습니다. + + + +
+
삭제될 아이템 목록:
+ +
+ {selectedItems.map((item) => ( +
+
+
+
+ {item.vendorName} ({item.vendorCode}) +
+
+ 아이템코드: {item.itemCode} +
+ {item.itemList && ( +
+ 아이템리스트: {item.itemList} +
+ )} + {item.workType && ( +
+ 공종: {item.workType} +
+ )} +
+
+ {parseVendorTypes(item.techVendorType).map((type, index) => ( + + {type} + + ))} +
+
+
+ ))} +
+
+
+ + + + + +
+
+ ); +} \ No newline at end of file diff --git a/lib/tech-vendor-possible-items/table/excel-export.tsx b/lib/tech-vendor-possible-items/table/excel-export.tsx index d3c4dea5..e6fcceed 100644 --- a/lib/tech-vendor-possible-items/table/excel-export.tsx +++ b/lib/tech-vendor-possible-items/table/excel-export.tsx @@ -5,7 +5,7 @@ import { format } from 'date-fns'; import { ko } from 'date-fns/locale'; /** - * 기술영업 벤더 가능 아이템 데이터를 Excel 파일로 내보내기 + * 기술영업 벤더 가능 아이템 데이터를 Excel 파일로 내보내기 (새로운 스키마 버전) */ export async function exportTechVendorPossibleItemsToExcel( data: TechVendorPossibleItemsData[] @@ -19,13 +19,18 @@ export async function exportTechVendorPossibleItemsToExcel( // 워크시트 생성 const worksheet = workbook.addWorksheet('기술영업 벤더 가능 아이템'); - // 컬럼 헤더 정의 및 스타일 적용 + // 컬럼 헤더 정의 및 스타일 적용 (새로운 스키마에 맞춰) worksheet.columns = [ { header: '번호', key: 'id', width: 10 }, { header: '벤더코드', key: 'vendorCode', width: 15 }, { header: '벤더명', key: 'vendorName', width: 25 }, + { header: '벤더이메일', key: 'vendorEmail', width: 30 }, { header: '벤더타입', key: 'techVendorType', width: 20 }, { header: '아이템코드', key: 'itemCode', width: 20 }, + { header: '공종', key: 'workType', width: 15 }, + { header: '선종', key: 'shipTypes', width: 20 }, + { header: '아이템리스트', key: 'itemList', width: 30 }, + { header: '서브아이템리스트', key: 'subItemList', width: 30 }, { header: '생성일시', key: 'createdAt', width: 20 }, ]; @@ -53,7 +58,7 @@ export async function exportTechVendorPossibleItemsToExcel( }; }); - // 데이터 추가 + // 데이터 추가 (새로운 스키마 필드들 포함) data.forEach((item, index) => { // 벤더 타입 파싱 let vendorTypes = ''; @@ -68,8 +73,13 @@ export async function exportTechVendorPossibleItemsToExcel( id: item.id, vendorCode: item.vendorCode || '-', vendorName: item.vendorName, + vendorEmail: item.vendorEmail || '-', techVendorType: vendorTypes, itemCode: item.itemCode, + workType: item.workType || '-', + shipTypes: item.shipTypes || '-', + itemList: item.itemList || '-', + subItemList: item.subItemList || '-', createdAt: format(item.createdAt, 'yyyy-MM-dd HH:mm', { locale: ko }), }); @@ -89,6 +99,15 @@ export async function exportTechVendorPossibleItemsToExcel( // 나머지 컬럼 왼쪽 정렬 cell.alignment = { vertical: 'middle', horizontal: 'left' }; } + + // 텍스트 줄바꿈 처리 (긴 텍스트 필드들) + if (colNumber >= 9 && colNumber <= 10) { // itemList, subItemList 컬럼 + cell.alignment = { + vertical: 'top', + horizontal: 'left', + wrapText: true + }; + } }); // 홀수 행 배경색 @@ -103,19 +122,29 @@ export async function exportTechVendorPossibleItemsToExcel( } }); - // 요약 정보 워크시트 생성 + // 요약 정보 워크시트 생성 (새로운 스키마 통계 포함) const summarySheet = workbook.addWorksheet('요약 정보'); const summaryData = [ - ['기술영업 벤더 가능 아이템 현황', ''], + ['기술영업 벤더 가능 아이템 현황 (새로운 스키마)', ''], ['', ''], + ['📊 기본 통계:', ''], ['총 항목 수:', data.length.toLocaleString()], ['고유 벤더 수:', new Set(data.map(item => item.vendorId)).size.toLocaleString()], ['고유 아이템 수:', new Set(data.map(item => item.itemCode)).size.toLocaleString()], ['', ''], - ['벤더 타입별 분포:', ''], + ['🏢 벤더 타입별 분포:', ''], ...getVendorTypeDistribution(data), ['', ''], + ['⚙️ 공종별 분포:', ''], + ...getWorkTypeDistribution(data), + ['', ''], + ['🚢 선종별 분포:', ''], + ...getShipTypeDistribution(data), + ['', ''], + ['📈 데이터 완성도:', ''], + ...getDataCompleteness(data), + ['', ''], ['내보내기 일시:', format(new Date(), 'yyyy-MM-dd HH:mm:ss', { locale: ko })], ]; @@ -127,10 +156,13 @@ export async function exportTechVendorPossibleItemsToExcel( } else if (typeof rowData[0] === 'string' && rowData[0].includes(':') && rowData[1] === '') { // 섹션 제목 스타일 row.getCell(1).font = { bold: true, color: { argb: 'FF1F4E79' } }; + } else if (typeof rowData[0] === 'string' && rowData[0].includes('📊') || rowData[0].includes('🏢') || rowData[0].includes('⚙️') || rowData[0].includes('🚢') || rowData[0].includes('📈')) { + // 이모지 섹션 제목 스타일 + row.getCell(1).font = { bold: true, color: { argb: 'FF1F4E79' } }; } }); - summarySheet.getColumn(1).width = 30; + summarySheet.getColumn(1).width = 40; summarySheet.getColumn(2).width = 20; // 파일 생성 및 다운로드 @@ -178,4 +210,64 @@ function getVendorTypeDistribution(data: TechVendorPossibleItemsData[]): [string return Array.from(typeCount.entries()) .sort((a, b) => b[1] - a[1]) .map(([type, count]) => [` - ${type}`, count.toLocaleString()]); +} + +/** + * 공종별 분포 계산 + */ +function getWorkTypeDistribution(data: TechVendorPossibleItemsData[]): [string, string][] { + const workTypeCount = new Map(); + + data.forEach(item => { + const workType = item.workType || '미분류'; + workTypeCount.set(workType, (workTypeCount.get(workType) || 0) + 1); + }); + + return Array.from(workTypeCount.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) // 상위 10개만 표시 + .map(([type, count]) => [` - ${type}`, count.toLocaleString()]); +} + +/** + * 선종별 분포 계산 + */ +function getShipTypeDistribution(data: TechVendorPossibleItemsData[]): [string, string][] { + const shipTypeCount = new Map(); + + data.forEach(item => { + if (item.shipTypes) { + // 여러 선종이 콤마로 구분되어 있을 수 있음 + const shipTypes = item.shipTypes.split(',').map(s => s.trim()); + shipTypes.forEach(shipType => { + if (shipType) { + shipTypeCount.set(shipType, (shipTypeCount.get(shipType) || 0) + 1); + } + }); + } else { + shipTypeCount.set('미분류', (shipTypeCount.get('미분류') || 0) + 1); + } + }); + + return Array.from(shipTypeCount.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) // 상위 10개만 표시 + .map(([type, count]) => [` - ${type}`, count.toLocaleString()]); +} + +/** + * 데이터 완성도 계산 + */ +function getDataCompleteness(data: TechVendorPossibleItemsData[]): [string, string][] { + const total = data.length; + + const completeness = [ + ['벤더이메일 있음', `${data.filter(item => item.vendorEmail).length}/${total} (${((data.filter(item => item.vendorEmail).length / total) * 100).toFixed(1)}%)`], + ['공종 있음', `${data.filter(item => item.workType).length}/${total} (${((data.filter(item => item.workType).length / total) * 100).toFixed(1)}%)`], + ['선종 있음', `${data.filter(item => item.shipTypes).length}/${total} (${((data.filter(item => item.shipTypes).length / total) * 100).toFixed(1)}%)`], + ['아이템리스트 있음', `${data.filter(item => item.itemList).length}/${total} (${((data.filter(item => item.itemList).length / total) * 100).toFixed(1)}%)`], + ['서브아이템리스트 있음', `${data.filter(item => item.subItemList).length}/${total} (${((data.filter(item => item.subItemList).length / total) * 100).toFixed(1)}%)`], + ]; + + return completeness.map(([label, stat]) => [` - ${label}`, stat]); } \ No newline at end of file diff --git a/lib/tech-vendor-possible-items/table/excel-import.tsx b/lib/tech-vendor-possible-items/table/excel-import.tsx index fbf984dd..743879b3 100644 --- a/lib/tech-vendor-possible-items/table/excel-import.tsx +++ b/lib/tech-vendor-possible-items/table/excel-import.tsx @@ -3,21 +3,35 @@ import * as ExcelJS from 'exceljs'; import { ImportTechVendorPossibleItemData, ImportResult, importTechVendorPossibleItems } from '../service'; import { saveAs } from "file-saver"; +import { decryptWithServerAction } from "@/components/drm/drmUtils" +import { toast } from 'sonner'; export interface ExcelImportResult extends ImportResult { errorFileUrl?: string; } /** - * Excel 파일에서 tech vendor possible items 데이터를 읽고 import + * Excel 파일에서 tech vendor possible items 데이터를 읽고 import (새로운 스키마 버전) */ export async function importTechVendorPossibleItemsFromExcel( file: File ): Promise { + try { - const buffer = await file.arrayBuffer(); - const workbook = new ExcelJS.Workbook(); - await workbook.xlsx.load(buffer); + // DRM 복호화 처리 - 서버 액션 직접 호출 + let arrayBuffer: ArrayBuffer; + try { + toast.info("파일 복호화 중..."); + arrayBuffer = await decryptWithServerAction(file); + } catch (decryptError) { + console.error("파일 복호화 실패, 원본 파일 사용:", decryptError); + toast.warning("파일 복호화에 실패하여 원본 파일을 사용합니다."); + // 복호화 실패 시 원본 파일 사용 + arrayBuffer = await file.arrayBuffer(); + } + // ExcelJS 워크북 로드 + const workbook = new ExcelJS.Workbook(); + await workbook.xlsx.load(arrayBuffer); // 첫 번째 워크시트에서 데이터 읽기 const worksheet = workbook.getWorksheet(1); @@ -33,31 +47,48 @@ export async function importTechVendorPossibleItemsFromExcel( const data: ImportTechVendorPossibleItemData[] = []; // 데이터 행 읽기 (헤더 제외) + // 새로운 스키마: 벤더이메일, 아이템코드, 공종, 선종, 아이템리스트, 서브아이템리스트 worksheet.eachRow((row, rowNumber) => { if (rowNumber === 1) return; // 헤더 건너뛰기 - const itemCode = row.getCell(1).value?.toString()?.trim(); - const vendorCode = row.getCell(2).value?.toString()?.trim(); - const vendorEmail = row.getCell(3).value?.toString()?.trim(); + const vendorEmail = row.getCell(1).value?.toString()?.trim(); // 필수 + const itemCode = row.getCell(2).value?.toString()?.trim(); // 필수 + const workType = row.getCell(3).value?.toString()?.trim(); // 선택 + const shipTypes = row.getCell(4).value?.toString()?.trim(); // 선택 + const itemList = row.getCell(5).value?.toString()?.trim(); // 선택 + const subItemList = row.getCell(6).value?.toString()?.trim(); // 선택 + const vendorCode = row.getCell(7).value?.toString()?.trim(); // 선택 (호환성) // 빈 행 건너뛰기 - if (!itemCode && !vendorCode && !vendorEmail) return; + if (!vendorEmail && !itemCode && !workType && !shipTypes && !itemList && !subItemList && !vendorCode) { + return; + } - // 벤더 코드 또는 이메일 중 하나는 있어야 함 - if (itemCode && (vendorCode || vendorEmail)) { + // 필수 필드 체크: 벤더이메일, 아이템코드 + if (!vendorEmail || !itemCode) { + // 불완전한 데이터도 포함하여 에러 처리 data.push({ - vendorCode: vendorCode || '', - vendorEmail: vendorEmail || '', - itemCode, - }); - } else { - // 불완전한 데이터 처리 - data.push({ - vendorCode: vendorCode || '', vendorEmail: vendorEmail || '', itemCode: itemCode || '', + workType: workType || undefined, + shipTypes: shipTypes || undefined, + itemList: itemList || undefined, + subItemList: subItemList || undefined, + vendorCode: vendorCode || undefined, }); + return; } + + // 완전한 데이터 추가 + data.push({ + vendorEmail, + itemCode, + workType: workType || undefined, + shipTypes: shipTypes || undefined, + itemList: itemList || undefined, + subItemList: subItemList || undefined, + vendorCode: vendorCode || undefined, + }); }); if (data.length === 0) { @@ -99,7 +130,7 @@ export async function importTechVendorPossibleItemsFromExcel( } /** - * 실패한 항목들을 포함한 오류 Excel 파일 생성 + * 실패한 항목들을 포함한 오류 Excel 파일 생성 (새로운 스키마 버전) */ async function createErrorExcelFile( failedRows: ImportResult['failedRows'] @@ -108,12 +139,16 @@ async function createErrorExcelFile( const workbook = new ExcelJS.Workbook(); const worksheet = workbook.addWorksheet('Import 오류 목록'); - // 헤더 설정 + // 헤더 설정 (새로운 스키마에 맞춰) worksheet.columns = [ { header: '행 번호', key: 'row', width: 10 }, + { header: '벤더이메일', key: 'vendorEmail', width: 30 }, { header: '아이템코드', key: 'itemCode', width: 20 }, + { header: '공종', key: 'workType', width: 15 }, + { header: '선종', key: 'shipTypes', width: 20 }, + { header: '아이템리스트', key: 'itemList', width: 30 }, + { header: '서브아이템리스트', key: 'subItemList', width: 30 }, { header: '벤더코드', key: 'vendorCode', width: 15 }, - { header: '벤더이메일', key: 'vendorEmail', width: 30 }, { header: '오류 내용', key: 'error', width: 60 }, { header: '해결 방법', key: 'solution', width: 40 }, ]; @@ -142,19 +177,25 @@ async function createErrorExcelFile( failedRows.forEach((item) => { let solution = '시스템 관리자에게 문의하세요'; - if (item.error.includes('벤더 코드') || item.error.includes('벤더 이메일')) { - solution = '등록된 벤더 코드 또는 이메일인지 확인하세요'; + if (item.error.includes('벤더 이메일')) { + solution = '올바른 이메일 형식으로 등록된 벤더 이메일인지 확인하세요'; } else if (item.error.includes('아이템 코드')) { - solution = '벤더 타입에 맞는 아이템 코드인지 확인하세요'; + solution = '아이템 코드가 누락되었거나 잘못된 형식입니다'; } else if (item.error.includes('이미 존재')) { - solution = '중복된 조합입니다. 제거하거나 건너뛰세요'; + solution = '중복된 조합입니다. 기존 데이터를 확인하세요'; + } else if (item.error.includes('찾을 수 없습니다')) { + solution = '벤더 이메일이 시스템에 등록되어 있는지 확인하세요'; } const row = worksheet.addRow({ row: item.row, - itemCode: item.itemCode || '누락', - vendorCode: item.vendorCode || '누락', vendorEmail: item.vendorEmail || '누락', + itemCode: item.itemCode || '누락', + workType: item.workType || '', + shipTypes: item.shipTypes || '', + itemList: item.itemList || '', + subItemList: item.subItemList || '', + vendorCode: item.vendorCode || '', error: item.error, solution: solution, }); @@ -169,24 +210,35 @@ async function createErrorExcelFile( }); }); - // 안내사항 추가 + // 안내사항 추가 (새로운 스키마에 맞춰) const instructionSheet = workbook.addWorksheet('오류 해결 가이드'); const instructions = [ - ['📋 오류 유형별 해결 방법', ''], + ['📋 새로운 스키마 Import 가이드', ''], ['', ''], - ['1. 벤더 코드/이메일 오류:', ''], - [' • 시스템에 등록된 벤더 코드 또는 이메일인지 확인', ''], + ['📌 필수 필드:', ''], + [' • 벤더이메일: 시스템에 등록된 벤더의 이메일 주소', ''], + [' • 아이템코드: 처리할 아이템의 코드', ''], + ['', ''], + ['📌 선택 필드:', ''], + [' • 공종: 작업 유형 (예: 용접, 도장, 기계 등)', ''], + [' • 선종: 선박 유형 (예: 컨테이너선, 벌크선, 탱커 등)', ''], + [' • 아이템리스트: 아이템에 대한 상세 설명', ''], + [' • 서브아이템리스트: 세부 아이템들에 대한 설명', ''], + [' • 벤더코드: 호환성을 위한 선택 필드', ''], + ['', ''], + ['🔍 오류 유형별 해결 방법:', ''], + ['', ''], + ['1. 벤더 이메일 오류:', ''], + [' • 올바른 이메일 형식 확인 (예: vendor@example.com)', ''], + [' • 시스템에 등록된 벤더 이메일인지 확인', ''], [' • 벤더 관리 메뉴에서 등록 상태 확인', ''], - [' • 벤더 코드가 없으면 벤더 이메일로 대체 가능', ''], ['', ''], ['2. 아이템 코드 오류:', ''], - [' • 벤더 타입과 일치하는 아이템인지 확인', ''], - [' • 조선 벤더 → item_shipbuilding 테이블', ''], - [' • 해양TOP 벤더 → item_offshore_top 테이블', ''], - [' • 해양HULL 벤더 → item_offshore_hull 테이블', ''], + [' • 아이템 코드가 누락되지 않았는지 확인', ''], + [' • 특수문자나 공백이 포함되지 않았는지 확인', ''], ['', ''], ['3. 중복 오류:', ''], - [' • 이미 등록된 벤더-아이템 조합', ''], + [' • 동일한 벤더 + 아이템코드 + 공종 + 선종 조합', ''], [' • 기존 데이터 확인 후 중복 제거', ''], ['', ''], ['📞 추가 문의: 시스템 관리자', ''], @@ -196,12 +248,14 @@ async function createErrorExcelFile( const row = instructionSheet.addRow(rowData); if (index === 0) { row.getCell(1).font = { bold: true, size: 14, color: { argb: 'FF1F4E79' } }; + } else if (rowData[0]?.includes('📌') || rowData[0]?.includes('🔍')) { + row.getCell(1).font = { bold: true, color: { argb: 'FF1F4E79' } }; } else if (rowData[0]?.includes(':')) { row.getCell(1).font = { bold: true, color: { argb: 'FF1F4E79' } }; } }); - instructionSheet.getColumn(1).width = 50; + instructionSheet.getColumn(1).width = 60; // 파일 생성 및 다운로드 const buffer = await workbook.xlsx.writeBuffer(); diff --git a/lib/tech-vendor-possible-items/table/excel-template.tsx b/lib/tech-vendor-possible-items/table/excel-template.tsx index 70a7eddf..20880350 100644 --- a/lib/tech-vendor-possible-items/table/excel-template.tsx +++ b/lib/tech-vendor-possible-items/table/excel-template.tsx @@ -2,7 +2,7 @@ import * as ExcelJS from 'exceljs'; import { saveAs } from "file-saver"; /** - * 기술영업 벤더 가능 아이템 Import를 위한 Excel 템플릿 파일 생성 및 다운로드 + * 기술영업 벤더 가능 아이템 Import를 위한 Excel 템플릿 파일 생성 및 다운로드 (새로운 스키마 버전) */ export async function exportTechVendorPossibleItemsTemplate() { // 워크북 생성 @@ -13,11 +13,15 @@ export async function exportTechVendorPossibleItemsTemplate() { // 워크시트 생성 const worksheet = workbook.addWorksheet('기술영업 벤더 가능 아이템'); - // 컬럼 헤더 정의 및 스타일 적용 + // 컬럼 헤더 정의 및 스타일 적용 (새로운 스키마에 맞춰) worksheet.columns = [ - { header: '아이템코드', key: 'itemCode', width: 20 }, - { header: '벤더코드', key: 'vendorCode', width: 15 }, - { header: '벤더이메일', key: 'vendorEmail', width: 30 }, + { header: '벤더이메일 (필수)', key: 'vendorEmail', width: 30 }, + { header: '아이템코드 (필수)', key: 'itemCode', width: 20 }, + { header: '공종 (선택)', key: 'workType', width: 15 }, + { header: '선종 (선택)', key: 'shipTypes', width: 20 }, + { header: '아이템리스트 (선택)', key: 'itemList', width: 35 }, + { header: '서브아이템리스트 (선택)', key: 'subItemList', width: 35 }, + { header: '벤더코드 (호환성)', key: 'vendorCode', width: 15 }, ]; // 헤더 스타일 적용 @@ -44,18 +48,58 @@ export async function exportTechVendorPossibleItemsTemplate() { }; }); - // 샘플 데이터 추가 + // 샘플 데이터 추가 (새로운 스키마에 맞춰) const sampleData = [ - { itemCode: 'ITEM001', vendorCode: 'V001', vendorEmail: '' }, - { itemCode: 'ITEM001', vendorCode: 'V002', vendorEmail: '' }, - { itemCode: 'ITEM002', vendorCode: '', vendorEmail: 'vendor@example.com' }, - { itemCode: 'ITEM002', vendorCode: 'V002', vendorEmail: '' }, - { itemCode: 'ITEM004', vendorCode: '', vendorEmail: 'vendor2@example.com' }, + { + vendorEmail: 'vendor1@example.com', + itemCode: 'ITEM001', + workType: '용접', + shipTypes: '컨테이너선', + itemList: '선체 용접 작업', + subItemList: '외판 용접, 내부 구조 용접', + vendorCode: 'V001' + }, + { + vendorEmail: 'vendor2@example.com', + itemCode: 'ITEM002', + workType: '도장', + shipTypes: '벌크선', + itemList: '선체 도장 작업', + subItemList: '프라이머, 탑코트', + vendorCode: '' + }, + { + vendorEmail: 'vendor3@example.com', + itemCode: 'ITEM003', + workType: '기계', + shipTypes: '탱커', + itemList: '기계 설비 설치', + subItemList: '엔진, 펌프, 배관', + vendorCode: '' + }, + { + vendorEmail: 'vendor1@example.com', + itemCode: 'ITEM004', + workType: '용접', + shipTypes: '컨테이너선, 벌크선', + itemList: '특수 용접 작업', + subItemList: '', + vendorCode: 'V001' + }, + { + vendorEmail: 'vendor4@example.com', + itemCode: 'ITEM005', + workType: '', + shipTypes: '', + itemList: '', + subItemList: '', + vendorCode: 'V004' + }, ]; sampleData.forEach((data) => { const row = worksheet.addRow(data); - row.eachCell((cell) => { + row.eachCell((cell, colNumber) => { cell.border = { top: { style: 'thin' }, left: { style: 'thin' }, @@ -66,35 +110,75 @@ export async function exportTechVendorPossibleItemsTemplate() { vertical: 'middle', horizontal: 'left' }; + + // 긴 텍스트 필드는 줄바꿈 허용 + if (colNumber >= 5 && colNumber <= 6) { // itemList, subItemList + cell.alignment = { + vertical: 'top', + horizontal: 'left', + wrapText: true + }; + } }); }); - // 안내사항 워크시트 생성 + // 안내사항 워크시트 생성 (새로운 스키마에 맞춰) const guideSheet = workbook.addWorksheet('사용 가이드'); const guideData = [ - ['기술영업 벤더 가능 아이템 Import 템플릿', ''], + ['기술영업 벤더 가능 아이템 Import 템플릿 (새로운 스키마)', ''], ['', ''], - ['📋 사용 방법:', ''], + ['📋 새로운 스키마 특징:', ''], + ['- 더욱 구체적인 아이템 정보 관리', ''], + ['- 공종과 선종으로 세분화된 분류', ''], + ['- 아이템 상세 설명 및 서브 아이템 정보', ''], + ['- 중복 아이템도 공종/선종이 다르면 별도 관리', ''], + ['', ''], + ['📌 필수 입력 필드:', ''], + ['1. 벤더이메일: 시스템에 등록된 벤더의 이메일 주소', ''], + [' 예: vendor@company.com', ''], + ['2. 아이템코드: 처리할 아이템의 고유 코드', ''], + [' 예: ITEM001, WELD_001, PAINT_002', ''], + ['', ''], + ['📝 선택 입력 필드:', ''], + ['3. 공종: 작업 유형 분류', ''], + [' 예: 용접, 도장, 기계, 전기, 배관 등', ''], + ['4. 선종: 적용 가능한 선박 유형', ''], + [' 예: 컨테이너선, 벌크선, 탱커, LNG선 등', ''], + [' - 여러 선종은 콤마로 구분: "컨테이너선, 벌크선"', ''], + ['5. 아이템리스트: 아이템에 대한 상세 설명', ''], + [' 예: "선체 용접 작업", "외판 도장 및 마감"', ''], + ['6. 서브아이템리스트: 세부 작업 항목들', ''], + [' 예: "외판 용접, 내부 구조 용접, 배관 용접"', ''], + ['7. 벤더코드: 기존 호환성을 위한 선택 필드', ''], + ['', ''], + ['🔍 중복 처리 로직:', ''], + ['- 동일한 벤더 + 아이템코드 + 공종 + 선종 = 중복으로 처리', ''], + ['- 아이템코드가 같아도 공종이나 선종이 다르면 별도 항목', ''], + ['- 예: ITEM001 + 용접 + 컨테이너선 ≠ ITEM001 + 도장 + 컨테이너선', ''], + ['', ''], + ['💡 사용 방법:', ''], ['1. "기술영업 벤더 가능 아이템" 시트에 데이터를 입력하세요', ''], - ['2. 벤더 식별: 벤더코드 또는 벤더이메일 중 하나는 반드시 입력', ''], - [' • 벤더코드가 있으면 벤더코드를 우선 사용', ''], - [' • 벤더코드가 없으면 벤더이메일로 벤더 검색', ''], - ['3. 아이템코드는 실제 존재하는 아이템코드를 사용하세요', ''], - ['4. 한 아이템코드에 여러 벤더를 매핑할 수 있습니다 (1:N 관계)', ''], - ['5. 중복된 벤더-아이템 조합은 무시됩니다', ''], + ['2. 필수 필드(벤더이메일, 아이템코드)는 반드시 입력', ''], + ['3. 선택 필드는 필요에 따라 입력 (빈 칸으로 두어도 됨)', ''], + ['4. 한 벤더가 여러 아이템을 담당할 수 있습니다 (1:N 관계)', ''], + ['5. 한 아이템에 여러 벤더를 배정할 수 있습니다 (N:M 관계)', ''], ['6. 파일 저장 후 시스템에서 업로드하세요', ''], ['', ''], - ['⚠️ 중요 사항:', ''], - ['- 벤더코드 또는 벤더이메일 중 하나는 반드시 필요', ''], - ['- 벤더코드가 우선, 없으면 벤더이메일로 검색', ''], - ['- 중복된 벤더-아이템 조합은 건너뜁니다', ''], + ['⚠️ 주의사항:', ''], + ['- 벤더이메일은 시스템에 이미 등록된 이메일이어야 함', ''], + ['- 이메일 형식 확인: @를 포함한 올바른 이메일 형식', ''], + ['- 아이템코드는 특수문자나 공백 주의', ''], + ['- 긴 텍스트 필드(아이템리스트, 서브아이템리스트)는 줄바꿈 가능', ''], ['- 오류가 있는 항목은 별도 파일로 다운로드됩니다', ''], - ['- 빈 셀이 있으면 해당 행은 무시됩니다', ''], ['', ''], - ['💡 팁:', ''], - ['- 벤더코드만 존재하면 어떤 아이템코드든 입력 가능합니다', ''], - ['- 아이템코드는 그대로 시스템에 저장됩니다', ''], + ['📊 데이터 예시:', ''], + ['벤더이메일: welding@company.com', ''], + ['아이템코드: WELD_HULL_001', ''], + ['공종: 용접', ''], + ['선종: 컨테이너선, 벌크선', ''], + ['아이템리스트: 선체 구조 용접 작업', ''], + ['서브아이템리스트: 외판 용접, 격벽 용접, 갑판 용접', ''], ['', ''], ['📞 문의사항이 있으시면 시스템 관리자에게 연락하세요.', ''], ]; @@ -104,16 +188,19 @@ export async function exportTechVendorPossibleItemsTemplate() { if (index === 0) { // 제목 스타일 row.getCell(1).font = { bold: true, size: 16, color: { argb: 'FF1F4E79' } }; + } else if (rowData[0]?.includes('📋') || rowData[0]?.includes('📌') || rowData[0]?.includes('📝') || rowData[0]?.includes('🔍') || rowData[0]?.includes('💡') || rowData[0]?.includes('⚠️') || rowData[0]?.includes('📊')) { + // 섹션 제목 스타일 (이모지 포함) + row.getCell(1).font = { bold: true, color: { argb: 'FF1F4E79' } }; } else if (rowData[0]?.includes(':')) { - // 섹션 제목 스타일 + // 일반 제목 스타일 row.getCell(1).font = { bold: true, color: { argb: 'FF1F4E79' } }; - } else if (rowData[0]?.includes('•') || rowData[0]?.includes('-')) { + } else if (rowData[0]?.includes('-') || rowData[0]?.includes('•') || rowData[0]?.includes('예:')) { // 리스트 아이템 스타일 row.getCell(1).font = { color: { argb: 'FF333333' } }; } }); - guideSheet.getColumn(1).width = 70; + guideSheet.getColumn(1).width = 80; guideSheet.getColumn(2).width = 20; // 파일 생성 및 다운로드 diff --git a/lib/tech-vendor-possible-items/table/possible-items-data-table.tsx b/lib/tech-vendor-possible-items/table/possible-items-data-table.tsx index 5252684b..28b9774f 100644 --- a/lib/tech-vendor-possible-items/table/possible-items-data-table.tsx +++ b/lib/tech-vendor-possible-items/table/possible-items-data-table.tsx @@ -17,6 +17,10 @@ type TechVendorPossibleItemsData = { vendorName: string; techVendorType: string; itemCode: string; + itemList: string | null; + workType: string | null; + shipTypes: string | null; + subItemList: string | null; createdAt: Date; updatedAt: Date; }; @@ -51,6 +55,26 @@ export function PossibleItemsDataTable({ promises }: PossibleItemsDataTableProps label: "아이템코드", type: "text", }, + { + id: "itemList", + label: "아이템리스트", + type: "text", + }, + { + id: "workType", + label: "공종", + type: "text", + }, + { + id: "shipTypes", + label: "선종", + type: "text", + }, + { + id: "subItemList", + label: "서브아이템리스트", + type: "text", + }, { id: "techVendorType", label: "벤더타입", @@ -73,7 +97,7 @@ export function PossibleItemsDataTable({ promises }: PossibleItemsDataTableProps sorting: [{ id: "createdAt", desc: true }], pagination: { pageIndex: 0, pageSize: 10 }, }, - getRowId: (originalRow) => String(originalRow.id), + getRowId: (originalRow) => `${originalRow.vendorId}-${originalRow.itemCode}-${originalRow.id}`, shallow: false, clearOnDefault: true, }); diff --git a/lib/tech-vendor-possible-items/table/possible-items-table-columns.tsx b/lib/tech-vendor-possible-items/table/possible-items-table-columns.tsx index 520c089e..7fdcc900 100644 --- a/lib/tech-vendor-possible-items/table/possible-items-table-columns.tsx +++ b/lib/tech-vendor-possible-items/table/possible-items-table-columns.tsx @@ -11,7 +11,12 @@ type TechVendorPossibleItemsData = { vendorCode: string | null; vendorName: string; techVendorType: string; + vendorStatus: string; itemCode: string; + itemList: string | null; + workType: string | null; + shipTypes: string | null; + subItemList: string | null; createdAt: Date; updatedAt: Date; }; @@ -55,6 +60,70 @@ export function getColumns(): ColumnDef[] { return
{itemCode}
; }, }, + { + accessorKey: "itemList", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const itemList = row.getValue("itemList") as string | null; + return
{itemList || "-"}
; + }, + filterFn: (row, id, value) => { + const itemList = row.getValue(id) as string | null; + if (!value) return true; + if (!itemList) return false; + return itemList.toLowerCase().includes(value.toLowerCase()); + }, + }, + { + accessorKey: "workType", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const workType = row.getValue("workType") as string | null; + return
{workType || "-"}
; + }, + filterFn: (row, id, value) => { + const workType = row.getValue(id) as string | null; + if (!value) return true; + if (!workType) return false; + return workType.toLowerCase().includes(value.toLowerCase()); + }, + }, + { + accessorKey: "shipTypes", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const shipTypes = row.getValue("shipTypes") as string | null; + return
{shipTypes || "-"}
; + }, + filterFn: (row, id, value) => { + const shipTypes = row.getValue(id) as string | null; + if (!value) return true; + if (!shipTypes) return false; + return shipTypes.toLowerCase().includes(value.toLowerCase()); + }, + }, + { + accessorKey: "subItemList", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const subItemList = row.getValue("subItemList") as string | null; + return
{subItemList || "-"}
; + }, + filterFn: (row, id, value) => { + const subItemList = row.getValue(id) as string | null; + if (!value) return true; + if (!subItemList) return false; + return subItemList.toLowerCase().includes(value.toLowerCase()); + }, + }, { accessorKey: "vendorCode", header: ({ column }) => ( @@ -91,37 +160,85 @@ export function getColumns(): ColumnDef[] { cell: ({ row }) => { const techVendorType = row.getValue("techVendorType") as string; - // JSON 배열인지 확인하고 파싱 + // 벤더 타입 파싱 개선 let types: string[] = []; - try { - const parsed = JSON.parse(techVendorType || "[]"); - types = Array.isArray(parsed) ? parsed : [techVendorType]; - } catch { - types = [techVendorType]; + if (!techVendorType) { + types = []; + } else if (techVendorType.startsWith('[') && techVendorType.endsWith(']')) { + // JSON 배열 형태 + try { + const parsed = JSON.parse(techVendorType); + types = Array.isArray(parsed) ? parsed.filter(Boolean) : [techVendorType]; + } catch { + types = [techVendorType]; + } + } else if (techVendorType.includes(',')) { + // 콤마로 구분된 문자열 + types = techVendorType.split(',').map(t => t.trim()).filter(Boolean); + } else { + // 단일 문자열 + types = [techVendorType.trim()].filter(Boolean); } return (
- {types.map((type, index) => ( - + {types.length > 0 ? types.map((type, index) => ( + {type} - ))} + )) : ( + - + )}
); }, filterFn: (row, id, value) => { const techVendorType = row.getValue(id) as string; - try { - const parsed = JSON.parse(techVendorType || "[]"); - const types = Array.isArray(parsed) ? parsed : [techVendorType]; - return types.some(type => type.includes(value)); - } catch { - return techVendorType?.includes(value) || false; + if (!techVendorType || !value) return false; + + let types: string[] = []; + if (techVendorType.startsWith('[') && techVendorType.endsWith(']')) { + try { + const parsed = JSON.parse(techVendorType); + types = Array.isArray(parsed) ? parsed : [techVendorType]; + } catch { + types = [techVendorType]; + } + } else if (techVendorType.includes(',')) { + types = techVendorType.split(',').map(t => t.trim()); + } else { + types = [techVendorType.trim()]; } + + return types.some(type => + type.toLowerCase().includes(value.toLowerCase()) + ); }, }, + { + accessorKey: "vendorStatus", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const vendorStatus = row.getValue("vendorStatus") as string; + const getStatusColor = (status: string) => { + switch (status) { + case "ACTIVE": return "bg-green-100 text-green-800"; + case "PENDING_INVITE": return "bg-yellow-100 text-yellow-800"; + case "PENDING_REVIEW": return "bg-blue-100 text-blue-800"; + case "INACTIVE": return "bg-gray-100 text-gray-800"; + default: return "bg-gray-100 text-gray-800"; + } + }; + return ( + + {vendorStatus} + + ); + }, + }, { accessorKey: "createdAt", header: ({ column }) => ( diff --git a/lib/tech-vendor-possible-items/table/possible-items-table-toolbar-actions.tsx b/lib/tech-vendor-possible-items/table/possible-items-table-toolbar-actions.tsx index 3628f87e..dc67221f 100644 --- a/lib/tech-vendor-possible-items/table/possible-items-table-toolbar-actions.tsx +++ b/lib/tech-vendor-possible-items/table/possible-items-table-toolbar-actions.tsx @@ -2,12 +2,13 @@ import * as React from "react"; import { type Table } from "@tanstack/react-table"; -import { Download, Upload, FileSpreadsheet, Trash2 } from "lucide-react"; +import { Download, Upload, FileSpreadsheet, Plus } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { useToast } from "@/hooks/use-toast"; -import { deleteTechVendorPossibleItems } from "@/lib/tech-vendor-possible-items/service"; +import { AddPossibleItemDialog } from "./add-possible-item-dialog"; +import { DeletePossibleItemsDialog } from "./delete-possible-items-dialog"; // Excel 함수들을 동적 import로만 사용하기 위해 타입만 import type TechVendorPossibleItemsData = { id: number; @@ -16,6 +17,10 @@ type TechVendorPossibleItemsData = { vendorName: string; techVendorType: string; itemCode: string; + itemList: string | null; + workType: string | null; + shipTypes: string | null; + subItemList: string | null; createdAt: Date; updatedAt: Date; }; @@ -28,44 +33,15 @@ export function PossibleItemsTableToolbarActions({ table, }: PossibleItemsTableToolbarActionsProps) { const { toast } = useToast(); - const [isPending, startTransition] = React.useTransition(); const selectedRows = table.getFilteredSelectedRowModel().rows; const hasSelection = selectedRows.length > 0; + const selectedItems = selectedRows.map(row => row.original); - const handleDelete = () => { - if (!hasSelection) return; - - startTransition(async () => { - const selectedIds = selectedRows.map((row) => row.original.id); - - try { - const result = await deleteTechVendorPossibleItems(selectedIds); - - if (result.success) { - toast({ - title: "성공", - description: `${selectedIds.length}개의 아이템이 삭제되었습니다.`, - }); - table.toggleAllRowsSelected(false); - // 페이지 새로고침이나 데이터 다시 로드 필요 - window.location.reload(); - } else { - toast({ - title: "오류", - description: result.error || "삭제 중 오류가 발생했습니다.", - variant: "destructive", - }); - } - } catch (error) { - console.error("Delete error:", error); - toast({ - title: "오류", - description: "삭제 중 오류가 발생했습니다.", - variant: "destructive", - }); - } - }); + const handleSuccess = () => { + table.toggleAllRowsSelected(false); + // 페이지 새로고침이나 데이터 다시 로드 필요 + window.location.reload(); }; const handleExport = async () => { @@ -158,17 +134,27 @@ export function PossibleItemsTableToolbarActions({ return (
- {hasSelection && ( - + {hasSelection && ( + )} + + + + +