summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/gtc-contract/service.ts269
-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
-rw-r--r--lib/vendors/table/request-pq-dialog.tsx152
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">
{/* 선택된 협력업체 정보 */}