summaryrefslogtreecommitdiff
path: root/lib/tech-vendors
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-08-26 08:23:00 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-08-26 08:23:00 +0000
commit30683aee60eedd39893c79773fbae913349a0417 (patch)
treea0e1ea0a062cbfce0f9f5a1c615ef01b2f3eaa14 /lib/tech-vendors
parentebab13aa5962f8974a3d8f80e745280d66eb255e (diff)
(최겸) gtc 레코드 생성 및 기술영업 아이템 import 수정
Diffstat (limited to 'lib/tech-vendors')
-rw-r--r--lib/tech-vendors/possible-items/possible-items-table.tsx8
-rw-r--r--lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx156
-rw-r--r--lib/tech-vendors/service.ts478
3 files changed, 637 insertions, 5 deletions
diff --git a/lib/tech-vendors/possible-items/possible-items-table.tsx b/lib/tech-vendors/possible-items/possible-items-table.tsx
index b54e12d4..100ef04a 100644
--- a/lib/tech-vendors/possible-items/possible-items-table.tsx
+++ b/lib/tech-vendors/possible-items/possible-items-table.tsx
@@ -186,10 +186,14 @@ export function TechVendorPossibleItemsTable({
</Button>
)}
- <PossibleItemsTableToolbarActions
- table={table}
+ <PossibleItemsTableToolbarActions
+ table={table}
vendorId={vendorId}
onAdd={() => setShowAddDialog(true)} // 주석처리
+ onRefresh={() => {
+ // 페이지 새로고침을 위한 콜백
+ window.location.reload()
+ }}
/>
</div>
</DataTableAdvancedToolbar>
diff --git a/lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx b/lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx
index 192bf614..371f88f9 100644
--- a/lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx
+++ b/lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx
@@ -2,7 +2,7 @@
import * as React from "react"
import type { Table } from "@tanstack/react-table"
-import { Plus, Trash2 } from "lucide-react"
+import { Plus, Trash2, Upload, Download } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
@@ -19,21 +19,33 @@ import {
} from "@/components/ui/alert-dialog"
import type { TechVendorPossibleItem } from "../validations"
-import { deleteTechVendorPossibleItemsNew } from "../service"
+import {
+ deleteTechVendorPossibleItemsNew,
+ parsePossibleItemsImportFile,
+ importPossibleItemsFromExcel,
+ generatePossibleItemsImportTemplate,
+ generatePossibleItemsErrorExcel,
+ type PossibleItemImportData,
+ type PossibleItemErrorData
+} from "../service"
interface PossibleItemsTableToolbarActionsProps {
table: Table<TechVendorPossibleItem>
vendorId: number
onAdd: () => void // 주석처리
+ onRefresh?: () => void // 데이터 새로고침 콜백
}
export function PossibleItemsTableToolbarActions({
table,
vendorId,
onAdd, // 주석처리
+ onRefresh,
}: PossibleItemsTableToolbarActionsProps) {
const [showDeleteAlert, setShowDeleteAlert] = React.useState(false)
const [isDeleting, setIsDeleting] = React.useState(false)
+ const [isImporting, setIsImporting] = React.useState(false)
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
const selectedRows = table.getFilteredSelectedRowModel().rows
@@ -42,7 +54,7 @@ export function PossibleItemsTableToolbarActions({
try {
const ids = selectedRows.map((row) => row.original.id)
const { error } = await deleteTechVendorPossibleItemsNew(ids, vendorId)
-
+
if (error) {
throw new Error(error)
}
@@ -50,6 +62,7 @@ export function PossibleItemsTableToolbarActions({
toast.success(`${ids.length}개의 아이템이 삭제되었습니다`)
table.resetRowSelection()
setShowDeleteAlert(false)
+ onRefresh?.() // 데이터 새로고침
} catch {
toast.error("아이템 삭제 중 오류가 발생했습니다")
} finally {
@@ -57,9 +70,146 @@ export function PossibleItemsTableToolbarActions({
}
}
+ // 템플릿 다운로드 핸들러
+ async function handleTemplateDownload() {
+ try {
+ const templateBlob = await generatePossibleItemsImportTemplate()
+ const url = window.URL.createObjectURL(templateBlob)
+ const link = document.createElement("a")
+ link.href = url
+ link.download = "벤더_possible_items_템플릿.xlsx"
+ document.body.appendChild(link)
+ link.click()
+ document.body.removeChild(link)
+ window.URL.revokeObjectURL(url)
+ toast.success("템플릿 파일이 다운로드되었습니다")
+ } catch (error) {
+ toast.error("템플릿 다운로드 중 오류가 발생했습니다")
+ }
+ }
+
+ // 파일 선택 핸들러
+ function handleFileSelect() {
+ fileInputRef.current?.click()
+ }
+
+ // Excel 파일 import 핸들러
+ async function handleFileImport(event: React.ChangeEvent<HTMLInputElement>) {
+ const file = event.target.files?.[0]
+ if (!file) return
+
+ // 파일 타입 검증
+ if (!file.name.endsWith('.xlsx') && !file.name.endsWith('.xls')) {
+ toast.error("Excel 파일(.xlsx 또는 .xls)만 업로드 가능합니다")
+ return
+ }
+
+ setIsImporting(true)
+ try {
+ // Excel 파일 파싱
+ const importData: PossibleItemImportData[] = await parsePossibleItemsImportFile(file)
+
+ if (importData.length === 0) {
+ toast.error("업로드할 데이터가 없습니다")
+ return
+ }
+
+ // 데이터 import 실행
+ const result = await importPossibleItemsFromExcel(importData)
+
+ // 결과 메시지 생성
+ const successMessage = `${result.successCount}개의 아이템이 성공적으로 등록되었습니다`
+ const failMessage = result.failedRows.length > 0
+ ? `, ${result.failedRows.length}개의 아이템 등록 실패`
+ : ""
+
+ toast.success(successMessage + failMessage)
+
+ // 실패한 행이 있는 경우 에러 파일 다운로드
+ if (result.failedRows.length > 0) {
+ const errorData: PossibleItemErrorData[] = result.failedRows.map(failedRow => ({
+ vendorEmail: failedRow.vendorEmail,
+ itemCode: failedRow.itemCode,
+ itemType: failedRow.itemType,
+ error: failedRow.error,
+ }))
+
+ const errorBlob = await generatePossibleItemsErrorExcel(errorData)
+ const url = window.URL.createObjectURL(errorBlob)
+ const link = document.createElement("a")
+ link.href = url
+ link.download = "possible_items_import_에러.xlsx"
+ document.body.appendChild(link)
+ link.click()
+ document.body.removeChild(link)
+ window.URL.revokeObjectURL(url)
+
+ toast.error("에러 내역 파일이 다운로드되었습니다")
+ }
+
+ // 파일 입력 초기화
+ if (fileInputRef.current) {
+ fileInputRef.current.value = ""
+ }
+
+ // 데이터 새로고침
+ onRefresh?.()
+
+ } catch (error) {
+ console.error("Import error:", error)
+ toast.error(error instanceof Error ? error.message : "데이터 등록 중 오류가 발생했습니다")
+ } finally {
+ setIsImporting(false)
+ }
+ }
+
return (
<>
<div className="flex items-center gap-2">
+ {/* 템플릿 다운로드 버튼 */}
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleTemplateDownload}
+ >
+ <Download className="mr-2 h-4 w-4" />
+ 템플릿
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ Excel 템플릿 파일 다운로드
+ </TooltipContent>
+ </Tooltip>
+
+ {/* Excel Import 버튼 */}
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleFileSelect}
+ disabled={isImporting}
+ >
+ <Upload className="mr-2 h-4 w-4" />
+ {isImporting ? "등록 중..." : "Import"}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ Excel 파일로 아이템 일괄 등록
+ </TooltipContent>
+ </Tooltip>
+
+ {/* 숨겨진 파일 입력 */}
+ <input
+ ref={fileInputRef}
+ type="file"
+ accept=".xlsx,.xls"
+ onChange={handleFileImport}
+ className="hidden"
+ />
+
{/* 아이템 추가 버튼 주석처리 */}
<Button
variant="outline"
diff --git a/lib/tech-vendors/service.ts b/lib/tech-vendors/service.ts
index 4eba6b2b..f5380889 100644
--- a/lib/tech-vendors/service.ts
+++ b/lib/tech-vendors/service.ts
@@ -2784,4 +2784,482 @@ export async function parseContactImportFile(file: File): Promise<ImportContactD
})
return data
+}
+
+// ================================================
+// Possible Items Excel Import 관련 함수들
+// ================================================
+
+export interface PossibleItemImportData {
+ vendorEmail: string
+ itemCode: string
+ itemType: "조선" | "해양TOP" | "해양HULL"
+}
+
+export interface PossibleItemImportResult {
+ success: boolean
+ totalRows: number
+ successCount: number
+ failedRows: Array<{
+ row: number
+ error: string
+ vendorEmail: string
+ itemCode: string
+ itemType: "조선" | "해양TOP" | "해양HULL"
+ }>
+}
+
+export interface PossibleItemErrorData {
+ vendorEmail: string
+ itemCode: string
+ itemType: string
+ error: string
+}
+
+export interface FoundItem {
+ id: number
+ itemCode: string | null
+ workType: string | null
+ itemList: string | null
+ shipTypes: string | null
+ itemType: "SHIP" | "TOP" | "HULL"
+}
+
+/**
+ * 벤더 이메일로 벤더 찾기 (possible items import용)
+ */
+async function findVendorByEmail(email: string) {
+ const vendor = await db
+ .select({
+ id: techVendors.id,
+ vendorName: techVendors.vendorName,
+ email: techVendors.email,
+ techVendorType: techVendors.techVendorType,
+ })
+ .from(techVendors)
+ .where(eq(techVendors.email, email))
+ .limit(1)
+
+ return vendor[0] || null
+}
+
+/**
+ * 아이템 타입과 코드로 아이템 찾기
+ * 조선의 경우 같은 아이템 코드에 선종이 다른 여러 레코드가 있을 수 있으므로 배열로 반환
+ */
+async function findItemByCodeAndType(itemCode: string, itemType: "조선" | "해양TOP" | "해양HULL"): Promise<FoundItem[]> {
+ try {
+ switch (itemType) {
+ case "조선":
+ const shipItems = await db
+ .select({
+ id: itemShipbuilding.id,
+ itemCode: itemShipbuilding.itemCode,
+ workType: itemShipbuilding.workType,
+ itemList: itemShipbuilding.itemList,
+ shipTypes: itemShipbuilding.shipTypes,
+ })
+ .from(itemShipbuilding)
+ .where(eq(itemShipbuilding.itemCode, itemCode))
+
+ return shipItems.length > 0
+ ? shipItems.map(item => ({ ...item, itemType: "SHIP" as const }))
+ : []
+
+ case "해양TOP":
+ const topItems = await db
+ .select({
+ id: itemOffshoreTop.id,
+ itemCode: itemOffshoreTop.itemCode,
+ workType: itemOffshoreTop.workType,
+ itemList: itemOffshoreTop.itemList,
+ shipTypes: sql<string>`null`.as("shipTypes"),
+ })
+ .from(itemOffshoreTop)
+ .where(eq(itemOffshoreTop.itemCode, itemCode))
+
+ return topItems.length > 0
+ ? topItems.map(item => ({ ...item, itemType: "TOP" as const }))
+ : []
+
+ case "해양HULL":
+ const hullItems = await db
+ .select({
+ id: itemOffshoreHull.id,
+ itemCode: itemOffshoreHull.itemCode,
+ workType: itemOffshoreHull.workType,
+ itemList: itemOffshoreHull.itemList,
+ shipTypes: sql<string>`null`.as("shipTypes"),
+ })
+ .from(itemOffshoreHull)
+ .where(eq(itemOffshoreHull.itemCode, itemCode))
+
+ return hullItems.length > 0
+ ? hullItems.map(item => ({ ...item, itemType: "HULL" as const }))
+ : []
+
+ default:
+ return []
+ }
+ } catch (error) {
+ console.error("Error finding item:", error)
+ return []
+ }
+}
+
+/**
+ * tech-vendor-possible-items에 중복 데이터 확인
+ * 여러 아이템 ID를 한 번에 확인할 수 있도록 수정
+ */
+async function checkPossibleItemDuplicate(vendorId: number, items: FoundItem[]) {
+ try {
+ if (items.length === 0) return []
+
+ const shipIds = items.filter(item => item.itemType === "SHIP").map(item => item.id)
+ const topIds = items.filter(item => item.itemType === "TOP").map(item => item.id)
+ const hullIds = items.filter(item => item.itemType === "HULL").map(item => item.id)
+
+ const whereConditions = [eq(techVendorPossibleItems.vendorId, vendorId)]
+ const orConditions = []
+
+ if (shipIds.length > 0) {
+ orConditions.push(inArray(techVendorPossibleItems.shipbuildingItemId, shipIds))
+ }
+ if (topIds.length > 0) {
+ orConditions.push(inArray(techVendorPossibleItems.offshoreTopItemId, topIds))
+ }
+ if (hullIds.length > 0) {
+ orConditions.push(inArray(techVendorPossibleItems.offshoreHullItemId, hullIds))
+ }
+
+ if (orConditions.length > 0) {
+ whereConditions.push(or(...orConditions))
+ }
+
+ const existing = await db
+ .select({
+ id: techVendorPossibleItems.id,
+ shipbuildingItemId: techVendorPossibleItems.shipbuildingItemId,
+ offshoreTopItemId: techVendorPossibleItems.offshoreTopItemId,
+ offshoreHullItemId: techVendorPossibleItems.offshoreHullItemId,
+ })
+ .from(techVendorPossibleItems)
+ .where(and(...whereConditions))
+
+ return existing
+ } catch (error) {
+ console.error("Error checking duplicate:", error)
+ return []
+ }
+}
+
+/**
+ * possible items Excel 파일에서 데이터 파싱
+ */
+export async function parsePossibleItemsImportFile(file: File): Promise<PossibleItemImportData[]> {
+ const arrayBuffer = await file.arrayBuffer()
+ const workbook = new ExcelJS.Workbook()
+ await workbook.xlsx.load(arrayBuffer)
+
+ const worksheet = workbook.worksheets[0]
+ if (!worksheet) {
+ throw new Error("Excel 파일에 워크시트가 없습니다.")
+ }
+
+ const data: PossibleItemImportData[] = []
+
+ worksheet.eachRow((row, index) => {
+ // 헤더 행 건너뛰기 (1행)
+ if (index === 1) return
+
+ const values = row.values as (string | null)[]
+ if (!values || values.length < 3) return
+
+ const vendorEmail = values[1]?.toString().trim()
+ const itemCode = values[2]?.toString().trim()
+ const itemType = values[3]?.toString().trim()
+
+ // 필수 필드 검증
+ if (!vendorEmail || !itemCode || !itemType) {
+ return
+ }
+
+ // 아이템 타입 검증 및 변환
+ let validatedItemType: "조선" | "해양TOP" | "해양HULL" | null = null
+ if (itemType === "조선") {
+ validatedItemType = "조선"
+ } else if (itemType === "해양TOP") {
+ validatedItemType = "해양TOP"
+ } else if (itemType === "해양HULL") {
+ validatedItemType = "해양HULL"
+ }
+
+ if (!validatedItemType) {
+ return
+ }
+
+ data.push({
+ vendorEmail,
+ itemCode,
+ itemType: validatedItemType,
+ })
+ })
+
+ return data
+}
+
+/**
+ * possible items 일괄 import
+ */
+export async function importPossibleItemsFromExcel(
+ data: PossibleItemImportData[]
+): Promise<PossibleItemImportResult> {
+ const result: PossibleItemImportResult = {
+ 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 {
+ // 1. 벤더 이메일로 벤더 찾기
+ if (!row.vendorEmail || !row.vendorEmail.trim()) {
+ result.failedRows.push({
+ row: rowNumber,
+ error: "벤더 이메일은 필수입니다.",
+ vendorEmail: row.vendorEmail,
+ itemCode: row.itemCode,
+ itemType: row.itemType as "조선" | "해양TOP" | "해양HULL",
+ })
+ continue
+ }
+
+ const vendor = await findVendorByEmail(row.vendorEmail.trim())
+ if (!vendor) {
+ result.failedRows.push({
+ row: rowNumber,
+ error: `벤더 이메일 '${row.vendorEmail}'을(를) 찾을 수 없습니다.`,
+ vendorEmail: row.vendorEmail,
+ itemCode: row.itemCode,
+ itemType: row.itemType as "조선" | "해양TOP" | "해양HULL",
+ })
+ continue
+ }
+
+ // 2. 아이템 코드로 아이템 찾기
+ if (!row.itemCode || !row.itemCode.trim()) {
+ result.failedRows.push({
+ row: rowNumber,
+ error: "아이템 코드는 필수입니다.",
+ vendorEmail: row.vendorEmail,
+ itemCode: row.itemCode,
+ itemType: row.itemType as "조선" | "해양TOP" | "해양HULL",
+ })
+ continue
+ }
+
+ const items = await findItemByCodeAndType(row.itemCode.trim(), row.itemType)
+ if (!items || items.length === 0) {
+ result.failedRows.push({
+ row: rowNumber,
+ error: `아이템 코드 '${row.itemCode}'을(를) '${row.itemType}' 타입에서 찾을 수 없습니다.`,
+ vendorEmail: row.vendorEmail,
+ itemCode: row.itemCode,
+ itemType: row.itemType as "조선" | "해양TOP" | "해양HULL",
+ })
+ continue
+ }
+
+ // 3. 중복 데이터 확인 (모든 아이템에 대해)
+ const existingItems = await checkPossibleItemDuplicate(vendor.id, items)
+
+ // 중복되지 않은 아이템들만 필터링
+ const nonDuplicateItems = items.filter(item => {
+ const existingItem = existingItems.find(existing => {
+ if (item.itemType === "SHIP") {
+ return existing.shipbuildingItemId === item.id
+ } else if (item.itemType === "TOP") {
+ return existing.offshoreTopItemId === item.id
+ } else if (item.itemType === "HULL") {
+ return existing.offshoreHullItemId === item.id
+ }
+ return false
+ })
+ return !existingItem
+ })
+
+ if (nonDuplicateItems.length === 0) {
+ result.failedRows.push({
+ row: rowNumber,
+ error: "모든 아이템이 이미 등록되어 있습니다.",
+ vendorEmail: row.vendorEmail,
+ itemCode: row.itemCode,
+ itemType: row.itemType as "조선" | "해양TOP" | "해양HULL",
+ })
+ continue
+ }
+
+ // 4. tech-vendor-possible-items에 데이터 삽입 (중복되지 않은 아이템들만)
+ for (const item of nonDuplicateItems) {
+ const insertData: {
+ vendorId: number
+ shipbuildingItemId?: number
+ offshoreTopItemId?: number
+ offshoreHullItemId?: number
+ } = {
+ vendorId: vendor.id,
+ }
+
+ if (item.itemType === "SHIP") {
+ insertData.shipbuildingItemId = item.id
+ } else if (item.itemType === "TOP") {
+ insertData.offshoreTopItemId = item.id
+ } else if (item.itemType === "HULL") {
+ insertData.offshoreHullItemId = item.id
+ }
+
+ await db.insert(techVendorPossibleItems).values(insertData)
+ result.successCount++
+ }
+
+ // 부분 성공/실패 처리: 일부 아이템만 등록된 경우
+ const duplicateCount = items.length - nonDuplicateItems.length
+ if (duplicateCount > 0) {
+ result.failedRows.push({
+ row: rowNumber,
+ error: `${duplicateCount}개 아이템이 중복되어 제외되었습니다.`,
+ vendorEmail: row.vendorEmail,
+ itemCode: row.itemCode,
+ itemType: row.itemType as "조선" | "해양TOP" | "해양HULL",
+ })
+ }
+
+ } catch (error) {
+ result.failedRows.push({
+ row: rowNumber,
+ error: error instanceof Error ? error.message : "알 수 없는 오류",
+ vendorEmail: row.vendorEmail,
+ itemCode: row.itemCode,
+ itemType: row.itemType,
+ })
+ }
+ }
+
+ // 캐시 무효화
+ revalidateTag("tech-vendor-possible-items")
+
+ return result
+}
+
+/**
+ * possible items 템플릿 Excel 파일 생성
+ */
+export async function generatePossibleItemsImportTemplate(): Promise<Blob> {
+ const workbook = new ExcelJS.Workbook()
+ const worksheet = workbook.addWorksheet("벤더_Possible_Items_템플릿")
+
+ // 헤더 설정
+ worksheet.columns = [
+ { header: "벤더이메일*", key: "vendorEmail", width: 25 },
+ { header: "아이템코드*", key: "itemCode", width: 20 },
+ { header: "아이템타입*", key: "itemType", width: 15 },
+ ]
+
+ // 헤더 스타일 설정
+ const headerRow = worksheet.getRow(1)
+ headerRow.font = { bold: true }
+ headerRow.fill = {
+ type: "pattern",
+ pattern: "solid",
+ fgColor: { argb: "FFE0E0E0" },
+ }
+
+ // 예시 데이터 추가
+ worksheet.addRow({
+ vendorEmail: "vendor@example.com",
+ itemCode: "ITEM001",
+ itemType: "조선",
+ })
+
+ worksheet.addRow({
+ vendorEmail: "vendor@example.com",
+ itemCode: "TOP001",
+ itemType: "해양TOP",
+ })
+
+ worksheet.addRow({
+ vendorEmail: "vendor@example.com",
+ itemCode: "HULL001",
+ itemType: "해양HULL",
+ })
+
+ // 설명 시트 추가
+ const infoSheet = workbook.addWorksheet("설명")
+ infoSheet.getColumn(1).width = 50
+ infoSheet.getColumn(2).width = 100
+
+ infoSheet.addRow(["템플릿 사용 방법"])
+ infoSheet.addRow(["1. 벤더이메일", "벤더의 이메일 주소 (필수)"])
+ infoSheet.addRow(["2. 아이템코드", "아이템 코드 (필수)"])
+ infoSheet.addRow(["3. 아이템타입", "조선, 해양TOP, 해양HULL 중 하나 (필수)"])
+ infoSheet.addRow([])
+ infoSheet.addRow(["중요 안내"])
+ infoSheet.addRow(["• 조선 아이템의 경우", "같은 아이템 코드라도 선종이 다른 여러 레코드가 있을 수 있습니다."])
+ infoSheet.addRow(["• 조선 아이템 등록 시", "아이템 코드 하나로 선종이 다른 모든 레코드가 자동으로 등록됩니다."])
+ infoSheet.addRow(["• 해양TOP/HULL의 경우", "아이템 코드 하나에 하나의 레코드만 존재합니다."])
+ infoSheet.addRow([])
+ infoSheet.addRow(["주의사항"])
+ infoSheet.addRow(["• 벤더이메일은 시스템에 등록된 이메일이어야 합니다."])
+ infoSheet.addRow(["• 아이템코드는 해당 타입의 아이템 테이블에 존재해야 합니다."])
+ infoSheet.addRow(["• 이미 등록된 아이템-벤더 조합은 중복 등록되지 않습니다."])
+ infoSheet.addRow(["• 조선 아이템의 경우 일부 선종만 중복인 경우 나머지 선종은 등록됩니다."])
+
+ const buffer = await workbook.xlsx.writeBuffer()
+ return new Blob([buffer], {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ })
+}
+
+/**
+ * possible items 에러 Excel 파일 생성
+ */
+export async function generatePossibleItemsErrorExcel(errors: PossibleItemErrorData[]): Promise<Blob> {
+ const workbook = new ExcelJS.Workbook()
+ const worksheet = workbook.addWorksheet("Import_에러_내역")
+
+ // 헤더 설정
+ worksheet.columns = [
+ { header: "벤더이메일", key: "vendorEmail", width: 25 },
+ { header: "아이템코드", key: "itemCode", width: 20 },
+ { header: "아이템타입", key: "itemType", width: 15 },
+ { header: "에러내용", key: "error", width: 50 },
+ ]
+
+ // 헤더 스타일 설정
+ const headerRow = worksheet.getRow(1)
+ headerRow.font = { bold: true }
+ headerRow.fill = {
+ type: "pattern",
+ pattern: "solid",
+ fgColor: { argb: "FFFFCCCC" },
+ }
+
+ // 에러 데이터 추가
+ errors.forEach(error => {
+ worksheet.addRow({
+ vendorEmail: error.vendorEmail,
+ itemCode: error.itemCode,
+ itemType: error.itemType,
+ error: error.error,
+ })
+ })
+
+ const buffer = await workbook.xlsx.writeBuffer()
+ return new Blob([buffer], {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ })
} \ No newline at end of file