diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-03 02:47:09 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-03 02:47:09 +0000 |
| commit | 6f22fc9ebc8d175041aa18cf0986592e57d03f63 (patch) | |
| tree | a1f511d42cf6eaeb18ab41a61374731166886ecd | |
| parent | 78d76dd27148a8b74a99b4ee984fd800fd92d00d (diff) | |
(최겸) 기술영업 벤더별 아이템 조회 기능 추가
10 files changed, 1702 insertions, 0 deletions
diff --git a/app/[lng]/evcp/(evcp)/tech-vendor-possible-items/page.tsx b/app/[lng]/evcp/(evcp)/tech-vendor-possible-items/page.tsx new file mode 100644 index 00000000..00192d85 --- /dev/null +++ b/app/[lng]/evcp/(evcp)/tech-vendor-possible-items/page.tsx @@ -0,0 +1,58 @@ +import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+
+import { searchParamsTechVendorPossibleItemsCache } from "@/lib/tech-vendor-possible-items/validations"
+import { getTechVendorPossibleItems, getUniqueTechVendorTypes } from "@/lib/tech-vendor-possible-items/service"
+import { PossibleItemsDataTable } from "@/lib/tech-vendor-possible-items/table/possible-items-data-table"
+import { TechVendorPossibleItemsContainer } from "@/components/tech-vendor-possible-items/tech-vendor-possible-items-container"
+
+interface TechVendorPossibleItemsPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function TechVendorPossibleItemsPage(props: TechVendorPossibleItemsPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsTechVendorPossibleItemsCache.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ // 벤더 타입 정의
+ const vendorTypes = [
+ { id: "all", name: "전체", value: "" },
+ { id: "ship", name: "조선", value: "조선" },
+ { id: "top", name: "해양TOP", value: "해양TOP" },
+ { id: "hull", name: "해양HULL", value: "해양HULL" },
+ ]
+
+ const promises = Promise.all([
+ getTechVendorPossibleItems({
+ ...search,
+ filters: validFilters,
+ }),
+ getUniqueTechVendorTypes(),
+ ])
+
+ return (
+ <Shell className="gap-4">
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={7}
+ searchableColumnCount={4}
+ filterableColumnCount={1}
+ cellWidths={["3rem", "10rem", "15rem", "12rem", "15rem", "20rem", "12rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <TechVendorPossibleItemsContainer vendorTypes={vendorTypes}>
+ <PossibleItemsDataTable promises={promises} />
+ </TechVendorPossibleItemsContainer>
+ </React.Suspense>
+ </Shell>
+ )
+}
\ No newline at end of file diff --git a/lib/tech-vendor-possible-items/repository.ts b/lib/tech-vendor-possible-items/repository.ts new file mode 100644 index 00000000..b2588395 --- /dev/null +++ b/lib/tech-vendor-possible-items/repository.ts @@ -0,0 +1,47 @@ +import { eq, desc, count } from "drizzle-orm";
+import {
+ techVendors,
+ techVendorPossibleItems
+} from "@/db/schema/techVendors";
+
+/**
+ * 기술영업 벤더 가능 아이템 목록 조회 (조인 포함)
+ */
+export async function selectTechVendorPossibleItemsWithJoin(
+ tx: any,
+ where: any,
+ orderBy: any[],
+ offset: number,
+ limit: number
+) {
+ return await tx
+ .select({
+ id: techVendorPossibleItems.id,
+ vendorId: techVendorPossibleItems.vendorId,
+ vendorCode: techVendors.vendorCode,
+ vendorName: techVendors.vendorName,
+ techVendorType: techVendors.techVendorType,
+ itemCode: techVendorPossibleItems.itemCode,
+ createdAt: techVendorPossibleItems.createdAt,
+ updatedAt: techVendorPossibleItems.updatedAt,
+ })
+ .from(techVendorPossibleItems)
+ .innerJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id))
+ .where(where)
+ .orderBy(...(orderBy || [desc(techVendorPossibleItems.createdAt)]))
+ .limit(limit)
+ .offset(offset);
+}
+
+/**
+ * 기술영업 벤더 가능 아이템 총 개수 조회 (조인 포함)
+ */
+export async function countTechVendorPossibleItemsWithJoin(tx: any, where?: any) {
+ const [result] = await tx
+ .select({ count: count() })
+ .from(techVendorPossibleItems)
+ .innerJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id))
+ .where(where);
+
+ return result.count;
+}
\ No newline at end of file diff --git a/lib/tech-vendor-possible-items/service.ts b/lib/tech-vendor-possible-items/service.ts new file mode 100644 index 00000000..efe9be51 --- /dev/null +++ b/lib/tech-vendor-possible-items/service.ts @@ -0,0 +1,583 @@ +"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택)
+import { eq, and, inArray, desc, asc, or, ilike } from "drizzle-orm";
+import db from "@/db/db";
+import {
+ techVendors,
+ techVendorPossibleItems
+} from "@/db/schema/techVendors";
+import { itemShipbuilding, itemOffshoreTop, itemOffshoreHull } from "@/db/schema/items";
+import { unstable_cache } from "@/lib/unstable-cache";
+import { filterColumns } from "@/lib/filter-columns";
+import type { GetTechVendorPossibleItemsSchema } from "./validations";
+import {
+ selectTechVendorPossibleItemsWithJoin,
+ countTechVendorPossibleItemsWithJoin
+} from "./repository";
+
+export interface TechVendorPossibleItemsData {
+ id: number;
+ vendorId: number;
+ vendorCode: string | null;
+ vendorName: string;
+ techVendorType: string;
+ itemCode: string;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+export interface CreateTechVendorPossibleItemData {
+ vendorId: number;
+ itemCode: string;
+}
+
+export interface ImportTechVendorPossibleItemData {
+ vendorCode: string;
+ vendorEmail?: string;
+ itemCode: string;
+}
+
+export interface ImportResult {
+ success: boolean;
+ totalRows: number;
+ successCount: number;
+ failedRows: {
+ row: number;
+ error: string;
+ vendorCode?: string;
+ vendorEmail?: string;
+ itemCode?: string;
+ }[];
+}
+
+
+
+/**
+ * 견적프로젝트 패턴에 맞는 메인 조회 함수
+ */
+export async function getTechVendorPossibleItems(input: GetTechVendorPossibleItemsSchema) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // 고급 필터링 (DataTableAdvancedToolbar용)
+ const advancedWhere = filterColumns({
+ table: techVendorPossibleItems,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+ // 전역 검색 (search box용)
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(techVendors.vendorCode, s),
+ ilike(techVendors.vendorName, s),
+ ilike(techVendorPossibleItems.itemCode, s),
+ );
+ }
+
+ // 기존 호환성을 위한 개별 필터들
+ const legacyFilters = [];
+ if (input.vendorCode) {
+ legacyFilters.push(ilike(techVendors.vendorCode, `%${input.vendorCode}%`));
+ }
+ if (input.vendorName) {
+ legacyFilters.push(ilike(techVendors.vendorName, `%${input.vendorName}%`));
+ }
+ if (input.itemCode) {
+ legacyFilters.push(ilike(techVendorPossibleItems.itemCode, `%${input.itemCode}%`));
+ }
+
+ // 벤더 타입 필터링
+ if (input.vendorType && input.vendorType !== "all") {
+ const vendorTypeMap = {
+ "ship": "조선",
+ "top": "해양TOP",
+ "hull": "해양HULL"
+ };
+
+ const actualVendorType = vendorTypeMap[input.vendorType as keyof typeof vendorTypeMap] || input.vendorType;
+
+ if (actualVendorType) {
+ legacyFilters.push(ilike(techVendors.techVendorType, `%${actualVendorType}%`));
+ }
+ }
+
+ // 모든 조건 결합
+ const finalWhere = and(
+ advancedWhere,
+ globalWhere,
+ ...(legacyFilters.length > 0 ? [and(...legacyFilters)] : [])
+ );
+
+ // 정렬 조건
+ const orderBy = [desc(techVendorPossibleItems.createdAt)];
+
+ // 트랜잭션 내에서 Repository 호출
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectTechVendorPossibleItemsWithJoin(tx, finalWhere, orderBy, offset, input.perPage);
+
+ const total = await countTechVendorPossibleItemsWithJoin(tx, finalWhere);
+ return { data, total };
+ });
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data, pageCount, totalCount: total };
+ } catch (error) {
+ console.error("Error fetching tech vendor possible items:", error);
+ return { data: [], pageCount: 0, totalCount: 0 };
+ }
+ },
+ [JSON.stringify(input)],
+ {
+ revalidate: 3600,
+ tags: ["tech-vendor-possible-items"],
+ }
+ )();
+}
+
+
+
+/**
+ * 페이지네이션을 포함한 tech vendor possible items 조회
+ */
+// export async function getTechVendorPossibleItemsWithPagination(
+// page: number = 1,
+// pageSize: number = 50,
+// searchTerm?: string,
+// vendorType?: string
+// ): Promise<{
+// data: TechVendorPossibleItemsData[];
+// totalCount: number;
+// totalPages: number;
+// }> {
+// const whereConditions = [];
+
+// if (searchTerm) {
+// whereConditions.push(
+// sql`(
+// ${techVendors.vendorName} ILIKE ${`%${searchTerm}%`} OR
+// ${techVendors.vendorCode} ILIKE ${`%${searchTerm}%`} OR
+// ${techVendorPossibleItems.itemCode} ILIKE ${`%${searchTerm}%`}
+// )`
+// );
+// }
+
+// // 벤더 타입 필터링 로직 추가
+// if (vendorType && vendorType !== "all") {
+// // URL의 vendorType 파라미터를 실제 벤더 타입으로 매핑
+// const vendorTypeMap = {
+// "ship": "조선",
+// "top": "해양TOP",
+// "hull": "해양HULL"
+// };
+
+// const actualVendorType = vendorType in vendorTypeMap
+// ? vendorTypeMap[vendorType as keyof typeof vendorTypeMap]
+// : vendorType; // 매핑되지 않는 경우 원본 값 사용
+
+// if (actualVendorType) {
+// // techVendorType 필드는 콤마로 구분된 문자열이므로 LIKE 사용
+// whereConditions.push(sql`${techVendors.techVendorType} ILIKE ${`%${actualVendorType}%`}`);
+// }
+// }
+
+// const whereClause = whereConditions.length > 0 ? and(...whereConditions) : undefined;
+
+// // 총 개수 조회
+// const [totalCountResult] = await db
+// .select({ count: count() })
+// .from(techVendorPossibleItems)
+// .innerJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id))
+// .where(whereClause);
+
+// const totalCount = totalCountResult.count;
+// const totalPages = Math.ceil(totalCount / pageSize);
+// const offset = (page - 1) * pageSize;
+
+// // 데이터 조회
+// const data = await db
+// .select({
+// id: techVendorPossibleItems.id,
+// vendorId: techVendorPossibleItems.vendorId,
+// vendorCode: techVendors.vendorCode,
+// vendorName: techVendors.vendorName,
+// techVendorType: techVendors.techVendorType,
+// itemCode: techVendorPossibleItems.itemCode,
+// createdAt: techVendorPossibleItems.createdAt,
+// updatedAt: techVendorPossibleItems.updatedAt,
+// })
+// .from(techVendorPossibleItems)
+// .innerJoin(techVendors, eq(techVendorPossibleItems.vendorId, techVendors.id))
+// .where(whereClause)
+// .orderBy(desc(techVendorPossibleItems.createdAt))
+// .limit(pageSize)
+// .offset(offset);
+
+// return {
+// data,
+// totalCount,
+// totalPages,
+// };
+// }
+
+/**
+ * tech vendor possible item 생성 (간단 버전)
+ */
+export async function createTechVendorPossibleItem(
+ data: CreateTechVendorPossibleItemData
+): Promise<{ success: boolean; error?: string }> {
+ try {
+ // 벤더 존재 여부만 확인
+ const vendor = await db
+ .select()
+ .from(techVendors)
+ .where(eq(techVendors.id, data.vendorId))
+ .limit(1);
+
+ if (!vendor[0]) {
+ return { success: false, error: "벤더를 찾을 수 없습니다." };
+ }
+
+ // 중복 체크
+ const existing = await db
+ .select()
+ .from(techVendorPossibleItems)
+ .where(
+ and(
+ eq(techVendorPossibleItems.vendorId, data.vendorId),
+ eq(techVendorPossibleItems.itemCode, data.itemCode)
+ )
+ )
+ .limit(1);
+
+ if (existing.length > 0) {
+ return { success: false, error: "이미 존재하는 벤더-아이템 조합입니다." };
+ }
+
+ // 아이템 코드 검증 없이 바로 삽입
+ await db.insert(techVendorPossibleItems).values({
+ vendorId: data.vendorId,
+ itemCode: data.itemCode,
+ });
+
+ return { success: true };
+ } catch (error) {
+ console.error("Failed to create tech vendor possible item:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "생성 중 오류가 발생했습니다."
+ };
+ }
+}
+
+/**
+ * tech vendor possible items 삭제
+ */
+export async function deleteTechVendorPossibleItems(
+ ids: number[]
+): Promise<{ success: boolean; error?: string }> {
+ try {
+ await db
+ .delete(techVendorPossibleItems)
+ .where(inArray(techVendorPossibleItems.id, ids));
+
+ return { success: true };
+ } catch (error) {
+ console.error("Failed to delete tech vendor possible items:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "삭제 중 오류가 발생했습니다."
+ };
+ }
+}
+
+/**
+ * 벤더 코드로 벤더 정보 조회
+ */
+export async function getTechVendorByCode(vendorCode: string) {
+ const result = await db
+ .select()
+ .from(techVendors)
+ .where(eq(techVendors.vendorCode, vendorCode))
+ .limit(1);
+
+ return result[0] || null;
+}
+
+/**
+ * 벤더 이메일로 벤더 정보 조회
+ */
+export async function getTechVendorByEmail(vendorEmail: string) {
+ const result = await db
+ .select()
+ .from(techVendors)
+ .where(eq(techVendors.email, vendorEmail))
+ .limit(1);
+
+ return result[0] || null;
+}
+
+/**
+ * 벤더 타입에 따라 적절한 아이템 테이블에서 아이템 조회
+ */
+export async function getItemByCodeAndVendorType(itemCode: string, vendorType: string) {
+ try {
+ switch (vendorType) {
+ case "조선":
+ const shipItem = await db
+ .select()
+ .from(itemShipbuilding)
+ .where(eq(itemShipbuilding.itemCode, itemCode))
+ .limit(1);
+ return shipItem[0] ? {
+ itemCode: shipItem[0].itemCode,
+ workType: shipItem[0].workType
+ } : null;
+
+ case "해양TOP":
+ const topItem = await db
+ .select()
+ .from(itemOffshoreTop)
+ .where(eq(itemOffshoreTop.itemCode, itemCode))
+ .limit(1);
+ return topItem[0] ? {
+ itemCode: topItem[0].itemCode,
+ workType: topItem[0].workType
+ } : null;
+
+ case "해양HULL":
+ const hullItem = await db
+ .select()
+ .from(itemOffshoreHull)
+ .where(eq(itemOffshoreHull.itemCode, itemCode))
+ .limit(1);
+ return hullItem[0] ? {
+ itemCode: hullItem[0].itemCode,
+ workType: hullItem[0].workType
+ } : null;
+
+ default:
+ return null;
+ }
+ } catch (error) {
+ console.error("Error fetching item by code and vendor type:", error);
+ return null;
+ }
+}
+
+/**
+ * 아이템 코드로 아이템 정보 조회 (기존 함수 - 호환성 유지)
+ */
+export async function getItemByCode(itemCode: string) {
+ // 기존 items 테이블 대신 조선 테이블에서 먼저 조회 시도
+ try {
+ const shipItem = await db
+ .select()
+ .from(itemShipbuilding)
+ .where(eq(itemShipbuilding.itemCode, itemCode))
+ .limit(1);
+
+ if (shipItem[0]) {
+ return {
+ itemCode: shipItem[0].itemCode,
+ };
+ }
+
+ const topItem = await db
+ .select()
+ .from(itemOffshoreTop)
+ .where(eq(itemOffshoreTop.itemCode, itemCode))
+ .limit(1);
+
+ if (topItem[0]) {
+ return {
+ itemCode: topItem[0].itemCode,
+ };
+ }
+
+ const hullItem = await db
+ .select()
+ .from(itemOffshoreHull)
+ .where(eq(itemOffshoreHull.itemCode, itemCode))
+ .limit(1);
+
+ if (hullItem[0]) {
+ return {
+ itemCode: hullItem[0].itemCode,
+ };
+ }
+
+ return null;
+ } catch (error) {
+ console.error("Error fetching item by code:", error);
+ return null;
+ }
+}
+
+/**
+ * Import 기능: 벤더코드와 아이템코드를 통한 batch insert (간단 버전)
+ */
+export async function importTechVendorPossibleItems(
+ data: ImportTechVendorPossibleItemData[]
+): Promise<ImportResult> {
+ const result: ImportResult = {
+ success: true,
+ totalRows: data.length,
+ successCount: 0,
+ failedRows: [],
+ };
+
+ for (let i = 0; i < data.length; i++) {
+ const row = data[i];
+ const rowNumber = i + 1;
+
+ try {
+ // 벤더 코드 또는 이메일로 벤더 찾기
+ let vendor = null;
+
+ if (row.vendorCode && row.vendorCode.trim()) {
+ // 벤더 코드가 있으면 먼저 벤더 코드로 검색
+ vendor = await getTechVendorByCode(row.vendorCode);
+ } else if (row.vendorEmail && row.vendorEmail.trim()) {
+ // 벤더 코드가 없으면 이메일로 검색
+ vendor = await getTechVendorByEmail(row.vendorEmail);
+ }
+
+ if (!vendor) {
+ const identifier = row.vendorCode ? `벤더 코드 '${row.vendorCode}'` :
+ row.vendorEmail ? `벤더 이메일 '${row.vendorEmail}'` :
+ '벤더 코드 또는 이메일';
+ result.failedRows.push({
+ row: rowNumber,
+ error: `${identifier}을(를) 찾을 수 없습니다.`,
+ vendorCode: row.vendorCode,
+ vendorEmail: row.vendorEmail,
+ itemCode: row.itemCode,
+ });
+ continue;
+ }
+
+ // 중복 체크
+ const existing = await db
+ .select()
+ .from(techVendorPossibleItems)
+ .where(
+ and(
+ eq(techVendorPossibleItems.vendorId, vendor.id),
+ eq(techVendorPossibleItems.itemCode, row.itemCode)
+ )
+ )
+ .limit(1);
+
+ if (existing.length > 0) {
+ result.failedRows.push({
+ row: rowNumber,
+ error: `이미 존재하는 벤더-아이템 조합입니다.`,
+ vendorCode: row.vendorCode,
+ vendorEmail: row.vendorEmail,
+ itemCode: row.itemCode,
+ });
+ continue;
+ }
+
+ // 아이템 코드 검증 없이 바로 삽입
+ await db.insert(techVendorPossibleItems).values({
+ vendorId: vendor.id,
+ itemCode: row.itemCode,
+ });
+
+ result.successCount++;
+ } catch (error) {
+ result.failedRows.push({
+ row: rowNumber,
+ error: error instanceof Error ? error.message : "알 수 없는 오류",
+ vendorCode: row.vendorCode,
+ vendorEmail: row.vendorEmail,
+ itemCode: row.itemCode,
+ });
+ }
+ }
+
+ if (result.failedRows.length > 0) {
+ result.success = false;
+ }
+
+ return result;
+}
+
+/**
+ * 모든 기술영업 벤더 조회 (드롭다운용)
+ */
+export async function getAllTechVendors() {
+ return await db
+ .select({
+ id: techVendors.id,
+ vendorCode: techVendors.vendorCode,
+ vendorName: techVendors.vendorName,
+ techVendorType: techVendors.techVendorType,
+ })
+ .from(techVendors)
+ .where(eq(techVendors.status, "ACTIVE"))
+ .orderBy(asc(techVendors.vendorName));
+}
+
+/**
+ * 고유한 벤더 타입 목록 조회 (필터용)
+ */
+export async function getUniqueTechVendorTypes(): Promise<string[]> {
+ try {
+ const result = await db
+ .select({
+ techVendorType: techVendors.techVendorType,
+ })
+ .from(techVendors)
+ .where(eq(techVendors.status, "ACTIVE"));
+
+ // techVendorType이 JSON 배열 형태로 저장된 경우를 고려
+ const allTypes = new Set<string>();
+
+ result.forEach(row => {
+ try {
+ // techVendorType이 JSON 문자열인지 확인
+ if (row.techVendorType && row.techVendorType.startsWith('[')) {
+ const types = JSON.parse(row.techVendorType);
+ if (Array.isArray(types)) {
+ types.forEach(type => {
+ if (type && typeof type === 'string') {
+ allTypes.add(type.trim());
+ }
+ });
+ }
+ } else if (row.techVendorType) {
+ // 단순 문자열인 경우
+ row.techVendorType.split(',').forEach(type => {
+ const trimmedType = type.trim();
+ if (trimmedType) {
+ allTypes.add(trimmedType);
+ }
+ });
+ }
+ } catch {
+ // JSON 파싱 실패시 문자열로 처리
+ if (row.techVendorType) {
+ row.techVendorType.split(',').forEach(type => {
+ const trimmedType = type.trim();
+ if (trimmedType) {
+ allTypes.add(trimmedType);
+ }
+ });
+ }
+ }
+ });
+
+ return Array.from(allTypes).sort();
+ } catch (error) {
+ console.error("Error fetching unique tech vendor types:", error);
+ // 오류 발생시 기본 벤더 타입 반환
+ return ["조선", "해양TOP", "해양HULL"];
+ }
+}
\ No newline at end of file diff --git a/lib/tech-vendor-possible-items/table/excel-export.tsx b/lib/tech-vendor-possible-items/table/excel-export.tsx new file mode 100644 index 00000000..d3c4dea5 --- /dev/null +++ b/lib/tech-vendor-possible-items/table/excel-export.tsx @@ -0,0 +1,181 @@ +import * as ExcelJS from 'exceljs';
+import { saveAs } from "file-saver";
+import type { TechVendorPossibleItemsData } from '../service';
+import { format } from 'date-fns';
+import { ko } from 'date-fns/locale';
+
+/**
+ * 기술영업 벤더 가능 아이템 데이터를 Excel 파일로 내보내기
+ */
+export async function exportTechVendorPossibleItemsToExcel(
+ data: TechVendorPossibleItemsData[]
+) {
+ try {
+ // 워크북 생성
+ const workbook = new ExcelJS.Workbook();
+ workbook.creator = 'Tech Vendor Possible Items Management System';
+ workbook.created = new Date();
+
+ // 워크시트 생성
+ const worksheet = workbook.addWorksheet('기술영업 벤더 가능 아이템');
+
+ // 컬럼 헤더 정의 및 스타일 적용
+ worksheet.columns = [
+ { header: '번호', key: 'id', width: 10 },
+ { header: '벤더코드', key: 'vendorCode', width: 15 },
+ { header: '벤더명', key: 'vendorName', width: 25 },
+ { header: '벤더타입', key: 'techVendorType', width: 20 },
+ { header: '아이템코드', key: 'itemCode', width: 20 },
+ { header: '생성일시', key: 'createdAt', width: 20 },
+ ];
+
+ // 헤더 스타일 적용
+ const headerRow = worksheet.getRow(1);
+ headerRow.eachCell((cell) => {
+ cell.fill = {
+ type: 'pattern',
+ pattern: 'solid',
+ fgColor: { argb: 'FFE6F3FF' }
+ };
+ cell.font = {
+ bold: true,
+ color: { argb: 'FF1F4E79' }
+ };
+ cell.border = {
+ top: { style: 'thin' },
+ left: { style: 'thin' },
+ bottom: { style: 'thin' },
+ right: { style: 'thin' }
+ };
+ cell.alignment = {
+ vertical: 'middle',
+ horizontal: 'center'
+ };
+ });
+
+ // 데이터 추가
+ data.forEach((item, index) => {
+ // 벤더 타입 파싱
+ let vendorTypes = '';
+ try {
+ const parsed = JSON.parse(item.techVendorType || "[]");
+ vendorTypes = Array.isArray(parsed) ? parsed.join(', ') : item.techVendorType;
+ } catch {
+ vendorTypes = item.techVendorType;
+ }
+
+ const row = worksheet.addRow({
+ id: item.id,
+ vendorCode: item.vendorCode || '-',
+ vendorName: item.vendorName,
+ techVendorType: vendorTypes,
+ itemCode: item.itemCode,
+ createdAt: format(item.createdAt, 'yyyy-MM-dd HH:mm', { locale: ko }),
+ });
+
+ // 데이터 행 스타일
+ row.eachCell((cell, colNumber) => {
+ cell.border = {
+ top: { style: 'thin' },
+ left: { style: 'thin' },
+ bottom: { style: 'thin' },
+ right: { style: 'thin' }
+ };
+
+ if (colNumber === 1) {
+ // ID 컬럼 가운데 정렬
+ cell.alignment = { vertical: 'middle', horizontal: 'center' };
+ } else {
+ // 나머지 컬럼 왼쪽 정렬
+ cell.alignment = { vertical: 'middle', horizontal: 'left' };
+ }
+ });
+
+ // 홀수 행 배경색
+ if (index % 2 === 1) {
+ row.eachCell((cell) => {
+ cell.fill = {
+ type: 'pattern',
+ pattern: 'solid',
+ fgColor: { argb: 'FFF8F9FA' }
+ };
+ });
+ }
+ });
+
+ // 요약 정보 워크시트 생성
+ const summarySheet = workbook.addWorksheet('요약 정보');
+
+ const summaryData = [
+ ['기술영업 벤더 가능 아이템 현황', ''],
+ ['', ''],
+ ['총 항목 수:', data.length.toLocaleString()],
+ ['고유 벤더 수:', new Set(data.map(item => item.vendorId)).size.toLocaleString()],
+ ['고유 아이템 수:', new Set(data.map(item => item.itemCode)).size.toLocaleString()],
+ ['', ''],
+ ['벤더 타입별 분포:', ''],
+ ...getVendorTypeDistribution(data),
+ ['', ''],
+ ['내보내기 일시:', format(new Date(), 'yyyy-MM-dd HH:mm:ss', { locale: ko })],
+ ];
+
+ summaryData.forEach((rowData, index) => {
+ const row = summarySheet.addRow(rowData);
+ if (index === 0) {
+ // 제목 스타일
+ row.getCell(1).font = { bold: true, size: 16, color: { argb: 'FF1F4E79' } };
+ } else if (typeof rowData[0] === 'string' && rowData[0].includes(':') && rowData[1] === '') {
+ // 섹션 제목 스타일
+ row.getCell(1).font = { bold: true, color: { argb: 'FF1F4E79' } };
+ }
+ });
+
+ summarySheet.getColumn(1).width = 30;
+ summarySheet.getColumn(2).width = 20;
+
+ // 파일 생성 및 다운로드
+ const buffer = await workbook.xlsx.writeBuffer();
+ const blob = new Blob([buffer], {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
+ });
+
+ const fileName = `기술영업_벤더_가능_아이템_${format(new Date(), 'yyyyMMdd_HHmmss')}.xlsx`;
+ saveAs(blob, fileName);
+
+ return { success: true };
+ } catch (error) {
+ console.error("Excel 내보내기 중 오류:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "내보내기 중 오류가 발생했습니다."
+ };
+ }
+}
+
+/**
+ * 벤더 타입별 분포 계산
+ */
+function getVendorTypeDistribution(data: TechVendorPossibleItemsData[]): [string, string][] {
+ const typeCount = new Map<string, number>();
+
+ data.forEach(item => {
+ try {
+ const parsed = JSON.parse(item.techVendorType || "[]");
+ const types = Array.isArray(parsed) ? parsed : [item.techVendorType];
+
+ types.forEach(type => {
+ if (type) {
+ typeCount.set(type, (typeCount.get(type) || 0) + 1);
+ }
+ });
+ } catch {
+ if (item.techVendorType) {
+ typeCount.set(item.techVendorType, (typeCount.get(item.techVendorType) || 0) + 1);
+ }
+ }
+ });
+
+ return Array.from(typeCount.entries())
+ .sort((a, b) => b[1] - a[1])
+ .map(([type, count]) => [` - ${type}`, count.toLocaleString()]);
+}
\ No newline at end of file diff --git a/lib/tech-vendor-possible-items/table/excel-import.tsx b/lib/tech-vendor-possible-items/table/excel-import.tsx new file mode 100644 index 00000000..fbf984dd --- /dev/null +++ b/lib/tech-vendor-possible-items/table/excel-import.tsx @@ -0,0 +1,220 @@ +"use client";
+
+import * as ExcelJS from 'exceljs';
+import { ImportTechVendorPossibleItemData, ImportResult, importTechVendorPossibleItems } from '../service';
+import { saveAs } from "file-saver";
+
+export interface ExcelImportResult extends ImportResult {
+ errorFileUrl?: string;
+}
+
+/**
+ * Excel 파일에서 tech vendor possible items 데이터를 읽고 import
+ */
+export async function importTechVendorPossibleItemsFromExcel(
+ file: File
+): Promise<ExcelImportResult> {
+ try {
+ const buffer = await file.arrayBuffer();
+ const workbook = new ExcelJS.Workbook();
+ await workbook.xlsx.load(buffer);
+
+ // 첫 번째 워크시트에서 데이터 읽기
+ const worksheet = workbook.getWorksheet(1);
+ if (!worksheet) {
+ return {
+ success: false,
+ totalRows: 0,
+ successCount: 0,
+ failedRows: [{ row: 0, error: "워크시트를 찾을 수 없습니다." }],
+ };
+ }
+
+ const data: ImportTechVendorPossibleItemData[] = [];
+
+ // 데이터 행 읽기 (헤더 제외)
+ worksheet.eachRow((row, rowNumber) => {
+ if (rowNumber === 1) return; // 헤더 건너뛰기
+
+ const itemCode = row.getCell(1).value?.toString()?.trim();
+ const vendorCode = row.getCell(2).value?.toString()?.trim();
+ const vendorEmail = row.getCell(3).value?.toString()?.trim();
+
+ // 빈 행 건너뛰기
+ if (!itemCode && !vendorCode && !vendorEmail) return;
+
+ // 벤더 코드 또는 이메일 중 하나는 있어야 함
+ if (itemCode && (vendorCode || vendorEmail)) {
+ data.push({
+ vendorCode: vendorCode || '',
+ vendorEmail: vendorEmail || '',
+ itemCode,
+ });
+ } else {
+ // 불완전한 데이터 처리
+ data.push({
+ vendorCode: vendorCode || '',
+ vendorEmail: vendorEmail || '',
+ itemCode: itemCode || '',
+ });
+ }
+ });
+
+ if (data.length === 0) {
+ return {
+ success: false,
+ totalRows: 0,
+ successCount: 0,
+ failedRows: [{ row: 0, error: "가져올 데이터가 없습니다. 템플릿 형식을 확인하세요." }],
+ };
+ }
+
+ // 서비스를 통해 import 실행
+ const result = await importTechVendorPossibleItems(data);
+
+ // 실패한 항목이 있으면 오류 파일 생성
+ if (result.failedRows.length > 0) {
+ const errorFileUrl = await createErrorExcelFile(result.failedRows);
+ return {
+ ...result,
+ errorFileUrl,
+ };
+ }
+
+ return result;
+ } catch (error) {
+ console.error("Excel import 중 오류:", error);
+ return {
+ success: false,
+ totalRows: 0,
+ successCount: 0,
+ failedRows: [
+ {
+ row: 0,
+ error: error instanceof Error ? error.message : "파일 처리 중 오류가 발생했습니다. 파일 형식을 확인하세요.",
+ },
+ ],
+ };
+ }
+}
+
+/**
+ * 실패한 항목들을 포함한 오류 Excel 파일 생성
+ */
+async function createErrorExcelFile(
+ failedRows: ImportResult['failedRows']
+): Promise<string> {
+ try {
+ const workbook = new ExcelJS.Workbook();
+ const worksheet = workbook.addWorksheet('Import 오류 목록');
+
+ // 헤더 설정
+ worksheet.columns = [
+ { header: '행 번호', key: 'row', width: 10 },
+ { header: '아이템코드', key: 'itemCode', width: 20 },
+ { header: '벤더코드', key: 'vendorCode', width: 15 },
+ { header: '벤더이메일', key: 'vendorEmail', width: 30 },
+ { header: '오류 내용', key: 'error', width: 60 },
+ { header: '해결 방법', key: 'solution', width: 40 },
+ ];
+
+ // 헤더 스타일
+ const headerRow = worksheet.getRow(1);
+ headerRow.eachCell((cell) => {
+ cell.fill = {
+ type: 'pattern',
+ pattern: 'solid',
+ fgColor: { argb: 'FFFF6B6B' }
+ };
+ cell.font = {
+ bold: true,
+ color: { argb: 'FFFFFFFF' }
+ };
+ cell.border = {
+ top: { style: 'thin' },
+ left: { style: 'thin' },
+ bottom: { style: 'thin' },
+ right: { style: 'thin' }
+ };
+ });
+
+ // 오류 데이터 추가
+ failedRows.forEach((item) => {
+ let solution = '시스템 관리자에게 문의하세요';
+
+ if (item.error.includes('벤더 코드') || item.error.includes('벤더 이메일')) {
+ solution = '등록된 벤더 코드 또는 이메일인지 확인하세요';
+ } else if (item.error.includes('아이템 코드')) {
+ solution = '벤더 타입에 맞는 아이템 코드인지 확인하세요';
+ } else if (item.error.includes('이미 존재')) {
+ solution = '중복된 조합입니다. 제거하거나 건너뛰세요';
+ }
+
+ const row = worksheet.addRow({
+ row: item.row,
+ itemCode: item.itemCode || '누락',
+ vendorCode: item.vendorCode || '누락',
+ vendorEmail: item.vendorEmail || '누락',
+ error: item.error,
+ solution: solution,
+ });
+
+ row.eachCell((cell) => {
+ cell.border = {
+ top: { style: 'thin' },
+ left: { style: 'thin' },
+ bottom: { style: 'thin' },
+ right: { style: 'thin' }
+ };
+ });
+ });
+
+ // 안내사항 추가
+ const instructionSheet = workbook.addWorksheet('오류 해결 가이드');
+ const instructions = [
+ ['📋 오류 유형별 해결 방법', ''],
+ ['', ''],
+ ['1. 벤더 코드/이메일 오류:', ''],
+ [' • 시스템에 등록된 벤더 코드 또는 이메일인지 확인', ''],
+ [' • 벤더 관리 메뉴에서 등록 상태 확인', ''],
+ [' • 벤더 코드가 없으면 벤더 이메일로 대체 가능', ''],
+ ['', ''],
+ ['2. 아이템 코드 오류:', ''],
+ [' • 벤더 타입과 일치하는 아이템인지 확인', ''],
+ [' • 조선 벤더 → item_shipbuilding 테이블', ''],
+ [' • 해양TOP 벤더 → item_offshore_top 테이블', ''],
+ [' • 해양HULL 벤더 → item_offshore_hull 테이블', ''],
+ ['', ''],
+ ['3. 중복 오류:', ''],
+ [' • 이미 등록된 벤더-아이템 조합', ''],
+ [' • 기존 데이터 확인 후 중복 제거', ''],
+ ['', ''],
+ ['📞 추가 문의: 시스템 관리자', ''],
+ ];
+
+ instructions.forEach((rowData, index) => {
+ const row = instructionSheet.addRow(rowData);
+ if (index === 0) {
+ row.getCell(1).font = { bold: true, size: 14, color: { argb: 'FF1F4E79' } };
+ } else if (rowData[0]?.includes(':')) {
+ row.getCell(1).font = { bold: true, color: { argb: 'FF1F4E79' } };
+ }
+ });
+
+ instructionSheet.getColumn(1).width = 50;
+
+ // 파일 생성 및 다운로드
+ const buffer = await workbook.xlsx.writeBuffer();
+ const blob = new Blob([buffer], {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
+ });
+
+ const fileName = `Import_오류_${new Date().toISOString().split('T')[0]}_${Date.now()}.xlsx`;
+ saveAs(blob, fileName);
+
+ return fileName;
+ } catch (error) {
+ console.error("오류 파일 생성 중 오류:", error);
+ return '';
+ }
+}
\ No newline at end of file diff --git a/lib/tech-vendor-possible-items/table/excel-template.tsx b/lib/tech-vendor-possible-items/table/excel-template.tsx new file mode 100644 index 00000000..70a7eddf --- /dev/null +++ b/lib/tech-vendor-possible-items/table/excel-template.tsx @@ -0,0 +1,137 @@ +import * as ExcelJS from 'exceljs';
+import { saveAs } from "file-saver";
+
+/**
+ * 기술영업 벤더 가능 아이템 Import를 위한 Excel 템플릿 파일 생성 및 다운로드
+ */
+export async function exportTechVendorPossibleItemsTemplate() {
+ // 워크북 생성
+ const workbook = new ExcelJS.Workbook();
+ workbook.creator = 'Tech Vendor Possible Items Management System';
+ workbook.created = new Date();
+
+ // 워크시트 생성
+ const worksheet = workbook.addWorksheet('기술영업 벤더 가능 아이템');
+
+ // 컬럼 헤더 정의 및 스타일 적용
+ worksheet.columns = [
+ { header: '아이템코드', key: 'itemCode', width: 20 },
+ { header: '벤더코드', key: 'vendorCode', width: 15 },
+ { header: '벤더이메일', key: 'vendorEmail', width: 30 },
+ ];
+
+ // 헤더 스타일 적용
+ const headerRow = worksheet.getRow(1);
+ headerRow.eachCell((cell) => {
+ cell.fill = {
+ type: 'pattern',
+ pattern: 'solid',
+ fgColor: { argb: 'FFE6F3FF' }
+ };
+ cell.font = {
+ bold: true,
+ color: { argb: 'FF1F4E79' }
+ };
+ cell.border = {
+ top: { style: 'thin' },
+ left: { style: 'thin' },
+ bottom: { style: 'thin' },
+ right: { style: 'thin' }
+ };
+ cell.alignment = {
+ vertical: 'middle',
+ horizontal: 'center'
+ };
+ });
+
+ // 샘플 데이터 추가
+ const sampleData = [
+ { itemCode: 'ITEM001', vendorCode: 'V001', vendorEmail: '' },
+ { itemCode: 'ITEM001', vendorCode: 'V002', vendorEmail: '' },
+ { itemCode: 'ITEM002', vendorCode: '', vendorEmail: 'vendor@example.com' },
+ { itemCode: 'ITEM002', vendorCode: 'V002', vendorEmail: '' },
+ { itemCode: 'ITEM004', vendorCode: '', vendorEmail: 'vendor2@example.com' },
+ ];
+
+ sampleData.forEach((data) => {
+ const row = worksheet.addRow(data);
+ row.eachCell((cell) => {
+ cell.border = {
+ top: { style: 'thin' },
+ left: { style: 'thin' },
+ bottom: { style: 'thin' },
+ right: { style: 'thin' }
+ };
+ cell.alignment = {
+ vertical: 'middle',
+ horizontal: 'left'
+ };
+ });
+ });
+
+ // 안내사항 워크시트 생성
+ const guideSheet = workbook.addWorksheet('사용 가이드');
+
+ const guideData = [
+ ['기술영업 벤더 가능 아이템 Import 템플릿', ''],
+ ['', ''],
+ ['📋 사용 방법:', ''],
+ ['1. "기술영업 벤더 가능 아이템" 시트에 데이터를 입력하세요', ''],
+ ['2. 벤더 식별: 벤더코드 또는 벤더이메일 중 하나는 반드시 입력', ''],
+ [' • 벤더코드가 있으면 벤더코드를 우선 사용', ''],
+ [' • 벤더코드가 없으면 벤더이메일로 벤더 검색', ''],
+ ['3. 아이템코드는 실제 존재하는 아이템코드를 사용하세요', ''],
+ ['4. 한 아이템코드에 여러 벤더를 매핑할 수 있습니다 (1:N 관계)', ''],
+ ['5. 중복된 벤더-아이템 조합은 무시됩니다', ''],
+ ['6. 파일 저장 후 시스템에서 업로드하세요', ''],
+ ['', ''],
+ ['⚠️ 중요 사항:', ''],
+ ['- 벤더코드 또는 벤더이메일 중 하나는 반드시 필요', ''],
+ ['- 벤더코드가 우선, 없으면 벤더이메일로 검색', ''],
+ ['- 중복된 벤더-아이템 조합은 건너뜁니다', ''],
+ ['- 오류가 있는 항목은 별도 파일로 다운로드됩니다', ''],
+ ['- 빈 셀이 있으면 해당 행은 무시됩니다', ''],
+ ['', ''],
+ ['💡 팁:', ''],
+ ['- 벤더코드만 존재하면 어떤 아이템코드든 입력 가능합니다', ''],
+ ['- 아이템코드는 그대로 시스템에 저장됩니다', ''],
+ ['', ''],
+ ['📞 문의사항이 있으시면 시스템 관리자에게 연락하세요.', ''],
+ ];
+
+ guideData.forEach((rowData, index) => {
+ const row = guideSheet.addRow(rowData);
+ if (index === 0) {
+ // 제목 스타일
+ row.getCell(1).font = { bold: true, size: 16, color: { argb: 'FF1F4E79' } };
+ } else if (rowData[0]?.includes(':')) {
+ // 섹션 제목 스타일
+ row.getCell(1).font = { bold: true, color: { argb: 'FF1F4E79' } };
+ } else if (rowData[0]?.includes('•') || rowData[0]?.includes('-')) {
+ // 리스트 아이템 스타일
+ row.getCell(1).font = { color: { argb: 'FF333333' } };
+ }
+ });
+
+ guideSheet.getColumn(1).width = 70;
+ guideSheet.getColumn(2).width = 20;
+
+ // 파일 생성 및 다운로드
+ try {
+ const buffer = await workbook.xlsx.writeBuffer();
+ const blob = new Blob([buffer], {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
+ });
+
+ const fileName = `기술영업_벤더_가능_아이템_템플릿_${new Date().toISOString().split('T')[0]}.xlsx`;
+ saveAs(blob, fileName);
+
+ return { success: true };
+ } catch (error) {
+ console.error("Excel 템플릿 생성 중 오류:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "템플릿 생성 중 오류가 발생했습니다."
+ };
+ }
+}
\ No newline at end of file diff --git a/lib/tech-vendor-possible-items/table/possible-items-data-table.tsx b/lib/tech-vendor-possible-items/table/possible-items-data-table.tsx new file mode 100644 index 00000000..5252684b --- /dev/null +++ b/lib/tech-vendor-possible-items/table/possible-items-data-table.tsx @@ -0,0 +1,90 @@ +"use client";
+
+import * as React from "react";
+
+import { useDataTable } from "@/hooks/use-data-table";
+import { DataTable } from "@/components/data-table/data-table";
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar";
+
+import { getColumns } from "./possible-items-table-columns";
+import { PossibleItemsTableToolbarActions } from "./possible-items-table-toolbar-actions";
+
+// 타입만 import
+type TechVendorPossibleItemsData = {
+ id: number;
+ vendorId: number;
+ vendorCode: string | null;
+ vendorName: string;
+ techVendorType: string;
+ itemCode: string;
+ createdAt: Date;
+ updatedAt: Date;
+};
+import type { DataTableAdvancedFilterField } from "@/types/table";
+
+interface PossibleItemsDataTableProps {
+ promises: Promise<[{
+ data: TechVendorPossibleItemsData[];
+ pageCount: number;
+ totalCount: number;
+ }, string[]]>;
+}
+
+export function PossibleItemsDataTable({ promises }: PossibleItemsDataTableProps) {
+ const [{ data, pageCount }, vendorTypes] = React.use(promises);
+
+ const columns = React.useMemo(() => getColumns(), []);
+
+ const filterFields: DataTableAdvancedFilterField<TechVendorPossibleItemsData>[] = [
+ {
+ id: "vendorCode",
+ label: "벤더코드",
+ type: "text",
+ },
+ {
+ id: "vendorName",
+ label: "벤더명",
+ type: "text",
+ },
+ {
+ id: "itemCode",
+ label: "아이템코드",
+ type: "text",
+ },
+ {
+ id: "techVendorType",
+ label: "벤더타입",
+ type: "multi-select",
+ options: Array.isArray(vendorTypes) ? vendorTypes.map((type: string) => ({
+ label: type,
+ value: type,
+ count: 0,
+ })) : [],
+ },
+ ];
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "createdAt", desc: true }],
+ pagination: { pageIndex: 0, pageSize: 10 },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ });
+
+ return (
+ <>
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar table={table} filterFields={filterFields}>
+ <PossibleItemsTableToolbarActions table={table} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ </>
+ );
+}
\ No newline at end of file diff --git a/lib/tech-vendor-possible-items/table/possible-items-table-columns.tsx b/lib/tech-vendor-possible-items/table/possible-items-table-columns.tsx new file mode 100644 index 00000000..520c089e --- /dev/null +++ b/lib/tech-vendor-possible-items/table/possible-items-table-columns.tsx @@ -0,0 +1,140 @@ +"use client";
+
+import { ColumnDef } from "@tanstack/react-table";
+import { Checkbox } from "@/components/ui/checkbox";
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header";
+import Link from "next/link";
+// 타입만 import
+type TechVendorPossibleItemsData = {
+ id: number;
+ vendorId: number;
+ vendorCode: string | null;
+ vendorName: string;
+ techVendorType: string;
+ itemCode: string;
+ createdAt: Date;
+ updatedAt: Date;
+};
+import { format } from "date-fns";
+import { ko } from "date-fns/locale";
+import { Badge } from "@/components/ui/badge";
+
+export function getColumns(): ColumnDef<TechVendorPossibleItemsData>[] {
+ return [
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="모두 선택"
+ className="translate-y-[2px]"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="행 선택"
+ className="translate-y-[2px]"
+ />
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ },
+ {
+ accessorKey: "itemCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="아이템코드" />
+ ),
+ cell: ({ row }) => {
+ const itemCode = row.getValue("itemCode") as string;
+ return <div className="font-medium">{itemCode}</div>;
+ },
+ },
+ {
+ accessorKey: "vendorCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="벤더코드" />
+ ),
+ cell: ({ row }) => {
+ const vendorCode = row.getValue("vendorCode") as string;
+ return <div className="font-medium">{vendorCode || "-"}</div>;
+ },
+ },
+ {
+ accessorKey: "vendorName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="벤더명" />
+ ),
+ cell: ({ row }) => {
+ const vendorName = row.getValue("vendorName") as string;
+ const vendorId = row.original.vendorId;
+ return (
+ <Link
+ href={`/ko/evcp/tech-vendors/${vendorId}/info`}
+ className="max-w-[200px] truncate hover:underline"
+ >
+ {vendorName}
+ </Link>
+ );
+ },
+ },
+ {
+ accessorKey: "techVendorType",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="벤더타입" />
+ ),
+ cell: ({ row }) => {
+ const techVendorType = row.getValue("techVendorType") as string;
+
+ // JSON 배열인지 확인하고 파싱
+ let types: string[] = [];
+ try {
+ const parsed = JSON.parse(techVendorType || "[]");
+ types = Array.isArray(parsed) ? parsed : [techVendorType];
+ } catch {
+ types = [techVendorType];
+ }
+
+ return (
+ <div className="flex flex-wrap gap-1">
+ {types.map((type, index) => (
+ <Badge key={index} variant="secondary" className="text-xs">
+ {type}
+ </Badge>
+ ))}
+ </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;
+ }
+ },
+ },
+
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="생성일시" />
+ ),
+ cell: ({ row }) => {
+ const createdAt = row.getValue("createdAt") as Date;
+ return (
+ <div className="text-sm text-muted-foreground">
+ {format(createdAt, "yyyy-MM-dd HH:mm", { locale: ko })}
+ </div>
+ );
+ },
+ },
+ ];
+}
\ No newline at end of file diff --git a/lib/tech-vendor-possible-items/table/possible-items-table-toolbar-actions.tsx b/lib/tech-vendor-possible-items/table/possible-items-table-toolbar-actions.tsx new file mode 100644 index 00000000..3628f87e --- /dev/null +++ b/lib/tech-vendor-possible-items/table/possible-items-table-toolbar-actions.tsx @@ -0,0 +1,201 @@ +"use client";
+
+import * as React from "react";
+import { type Table } from "@tanstack/react-table";
+import { Download, Upload, FileSpreadsheet, Trash2 } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { useToast } from "@/hooks/use-toast";
+import { deleteTechVendorPossibleItems } from "@/lib/tech-vendor-possible-items/service";
+// Excel 함수들을 동적 import로만 사용하기 위해 타입만 import
+type TechVendorPossibleItemsData = {
+ id: number;
+ vendorId: number;
+ vendorCode: string | null;
+ vendorName: string;
+ techVendorType: string;
+ itemCode: string;
+ createdAt: Date;
+ updatedAt: Date;
+};
+
+interface PossibleItemsTableToolbarActionsProps {
+ table: Table<TechVendorPossibleItemsData>;
+}
+
+export function PossibleItemsTableToolbarActions({
+ table,
+}: PossibleItemsTableToolbarActionsProps) {
+ const { toast } = useToast();
+ const [isPending, startTransition] = React.useTransition();
+
+ const selectedRows = table.getFilteredSelectedRowModel().rows;
+ const hasSelection = selectedRows.length > 0;
+
+ const handleDelete = () => {
+ if (!hasSelection) return;
+
+ startTransition(async () => {
+ const selectedIds = selectedRows.map((row) => row.original.id);
+
+ try {
+ const result = await deleteTechVendorPossibleItems(selectedIds);
+
+ if (result.success) {
+ toast({
+ title: "성공",
+ description: `${selectedIds.length}개의 아이템이 삭제되었습니다.`,
+ });
+ table.toggleAllRowsSelected(false);
+ // 페이지 새로고침이나 데이터 다시 로드 필요
+ window.location.reload();
+ } else {
+ toast({
+ title: "오류",
+ description: result.error || "삭제 중 오류가 발생했습니다.",
+ variant: "destructive",
+ });
+ }
+ } catch (error) {
+ console.error("Delete error:", error);
+ toast({
+ title: "오류",
+ description: "삭제 중 오류가 발생했습니다.",
+ variant: "destructive",
+ });
+ }
+ });
+ };
+
+ const handleExport = async () => {
+ try {
+ const { exportTechVendorPossibleItemsToExcel } = await import("./excel-export");
+ const result = await exportTechVendorPossibleItemsToExcel(table.getFilteredRowModel().rows.map(row => row.original));
+
+ if (result.success) {
+ toast({
+ title: "성공",
+ description: "Excel 파일이 다운로드되었습니다.",
+ });
+ } else {
+ toast({
+ title: "오류",
+ description: result.error || "내보내기 중 오류가 발생했습니다.",
+ variant: "destructive",
+ });
+ }
+ } catch (error) {
+ console.error("Export error:", error);
+ toast({
+ title: "오류",
+ description: "내보내기 중 오류가 발생했습니다.",
+ variant: "destructive",
+ });
+ }
+ };
+
+ const handleImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
+ const file = event.target.files?.[0];
+ if (!file) return;
+
+ try {
+ const { importTechVendorPossibleItemsFromExcel } = await import("./excel-import");
+ const result = await importTechVendorPossibleItemsFromExcel(file);
+
+ if (result.success) {
+ toast({
+ title: "성공",
+ description: `${result.successCount}개의 아이템이 가져와졌습니다.`,
+ });
+ // 페이지 새로고침이나 데이터 다시 로드 필요
+ window.location.reload();
+ } else {
+ toast({
+ title: "가져오기 완료",
+ description: `${result.successCount}개 성공, ${result.failedRows.length}개 실패`,
+ variant: result.successCount > 0 ? "default" : "destructive",
+ });
+ }
+ } catch (error) {
+ console.error("Import error:", error);
+ toast({
+ title: "오류",
+ description: "가져오기 중 오류가 발생했습니다.",
+ variant: "destructive",
+ });
+ }
+
+ // Reset input
+ event.target.value = "";
+ };
+
+ const handleDownloadTemplate = async () => {
+ try {
+ const { exportTechVendorPossibleItemsTemplate } = await import("./excel-template");
+ const result = await exportTechVendorPossibleItemsTemplate();
+ if (result.success) {
+ toast({
+ title: "성공",
+ description: "템플릿 파일이 다운로드되었습니다.",
+ });
+ } else {
+ toast({
+ title: "오류",
+ description: result.error || "템플릿 다운로드 중 오류가 발생했습니다.",
+ variant: "destructive",
+ });
+ }
+ } catch (error) {
+ console.error("Template download error:", error);
+ toast({
+ title: "오류",
+ description: "템플릿 다운로드 중 오류가 발생했습니다.",
+ variant: "destructive",
+ });
+ }
+ };
+
+ return (
+ <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>
+ )}
+
+ <Button variant="outline" size="sm" onClick={handleExport}>
+ <Download className="mr-2 h-4 w-4" />
+ Export
+ </Button>
+
+ <Input
+ id="import-file"
+ type="file"
+ accept=".xlsx,.xls"
+ onChange={handleImport}
+ 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" />
+ Download Template
+ </Button>
+ </div>
+ );
+}
\ No newline at end of file diff --git a/lib/tech-vendor-possible-items/validations.ts b/lib/tech-vendor-possible-items/validations.ts new file mode 100644 index 00000000..1e42264b --- /dev/null +++ b/lib/tech-vendor-possible-items/validations.ts @@ -0,0 +1,45 @@ +import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server"
+import * as z from "zod"
+
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+import { techVendorPossibleItems } from "@/db/schema/techVendors"
+
+export const searchParamsTechVendorPossibleItemsCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(20),
+ sort: getSortingStateParser<typeof techVendorPossibleItems>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+
+ // advanced filter
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+
+ // 추가 필터 (기존 호환성)
+ vendorCode: parseAsString.withDefault(""),
+ vendorName: parseAsString.withDefault(""),
+ itemCode: parseAsString.withDefault(""),
+ vendorType: parseAsString.withDefault(""),
+})
+
+export const createTechVendorPossibleItemSchema = z.object({
+ vendorId: z.number().min(1, "벤더를 선택해주세요"),
+ itemCode: z.string().min(1, "아이템 코드를 입력해주세요"),
+})
+
+export const updateTechVendorPossibleItemSchema = createTechVendorPossibleItemSchema.extend({
+ id: z.number(),
+})
+
+export type CreateTechVendorPossibleItemSchema = z.infer<typeof createTechVendorPossibleItemSchema>
+export type UpdateTechVendorPossibleItemSchema = z.infer<typeof updateTechVendorPossibleItemSchema>
+
+export type GetTechVendorPossibleItemsSchema = Awaited<ReturnType<typeof searchParamsTechVendorPossibleItemsCache.parse>>
\ No newline at end of file |
