From 14f61e24947fb92dd71ec0a7196a6e815f8e66da Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 21 Jul 2025 07:54:26 +0000 Subject: (최겸)기술영업 RFQ 담당자 초대, 요구사항 반영 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/items-tech/repository.ts | 248 ++++----- lib/items-tech/service.ts | 34 +- lib/items-tech/table/delete-items-dialog.tsx | 388 ++++++------- lib/items-tech/table/feature-flags.tsx | 192 +++---- lib/items-tech/table/hull/import-item-handler.tsx | 254 ++++----- lib/items-tech/table/hull/item-excel-template.tsx | 210 +++---- lib/items-tech/table/import-excel-button.tsx | 606 ++++++++++----------- lib/items-tech/table/ship/import-item-handler.tsx | 266 +++++---- lib/items-tech/table/ship/item-excel-template.tsx | 220 ++++---- .../table/ship/items-table-toolbar-actions.tsx | 352 ++++++------ lib/items-tech/table/top/import-item-handler.tsx | 271 +++++---- lib/items-tech/table/top/item-excel-template.tsx | 218 ++++---- 12 files changed, 1634 insertions(+), 1625 deletions(-) (limited to 'lib/items-tech') diff --git a/lib/items-tech/repository.ts b/lib/items-tech/repository.ts index 1f4f7933..10ae2dab 100644 --- a/lib/items-tech/repository.ts +++ b/lib/items-tech/repository.ts @@ -1,124 +1,124 @@ -// src/lib/items/repository.ts -import db from "@/db/db"; -import { Item, ItemOffshoreTop, ItemOffshoreHull, itemOffshoreHull, itemOffshoreTop, items } from "@/db/schema/items"; -import { - eq, - inArray, - asc, - desc, - count, -} from "drizzle-orm"; -import { PgTransaction } from "drizzle-orm/pg-core"; -export type NewItem = typeof items.$inferInsert - -/** - * 단건/복수 조회 시 공통으로 사용 가능한 SELECT 함수 예시 - * - 트랜잭션(tx)을 받아서 사용하도록 구현 - */ -export async function selectItems( - tx: PgTransaction, - params: { - where?: any; // drizzle-orm의 조건식 (and, eq...) 등 - orderBy?: (ReturnType | ReturnType)[]; - offset?: number; - limit?: number; - } -) { - const { where, orderBy, offset = 0, limit = 10 } = params; - - return tx - .select() - .from(items) - .where(where) - .orderBy(...(orderBy ?? [])) - .offset(offset) - .limit(limit); -} -/** 총 개수 count */ -export async function countItems( - tx: PgTransaction, - where?: any -) { - const res = await tx.select({ count: count() }).from(items).where(where); - return res[0]?.count ?? 0; -} - -/** 단건 Insert 예시 */ -export async function insertItem( - tx: PgTransaction, - data: NewItem // DB와 동일한 insert 가능한 타입 -) { - // returning() 사용 시 배열로 돌아오므로 [0]만 리턴 - return tx - .insert(items) - .values(data) - .returning({ id: items.id, createdAt: items.createdAt }); -} - -/** 복수 Insert 예시 */ -export async function insertItems( - tx: PgTransaction, - data: Item[] -) { - return tx.insert(items).values(data).onConflictDoNothing(); -} - - - -/** 단건 삭제 */ -export async function deleteItemById( - tx: PgTransaction, - itemId: number -) { - return tx.delete(items).where(eq(items.id, itemId)); -} - -/** 복수 삭제 */ -export async function deleteItemsByIds( - tx: PgTransaction, - ids: number[] -) { - return tx.delete(items).where(inArray(items.id, ids)); -} - -/** 전체 삭제 */ -export async function deleteAllItems( - tx: PgTransaction, -) { - return tx.delete(items); -} - -/** 단건 업데이트 */ -export async function updateItem( - tx: PgTransaction, - itemId: number, - data: Partial -) { - return tx - .update(items) - .set(data) - .where(eq(items.id, itemId)) - .returning({ id: items.id, createdAt: items.createdAt }); -} - -/** 복수 업데이트 */ -export async function updateItems( - tx: PgTransaction, - ids: number[], - data: Partial -) { - return tx - .update(items) - .set(data) - .where(inArray(items.id, ids)) - .returning({ id: items.id, createdAt: items.createdAt }); -} - -export async function findAllItems(): Promise { - return db.select().from(items).orderBy(asc(items.itemCode)); -} -export async function findAllOffshoreItems(): Promise<(ItemOffshoreHull | ItemOffshoreTop)[]> { - const hullItems = await db.select().from(itemOffshoreHull); - const topItems = await db.select().from(itemOffshoreTop); - return [...hullItems, ...topItems]; -} +// src/lib/items/repository.ts +import db from "@/db/db"; +import { Item, ItemOffshoreTop, ItemOffshoreHull, itemOffshoreHull, itemOffshoreTop, items } from "@/db/schema/items"; +import { + eq, + inArray, + asc, + desc, + count, +} from "drizzle-orm"; +import { PgTransaction } from "drizzle-orm/pg-core"; +export type NewItem = typeof items.$inferInsert + +/** + * 단건/복수 조회 시 공통으로 사용 가능한 SELECT 함수 예시 + * - 트랜잭션(tx)을 받아서 사용하도록 구현 + */ +export async function selectItems( + tx: PgTransaction, + params: { + where?: any; // drizzle-orm의 조건식 (and, eq...) 등 + orderBy?: (ReturnType | ReturnType)[]; + offset?: number; + limit?: number; + } +) { + const { where, orderBy, offset = 0, limit = 10 } = params; + + return tx + .select() + .from(items) + .where(where) + .orderBy(...(orderBy ?? [])) + .offset(offset) + .limit(limit); +} +/** 총 개수 count */ +export async function countItems( + tx: PgTransaction, + where?: any +) { + const res = await tx.select({ count: count() }).from(items).where(where); + return res[0]?.count ?? 0; +} + +/** 단건 Insert 예시 */ +export async function insertItem( + tx: PgTransaction, + data: NewItem // DB와 동일한 insert 가능한 타입 +) { + // returning() 사용 시 배열로 돌아오므로 [0]만 리턴 + return tx + .insert(items) + .values(data) + .returning({ id: items.id, createdAt: items.createdAt }); +} + +/** 복수 Insert 예시 */ +export async function insertItems( + tx: PgTransaction, + data: Item[] +) { + return tx.insert(items).values(data).onConflictDoNothing(); +} + + + +/** 단건 삭제 */ +export async function deleteItemById( + tx: PgTransaction, + itemId: number +) { + return tx.delete(items).where(eq(items.id, itemId)); +} + +/** 복수 삭제 */ +export async function deleteItemsByIds( + tx: PgTransaction, + ids: number[] +) { + return tx.delete(items).where(inArray(items.id, ids)); +} + +/** 전체 삭제 */ +export async function deleteAllItems( + tx: PgTransaction, +) { + return tx.delete(items); +} + +/** 단건 업데이트 */ +export async function updateItem( + tx: PgTransaction, + itemId: number, + data: Partial +) { + return tx + .update(items) + .set(data) + .where(eq(items.id, itemId)) + .returning({ id: items.id, createdAt: items.createdAt }); +} + +/** 복수 업데이트 */ +export async function updateItems( + tx: PgTransaction, + ids: number[], + data: Partial +) { + return tx + .update(items) + .set(data) + .where(inArray(items.id, ids)) + .returning({ id: items.id, createdAt: items.createdAt }); +} + +export async function findAllItems(): Promise { + return db.select().from(items).orderBy(asc(items.itemCode)); +} +export async function findAllOffshoreItems(): Promise<(ItemOffshoreHull | ItemOffshoreTop)[]> { + const hullItems = await db.select().from(itemOffshoreHull); + const topItems = await db.select().from(itemOffshoreTop); + return [...hullItems, ...topItems]; +} diff --git a/lib/items-tech/service.ts b/lib/items-tech/service.ts index bf2684d7..d93c5f96 100644 --- a/lib/items-tech/service.ts +++ b/lib/items-tech/service.ts @@ -405,7 +405,14 @@ export async function createShipbuildingItem(input: TypedItemCreateData) { unstable_noStore() try { - // itemCode는 nullable하게 변경 + if (!input.itemCode) { + return { + success: false, + message: "아이템 코드는 필수입니다", + data: null, + error: "필수 필드 누락" + } + } const shipData = input as ShipbuildingItemCreateData; const result = await db.insert(itemShipbuilding).values({ @@ -459,7 +466,14 @@ export async function createShipbuildingImportItem(input: { unstable_noStore(); try { - // itemCode는 nullable하게 변경 + if (!input.itemCode) { + return { + success: false, + message: "아이템 코드는 필수입니다", + data: null, + error: "필수 필드 누락" + } + } // 기존 아이템 및 선종 확인 (itemCode가 있을 경우에만) if (input.itemCode) { @@ -525,6 +539,14 @@ export async function createOffshoreTopItem(data: OffshoreTopItemCreateData) { unstable_noStore(); try { + if (!data.itemCode) { + return { + success: false, + message: "아이템 코드는 필수입니다", + data: null, + error: "필수 필드 누락" + } + } // itemCode가 있는 경우 중복 체크 if (data.itemCode && data.itemCode.trim() !== "") { const existingItem = await db @@ -586,6 +608,14 @@ export async function createOffshoreHullItem(data: OffshoreHullItemCreateData) { unstable_noStore(); try { + if (!data.itemCode) { + return { + success: false, + message: "아이템 코드는 필수입니다", + data: null, + error: "필수 필드 누락" + } + } // itemCode가 있는 경우 중복 체크 if (data.itemCode && data.itemCode.trim() !== "") { const existingItem = await db diff --git a/lib/items-tech/table/delete-items-dialog.tsx b/lib/items-tech/table/delete-items-dialog.tsx index b94a2333..6ec4b4c7 100644 --- a/lib/items-tech/table/delete-items-dialog.tsx +++ b/lib/items-tech/table/delete-items-dialog.tsx @@ -1,194 +1,194 @@ -"use client" - -import * as React from "react" -import { type Row } from "@tanstack/react-table" -import { Loader, Trash } from "lucide-react" -import { toast } from "sonner" - -import { useMediaQuery } from "@/hooks/use-media-query" -import { Button } from "@/components/ui/button" -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog" -import { - Drawer, - DrawerClose, - DrawerContent, - DrawerDescription, - DrawerFooter, - DrawerHeader, - DrawerTitle, - DrawerTrigger, -} from "@/components/ui/drawer" - -import { Item } from "@/db/schema/items" -import { - removeShipbuildingItems, - removeOffshoreTopItems, - removeOffshoreHullItems -} from "../service" - -export type ItemType = 'shipbuilding' | 'offshoreTop' | 'offshoreHull'; - -interface DeleteItemsDialogProps - extends React.ComponentPropsWithoutRef { - items: Row["original"][] - showTrigger?: boolean - onSuccess?: () => void - itemType: ItemType -} - -export function DeleteItemsDialog({ - items, - showTrigger = true, - onSuccess, - itemType, - ...props -}: DeleteItemsDialogProps) { - const [isDeletePending, startDeleteTransition] = React.useTransition() - const isDesktop = useMediaQuery("(min-width: 640px)") - - const getItemTypeLabel = () => { - switch (itemType) { - case 'shipbuilding': - return '조선 아이템'; - case 'offshoreTop': - return '해양 TOP 아이템'; - case 'offshoreHull': - return '해양 HULL 아이템'; - default: - return '아이템'; - } - } - - async function onDelete() { - try { - startDeleteTransition(async () => { - let result; - - switch (itemType) { - case 'shipbuilding': - result = await removeShipbuildingItems({ - ids: items.map((item) => item.id), - }); - break; - case 'offshoreTop': - result = await removeOffshoreTopItems({ - ids: items.map((item) => item.id), - }); - break; - case 'offshoreHull': - result = await removeOffshoreHullItems({ - ids: items.map((item) => item.id), - }); - break; - default: - toast.error("지원하지 않는 아이템 타입입니다"); - return; - } - - if (result.error) { - toast.error(result.error) - return - } - - props.onOpenChange?.(false) - toast.success("아이템 삭제 완료") - onSuccess?.() - }) - } catch (error) { - toast.error("오류가 발생했습니다.") - console.error(error) - } - } - - if (isDesktop) { - return ( - - {showTrigger ? ( - - - - ) : null} - - - 정말로 삭제하시겠습니까? - - 이 작업은 되돌릴 수 없습니다. 선택한{" "} - {items.length} - 개의 {getItemTypeLabel()}이(가) 영구적으로 삭제됩니다. - - - - - - - - - - - ) - } - - return ( - - {showTrigger ? ( - - - - ) : null} - - - 정말로 삭제하시겠습니까? - - 이 작업은 되돌릴 수 없습니다. 선택한{" "} - {items.length} - 개의 {getItemTypeLabel()}이(가) 영구적으로 삭제됩니다. - - - - - - - - - - - ) -} +"use client" + +import * as React from "react" +import { type Row } from "@tanstack/react-table" +import { Loader, Trash } from "lucide-react" +import { toast } from "sonner" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" + +import { Item } from "@/db/schema/items" +import { + removeShipbuildingItems, + removeOffshoreTopItems, + removeOffshoreHullItems +} from "../service" + +export type ItemType = 'shipbuilding' | 'offshoreTop' | 'offshoreHull'; + +interface DeleteItemsDialogProps + extends React.ComponentPropsWithoutRef { + items: Row["original"][] + showTrigger?: boolean + onSuccess?: () => void + itemType: ItemType +} + +export function DeleteItemsDialog({ + items, + showTrigger = true, + onSuccess, + itemType, + ...props +}: DeleteItemsDialogProps) { + const [isDeletePending, startDeleteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + const getItemTypeLabel = () => { + switch (itemType) { + case 'shipbuilding': + return '조선 아이템'; + case 'offshoreTop': + return '해양 TOP 아이템'; + case 'offshoreHull': + return '해양 HULL 아이템'; + default: + return '아이템'; + } + } + + async function onDelete() { + try { + startDeleteTransition(async () => { + let result; + + switch (itemType) { + case 'shipbuilding': + result = await removeShipbuildingItems({ + ids: items.map((item) => item.id), + }); + break; + case 'offshoreTop': + result = await removeOffshoreTopItems({ + ids: items.map((item) => item.id), + }); + break; + case 'offshoreHull': + result = await removeOffshoreHullItems({ + ids: items.map((item) => item.id), + }); + break; + default: + toast.error("지원하지 않는 아이템 타입입니다"); + return; + } + + if (result.error) { + toast.error(result.error) + return + } + + props.onOpenChange?.(false) + toast.success("아이템 삭제 완료") + onSuccess?.() + }) + } catch (error) { + toast.error("오류가 발생했습니다.") + console.error(error) + } + } + + if (isDesktop) { + return ( + + {showTrigger ? ( + + + + ) : null} + + + 정말로 삭제하시겠습니까? + + 이 작업은 되돌릴 수 없습니다. 선택한{" "} + {items.length} + 개의 {getItemTypeLabel()}이(가) 영구적으로 삭제됩니다. + + + + + + + + + + + ) + } + + return ( + + {showTrigger ? ( + + + + ) : null} + + + 정말로 삭제하시겠습니까? + + 이 작업은 되돌릴 수 없습니다. 선택한{" "} + {items.length} + 개의 {getItemTypeLabel()}이(가) 영구적으로 삭제됩니다. + + + + + + + + + + + ) +} diff --git a/lib/items-tech/table/feature-flags.tsx b/lib/items-tech/table/feature-flags.tsx index aaae6af2..cc5093ca 100644 --- a/lib/items-tech/table/feature-flags.tsx +++ b/lib/items-tech/table/feature-flags.tsx @@ -1,96 +1,96 @@ -"use client" - -import * as React from "react" -import { useQueryState } from "nuqs" - -import { dataTableConfig, type DataTableConfig } from "@/config/data-table" -import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip" - -type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"] - -interface TasksTableContextProps { - featureFlags: FeatureFlagValue[] - setFeatureFlags: (value: FeatureFlagValue[]) => void -} - -const TasksTableContext = React.createContext({ - featureFlags: [], - setFeatureFlags: () => {}, -}) - -export function useTasksTable() { - const context = React.useContext(TasksTableContext) - if (!context) { - throw new Error("useTasksTable must be used within a TasksTableProvider") - } - return context -} - -export function TasksTableProvider({ children }: React.PropsWithChildren) { - const [featureFlags, setFeatureFlags] = useQueryState( - "featureFlags", - { - defaultValue: [], - parse: (value) => value.split(",") as FeatureFlagValue[], - serialize: (value) => value.join(","), - eq: (a, b) => - a.length === b.length && a.every((value, index) => value === b[index]), - clearOnDefault: true, - } - ) - - return ( - void setFeatureFlags(value), - }} - > -
- setFeatureFlags(value)} - className="w-fit" - > - {dataTableConfig.featureFlags.map((flag) => ( - - - - - - -
{flag.tooltipTitle}
-
- {flag.tooltipDescription} -
-
-
- ))} -
-
- {children} -
- ) -} +"use client" + +import * as React from "react" +import { useQueryState } from "nuqs" + +import { dataTableConfig, type DataTableConfig } from "@/config/data-table" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" + +type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"] + +interface TasksTableContextProps { + featureFlags: FeatureFlagValue[] + setFeatureFlags: (value: FeatureFlagValue[]) => void +} + +const TasksTableContext = React.createContext({ + featureFlags: [], + setFeatureFlags: () => {}, +}) + +export function useTasksTable() { + const context = React.useContext(TasksTableContext) + if (!context) { + throw new Error("useTasksTable must be used within a TasksTableProvider") + } + return context +} + +export function TasksTableProvider({ children }: React.PropsWithChildren) { + const [featureFlags, setFeatureFlags] = useQueryState( + "featureFlags", + { + defaultValue: [], + parse: (value) => value.split(",") as FeatureFlagValue[], + serialize: (value) => value.join(","), + eq: (a, b) => + a.length === b.length && a.every((value, index) => value === b[index]), + clearOnDefault: true, + } + ) + + return ( + void setFeatureFlags(value), + }} + > +
+ setFeatureFlags(value)} + className="w-fit" + > + {dataTableConfig.featureFlags.map((flag) => ( + + + + + + +
{flag.tooltipTitle}
+
+ {flag.tooltipDescription} +
+
+
+ ))} +
+
+ {children} +
+ ) +} diff --git a/lib/items-tech/table/hull/import-item-handler.tsx b/lib/items-tech/table/hull/import-item-handler.tsx index 8c8fc57d..9090dab1 100644 --- a/lib/items-tech/table/hull/import-item-handler.tsx +++ b/lib/items-tech/table/hull/import-item-handler.tsx @@ -1,127 +1,127 @@ -"use client" - -import { z } from "zod" -import { createOffshoreHullItem } from "../../service" - -// 해양 HULL 기능(공종) 유형 enum -const HULL_WORK_TYPES = ["HA", "HE", "HH", "HM", "HO", "HP", "NC"] as const; - -// 아이템 데이터 검증을 위한 Zod 스키마 -const itemSchema = z.object({ - itemCode: z.string().optional(), - workType: z.enum(HULL_WORK_TYPES, { - required_error: "기능(공종)은 필수입니다", - }), - itemList: z.string().nullable().optional(), - subItemList: z.string().nullable().optional(), -}); - -interface ProcessResult { - successCount: number; - errorCount: number; - errors: Array<{ row: number; message: string; itemCode?: string; workType?: string }>; -} - -/** - * Excel 파일에서 가져온 해양 HULL 아이템 데이터 처리하는 함수 - */ -export async function processHullFileImport( - jsonData: Record[], - progressCallback?: (current: number, total: number) => void -): Promise { - // 결과 카운터 초기화 - let successCount = 0; - let errorCount = 0; - const errors: Array<{ row: number; message: string }> = []; - - // 빈 행 등 필터링 - const dataRows = jsonData.filter(row => { - // 빈 행 건너뛰기 - if (Object.values(row).every(val => !val)) { - return false; - } - return true; - }); - - // 데이터 행이 없으면 빈 결과 반환 - if (dataRows.length === 0) { - return { successCount: 0, errorCount: 0, errors: [] }; - } - - // 각 행에 대해 처리 - for (let i = 0; i < dataRows.length; i++) { - const row = dataRows[i]; - const rowIndex = i + 1; // 사용자에게 표시할 행 번호는 1부터 시작 - - // 진행 상황 콜백 호출 - if (progressCallback) { - progressCallback(i + 1, dataRows.length); - } - - try { - // 필드 매핑 (한글/영문 필드명 모두 지원) - const itemCode = row["자재 그룹"] || row["itemCode"] || ""; - const workType = row["기능(공종)"] || row["workType"] || ""; - const itemList = row["자재명"] || row["itemList"] || null; - const subItemList = row["자재명(상세)"] || row["subItemList"] || null; - - // 데이터 정제 - const cleanedRow = { - itemCode: typeof itemCode === 'string' ? itemCode.trim() : String(itemCode).trim(), - workType: typeof workType === 'string' ? workType.trim() : String(workType).trim(), - itemList: itemList ? (typeof itemList === 'string' ? itemList : String(itemList)) : null, - subItemList: subItemList ? (typeof subItemList === 'string' ? subItemList : String(subItemList)) : null, - }; - - // 데이터 유효성 검사 - const validationResult = itemSchema.safeParse(cleanedRow); - - if (!validationResult.success) { - const errorMessage = validationResult.error.errors.map( - err => `${err.path.join('.')}: ${err.message}` - ).join(', '); - - errors.push({ row: rowIndex, message: errorMessage }); - errorCount++; - continue; - } - - // 해양 HULL 아이템 생성 - const result = await createOffshoreHullItem({ - itemCode: cleanedRow.itemCode, - workType: cleanedRow.workType as "HA" | "HE" | "HH" | "HM" | "HO" | "HP" | "NC", - itemList: cleanedRow.itemList, - subItemList: cleanedRow.subItemList, - }); - - if (result.success) { - successCount++; - } else { - errors.push({ - row: rowIndex, - message: result.message || result.error || "알 수 없는 오류" - }); - errorCount++; - } - } catch (error) { - console.error(`${rowIndex}행 처리 오류:`, error); - errors.push({ - row: rowIndex, - message: error instanceof Error ? error.message : "알 수 없는 오류" - }); - errorCount++; - } - - // 비동기 작업 쓰로틀링 - if (i % 5 === 0) { - await new Promise(resolve => setTimeout(resolve, 10)); - } - } - - // 처리 결과 반환 - return { - successCount, - errorCount, - errors: errors.length > 0 ? errors : undefined - }; -} +"use client" + +import { z } from "zod" +import { createOffshoreHullItem } from "../../service" + +// 해양 HULL 기능(공종) 유형 enum +const HULL_WORK_TYPES = ["HA", "HE", "HH", "HM", "HO", "HP", "NC"] as const; + +// 아이템 데이터 검증을 위한 Zod 스키마 +const itemSchema = z.object({ + itemCode: z.string().optional(), + workType: z.enum(HULL_WORK_TYPES, { + required_error: "기능(공종)은 필수입니다", + }), + itemList: z.string().nullable().optional(), + subItemList: z.string().nullable().optional(), +}); + +interface ProcessResult { + successCount: number; + errorCount: number; + errors: Array<{ row: number; message: string }>; +} + +/** + * Excel 파일에서 가져온 해양 HULL 아이템 데이터 처리하는 함수 + */ +export async function processHullFileImport( + jsonData: Record[], + progressCallback?: (current: number, total: number) => void +): Promise { + // 결과 카운터 초기화 + let successCount = 0; + let errorCount = 0; + const errors: Array<{ row: number; message: string }> = []; + + // 빈 행 등 필터링 + const dataRows = jsonData.filter(row => { + // 빈 행 건너뛰기 + if (Object.values(row).every(val => !val)) { + return false; + } + return true; + }); + + // 데이터 행이 없으면 빈 결과 반환 + if (dataRows.length === 0) { + return { successCount: 0, errorCount: 0, errors: [] }; + } + + // 각 행에 대해 처리 + for (let i = 0; i < dataRows.length; i++) { + const row = dataRows[i]; + const rowIndex = i + 1; // 사용자에게 표시할 행 번호는 1부터 시작 + + // 진행 상황 콜백 호출 + if (progressCallback) { + progressCallback(i + 1, dataRows.length); + } + + try { + // 필드 매핑 (한글/영문 필드명 모두 지원) + const itemCode = row["자재 그룹"] || row["itemCode"] || ""; + const workType = row["기능(공종)"] || row["workType"] || ""; + const itemList = row["자재명"] || row["itemList"] || null; + const subItemList = row["자재명(상세)"] || row["subItemList"] || null; + + // 데이터 정제 + const cleanedRow = { + itemCode: typeof itemCode === 'string' ? itemCode.trim() : String(itemCode).trim(), + workType: typeof workType === 'string' ? workType.trim() : String(workType).trim(), + itemList: itemList ? (typeof itemList === 'string' ? itemList : String(itemList)) : null, + subItemList: subItemList ? (typeof subItemList === 'string' ? subItemList : String(subItemList)) : null, + }; + + // 데이터 유효성 검사 + const validationResult = itemSchema.safeParse(cleanedRow); + + if (!validationResult.success) { + const errorMessage = validationResult.error.errors.map( + err => `${err.path.join('.')}: ${err.message}` + ).join(', '); + + errors.push({ row: rowIndex, message: errorMessage }); + errorCount++; + continue; + } + + // 해양 HULL 아이템 생성 + const result = await createOffshoreHullItem({ + itemCode: cleanedRow.itemCode, + workType: cleanedRow.workType as "HA" | "HE" | "HH" | "HM" | "HO" | "HP" | "NC", + itemList: cleanedRow.itemList, + subItemList: cleanedRow.subItemList, + }); + + if (result.success) { + successCount++; + } else { + errors.push({ + row: rowIndex, + message: result.message || result.error || "알 수 없는 오류" + }); + errorCount++; + } + } catch (error) { + console.error(`${rowIndex}행 처리 오류:`, error); + errors.push({ + row: rowIndex, + message: error instanceof Error ? error.message : "알 수 없는 오류" + }); + errorCount++; + } + + // 비동기 작업 쓰로틀링 + if (i % 5 === 0) { + await new Promise(resolve => setTimeout(resolve, 10)); + } + } + + // 처리 결과 반환 + return { + successCount, + errorCount, + errors: errors.length > 0 ? errors : [] + }; +} diff --git a/lib/items-tech/table/hull/item-excel-template.tsx b/lib/items-tech/table/hull/item-excel-template.tsx index 79512b9b..2e5196e1 100644 --- a/lib/items-tech/table/hull/item-excel-template.tsx +++ b/lib/items-tech/table/hull/item-excel-template.tsx @@ -1,105 +1,105 @@ -import * as ExcelJS from 'exceljs'; -import { saveAs } from "file-saver"; - -/** - * 해양 HULL 아이템 데이터 가져오기를 위한 Excel 템플릿 파일 생성 및 다운로드 - */ -export async function exportHullItemTemplate() { - // 워크북 생성 - const workbook = new ExcelJS.Workbook(); - workbook.creator = 'Offshore HULL Item Management System'; - workbook.created = new Date(); - - // 워크시트 생성 - const worksheet = workbook.addWorksheet('해양 HULL 아이템'); - - // 컬럼 헤더 정의 및 스타일 적용 - worksheet.columns = [ - { header: '자재 그룹', key: 'itemCode', width: 15 }, - { header: '기능(공종)', key: 'workType', width: 15 }, - { header: '자재명', key: 'itemList', width: 20 }, - { header: '자재명(상세)', key: 'subItemList', width: 20 }, - ]; - - // 헤더 스타일 적용 - const headerRow = worksheet.getRow(1); - headerRow.font = { bold: true }; - headerRow.fill = { - type: 'pattern', - pattern: 'solid', - fgColor: { argb: 'FFE0E0E0' } - }; - headerRow.alignment = { vertical: 'middle', horizontal: 'center' }; - - // 테두리 스타일 적용 - headerRow.eachCell((cell) => { - cell.border = { - top: { style: 'thin' }, - left: { style: 'thin' }, - bottom: { style: 'thin' }, - right: { style: 'thin' } - }; - }); - - // 샘플 데이터 추가 - const sampleData = [ - { - itemCode: 'HULL001', - workType: 'HA', - itemList: '항목1 샘플 데이터', - subItemList: '항목2 샘플 데이터', - }, - { - itemCode: 'HULL002', - workType: 'HE', - itemList: '항목1 샘플 데이터', - subItemList: '항목2 샘플 데이터', - } - ]; - - // 데이터 행 추가 - sampleData.forEach(item => { - worksheet.addRow(item); - }); - - // 데이터 행 스타일 적용 - worksheet.eachRow((row, rowNumber) => { - if (rowNumber > 1) { // 헤더를 제외한 데이터 행 - row.eachCell((cell) => { - cell.border = { - top: { style: 'thin' }, - left: { style: 'thin' }, - bottom: { style: 'thin' }, - right: { style: 'thin' } - }; - }); - } - }); - - // 워크시트 보호 (선택적) - worksheet.protect('', { - selectLockedCells: true, - selectUnlockedCells: true, - formatColumns: true, - formatRows: true, - insertColumns: false, - insertRows: true, - insertHyperlinks: false, - deleteColumns: false, - deleteRows: true, - sort: true, - autoFilter: true, - pivotTables: false - }); - - try { - // 워크북을 Blob으로 변환 - const buffer = await workbook.xlsx.writeBuffer(); - const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); - saveAs(blob, 'offshore-hull-item-template.xlsx'); - return true; - } catch (error) { - console.error('Excel 템플릿 생성 오류:', error); - throw error; - } -} +import * as ExcelJS from 'exceljs'; +import { saveAs } from "file-saver"; + +/** + * 해양 HULL 아이템 데이터 가져오기를 위한 Excel 템플릿 파일 생성 및 다운로드 + */ +export async function exportHullItemTemplate() { + // 워크북 생성 + const workbook = new ExcelJS.Workbook(); + workbook.creator = 'Offshore HULL Item Management System'; + workbook.created = new Date(); + + // 워크시트 생성 + const worksheet = workbook.addWorksheet('해양 HULL 아이템'); + + // 컬럼 헤더 정의 및 스타일 적용 + worksheet.columns = [ + { header: '자재 그룹', key: 'itemCode', width: 15 }, + { header: '기능(공종)', key: 'workType', width: 15 }, + { header: '자재명', key: 'itemList', width: 20 }, + { header: '자재명(상세)', key: 'subItemList', width: 20 }, + ]; + + // 헤더 스타일 적용 + const headerRow = worksheet.getRow(1); + headerRow.font = { bold: true }; + headerRow.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFE0E0E0' } + }; + headerRow.alignment = { vertical: 'middle', horizontal: 'center' }; + + // 테두리 스타일 적용 + headerRow.eachCell((cell) => { + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + }; + }); + + // 샘플 데이터 추가 + const sampleData = [ + { + itemCode: 'HULL001', + workType: 'HA', + itemList: '항목1 샘플 데이터', + subItemList: '항목2 샘플 데이터', + }, + { + itemCode: 'HULL002', + workType: 'HE', + itemList: '항목1 샘플 데이터', + subItemList: '항목2 샘플 데이터', + } + ]; + + // 데이터 행 추가 + sampleData.forEach(item => { + worksheet.addRow(item); + }); + + // 데이터 행 스타일 적용 + worksheet.eachRow((row, rowNumber) => { + if (rowNumber > 1) { // 헤더를 제외한 데이터 행 + row.eachCell((cell) => { + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + }; + }); + } + }); + + // 워크시트 보호 (선택적) + worksheet.protect('', { + selectLockedCells: true, + selectUnlockedCells: true, + formatColumns: true, + formatRows: true, + insertColumns: false, + insertRows: true, + insertHyperlinks: false, + deleteColumns: false, + deleteRows: true, + sort: true, + autoFilter: true, + pivotTables: false + }); + + try { + // 워크북을 Blob으로 변환 + const buffer = await workbook.xlsx.writeBuffer(); + const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); + saveAs(blob, 'offshore-hull-item-template.xlsx'); + return true; + } catch (error) { + console.error('Excel 템플릿 생성 오류:', error); + throw error; + } +} diff --git a/lib/items-tech/table/import-excel-button.tsx b/lib/items-tech/table/import-excel-button.tsx index 4565c365..f8ba9f6d 100644 --- a/lib/items-tech/table/import-excel-button.tsx +++ b/lib/items-tech/table/import-excel-button.tsx @@ -1,304 +1,304 @@ -"use client" - -import * as React from "react" -import { Upload } from "lucide-react" -import { toast } from "sonner" -import * as ExcelJS from 'exceljs' - -import { Button } from "@/components/ui/button" -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { Progress } from "@/components/ui/progress" -import { processFileImport } from "./ship/import-item-handler" -import { processTopFileImport } from "./top/import-item-handler" -import { processHullFileImport } from "./hull/import-item-handler" -import { decryptWithServerAction } from "@/components/drm/drmUtils" - - -// 선박 아이템 타입 -type ItemType = "ship" | "top" | "hull"; - -const ITEM_TYPE_NAMES = { - ship: "조선 아이템", - top: "해양 TOP 아이템", - hull: "해양 HULL 아이템", -}; - -interface ImportItemButtonProps { - itemType: ItemType; - onSuccess?: () => void; -} - -export function ImportItemButton({ itemType, onSuccess }: ImportItemButtonProps) { - const [open, setOpen] = React.useState(false); - const [file, setFile] = React.useState(null); - const [isUploading, setIsUploading] = React.useState(false); - const [progress, setProgress] = React.useState(0); - const [error, setError] = React.useState(null); - - const fileInputRef = React.useRef(null); - - // 파일 선택 처리 - const handleFileChange = (e: React.ChangeEvent) => { - const selectedFile = e.target.files?.[0]; - if (!selectedFile) return; - - if (!selectedFile.name.endsWith('.xlsx') && !selectedFile.name.endsWith('.xls')) { - setError("Excel 파일(.xlsx 또는 .xls)만 가능합니다."); - return; - } - - setFile(selectedFile); - setError(null); - }; - - - // 데이터 가져오기 처리 - const handleImport = async () => { - if (!file) { - setError("가져올 파일을 선택해주세요."); - return; - } - - try { - setIsUploading(true); - setProgress(0); - setError(null); - - // DRM 복호화 처리 - 서버 액션 직접 호출 - let arrayBuffer: ArrayBuffer; - try { - setProgress(10); - toast.info("파일 복호화 중..."); - arrayBuffer = await decryptWithServerAction(file); - setProgress(30); - } 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.worksheets[0]; - if (!worksheet) { - throw new Error("Excel 파일에 워크시트가 없습니다."); - } - // 헤더 행 찾기 - let headerRowIndex = 1; - let headerRow: ExcelJS.Row | undefined; - let headerValues: (string | null)[] = []; - - - worksheet.eachRow((row, rowNumber) => { - const values = row.values as (string | null)[]; - if (!headerRow && values.some(v => v === "자재 그룹" || v === "자재 코드" || v === "itemCode" || v === "item_code")) { - headerRowIndex = rowNumber; - headerRow = row; - headerValues = [...values]; - } - }); - - if (!headerRow) { - throw new Error("Excel 파일에서 헤더 행을 찾을 수 없습니다."); - } - - // 헤더를 기반으로 인덱스 매핑 생성 - const headerMapping: Record = {}; - headerValues.forEach((value, index) => { - if (typeof value === 'string') { - headerMapping[value] = index; - } - }); - - // 필수 헤더 확인 (타입별 구분) - const requiredHeaders: string[] = ["자재 그룹", "기능(공종)"]; - - const alternativeHeaders = { - "자재 그룹": ["itemCode", "item_code"], - "기능(공종)": ["workType"], - "자재명": ["itemList"], - "자재명(상세)": ["subItemList"] - }; - - // 헤더 매핑 확인 (대체 이름 포함) - const missingHeaders = requiredHeaders.filter(header => { - const alternatives = alternativeHeaders[header as keyof typeof alternativeHeaders] || []; - return !(header in headerMapping) && - !alternatives.some(alt => alt in headerMapping); - }); - - if (missingHeaders.length > 0) { - throw new Error(`다음 필수 헤더가 누락되었습니다: ${missingHeaders.join(", ")}`); - } - - // 데이터 행 추출 (헤더 이후 행부터) - const dataRows: Record[] = []; - - worksheet.eachRow((row, rowNumber) => { - if (rowNumber > headerRowIndex) { - const rowData: Record = {}; - const values = row.values as (string | null | undefined)[]; - - // 헤더 매핑에 따라 데이터 추출 - Object.entries(headerMapping).forEach(([header, index]) => { - rowData[header] = values[index] || ""; - }); - - // 빈 행이 아닌 경우만 추가 - if (Object.values(rowData).some(value => value && value.toString().trim() !== "")) { - dataRows.push(rowData); - } - } - }); - - if (dataRows.length === 0) { - throw new Error("Excel 파일에 가져올 데이터가 없습니다."); - } - - // 진행 상황 업데이트를 위한 콜백 - const updateProgress = (current: number, total: number) => { - const percentage = Math.round((current / total) * 100); - setProgress(percentage); - }; - - // 선택된 타입에 따라 적절한 프로세스 함수 호출 - let result: { successCount: number; errorCount: number; errors?: Array<{ row: number; message: string; itemCode?: string; workType?: string }> }; - if (itemType === "top") { - result = await processTopFileImport(dataRows, updateProgress); - } else if (itemType === "hull") { - result = await processHullFileImport(dataRows, updateProgress); - } else { - result = await processFileImport(dataRows, updateProgress); - } - - toast.success(`${result.successCount}개의 ${ITEM_TYPE_NAMES[itemType]}이(가) 성공적으로 가져와졌습니다.`); - - if (result.errorCount > 0) { - const errorDetails = result.errors?.map((error: { row: number; message: string; itemCode?: string; workType?: string }) => - `행 ${error.row}: ${error.itemCode || '알 수 없음'} (${error.workType || '알 수 없음'}) - ${error.message}` - ).join('\n') || '오류 정보를 가져올 수 없습니다.'; - - console.error('Import 오류 상세:', errorDetails); - toast.error(`${result.errorCount}개의 항목 처리 실패. 콘솔에서 상세 내용을 확인하세요.`); - } - - // 상태 초기화 및 다이얼로그 닫기 - setFile(null); - setOpen(false); - - // 성공 콜백 호출 - if (onSuccess) { - onSuccess(); - } - } catch (error) { - console.error("Excel 파일 처리 중 오류 발생:", error); - setError(error instanceof Error ? error.message : "파일 처리 중 오류가 발생했습니다."); - } finally { - setIsUploading(false); - } - }; - - - - // 다이얼로그 열기/닫기 핸들러 - const handleOpenChange = (newOpen: boolean) => { - if (!newOpen) { - // 닫을 때 상태 초기화 - setFile(null); - setError(null); - setProgress(0); - if (fileInputRef.current) { - fileInputRef.current.value = ""; - } - } - setOpen(newOpen); - }; - - return ( - <> - - - - - - {ITEM_TYPE_NAMES[itemType]} 가져오기 - - {ITEM_TYPE_NAMES[itemType]}을 Excel 파일에서 가져옵니다. -
- 올바른 형식의 Excel 파일(.xlsx)을 업로드하세요. -
-
- -
-
- -
- - {file && ( -
- 선택된 파일: {file.name} ({(file.size / 1024).toFixed(1)} KB) -
- )} - - {isUploading && ( -
- -

- {progress}% 완료 -

-
- )} - - {error && ( -
- {error} -
- )} -
- - - - - -
-
- - ); +"use client" + +import * as React from "react" +import { Upload } from "lucide-react" +import { toast } from "sonner" +import * as ExcelJS from 'exceljs' + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Progress } from "@/components/ui/progress" +import { processFileImport } from "./ship/import-item-handler" +import { processTopFileImport } from "./top/import-item-handler" +import { processHullFileImport } from "./hull/import-item-handler" +import { decryptWithServerAction } from "@/components/drm/drmUtils" + + +// 선박 아이템 타입 +type ItemType = "ship" | "top" | "hull"; + +const ITEM_TYPE_NAMES = { + ship: "조선 아이템", + top: "해양 TOP 아이템", + hull: "해양 HULL 아이템", +}; + +interface ImportItemButtonProps { + itemType: ItemType; + onSuccess?: () => void; +} + +export function ImportItemButton({ itemType, onSuccess }: ImportItemButtonProps) { + const [open, setOpen] = React.useState(false); + const [file, setFile] = React.useState(null); + const [isUploading, setIsUploading] = React.useState(false); + const [progress, setProgress] = React.useState(0); + const [error, setError] = React.useState(null); + + const fileInputRef = React.useRef(null); + + // 파일 선택 처리 + const handleFileChange = (e: React.ChangeEvent) => { + const selectedFile = e.target.files?.[0]; + if (!selectedFile) return; + + if (!selectedFile.name.endsWith('.xlsx') && !selectedFile.name.endsWith('.xls')) { + setError("Excel 파일(.xlsx 또는 .xls)만 가능합니다."); + return; + } + + setFile(selectedFile); + setError(null); + }; + + + // 데이터 가져오기 처리 + const handleImport = async () => { + if (!file) { + setError("가져올 파일을 선택해주세요."); + return; + } + + try { + setIsUploading(true); + setProgress(0); + setError(null); + + // DRM 복호화 처리 - 서버 액션 직접 호출 + let arrayBuffer: ArrayBuffer; + try { + setProgress(10); + toast.info("파일 복호화 중..."); + arrayBuffer = await decryptWithServerAction(file); + setProgress(30); + } 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.worksheets[0]; + if (!worksheet) { + throw new Error("Excel 파일에 워크시트가 없습니다."); + } + // 헤더 행 찾기 + let headerRowIndex = 1; + let headerRow: ExcelJS.Row | undefined; + let headerValues: (string | null)[] = []; + + + worksheet.eachRow((row, rowNumber) => { + const values = row.values as (string | null)[]; + if (!headerRow && values.some(v => v === "자재 그룹" || v === "자재 코드" || v === "itemCode" || v === "item_code")) { + headerRowIndex = rowNumber; + headerRow = row; + headerValues = [...values]; + } + }); + + if (!headerRow) { + throw new Error("Excel 파일에서 헤더 행을 찾을 수 없습니다."); + } + + // 헤더를 기반으로 인덱스 매핑 생성 + const headerMapping: Record = {}; + headerValues.forEach((value, index) => { + if (typeof value === 'string') { + headerMapping[value] = index; + } + }); + + // 필수 헤더 확인 (타입별 구분) + const requiredHeaders: string[] = ["자재 그룹", "기능(공종)"]; + + const alternativeHeaders = { + "자재 그룹": ["itemCode", "item_code"], + "기능(공종)": ["workType"], + "자재명": ["itemList"], + "자재명(상세)": ["subItemList"] + }; + + // 헤더 매핑 확인 (대체 이름 포함) + const missingHeaders = requiredHeaders.filter(header => { + const alternatives = alternativeHeaders[header as keyof typeof alternativeHeaders] || []; + return !(header in headerMapping) && + !alternatives.some(alt => alt in headerMapping); + }); + + if (missingHeaders.length > 0) { + throw new Error(`다음 필수 헤더가 누락되었습니다: ${missingHeaders.join(", ")}`); + } + + // 데이터 행 추출 (헤더 이후 행부터) + const dataRows: Record[] = []; + + worksheet.eachRow((row, rowNumber) => { + if (rowNumber > headerRowIndex) { + const rowData: Record = {}; + const values = row.values as (string | null | undefined)[]; + + // 헤더 매핑에 따라 데이터 추출 + Object.entries(headerMapping).forEach(([header, index]) => { + rowData[header] = values[index] || ""; + }); + + // 빈 행이 아닌 경우만 추가 + if (Object.values(rowData).some(value => value && value.toString().trim() !== "")) { + dataRows.push(rowData); + } + } + }); + + if (dataRows.length === 0) { + throw new Error("Excel 파일에 가져올 데이터가 없습니다."); + } + + // 진행 상황 업데이트를 위한 콜백 + const updateProgress = (current: number, total: number) => { + const percentage = Math.round((current / total) * 100); + setProgress(percentage); + }; + + // 선택된 타입에 따라 적절한 프로세스 함수 호출 + let result: { successCount: number; errorCount: number; errors?: Array<{ row: number; message: string }> }; + if (itemType === "top") { + result = await processTopFileImport(dataRows, updateProgress); + } else if (itemType === "hull") { + result = await processHullFileImport(dataRows, updateProgress); + } else { + result = await processFileImport(dataRows, updateProgress); + } + + toast.success(`${result.successCount}개의 ${ITEM_TYPE_NAMES[itemType]}이(가) 성공적으로 가져와졌습니다.`); + + if (result.errorCount > 0) { + const errorDetails = result.errors?.map((error: { row: number; message: string; itemCode?: string; workType?: string }) => + `행 ${error.row}: ${error.itemCode || '알 수 없음'} (${error.workType || '알 수 없음'}) - ${error.message}` + ).join('\n') || '오류 정보를 가져올 수 없습니다.'; + + console.error('Import 오류 상세:', errorDetails); + toast.error(`${result.errorCount}개의 항목 처리 실패. 콘솔에서 상세 내용을 확인하세요.`); + } + + // 상태 초기화 및 다이얼로그 닫기 + setFile(null); + setOpen(false); + + // 성공 콜백 호출 + if (onSuccess) { + onSuccess(); + } + } catch (error) { + console.error("Excel 파일 처리 중 오류 발생:", error); + setError(error instanceof Error ? error.message : "파일 처리 중 오류가 발생했습니다."); + } finally { + setIsUploading(false); + } + }; + + + + // 다이얼로그 열기/닫기 핸들러 + const handleOpenChange = (newOpen: boolean) => { + if (!newOpen) { + // 닫을 때 상태 초기화 + setFile(null); + setError(null); + setProgress(0); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + } + setOpen(newOpen); + }; + + return ( + <> + + + + + + {ITEM_TYPE_NAMES[itemType]} 가져오기 + + {ITEM_TYPE_NAMES[itemType]}을 Excel 파일에서 가져옵니다. +
+ 올바른 형식의 Excel 파일(.xlsx)을 업로드하세요. +
+
+ +
+
+ +
+ + {file && ( +
+ 선택된 파일: {file.name} ({(file.size / 1024).toFixed(1)} KB) +
+ )} + + {isUploading && ( +
+ +

+ {progress}% 완료 +

+
+ )} + + {error && ( +
+ {error} +
+ )} +
+ + + + + +
+
+ + ); } \ No newline at end of file diff --git a/lib/items-tech/table/ship/import-item-handler.tsx b/lib/items-tech/table/ship/import-item-handler.tsx index 57546cc6..b0f475ff 100644 --- a/lib/items-tech/table/ship/import-item-handler.tsx +++ b/lib/items-tech/table/ship/import-item-handler.tsx @@ -1,139 +1,129 @@ -"use client" - -import { z } from "zod" -import { createShipbuildingImportItem } from "../../service" // 아이템 생성 서버 액션 - -// 아이템 데이터 검증을 위한 Zod 스키마 -const itemSchema = z.object({ - itemCode: z.string().optional(), - workType: z.enum(["기장", "전장", "선실", "배관", "철의", "선체"], { - required_error: "기능(공종)은 필수입니다", - }), - shipTypes: z.string().nullable().optional(), - itemList: z.string().nullable().optional(), -}); - -interface ProcessResult { - successCount: number; - errorCount: number; - errors: Array<{ row: number; message: string; itemCode?: string; workType?: string }>; -} - -/** - * Excel 파일에서 가져온 조선 아이템 데이터 처리하는 함수 - */ -export async function processFileImport( - jsonData: Record[], - progressCallback?: (current: number, total: number) => void -): Promise { - // 결과 카운터 초기화 - let successCount = 0; - let errorCount = 0; - const errors: Array<{ row: number; message: string }> = []; - - // 빈 행 등 필터링 - const dataRows = jsonData.filter(row => { - // 빈 행 건너뛰기 - if (Object.values(row).every(val => !val)) { - return false; - } - return true; - }); - - // 데이터 행이 없으면 빈 결과 반환 - if (dataRows.length === 0) { - return { successCount: 0, errorCount: 0, errors: [] }; - } - - // 각 행에 대해 처리 - for (let i = 0; i < dataRows.length; i++) { - const row = dataRows[i]; - const rowIndex = i + 1; // 사용자에게 표시할 행 번호는 1부터 시작 - - // 진행 상황 콜백 호출 - if (progressCallback) { - progressCallback(i + 1, dataRows.length); - } - - try { - // 필드 매핑 (한글/영문 필드명 모두 지원) - const itemCode = row["자재 그룹"] || row["itemCode"] || ""; - const workType = row["기능(공종)"] || row["workType"] || ""; - const shipTypes = row["선종"] || row["shipTypes"] || null; - const itemList = row["자재명"] || row["itemList"] || null; - - // 데이터 정제 - const cleanedRow = { - itemCode: typeof itemCode === 'string' ? itemCode.trim() : String(itemCode).trim(), - workType: typeof workType === 'string' ? workType.trim() : String(workType).trim(), - shipTypes: shipTypes ? (typeof shipTypes === 'string' ? shipTypes.trim() : String(shipTypes).trim()) : null, - itemList: itemList ? (typeof itemList === 'string' ? itemList : String(itemList)) : null, - }; - - // 데이터 유효성 검사 - const validationResult = itemSchema.safeParse(cleanedRow); - - if (!validationResult.success) { - const errorMessage = validationResult.error.errors.map( - err => `${err.path.join('.')}: ${err.message}` - ).join(', '); - - errors.push({ - row: rowIndex, - message: errorMessage, - itemCode: cleanedRow.itemCode, - workType: cleanedRow.workType - }); - errorCount++; - continue; - } - - // 아이템 생성 - const result = await createShipbuildingImportItem({ - itemCode: cleanedRow.itemCode, - workType: cleanedRow.workType as "기장" | "전장" | "선실" | "배관" | "철의" | "선체", - shipTypes: cleanedRow.shipTypes, - itemList: cleanedRow.itemList, - }); - - if (result.success || !result.error) { - successCount++; - } else { - errors.push({ - row: rowIndex, - message: result.message || result.error || "알 수 없는 오류", - itemCode: cleanedRow.itemCode, - workType: cleanedRow.workType - }); - errorCount++; - } - - } catch (error) { - console.error(`${rowIndex}행 처리 오류:`, error); - - // cleanedRow가 정의되지 않은 경우를 처리 - const itemCode = row["자재 그룹"] || row["itemCode"] || ""; - const workType = row["기능(공종)"] || row["workType"] || ""; - - errors.push({ - row: rowIndex, - message: error instanceof Error ? error.message : "알 수 없는 오류", - itemCode: typeof itemCode === 'string' ? itemCode.trim() : String(itemCode).trim(), - workType: typeof workType === 'string' ? workType.trim() : String(workType).trim() - }); - errorCount++; - } - - // 비동기 작업 쓰로틀링 - if (i % 5 === 0) { - await new Promise(resolve => setTimeout(resolve, 10)); - } - } - - // 처리 결과 반환 - return { - successCount, - errorCount, - errors - }; +"use client" + +import { z } from "zod" +import { createShipbuildingImportItem } from "../../service" // 아이템 생성 서버 액션 + +// 아이템 데이터 검증을 위한 Zod 스키마 +const itemSchema = z.object({ + itemCode: z.string().optional(), + workType: z.enum(["기장", "전장", "선실", "배관", "철의", "선체"], { + required_error: "기능(공종)은 필수입니다", + }), + shipTypes: z.string().nullable().optional(), + itemList: z.string().nullable().optional(), +}); + +interface ProcessResult { + successCount: number; + errorCount: number; + errors: Array<{ row: number; message: string }>; +} + +/** + * Excel 파일에서 가져온 조선 아이템 데이터 처리하는 함수 + */ +export async function processFileImport( + jsonData: Record[], + progressCallback?: (current: number, total: number) => void +): Promise { + // 결과 카운터 초기화 + let successCount = 0; + let errorCount = 0; + const errors: Array<{ row: number; message: string }> = []; + + // 빈 행 등 필터링 + const dataRows = jsonData.filter(row => { + // 빈 행 건너뛰기 + if (Object.values(row).every(val => !val)) { + return false; + } + return true; + }); + + // 데이터 행이 없으면 빈 결과 반환 + if (dataRows.length === 0) { + return { successCount: 0, errorCount: 0, errors: [] }; + } + + // 각 행에 대해 처리 + for (let i = 0; i < dataRows.length; i++) { + const row = dataRows[i]; + const rowIndex = i + 1; // 사용자에게 표시할 행 번호는 1부터 시작 + + // 진행 상황 콜백 호출 + if (progressCallback) { + progressCallback(i + 1, dataRows.length); + } + + try { + // 필드 매핑 (한글/영문 필드명 모두 지원) + const itemCode = row["자재 그룹"] || row["itemCode"] || ""; + const workType = row["기능(공종)"] || row["workType"] || ""; + const shipTypes = row["선종"] || row["shipTypes"] || null; + const itemList = row["자재명"] || row["itemList"] || null; + + // 데이터 정제 + const cleanedRow = { + itemCode: typeof itemCode === 'string' ? itemCode.trim() : String(itemCode).trim(), + workType: typeof workType === 'string' ? workType.trim() : String(workType).trim(), + shipTypes: shipTypes ? (typeof shipTypes === 'string' ? shipTypes.trim() : String(shipTypes).trim()) : null, + itemList: itemList ? (typeof itemList === 'string' ? itemList : String(itemList)) : null, + }; + + // 데이터 유효성 검사 + const validationResult = itemSchema.safeParse(cleanedRow); + + if (!validationResult.success) { + const errorMessage = validationResult.error.errors.map( + err => `${err.path.join('.')}: ${err.message}` + ).join(', '); + + errors.push({ + row: rowIndex, + message: errorMessage, + }); + errorCount++; + continue; + } + + // 아이템 생성 + const result = await createShipbuildingImportItem({ + itemCode: cleanedRow.itemCode, + workType: cleanedRow.workType as "기장" | "전장" | "선실" | "배관" | "철의" | "선체", + shipTypes: cleanedRow.shipTypes, + itemList: cleanedRow.itemList, + }); + + if (result.success || !result.error) { + successCount++; + } else { + errors.push({ + row: rowIndex, + message: result.message || result.error || "알 수 없는 오류", + }); + errorCount++; + } + + } catch (error) { + console.error(`${rowIndex}행 처리 오류:`, error); + + errors.push({ + row: rowIndex, + message: error instanceof Error ? error.message : "알 수 없는 오류", + }); + errorCount++; + } + + // 비동기 작업 쓰로틀링 + if (i % 5 === 0) { + await new Promise(resolve => setTimeout(resolve, 10)); + } + } + + // 처리 결과 반환 + return { + successCount, + errorCount, + errors + }; } \ No newline at end of file diff --git a/lib/items-tech/table/ship/item-excel-template.tsx b/lib/items-tech/table/ship/item-excel-template.tsx index 401fb911..fdff0de0 100644 --- a/lib/items-tech/table/ship/item-excel-template.tsx +++ b/lib/items-tech/table/ship/item-excel-template.tsx @@ -1,111 +1,111 @@ -import * as ExcelJS from 'exceljs'; -import { saveAs } from "file-saver"; - -/** - * 조선 아이템 데이터 가져오기를 위한 Excel 템플릿 파일 생성 및 다운로드 - */ -export async function exportItemTemplate() { - // 워크북 생성 - const workbook = new ExcelJS.Workbook(); - workbook.creator = 'Shipbuilding Item Management System'; - workbook.created = new Date(); - - // 워크시트 생성 - const worksheet = workbook.addWorksheet('조선 아이템'); - - // 컬럼 헤더 정의 및 스타일 적용 - worksheet.columns = [ - { header: '자재 그룹', key: 'itemCode', width: 15 }, - { header: '기능(공종)', key: 'workType', width: 15 }, - { header: '선종', key: 'shipTypes', width: 15 }, - { header: '자재명', key: 'itemList', width: 30 }, - ]; - - // 헤더 스타일 적용 - const headerRow = worksheet.getRow(1); - headerRow.font = { bold: true }; - headerRow.fill = { - type: 'pattern', - pattern: 'solid', - fgColor: { argb: 'FFE0E0E0' } - }; - headerRow.alignment = { vertical: 'middle', horizontal: 'center' }; - - // 테두리 스타일 적용 - headerRow.eachCell((cell) => { - cell.border = { - top: { style: 'thin' }, - left: { style: 'thin' }, - bottom: { style: 'thin' }, - right: { style: 'thin' } - }; - }); - - // 샘플 데이터 추가 - const sampleData = [ - { - itemCode: 'BG0001', - workType: '기장', - shipTypes: 'A-MAX', - itemList: '자재명', - }, - { - itemCode: 'BG0002', - workType: '전장', - shipTypes: 'LNGC', - itemList: '자재명', - }, - { - itemCode: 'BG0003', - workType: '선실', - shipTypes: 'VLCC', - itemList: '자재명', - } - ]; - - // 데이터 행 추가 - sampleData.forEach(item => { - worksheet.addRow(item); - }); - - // 데이터 행 스타일 적용 - worksheet.eachRow((row, rowNumber) => { - if (rowNumber > 1) { // 헤더를 제외한 데이터 행 - row.eachCell((cell) => { - cell.border = { - top: { style: 'thin' }, - left: { style: 'thin' }, - bottom: { style: 'thin' }, - right: { style: 'thin' } - }; - }); - } - }); - - // 워크시트 보호 (선택적) - worksheet.protect('', { - selectLockedCells: true, - selectUnlockedCells: true, - formatColumns: true, - formatRows: true, - insertColumns: false, - insertRows: true, - insertHyperlinks: false, - deleteColumns: false, - deleteRows: true, - sort: true, - autoFilter: true, - pivotTables: false - }); - - try { - // 워크북을 Blob으로 변환 - const buffer = await workbook.xlsx.writeBuffer(); - const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); - saveAs(blob, 'shipbuilding-item-template.xlsx'); - return true; - } catch (error) { - console.error('Excel 템플릿 생성 오류:', error); - throw error; - } +import * as ExcelJS from 'exceljs'; +import { saveAs } from "file-saver"; + +/** + * 조선 아이템 데이터 가져오기를 위한 Excel 템플릿 파일 생성 및 다운로드 + */ +export async function exportItemTemplate() { + // 워크북 생성 + const workbook = new ExcelJS.Workbook(); + workbook.creator = 'Shipbuilding Item Management System'; + workbook.created = new Date(); + + // 워크시트 생성 + const worksheet = workbook.addWorksheet('조선 아이템'); + + // 컬럼 헤더 정의 및 스타일 적용 + worksheet.columns = [ + { header: '자재 그룹', key: 'itemCode', width: 15 }, + { header: '기능(공종)', key: 'workType', width: 15 }, + { header: '선종', key: 'shipTypes', width: 15 }, + { header: '자재명', key: 'itemList', width: 30 }, + ]; + + // 헤더 스타일 적용 + const headerRow = worksheet.getRow(1); + headerRow.font = { bold: true }; + headerRow.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFE0E0E0' } + }; + headerRow.alignment = { vertical: 'middle', horizontal: 'center' }; + + // 테두리 스타일 적용 + headerRow.eachCell((cell) => { + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + }; + }); + + // 샘플 데이터 추가 + const sampleData = [ + { + itemCode: 'BG0001', + workType: '기장', + shipTypes: 'A-MAX', + itemList: '자재명', + }, + { + itemCode: 'BG0002', + workType: '전장', + shipTypes: 'LNGC', + itemList: '자재명', + }, + { + itemCode: 'BG0003', + workType: '선실', + shipTypes: 'VLCC', + itemList: '자재명', + } + ]; + + // 데이터 행 추가 + sampleData.forEach(item => { + worksheet.addRow(item); + }); + + // 데이터 행 스타일 적용 + worksheet.eachRow((row, rowNumber) => { + if (rowNumber > 1) { // 헤더를 제외한 데이터 행 + row.eachCell((cell) => { + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + }; + }); + } + }); + + // 워크시트 보호 (선택적) + worksheet.protect('', { + selectLockedCells: true, + selectUnlockedCells: true, + formatColumns: true, + formatRows: true, + insertColumns: false, + insertRows: true, + insertHyperlinks: false, + deleteColumns: false, + deleteRows: true, + sort: true, + autoFilter: true, + pivotTables: false + }); + + try { + // 워크북을 Blob으로 변환 + const buffer = await workbook.xlsx.writeBuffer(); + const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); + saveAs(blob, 'shipbuilding-item-template.xlsx'); + return true; + } catch (error) { + console.error('Excel 템플릿 생성 오류:', error); + throw error; + } } \ No newline at end of file diff --git a/lib/items-tech/table/ship/items-table-toolbar-actions.tsx b/lib/items-tech/table/ship/items-table-toolbar-actions.tsx index 29995327..82ceb298 100644 --- a/lib/items-tech/table/ship/items-table-toolbar-actions.tsx +++ b/lib/items-tech/table/ship/items-table-toolbar-actions.tsx @@ -1,177 +1,177 @@ -"use client" - -import * as React from "react" -import { type Table } from "@tanstack/react-table" -import { Download, FileDown } from "lucide-react" -import * as ExcelJS from 'exceljs' -import { saveAs } from "file-saver" - -import { Button } from "@/components/ui/button" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" - -import { DeleteItemsDialog } from "../delete-items-dialog" -import { AddItemDialog } from "../add-items-dialog" -import { exportItemTemplate } from "./item-excel-template" -import { ImportItemButton } from "../import-excel-button" - -// 조선 아이템 타입 정의 -interface ShipbuildingItem { - id: number; - itemId: number; - workType: "기장" | "전장" | "선실" | "배관" | "철의" | "선체"; - shipTypes: string; - itemCode: string; - itemName: string; - itemList: string | null; - description: string | null; - createdAt: Date; - updatedAt: Date; -} - -interface ItemsTableToolbarActionsProps { - table: Table -} - -export function ItemsTableToolbarActions({ table }: ItemsTableToolbarActionsProps) { - const [refreshKey, setRefreshKey] = React.useState(0) - - // 가져오기 성공 후 테이블 갱신 - const handleImportSuccess = () => { - setRefreshKey(prev => prev + 1) - } - - // Excel 내보내기 함수 - const exportTableToExcel = async ( - table: Table, - options: { - filename: string; - excludeColumns?: string[]; - sheetName?: string; - } - ) => { - const { filename, excludeColumns = [], sheetName = "조선 아이템 목록" } = options; - - // 워크북 생성 - const workbook = new ExcelJS.Workbook(); - workbook.creator = 'Shipbuilding Item Management System'; - workbook.created = new Date(); - - // 워크시트 생성 - const worksheet = workbook.addWorksheet(sheetName); - - // 테이블 데이터 가져오기 - const data = table.getFilteredRowModel().rows.map(row => row.original); - console.log("내보내기 데이터:", data); - - // 필요한 헤더 직접 정의 (필터링 문제 해결) - const headers = [ - { key: 'itemCode', header: '자재 그룹' }, - { key: 'workType', header: '기능(공종)' }, - { key: 'shipTypes', header: '선종' }, - { key: 'itemList', header: '자재명' }, - { key: 'subItemList', header: '자재명(상세)' }, - ].filter(header => !excludeColumns.includes(header.key)); - - console.log("내보내기 헤더:", headers); - // 컬럼 정의 - worksheet.columns = headers.map(header => ({ - header: header.header, - key: header.key, - width: 20 // 기본 너비 - })); - - // 스타일 적용 - const headerRow = worksheet.getRow(1); - headerRow.font = { bold: true }; - headerRow.fill = { - type: 'pattern', - pattern: 'solid', - fgColor: { argb: 'FFE0E0E0' } - }; - headerRow.alignment = { vertical: 'middle', horizontal: 'center' }; - - // 데이터 행 추가 - data.forEach(item => { - const row: Record = {}; - headers.forEach(header => { - row[header.key] = item[header.key as keyof ShipbuildingItem]; - }); - worksheet.addRow(row); - }); - - // 전체 셀에 테두리 추가 - worksheet.eachRow((row) => { - row.eachCell((cell) => { - cell.border = { - top: { style: 'thin' }, - left: { style: 'thin' }, - bottom: { style: 'thin' }, - right: { style: 'thin' } - }; - }); - }); - - try { - // 워크북을 Blob으로 변환 - const buffer = await workbook.xlsx.writeBuffer(); - const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); - saveAs(blob, `${filename}.xlsx`); - return true; - } catch (error) { - console.error("Excel 내보내기 오류:", error); - return false; - } - } - - return ( -
- {/* 선택된 로우가 있으면 삭제 다이얼로그 */} - {table.getFilteredSelectedRowModel().rows.length > 0 ? ( - row.original) as any} - onSuccess={() => table.toggleAllRowsSelected(false)} - itemType="shipbuilding" - /> - ) : null} - - {/* 새 아이템 추가 다이얼로그 */} - - - {/* Import 버튼 */} - - - {/* Export 드롭다운 메뉴 */} - - - - - - - exportTableToExcel(table, { - filename: "shipbuilding_items", - excludeColumns: ["select", "actions"], - sheetName: "조선 아이템 목록" - }) - } - > - - 현재 데이터 내보내기 - - exportItemTemplate()}> - - 템플릿 다운로드 - - - -
- ) +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, FileDown } from "lucide-react" +import * as ExcelJS from 'exceljs' +import { saveAs } from "file-saver" + +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" + +import { DeleteItemsDialog } from "../delete-items-dialog" +import { AddItemDialog } from "../add-items-dialog" +import { exportItemTemplate } from "./item-excel-template" +import { ImportItemButton } from "../import-excel-button" + +// 조선 아이템 타입 정의 +interface ShipbuildingItem { + id: number; + itemId: number; + workType: "기장" | "전장" | "선실" | "배관" | "철의" | "선체"; + shipTypes: string; + itemCode: string; + itemName: string; + itemList: string | null; + description: string | null; + createdAt: Date; + updatedAt: Date; +} + +interface ItemsTableToolbarActionsProps { + table: Table +} + +export function ItemsTableToolbarActions({ table }: ItemsTableToolbarActionsProps) { + const [refreshKey, setRefreshKey] = React.useState(0) + + // 가져오기 성공 후 테이블 갱신 + const handleImportSuccess = () => { + setRefreshKey(prev => prev + 1) + } + + // Excel 내보내기 함수 + const exportTableToExcel = async ( + table: Table, + options: { + filename: string; + excludeColumns?: string[]; + sheetName?: string; + } + ) => { + const { filename, excludeColumns = [], sheetName = "조선 아이템 목록" } = options; + + // 워크북 생성 + const workbook = new ExcelJS.Workbook(); + workbook.creator = 'Shipbuilding Item Management System'; + workbook.created = new Date(); + + // 워크시트 생성 + const worksheet = workbook.addWorksheet(sheetName); + + // 테이블 데이터 가져오기 + const data = table.getFilteredRowModel().rows.map(row => row.original); + console.log("내보내기 데이터:", data); + + // 필요한 헤더 직접 정의 (필터링 문제 해결) + const headers = [ + { key: 'itemCode', header: '자재 그룹' }, + { key: 'workType', header: '기능(공종)' }, + { key: 'shipTypes', header: '선종' }, + { key: 'itemList', header: '자재명' }, + { key: 'subItemList', header: '자재명(상세)' }, + ].filter(header => !excludeColumns.includes(header.key)); + + console.log("내보내기 헤더:", headers); + // 컬럼 정의 + worksheet.columns = headers.map(header => ({ + header: header.header, + key: header.key, + width: 20 // 기본 너비 + })); + + // 스타일 적용 + const headerRow = worksheet.getRow(1); + headerRow.font = { bold: true }; + headerRow.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFE0E0E0' } + }; + headerRow.alignment = { vertical: 'middle', horizontal: 'center' }; + + // 데이터 행 추가 + data.forEach(item => { + const row: Record = {}; + headers.forEach(header => { + row[header.key] = item[header.key as keyof ShipbuildingItem]; + }); + worksheet.addRow(row); + }); + + // 전체 셀에 테두리 추가 + worksheet.eachRow((row) => { + row.eachCell((cell) => { + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + }; + }); + }); + + try { + // 워크북을 Blob으로 변환 + const buffer = await workbook.xlsx.writeBuffer(); + const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); + saveAs(blob, `${filename}.xlsx`); + return true; + } catch (error) { + console.error("Excel 내보내기 오류:", error); + return false; + } + } + + return ( +
+ {/* 선택된 로우가 있으면 삭제 다이얼로그 */} + {table.getFilteredSelectedRowModel().rows.length > 0 ? ( + row.original) as any} + onSuccess={() => table.toggleAllRowsSelected(false)} + itemType="shipbuilding" + /> + ) : null} + + {/* 새 아이템 추가 다이얼로그 */} + + + {/* Import 버튼 */} + + + {/* Export 드롭다운 메뉴 */} + + + + + + + exportTableToExcel(table, { + filename: "shipbuilding_items", + excludeColumns: ["select", "actions"], + sheetName: "조선 아이템 목록" + }) + } + > + + 현재 데이터 내보내기 + + exportItemTemplate()}> + + 템플릿 다운로드 + + + +
+ ) } \ No newline at end of file diff --git a/lib/items-tech/table/top/import-item-handler.tsx b/lib/items-tech/table/top/import-item-handler.tsx index 0a163791..4f34cff2 100644 --- a/lib/items-tech/table/top/import-item-handler.tsx +++ b/lib/items-tech/table/top/import-item-handler.tsx @@ -1,141 +1,130 @@ -"use client" - -import { z } from "zod" -import { createOffshoreTopItem } from "../../service" - -// 해양 TOP 기능(공종) 유형 enum -const TOP_WORK_TYPES = ["TM", "TS", "TE", "TP"] as const; - -// 아이템 데이터 검증을 위한 Zod 스키마 -const itemSchema = z.object({ - itemCode: z.string().optional(), - workType: z.enum(TOP_WORK_TYPES, { - required_error: "기능(공종)은 필수입니다", - }), - itemList: z.string().nullable().optional(), - subItemList: z.string().nullable().optional(), -}); - -interface ProcessResult { - successCount: number; - errorCount: number; - errors: Array<{ row: number; message: string; itemCode?: string; workType?: string }>; -} - -/** - * Excel 파일에서 가져온 해양 TOP 아이템 데이터 처리하는 함수 - */ -export async function processTopFileImport( - jsonData: Record[], - progressCallback?: (current: number, total: number) => void -): Promise { - // 결과 카운터 초기화 - let successCount = 0; - let errorCount = 0; - const errors: Array<{ row: number; message: string }> = []; - - // 빈 행 등 필터링 - const dataRows = jsonData.filter(row => { - // 빈 행 건너뛰기 - if (Object.values(row).every(val => !val)) { - return false; - } - return true; - }); - - // 데이터 행이 없으면 빈 결과 반환 - if (dataRows.length === 0) { - return { successCount: 0, errorCount: 0, errors: [] }; - } - - // 각 행에 대해 처리 - for (let i = 0; i < dataRows.length; i++) { - const row = dataRows[i]; - const rowIndex = i + 1; // 사용자에게 표시할 행 번호는 1부터 시작 - - // 진행 상황 콜백 호출 - if (progressCallback) { - progressCallback(i + 1, dataRows.length); - } - - try { - // 필드 매핑 (한글/영문 필드명 모두 지원) - const itemCode = row["자재 그룹"] || row["itemCode"] || ""; - const workType = row["기능(공종)"] || row["workType"] || ""; - const itemList = row["자재명"] || row["itemList"] || null; - const subItemList = row["자재명(상세)"] || row["subItemList"] || null; - - // 데이터 정제 - const cleanedRow = { - itemCode: typeof itemCode === 'string' ? itemCode.trim() : String(itemCode).trim(), - workType: typeof workType === 'string' ? workType.trim() : String(workType).trim(), - itemList: itemList ? (typeof itemList === 'string' ? itemList : String(itemList)) : null, - subItemList: subItemList ? (typeof subItemList === 'string' ? subItemList : String(subItemList)) : null, - }; - - // 데이터 유효성 검사 - const validationResult = itemSchema.safeParse(cleanedRow); - - if (!validationResult.success) { - const errorMessage = validationResult.error.errors.map( - err => `${err.path.join('.')}: ${err.message}` - ).join(', '); - - errors.push({ - row: rowIndex, - message: errorMessage, - itemCode: cleanedRow.itemCode, - workType: cleanedRow.workType - }); - errorCount++; - continue; - } - - // 해양 TOP 아이템 생성 - const result = await createOffshoreTopItem({ - itemCode: cleanedRow.itemCode, - workType: cleanedRow.workType as "TM" | "TS" | "TE" | "TP", - itemList: cleanedRow.itemList, - subItemList: cleanedRow.subItemList, - }); - - if (result.success) { - successCount++; - } else { - errors.push({ - row: rowIndex, - message: result.message || result.error || "알 수 없는 오류", - itemCode: cleanedRow.itemCode, - workType: cleanedRow.workType - }); - errorCount++; - } - } catch (error) { - console.error(`${rowIndex}행 처리 오류:`, error); - - // cleanedRow가 정의되지 않은 경우를 처리 - const itemCode = row["자재 그룹"] || row["itemCode"] || ""; - const workType = row["기능(공종)"] || row["workType"] || ""; - - errors.push({ - row: rowIndex, - message: error instanceof Error ? error.message : "알 수 없는 오류", - itemCode: typeof itemCode === 'string' ? itemCode.trim() : String(itemCode).trim(), - workType: typeof workType === 'string' ? workType.trim() : String(workType).trim() - }); - errorCount++; - } - - // 비동기 작업 쓰로틀링 - if (i % 5 === 0) { - await new Promise(resolve => setTimeout(resolve, 10)); - } - } - - // 처리 결과 반환 - return { - successCount, - errorCount, - errors - }; -} +"use client" + +import { z } from "zod" +import { createOffshoreTopItem } from "../../service" + +// 해양 TOP 기능(공종) 유형 enum +const TOP_WORK_TYPES = ["TM", "TS", "TE", "TP"] as const; + +// 아이템 데이터 검증을 위한 Zod 스키마 +const itemSchema = z.object({ + itemCode: z.string().optional(), + workType: z.enum(TOP_WORK_TYPES, { + required_error: "기능(공종)은 필수입니다", + }), + itemList: z.string().nullable().optional(), + subItemList: z.string().nullable().optional(), +}); + +interface ProcessResult { + successCount: number; + errorCount: number; + errors: Array<{ row: number; message: string }>; +} + +/** + * Excel 파일에서 가져온 해양 TOP 아이템 데이터 처리하는 함수 + */ +export async function processTopFileImport( + jsonData: Record[], + progressCallback?: (current: number, total: number) => void +): Promise { + // 결과 카운터 초기화 + let successCount = 0; + let errorCount = 0; + const errors: Array<{ row: number; message: string }> = []; + + // 빈 행 등 필터링 + const dataRows = jsonData.filter(row => { + // 빈 행 건너뛰기 + if (Object.values(row).every(val => !val)) { + return false; + } + return true; + }); + + // 데이터 행이 없으면 빈 결과 반환 + if (dataRows.length === 0) { + return { successCount: 0, errorCount: 0, errors: [] }; + } + + // 각 행에 대해 처리 + for (let i = 0; i < dataRows.length; i++) { + const row = dataRows[i]; + const rowIndex = i + 1; // 사용자에게 표시할 행 번호는 1부터 시작 + + // 진행 상황 콜백 호출 + if (progressCallback) { + progressCallback(i + 1, dataRows.length); + } + + try { + // 필드 매핑 (한글/영문 필드명 모두 지원) + const itemCode = row["자재 그룹"] || row["itemCode"] || ""; + const workType = row["기능(공종)"] || row["workType"] || ""; + const itemList = row["자재명"] || row["itemList"] || null; + const subItemList = row["자재명(상세)"] || row["subItemList"] || null; + + // 데이터 정제 + const cleanedRow = { + itemCode: typeof itemCode === 'string' ? itemCode.trim() : String(itemCode).trim(), + workType: typeof workType === 'string' ? workType.trim() : String(workType).trim(), + itemList: itemList ? (typeof itemList === 'string' ? itemList : String(itemList)) : null, + subItemList: subItemList ? (typeof subItemList === 'string' ? subItemList : String(subItemList)) : null, + }; + + // 데이터 유효성 검사 + const validationResult = itemSchema.safeParse(cleanedRow); + + if (!validationResult.success) { + const errorMessage = validationResult.error.errors.map( + err => `${err.path.join('.')}: ${err.message}` + ).join(', '); + + errors.push({ + row: rowIndex, + message: errorMessage, + }); + errorCount++; + continue; + } + + // 해양 TOP 아이템 생성 + const result = await createOffshoreTopItem({ + itemCode: cleanedRow.itemCode, + workType: cleanedRow.workType as "TM" | "TS" | "TE" | "TP", + itemList: cleanedRow.itemList, + subItemList: cleanedRow.subItemList, + }); + + if (result.success) { + successCount++; + } else { + errors.push({ + row: rowIndex, + message: result.message || result.error || "알 수 없는 오류", + }); + errorCount++; + } + } catch (error) { + console.error(`${rowIndex}행 처리 오류:`, error); + errors.push({ + row: rowIndex, + message: error instanceof Error ? error.message : "알 수 없는 오류", + }); + errorCount++; + } + + // 비동기 작업 쓰로틀링 + if (i % 5 === 0) { + await new Promise(resolve => setTimeout(resolve, 10)); + } + } + + // 처리 결과 반환 + return { + successCount, + errorCount, + errors + }; +} diff --git a/lib/items-tech/table/top/item-excel-template.tsx b/lib/items-tech/table/top/item-excel-template.tsx index b67d91be..9121d70f 100644 --- a/lib/items-tech/table/top/item-excel-template.tsx +++ b/lib/items-tech/table/top/item-excel-template.tsx @@ -1,109 +1,109 @@ -import * as ExcelJS from 'exceljs'; -import { saveAs } from "file-saver"; - - -/** - * 해양 TOP 아이템 데이터 가져오기를 위한 Excel 템플릿 파일 생성 및 다운로드 - */ -export async function exportTopItemTemplate() { - // 워크북 생성 - const workbook = new ExcelJS.Workbook(); - workbook.creator = 'Offshore TOP Item Management System'; - workbook.created = new Date(); - - // 워크시트 생성 - const worksheet = workbook.addWorksheet('해양 TOP 아이템'); - - // 컬럼 헤더 정의 및 스타일 적용 - worksheet.columns = [ - { header: '자재 그룹', key: 'itemCode', width: 15 }, - { header: '기능(공종)', key: 'workType', width: 15 }, - { header: '자재명', key: 'itemList', width: 20 }, - { header: '자재명(상세)', key: 'subItemList', width: 20 }, - - ]; - - // 헤더 스타일 적용 - const headerRow = worksheet.getRow(1); - headerRow.font = { bold: true }; - headerRow.fill = { - type: 'pattern', - pattern: 'solid', - fgColor: { argb: 'FFE0E0E0' } - }; - headerRow.alignment = { vertical: 'middle', horizontal: 'center' }; - - // 테두리 스타일 적용 - headerRow.eachCell((cell) => { - cell.border = { - top: { style: 'thin' }, - left: { style: 'thin' }, - bottom: { style: 'thin' }, - right: { style: 'thin' } - }; - }); - - // 샘플 데이터 추가 - const sampleData = [ - { - itemCode: 'TOP001', - workType: 'TM', - itemList: '항목1 샘플 데이터', - subItemList: '항목2 샘플 데이터', - }, - { - itemCode: 'TOP002', - workType: 'TS', - itemList: '항목1 샘플 데이터', - subItemList: '항목2 샘플 데이터', - } - - ]; - - // 데이터 행 추가 - sampleData.forEach(item => { - worksheet.addRow(item); - }); - - // 데이터 행 스타일 적용 - worksheet.eachRow((row, rowNumber) => { - if (rowNumber > 1) { // 헤더를 제외한 데이터 행 - row.eachCell((cell) => { - cell.border = { - top: { style: 'thin' }, - left: { style: 'thin' }, - bottom: { style: 'thin' }, - right: { style: 'thin' } - }; - }); - } - }); - - - // 워크시트 보호 (선택적) - worksheet.protect('', { - selectLockedCells: true, - selectUnlockedCells: true, - formatColumns: true, - formatRows: true, - insertColumns: false, - insertRows: true, - insertHyperlinks: false, - deleteColumns: false, - deleteRows: true, - sort: true, - autoFilter: true, - pivotTables: false - }); - - try { - // 워크북을 Blob으로 변환 - const buffer = await workbook.xlsx.writeBuffer(); - const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); - saveAs(blob, 'offshore-top-item-template.xlsx'); - return true; - } catch (error) { - console.error('Excel 템플릿 생성 오류:', error); - throw error; - } -} +import * as ExcelJS from 'exceljs'; +import { saveAs } from "file-saver"; + + +/** + * 해양 TOP 아이템 데이터 가져오기를 위한 Excel 템플릿 파일 생성 및 다운로드 + */ +export async function exportTopItemTemplate() { + // 워크북 생성 + const workbook = new ExcelJS.Workbook(); + workbook.creator = 'Offshore TOP Item Management System'; + workbook.created = new Date(); + + // 워크시트 생성 + const worksheet = workbook.addWorksheet('해양 TOP 아이템'); + + // 컬럼 헤더 정의 및 스타일 적용 + worksheet.columns = [ + { header: '자재 그룹', key: 'itemCode', width: 15 }, + { header: '기능(공종)', key: 'workType', width: 15 }, + { header: '자재명', key: 'itemList', width: 20 }, + { header: '자재명(상세)', key: 'subItemList', width: 20 }, + + ]; + + // 헤더 스타일 적용 + const headerRow = worksheet.getRow(1); + headerRow.font = { bold: true }; + headerRow.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFE0E0E0' } + }; + headerRow.alignment = { vertical: 'middle', horizontal: 'center' }; + + // 테두리 스타일 적용 + headerRow.eachCell((cell) => { + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + }; + }); + + // 샘플 데이터 추가 + const sampleData = [ + { + itemCode: 'TOP001', + workType: 'TM', + itemList: '항목1 샘플 데이터', + subItemList: '항목2 샘플 데이터', + }, + { + itemCode: 'TOP002', + workType: 'TS', + itemList: '항목1 샘플 데이터', + subItemList: '항목2 샘플 데이터', + } + + ]; + + // 데이터 행 추가 + sampleData.forEach(item => { + worksheet.addRow(item); + }); + + // 데이터 행 스타일 적용 + worksheet.eachRow((row, rowNumber) => { + if (rowNumber > 1) { // 헤더를 제외한 데이터 행 + row.eachCell((cell) => { + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + }; + }); + } + }); + + + // 워크시트 보호 (선택적) + worksheet.protect('', { + selectLockedCells: true, + selectUnlockedCells: true, + formatColumns: true, + formatRows: true, + insertColumns: false, + insertRows: true, + insertHyperlinks: false, + deleteColumns: false, + deleteRows: true, + sort: true, + autoFilter: true, + pivotTables: false + }); + + try { + // 워크북을 Blob으로 변환 + const buffer = await workbook.xlsx.writeBuffer(); + const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); + saveAs(blob, 'offshore-top-item-template.xlsx'); + return true; + } catch (error) { + console.error('Excel 템플릿 생성 오류:', error); + throw error; + } +} -- cgit v1.2.3