diff options
| -rw-r--r-- | lib/gtc-contract/service.ts | 269 | ||||
| -rw-r--r-- | lib/tech-vendors/possible-items/possible-items-table.tsx | 8 | ||||
| -rw-r--r-- | lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx | 156 | ||||
| -rw-r--r-- | lib/tech-vendors/service.ts | 478 | ||||
| -rw-r--r-- | lib/vendors/table/request-pq-dialog.tsx | 152 |
5 files changed, 1045 insertions, 18 deletions
diff --git a/lib/gtc-contract/service.ts b/lib/gtc-contract/service.ts index 4d11ad0a..f9725f80 100644 --- a/lib/gtc-contract/service.ts +++ b/lib/gtc-contract/service.ts @@ -3,9 +3,10 @@ import { revalidateTag, unstable_cache } from "next/cache" import { and, desc, asc, eq, or, ilike, count, max , inArray, isNotNull, notInArray} from "drizzle-orm" import db from "@/db/db" -import { GtcClauseTreeView, gtcClauses, gtcDocuments, gtcDocumentsView, type GtcDocument, type GtcDocumentWithRelations } from "@/db/schema/gtc" +import { GtcClauseTreeView, gtcClauses, gtcDocuments, gtcDocumentsView, gtcVendorDocuments, type GtcDocument, type GtcDocumentWithRelations } from "@/db/schema/gtc" import { projects } from "@/db/schema/projects" import { users } from "@/db/schema/users" +import { vendors } from "@/db/schema/vendors" import { filterColumns } from "@/lib/filter-columns" import type { GetGtcDocumentsSchema, CreateGtcDocumentSchema, UpdateGtcDocumentSchema, CreateNewRevisionSchema, CloneGtcDocumentSchema } from "./validations" @@ -446,6 +447,8 @@ export async function getAvailableProjectsForGtc(): Promise<ProjectForFilter[]> }) .from(gtcDocuments) .where(isNotNull(gtcDocuments.projectId)) + + console.log(projectsWithGtc,"projectsWithGtc") const usedProjectIds = projectsWithGtc .map(row => row.projectId) @@ -457,6 +460,8 @@ export async function getAvailableProjectsForGtc(): Promise<ProjectForFilter[]> return await getProjectsForSelect() } + + return await db .select({ id: projects.id, @@ -844,4 +849,266 @@ export async function validateGtcClausesImport( } } } +} + +/** + * GTC 문서를 벤더별로 생성 + */ +export async function createGtcVendorDocuments({ + baseDocumentId, + vendorIds, + createdById, + documentTitle = "General GTC" +}: { + baseDocumentId: number + vendorIds: number[] + createdById: number + documentTitle?: string +}) { + try { + console.log(`🔍 [GTC] 표준 GTC 문서 생성 시작: baseDocumentId=${baseDocumentId}, title=${documentTitle}`) + + const results = [] + + for (let i = 0; i < vendorIds.length; i++) { + const vendorId = vendorIds[i] + try { + console.log(`📄 [GTC] 벤더 ${i + 1}/${vendorIds.length} 표준 GTC 문서 생성: vendorId=${vendorId}`) + + const result = await db.insert(gtcVendorDocuments).values({ + baseDocumentId, + vendorId, + name: documentTitle, + reviewStatus: "draft", + isActive: true, + createdById, + updatedById: createdById, + }).returning() + + console.log(`✅ [GTC] 표준 GTC 문서 생성 성공: vendorId=${vendorId}, insertedId=${result[0].id}`) + results.push(result[0]) + + } catch (vendorError) { + console.error(`❌ [GTC] 표준 GTC 문서 생성 실패: vendorId=${vendorId}`, vendorError) + continue + } + } + + console.log(`🎉 [GTC] 표준 GTC 문서 생성 완료: ${results.length}/${vendorIds.length}개 성공`) + + return { + success: true, + data: results, + count: results.length + } + } catch (error) { + console.error("❌ [GTC] 표준 GTC 벤더 문서 생성 오류:", error) + return { + success: false, + error: error instanceof Error ? error.message : "알 수 없는 오류" + } + } +} + +/** + * 프로젝트별 GTC 문서를 벤더별로 생성 + */ +export async function createProjectGtcVendorDocuments({ + projectCode, + vendorIds, + createdById, + documentTitle +}: { + projectCode: string + vendorIds: number[] + createdById: number + documentTitle?: string +}) { + try { + console.log(`🔍 [GTC] 프로젝트별 GTC 문서 생성 시작: ${projectCode}`) + + // 1. 프로젝트 코드로 GTC 문서 찾기 + console.log(`🔍 [GTC] GTC 문서 검색 쿼리 실행: projectCode=${projectCode}`) + + // 1. 프로젝트 코드로 프로젝트 id 조회 + const projectRow = await db + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.code, projectCode)) + .limit(1); + + if (!projectRow || projectRow.length === 0) { + console.log(`❌ [GTC] 프로젝트 코드에 해당하는 프로젝트를 찾을 수 없음: ${projectCode}`); + return { + success: false, + error: `프로젝트 코드 "${projectCode}"에 해당하는 프로젝트를 찾을 수 없습니다.` + } + } + + const projectId = projectRow[0].id; + console.log(projectId,"projectId") + + // 2. 해당 프로젝트 id로 GTC 문서 조회 + const gtcDocument = await db + .select({ + id: gtcDocuments.id, + title: gtcDocuments.title + }) + .from(gtcDocuments) + .where( + and( + eq(gtcDocuments.type, "project"), + eq(gtcDocuments.isActive, true), + eq(gtcDocuments.projectId, projectId) + ) + ) + .orderBy(desc(gtcDocuments.revision)) + .limit(1); + + console.log(`🔍 [GTC] GTC 문서 검색 결과:`, gtcDocument) + + if (!gtcDocument || gtcDocument.length === 0) { + console.log(`❌ [GTC] 프로젝트 GTC 문서를 찾을 수 없음: ${projectCode}`) + return { + success: false, + error: `프로젝트 코드 "${projectCode}"에 해당하는 GTC 문서를 찾을 수 없습니다.` + } + } + + const baseDocumentId = gtcDocument[0].id + const finalDocumentTitle = documentTitle || `${projectCode} GTC` + + console.log(`✅ [GTC] GTC 문서 찾음: id=${baseDocumentId}, title=${finalDocumentTitle}`) + + // 2. 각 벤더별로 GTC 벤더 문서 생성 + console.log(`📝 [GTC] 벤더별 GTC 문서 생성 시작: ${vendorIds.length}개 벤더`) + + const results = [] + + for (let i = 0; i < vendorIds.length; i++) { + const vendorId = vendorIds[i] + try { + console.log(`📄 [GTC] 벤더 ${i + 1}/${vendorIds.length} GTC 문서 생성: vendorId=${vendorId}`) + + const result = await db.insert(gtcVendorDocuments).values({ + baseDocumentId, + vendorId, + name: finalDocumentTitle, + reviewStatus: "draft", + isActive: true, + createdById, + updatedById: createdById, + }).returning() + + console.log(`✅ [GTC] 벤더 GTC 문서 생성 성공: vendorId=${vendorId}, insertedId=${result[0].id}`) + results.push(result[0]) + + } catch (vendorError) { + console.error(`❌ [GTC] 벤더 GTC 문서 생성 실패: vendorId=${vendorId}`, vendorError) + // 개별 벤더 실패는 전체 실패로 처리하지 않고 계속 진행 + continue + } + } + + console.log(`🎉 [GTC] 프로젝트 GTC 문서 생성 완료: ${results.length}/${vendorIds.length}개 성공`) + + return { + success: true, + data: results, + count: results.length + } + } catch (error) { + console.error("❌ [GTC] 프로젝트 GTC 벤더 문서 생성 오류:", error) + return { + success: false, + error: error instanceof Error ? error.message : "알 수 없는 오류" + } + } +} + +/** + * 표준 GTC 문서 ID 가져오기 (최신 리비전) + */ +export async function getStandardGtcDocumentId(): Promise<{ id: number; title: string } | null> { + try { + console.log(`🔍 [GTC-UTIL] 표준 GTC 문서 조회 시작`) + + const result = await db + .select({ + id: gtcDocuments.id, + title: gtcDocuments.title, + revision: gtcDocuments.revision + }) + .from(gtcDocuments) + .where( + and( + eq(gtcDocuments.type, "standard"), + eq(gtcDocuments.isActive, true) + ) + ) + .orderBy(desc(gtcDocuments.revision)) + .limit(1) + + console.log(`🔍 [GTC-UTIL] 표준 GTC 문서 조회 결과:`, result) + + if (result.length > 0) { + const gtcDoc = { + id: result[0].id, + title: result[0].title || "General GTC" + } + console.log(`✅ [GTC-UTIL] 표준 GTC 문서 찾음:`, gtcDoc) + return gtcDoc + } else { + console.log(`⚠️ [GTC-UTIL] 표준 GTC 문서를 찾을 수 없음`) + return null + } + } catch (error) { + console.error("❌ [GTC-UTIL] 표준 GTC 문서 ID 조회 오류:", error) + return null + } +} + +/** + * 프로젝트 코드로 GTC 문서 ID 가져오기 (최신 리비전) + */ +export async function getProjectGtcDocumentId(projectCode: string): Promise<{ id: number; title: string } | null> { + try { + console.log(`🔍 [GTC-UTIL] 프로젝트 GTC 문서 조회 시작: ${projectCode}`) + + const result = await db + .select({ + id: gtcDocuments.id, + title: gtcDocuments.title, + revision: gtcDocuments.revision, + projectCode: projects.code + }) + .from(gtcDocuments) + .leftJoin(projects, eq(gtcDocuments.projectId, projects.id)) + .where( + and( + eq(gtcDocuments.type, "project"), + eq(gtcDocuments.isActive, true), + eq(projects.code, projectCode) + ) + ) + .orderBy(desc(gtcDocuments.revision)) + .limit(1) + + console.log(`🔍 [GTC-UTIL] 프로젝트 GTC 문서 조회 결과:`, result) + + if (result.length > 0) { + const gtcDoc = { + id: result[0].id, + title: result[0].title || `${projectCode} GTC` + } + console.log(`✅ [GTC-UTIL] 프로젝트 GTC 문서 찾음:`, gtcDoc) + return gtcDoc + } else { + console.log(`⚠️ [GTC-UTIL] 프로젝트 GTC 문서를 찾을 수 없음: ${projectCode}`) + return null + } + } catch (error) { + console.error("❌ [GTC-UTIL] 프로젝트 GTC 문서 ID 조회 오류:", error) + return null + } }
\ No newline at end of file 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 diff --git a/lib/vendors/table/request-pq-dialog.tsx b/lib/vendors/table/request-pq-dialog.tsx index 14a1cd01..767b979f 100644 --- a/lib/vendors/table/request-pq-dialog.tsx +++ b/lib/vendors/table/request-pq-dialog.tsx @@ -49,6 +49,7 @@ import type { BasicContractTemplate } from "@/db/schema" import { searchItemsForPQ } from "@/lib/items/service"
import { saveNdaAttachments } from "../service"
import { useRouter } from "next/navigation"
+import { createGtcVendorDocuments, createProjectGtcVendorDocuments, getStandardGtcDocumentId, getProjectGtcDocumentId } from "@/lib/gtc-contract/service"
// import { PQContractViewer } from "../pq-contract-viewer" // 더 이상 사용하지 않음
interface RequestPQDialogProps extends React.ComponentPropsWithoutRef<typeof Dialog> {
@@ -145,19 +146,31 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro getALLBasicContractTemplates()
.then((templates) => {
setBasicContractTemplates(templates)
-
+
// 벤더 국가별 자동 선택 로직
if (vendors.length > 0) {
const isAllForeign = vendors.every(vendor => vendor.country !== 'KR')
const isAllDomestic = vendors.every(vendor => vendor.country === 'KR')
-
+
if (isAllForeign) {
- // 외자: 준법서약 (영문), GTC만 선택
- const foreignTemplates = templates.filter(template =>
+ // 외자: 준법서약 (영문), GTC 선택 (GTC는 1개만 선택하도록)
+ const foreignTemplates = templates.filter(template =>
template.templateName?.includes('준법서약') && template.templateName?.includes('영문') ||
- template.templateName?.includes('GTC')
+ template.templateName?.includes('gtc')
)
- setSelectedTemplateIds(foreignTemplates.map(t => t.id))
+ // GTC 템플릿 중 최신 리비전의 것만 선택
+ const gtcTemplates = foreignTemplates.filter(t => t.templateName?.includes('gtc'))
+ const nonGtcTemplates = foreignTemplates.filter(t => !t.templateName?.includes('gtc'))
+
+ if (gtcTemplates.length > 0) {
+ // GTC 템플릿 중 이름이 가장 긴 것 (프로젝트 GTC 우선) 선택
+ const selectedGtcTemplate = gtcTemplates.reduce((prev, current) =>
+ (prev.templateName?.length || 0) > (current.templateName?.length || 0) ? prev : current
+ )
+ setSelectedTemplateIds([...nonGtcTemplates.map(t => t.id), selectedGtcTemplate.id])
+ } else {
+ setSelectedTemplateIds(nonGtcTemplates.map(t => t.id))
+ }
} else if (isAllDomestic) {
// 내자: 준법서약 (영문), GTC 제외한 모든 템플릿 선택
const domesticTemplates = templates.filter(template => {
@@ -243,19 +256,35 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro if (type === "PROJECT" && !selectedProjectId) return toast.error("프로젝트를 선택하세요.")
if (!dueDate) return toast.error("마감일을 선택하세요.")
if (!session?.user?.id) return toast.error("인증 실패")
-
+
+ // GTC 템플릿 선택 검증
+ const selectedGtcTemplates = basicContractTemplates.filter(template =>
+ selectedTemplateIds.includes(template.id) &&
+ template.templateName?.toLowerCase().includes('gtc')
+ )
+
+ if (selectedGtcTemplates.length > 1) {
+ return toast.error("GTC 템플릿은 하나만 선택할 수 있습니다.")
+ }
+
// 프로그레스 바를 즉시 표시
setShowProgress(true)
setProgressValue(0)
setCurrentStep("시작 중...")
-
+
startApproveTransition(async () => {
try {
// 전체 단계 수 계산
- const totalSteps = 1 +
- (selectedTemplateIds.length > 0 ? 1 : 0) +
- (isNdaTemplateSelected() && ndaAttachments.length > 0 ? 1 : 0)
+ const gtcTemplates = basicContractTemplates.filter(template =>
+ selectedTemplateIds.includes(template.id) &&
+ template.templateName?.toLowerCase().includes('gtc')
+ )
+
+ const totalSteps = 1 +
+ (selectedTemplateIds.length > 0 ? 1 : 0) +
+ (isNdaTemplateSelected() && ndaAttachments.length > 0 ? 1 : 0) +
+ (gtcTemplates.length > 0 ? 1 : 0)
let completedSteps = 0
// 1단계: PQ 생성
@@ -318,7 +347,24 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro completedSteps++
setProgressValue((completedSteps / totalSteps) * 100)
}
-
+ //4단계: GTC 템플릿 처리
+ if (selectedGtcTemplates.length > 0) {
+ setCurrentStep(`GTC 문서 생성 중... (${selectedGtcTemplates.length}개 템플릿)`)
+ console.log("📋 GTC 문서 생성 시작", selectedGtcTemplates.length, "개 템플릿")
+
+ try {
+ await processGtcTemplates(selectedGtcTemplates, vendors)
+ completedSteps++
+ setProgressValue((completedSteps / totalSteps) * 100)
+ } catch (error) {
+ console.error("GTC 템플릿 처리 중 오류:", error)
+ toast.error(`GTC 템플릿 처리 중 오류가 발생했습니다`)
+ // GTC 처리 실패해도 PQ 생성은 성공으로 간주
+ completedSteps++
+ setProgressValue((completedSteps / totalSteps) * 100)
+ }
+ }
+
setCurrentStep("완료!")
setProgressValue(100)
@@ -473,6 +519,88 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro }
}
+ // GTC 템플릿 처리 함수
+ const processGtcTemplates = async (gtcTemplates: BasicContractTemplate[], vendors: any[]) => {
+ if (!session?.user?.id) {
+ toast.error("인증 정보가 없습니다")
+ return
+ }
+
+ try {
+ const vendorIds = vendors.map(v => v.id)
+ const userId = Number(session.user.id)
+
+ for (const template of gtcTemplates) {
+ const templateName = template.templateName?.toLowerCase() || ''
+
+ if (templateName.includes('general gtc') || (templateName.includes('gtc') && !templateName.includes(' '))) {
+ // General GTC 처리
+ console.log(`📄 General GTC 템플릿 처리: ${template.templateName}`)
+
+ const gtcDocument = await getStandardGtcDocumentId()
+ if (!gtcDocument) {
+ toast.error(`표준 GTC 문서를 찾을 수 없습니다.`)
+ continue
+ }
+
+ const result = await createGtcVendorDocuments({
+ baseDocumentId: gtcDocument.id,
+ vendorIds,
+ createdById: userId,
+ documentTitle: gtcDocument.title
+ })
+
+ if (result.success) {
+ console.log(`✅ General GTC 문서 생성 완료: ${result.count}개`)
+ } else {
+ toast.error(`General GTC 문서 생성 실패: ${result.error}`)
+ }
+
+ } else if (templateName.includes('gtc') && templateName.includes(' ')) {
+ // 프로젝트 GTC 처리 (프로젝트 코드 추출)
+ const projectCodeMatch = template.templateName?.match(/^([A-Z0-9]+)\s+GTC/)
+ console.log("🔄 프로젝트 GTC 템플릿 처리: ", template.templateName, projectCodeMatch)
+ console.log(` - 템플릿 이름 분석: "${template.templateName}"`)
+ console.log(` - 소문자 변환: "${templateName}"`)
+ if (projectCodeMatch) {
+ const projectCode = projectCodeMatch[1]
+ console.log(`📄 프로젝트 GTC 템플릿 처리: ${template.templateName} (프로젝트: ${projectCode})`)
+
+ // const gtcDocument = await getProjectGtcDocumentId(projectCode)
+ // if (!gtcDocument) {
+ // toast.error(`프로젝트 "${projectCode}"의 GTC 문서를 찾을 수 없습니다.`)
+ // continue
+ // }
+ // console.log("🔄 getProjectGtcDocumentId", gtcDocument)
+
+ const result = await createProjectGtcVendorDocuments({
+ projectCode,
+ vendorIds,
+ createdById: userId,
+ documentTitle: template.templateName
+ })
+
+ if (result.success) {
+ console.log(`✅ 프로젝트 GTC 문서 생성 완료: ${result.count}개`)
+ } else {
+ toast.error(`프로젝트 GTC 문서 생성 실패: ${result.error}`)
+ }
+ } else {
+ toast.error(`프로젝트 GTC 템플릿 이름 형식이 올바르지 않습니다: ${template.templateName}`)
+ }
+ } else {
+ console.log(`⚠️ GTC 템플릿이지만 처리할 수 없는 형식: ${template.templateName}`)
+ }
+ }
+
+ toast.success(`GTC 문서가 ${gtcTemplates.length}개 템플릿에 대해 생성되었습니다`)
+
+ } catch (error) {
+ console.error('GTC 템플릿 처리 중 오류:', error)
+ toast.error(`GTC 템플릿 처리 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`)
+ }
+ }
+
const dialogContent = (
<div className="space-y-4 py-2">
{/* 선택된 협력업체 정보 */}
|
