summaryrefslogtreecommitdiff
path: root/lib/tech-vendor-possible-items
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-07-21 07:54:26 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-07-21 07:54:26 +0000
commit14f61e24947fb92dd71ec0a7196a6e815f8e66da (patch)
tree317c501d64662d05914330628f867467fba78132 /lib/tech-vendor-possible-items
parent194bd4bd7e6144d5c09c5e3f5476d254234dce72 (diff)
(최겸)기술영업 RFQ 담당자 초대, 요구사항 반영
Diffstat (limited to 'lib/tech-vendor-possible-items')
-rw-r--r--lib/tech-vendor-possible-items/repository.ts123
-rw-r--r--lib/tech-vendor-possible-items/service.ts298
-rw-r--r--lib/tech-vendor-possible-items/table/add-possible-item-dialog.tsx450
-rw-r--r--lib/tech-vendor-possible-items/table/delete-possible-items-dialog.tsx175
-rw-r--r--lib/tech-vendor-possible-items/table/excel-export.tsx106
-rw-r--r--lib/tech-vendor-possible-items/table/excel-import.tsx130
-rw-r--r--lib/tech-vendor-possible-items/table/excel-template.tsx151
-rw-r--r--lib/tech-vendor-possible-items/table/possible-items-data-table.tsx26
-rw-r--r--lib/tech-vendor-possible-items/table/possible-items-table-columns.tsx147
-rw-r--r--lib/tech-vendor-possible-items/table/possible-items-table-toolbar-actions.tsx86
-rw-r--r--lib/tech-vendor-possible-items/validations.ts16
11 files changed, 1521 insertions, 187 deletions
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<any, any, any>,
+ 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<any, any, any>,
+ 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<any, any, any>,
+ groupBy: 'workType' | 'shipTypes' | 'vendorCode' | 'vendorEmail',
+ where?: SQL | undefined
+) {
+ const groupField = techVendorPossibleItems[groupBy];
+
+ return await tx
+ .select({
+ groupValue: groupField,
+ count: count(),
+ vendorCount: sql<number>`COUNT(DISTINCT ${techVendorPossibleItems.vendorId})`.as('vendorCount'),
+ itemCount: sql<number>`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<any, any, any>,
+ where?: SQL | undefined
+) {
+ return await tx
+ .select({
+ workType: techVendorPossibleItems.workType,
+ count: count(),
+ vendorCount: sql<number>`COUNT(DISTINCT ${techVendorPossibleItems.vendorId})`.as('vendorCount'),
+ itemCount: sql<number>`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<any, any, any>,
+ where?: SQL | undefined
+) {
+ return await tx
+ .select({
+ shipTypes: techVendorPossibleItems.shipTypes,
+ count: count(),
+ vendorCount: sql<number>`COUNT(DISTINCT ${techVendorPossibleItems.vendorId})`.as('vendorCount'),
+ itemCount: sql<number>`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<any, any, any>,
+ where?: SQL | undefined
+) {
+ return await tx
+ .select({
+ vendorId: techVendorPossibleItems.vendorId,
+ vendorCode: techVendorPossibleItems.vendorCode,
+ vendorName: techVendors.vendorName,
+ vendorEmail: techVendorPossibleItems.vendorEmail,
+ itemCount: count(),
+ distinctItemCount: sql<number>`COUNT(DISTINCT ${techVendorPossibleItems.itemCode})`.as('distinctItemCount'),
+ workTypeCount: sql<number>`COUNT(DISTINCT ${techVendorPossibleItems.workType})`.as('workTypeCount'),
+ shipTypeCount: sql<number>`COUNT(DISTINCT ${techVendorPossibleItems.shipTypes})`.as('shipTypeCount'),
+ latestUpdate: sql<Date>`MAX(${techVendorPossibleItems.updatedAt})`.as('latestUpdate'),
+ })
+ .from(techVendorPossibleItems)
+ .innerJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id))
+ .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<string[]> {
// 오류 발생시 기본 벤더 타입 반환
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<TechVendor[]>([]);
+ const [filteredVendors, setFilteredVendors] = React.useState<TechVendor[]>([]);
+ const [vendorSearch, setVendorSearch] = React.useState("");
+ const [selectedVendor, setSelectedVendor] = React.useState<TechVendor | null>(null);
+
+ // 아이템 관련 상태
+ const [items, setItems] = React.useState<ItemData[]>([]);
+ const [filteredItems, setFilteredItems] = React.useState<ItemData[]>([]);
+ const [itemSearch, setItemSearch] = React.useState("");
+ const [selectedItems, setSelectedItems] = React.useState<ItemData[]>([]);
+
+ const [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 (
+ <Dialog open={open} onOpenChange={setOpen}>
+ <DialogTrigger asChild>
+ {children || (
+ <Button size="sm">
+ <Plus className="mr-2 h-4 w-4" />
+ 추가
+ </Button>
+ )}
+ </DialogTrigger>
+ <DialogContent className="max-w-6xl max-h-[90vh] flex flex-col">
+ <DialogHeader>
+ <DialogTitle>
+ 벤더별 아이템 추가
+ </DialogTitle>
+ <DialogDescription>
+ 왼쪽에서 벤더를 선택하고, 오른쪽에서 아이템을 선택하세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="flex-1 min-h-0">
+ <div className="grid grid-cols-2 gap-4 h-[500px]">
+ {/* 왼쪽: 벤더 선택/표시 */}
+ <div className="space-y-4 h-full flex flex-col">
+ {!selectedVendor ? (
+ <>
+ <div className="space-y-2">
+ <Label htmlFor="vendor-search">벤더 검색</Label>
+ <div className="relative">
+ <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" /> <Input
+ id="vendor-search"
+ placeholder="벤더명 또는 벤더코드로 검색..."
+ value={vendorSearch}
+ onChange={(e) => setVendorSearch(e.target.value)}
+ className="pl-10"
+ />
+ </div>
+ </div>
+
+ <div className="max-h-96 overflow-y-auto border rounded-lg bg-gray-50 p-2">
+ <div className="space-y-2">
+ {isLoading ? (
+ <div className="text-center py-4">로딩 중...</div>
+ ) : filteredVendors.length === 0 ? (
+ <div className="text-center py-4 text-muted-foreground">
+ 검색 결과가 없습니다.
+ </div>
+ ) : (
+ filteredVendors.map((vendor) => (
+ <div
+ key={vendor.id}
+ className="p-3 bg-white border rounded-lg cursor-pointer transition-colors hover:bg-gray-50"
+ onClick={() => handleVendorSelect(vendor)}
+ >
+ <div className="font-medium">{vendor.vendorName}</div>
+ <div className="text-sm text-muted-foreground">
+ {vendor.vendorCode}
+ </div>
+ <div className="flex flex-wrap gap-1 mt-2">
+ {parseVendorTypes(vendor.techVendorType).map((type, index) => (
+ <Badge key={`${vendor.id}-${type}-${index}`} variant="secondary" className="text-xs">
+ {type}
+ </Badge>
+ ))}
+ </div>
+ </div>
+ ))
+ )}
+ </div>
+ </div>
+ </>
+ ) : (
+ <div className="space-y-4">
+ <div className="flex items-center justify-between">
+ <Label>선택된 벤더</Label>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ setSelectedVendor(null);
+ setSelectedItems([]);
+ setItems([]);
+ setFilteredItems([]);
+ }}
+ >
+ 변경
+ </Button>
+ </div>
+ <div className="p-4 border rounded-md bg-muted/20">
+ <div className="font-medium">{selectedVendor?.vendorName}</div>
+ <div className="text-sm text-muted-foreground">
+ {selectedVendor?.vendorCode}
+ </div>
+ <div className="flex flex-wrap gap-1 mt-2">
+ {selectedVendor && parseVendorTypes(selectedVendor.techVendorType).map((type, index) => (
+ <Badge key={`selected-${type}-${index}`} variant="outline" className="text-xs">
+ {type}
+ </Badge>
+ ))}
+ </div>
+ </div>
+ </div>
+ )}
+ </div>
+
+
+
+ {/* 오른쪽: 아이템 선택 */}
+ <div className="space-y-4 h-full flex flex-col">
+ {selectedVendor ? (
+ <>
+
+
+ <Label htmlFor="item-search">아이템 검색</Label>
+ <div className="relative">
+ <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" /> <Input
+ id="item-search"
+ placeholder="아이템코드, 아이템리스트, 공종으로 검색..."
+ value={itemSearch}
+ onChange={(e) => setItemSearch(e.target.value)}
+ className="pl-10"
+ />
+ </div>
+
+
+ {selectedItems.length > 0 && (
+ <div className="space-y-2">
+ <Label>선택된 아이템 ({selectedItems.length}개)</Label>
+ <div className="flex flex-wrap gap-1 p-2 border rounded-md bg-muted/50 max-h-20 overflow-y-auto">
+ {selectedItems.map((item) => (
+ <Badge key={`selected-${item.itemCode}`} variant="default" className="text-xs">
+ {item.itemCode}
+ <X
+ className="ml-1 h-3 w-3 cursor-pointer"
+ onClick={(e) => {
+ e.stopPropagation();
+ handleItemToggle(item);
+ }}
+ />
+ </Badge>
+ ))}
+ </div>
+ </div>
+ )}
+
+ <div className="max-h-80 overflow-y-auto border rounded-lg bg-gray-50 p-2">
+ <div className="space-y-2">
+ {isLoading ? (
+ <div className="text-center py-4">아이템 로딩 중...</div>
+ ) : filteredItems.length === 0 && items.length === 0 ? (
+ <div className="text-center py-4 text-muted-foreground">
+ 해당 벤더 타입에 대한 아이템이 없습니다.
+ </div>
+ ) : filteredItems.length === 0 ? (
+ <div className="text-center py-4 text-muted-foreground">
+ 검색 결과가 없습니다.
+ </div>
+ ) : (
+ filteredItems.map((item) => {
+ const isSelected = selectedItems.some(i => i.itemCode === item.itemCode);
+ return (
+ <div
+ key={`item-${item.itemCode}`}
+ className={`p-3 bg-white border rounded-lg cursor-pointer transition-colors ${
+ isSelected
+ ? "bg-primary/10 border-primary hover:bg-primary/20"
+ : "hover:bg-gray-50"
+ }`}
+ onClick={() => handleItemToggle(item)}
+ >
+ <div className="font-medium">{item.itemCode}</div>
+ <div className="text-sm text-muted-foreground">
+ {item.itemList || "-"}
+ </div>
+ <div className="flex gap-2 mt-1 text-xs">
+ <span>공종: {item.workType || "-"}</span>
+ {item.shipTypes && <span>선종: {item.shipTypes}</span>}
+ {item.subItemList && <span>서브아이템: {item.subItemList}</span>}
+ </div>
+ </div>
+ );
+ })
+ )}
+ </div>
+ </div>
+ </>
+ ) : (
+ <div className="flex-1 flex items-center justify-center text-muted-foreground">
+ 왼쪽에서 벤더를 선택하세요.
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+
+ <div className="flex justify-end gap-2 pt-4 border-t">
+ <Button variant="outline" onClick={handleClose}>
+ 취소
+ </Button>
+ <Button
+ onClick={handleSubmit}
+ disabled={!selectedVendor || selectedItems.length === 0 || isLoading}
+ >
+ {isLoading ? "추가 중..." : `추가 (${selectedItems.length})`}
+ </Button>
+ </div>
+ </DialogContent>
+ </Dialog>
+ );
+} \ No newline at end of file
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 (
+ <Dialog open={open} onOpenChange={setOpen}>
+ <DialogTrigger asChild>
+ {children || (
+ <Button
+ variant="destructive"
+ size="sm"
+ disabled={selectedItems.length === 0}
+ >
+ <Trash2 className="mr-2 h-4 w-4" />
+ 삭제 ({selectedItems.length})
+ </Button>
+ )}
+ </DialogTrigger>
+ <DialogContent className="max-w-2xl">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <AlertTriangle className="h-5 w-5 text-destructive" />
+ 아이템 삭제 확인
+ </DialogTitle>
+ <DialogDescription>
+ 선택한 {selectedItems.length}개의 벤더-아이템 조합을 삭제하시겠습니까?
+ 이 작업은 되돌릴 수 없습니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="py-4">
+ <div className="text-sm font-medium mb-3">삭제될 아이템 목록:</div>
+ <ScrollArea className="max-h-[300px] border rounded-md">
+ <div className="p-4 space-y-3">
+ {selectedItems.map((item) => (
+ <div key={item.id} className="border rounded-md p-3 bg-muted/50">
+ <div className="flex justify-between items-start">
+ <div className="space-y-1">
+ <div className="font-medium text-sm">
+ {item.vendorName} ({item.vendorCode})
+ </div>
+ <div className="text-sm text-muted-foreground">
+ 아이템코드: {item.itemCode}
+ </div>
+ {item.itemList && (
+ <div className="text-xs text-muted-foreground">
+ 아이템리스트: {item.itemList}
+ </div>
+ )}
+ {item.workType && (
+ <div className="text-xs text-muted-foreground">
+ 공종: {item.workType}
+ </div>
+ )}
+ </div>
+ <div className="flex flex-wrap gap-1">
+ {parseVendorTypes(item.techVendorType).map((type, index) => (
+ <Badge key={index} variant="secondary" className="text-xs">
+ {type}
+ </Badge>
+ ))}
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ </ScrollArea>
+ </div>
+
+ <DialogFooter>
+ <Button variant="outline" onClick={() => setOpen(false)}>
+ 취소
+ </Button>
+ <Button
+ variant="destructive"
+ onClick={handleDelete}
+ disabled={isLoading}
+ >
+ {isLoading ? "삭제 중..." : `삭제 (${selectedItems.length})`}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+} \ 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<string, number>();
+
+ 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<string, number>();
+
+ 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<ExcelImportResult> {
+
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;
};
@@ -52,6 +56,26 @@ export function PossibleItemsDataTable({ promises }: PossibleItemsDataTableProps
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: "벤더타입",
type: "multi-select",
@@ -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;
};
@@ -56,6 +61,70 @@ export function getColumns(): ColumnDef<TechVendorPossibleItemsData>[] {
},
},
{
+ accessorKey: "itemList",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="아이템리스트" />
+ ),
+ cell: ({ row }) => {
+ const itemList = row.getValue("itemList") as string | null;
+ return <div className="max-w-[200px] truncate">{itemList || "-"}</div>;
+ },
+ 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 }) => (
+ <DataTableColumnHeaderSimple column={column} title="공종" />
+ ),
+ cell: ({ row }) => {
+ const workType = row.getValue("workType") as string | null;
+ return <div className="font-medium">{workType || "-"}</div>;
+ },
+ 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 }) => (
+ <DataTableColumnHeaderSimple column={column} title="선종" />
+ ),
+ cell: ({ row }) => {
+ const shipTypes = row.getValue("shipTypes") as string | null;
+ return <div className="font-medium">{shipTypes || "-"}</div>;
+ },
+ 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 }) => (
+ <DataTableColumnHeaderSimple column={column} title="서브아이템리스트" />
+ ),
+ cell: ({ row }) => {
+ const subItemList = row.getValue("subItemList") as string | null;
+ return <div className="max-w-[200px] truncate">{subItemList || "-"}</div>;
+ },
+ 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 }) => (
<DataTableColumnHeaderSimple column={column} title="벤더코드" />
@@ -91,38 +160,86 @@ export function getColumns(): ColumnDef<TechVendorPossibleItemsData>[] {
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 (
<div className="flex flex-wrap gap-1">
- {types.map((type, index) => (
- <Badge key={index} variant="secondary" className="text-xs">
+ {types.length > 0 ? types.map((type, index) => (
+ <Badge key={`${type}-${index}`} variant="secondary" className="text-xs">
{type}
</Badge>
- ))}
+ )) : (
+ <span className="text-muted-foreground">-</span>
+ )}
</div>
);
},
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 }) => (
+ <DataTableColumnHeaderSimple column={column} title="벤더상태" />
+ ),
+ cell: ({ row }) => {
+ const vendorStatus = row.getValue("vendorStatus") as string;
+ const getStatusColor = (status: string) => {
+ switch (status) {
+ case "ACTIVE": return "bg-green-100 text-green-800";
+ case "PENDING_INVITE": return "bg-yellow-100 text-yellow-800";
+ case "PENDING_REVIEW": return "bg-blue-100 text-blue-800";
+ case "INACTIVE": return "bg-gray-100 text-gray-800";
+ default: return "bg-gray-100 text-gray-800";
+ }
+ };
+ return (
+ <Badge className={getStatusColor(vendorStatus)}>
+ {vendorStatus}
+ </Badge>
+ );
+ },
+ },
+ {
accessorKey: "createdAt",
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="생성일시" />
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 (
<div className="flex items-center gap-2">
- {hasSelection && (
- <Button
- variant="destructive"
- size="sm"
- onClick={handleDelete}
- disabled={isPending}
- >
- <Trash2 className="mr-2 h-4 w-4" />
- 삭제 ({selectedRows.length})
- </Button>
+ {hasSelection && (
+ <DeletePossibleItemsDialog
+ selectedItems={selectedItems}
+ onSuccess={handleSuccess}
+ />
)}
+ <AddPossibleItemDialog onSuccess={handleSuccess}>
+ <Button size="sm">
+ <Plus className="mr-2 h-4 w-4" />
+ 추가
+ </Button>
+ </AddPossibleItemDialog>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => document.getElementById("import-file")?.click()}
+ >
+ <Upload className="mr-2 h-4 w-4" />
+ Import
+ </Button>
<Button variant="outline" size="sm" onClick={handleExport}>
<Download className="mr-2 h-4 w-4" />
@@ -183,14 +169,6 @@ export function PossibleItemsTableToolbarActions({
className="hidden"
/>
- <Button
- variant="outline"
- size="sm"
- onClick={() => document.getElementById("import-file")?.click()}
- >
- <Upload className="mr-2 h-4 w-4" />
- Import
- </Button>
<Button variant="outline" size="sm" onClick={handleDownloadTemplate}>
<FileSpreadsheet className="mr-2 h-4 w-4" />
diff --git a/lib/tech-vendor-possible-items/validations.ts b/lib/tech-vendor-possible-items/validations.ts
index 1e42264b..6e930bb1 100644
--- a/lib/tech-vendor-possible-items/validations.ts
+++ b/lib/tech-vendor-possible-items/validations.ts
@@ -31,14 +31,28 @@ export const searchParamsTechVendorPossibleItemsCache = createSearchParamsCache(
})
export const createTechVendorPossibleItemSchema = z.object({
- vendorId: z.number().min(1, "벤더를 선택해주세요"),
+ vendorId: z.number().min(1, "벤더 ID는 필수입니다"),
itemCode: z.string().min(1, "아이템 코드를 입력해주세요"),
+ workType: z.string().nullable().optional(),
+ shipTypes: z.string().nullable().optional(),
+ itemList: z.string().nullable().optional(),
+ subItemList: z.string().nullable().optional(),
})
export const updateTechVendorPossibleItemSchema = createTechVendorPossibleItemSchema.extend({
id: z.number(),
})
+export const importTechVendorPossibleItemSchema = z.object({
+ vendorCode: z.string().optional(),
+ vendorEmail: z.string().email("올바른 이메일 형식을 입력해주세요").min(1, "벤더 이메일을 입력해주세요"),
+ itemCode: z.string().min(1, "아이템 코드를 입력해주세요"),
+ workType: z.string().optional(),
+ shipTypes: z.string().optional(),
+ itemList: z.string().optional(),
+ subItemList: z.string().optional(),
+})
+
export type CreateTechVendorPossibleItemSchema = z.infer<typeof createTechVendorPossibleItemSchema>
export type UpdateTechVendorPossibleItemSchema = z.infer<typeof updateTechVendorPossibleItemSchema>