summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-11-25 22:04:56 +0900
committerjoonhoekim <26rote@gmail.com>2025-11-25 22:04:56 +0900
commit2b59582194fc5c23140f52c42c793c324856a35e (patch)
tree0db8ef0e913b3a44dfd6e3e20fe92b8e4984aeba /lib
parent835df8ddc115ffa74414db2a4fab7efc0d0056a9 (diff)
(김준회) 벤더풀&AVL 구매 추가요청사항 반영
Diffstat (limited to 'lib')
-rw-r--r--lib/avl/service.ts53
-rw-r--r--lib/avl/table/avl-detail-virtual-columns.tsx280
-rw-r--r--lib/avl/table/avl-detail-virtual-table.tsx358
-rw-r--r--lib/vendor-pool/enrichment-service.ts54
-rw-r--r--lib/vendor-pool/excel-utils.ts40
-rw-r--r--lib/vendor-pool/service.ts687
-rw-r--r--lib/vendor-pool/table/bulk-insert-dialog.tsx (renamed from lib/vendor-pool/table/bulk-import-dialog.tsx)204
-rw-r--r--lib/vendor-pool/table/import-result-dialog.tsx8
-rw-r--r--lib/vendor-pool/table/vendor-pool-excel-import-button.tsx371
-rw-r--r--lib/vendor-pool/table/vendor-pool-table-columns.tsx851
-rw-r--r--lib/vendor-pool/table/vendor-pool-table.tsx75
-rw-r--r--lib/vendor-pool/table/vendor-pool-virtual-table.tsx779
-rw-r--r--lib/vendor-pool/types.ts44
-rw-r--r--lib/vendor-pool/validations.ts33
14 files changed, 2091 insertions, 1746 deletions
diff --git a/lib/avl/service.ts b/lib/avl/service.ts
index 5d7c2418..95d2dbfc 100644
--- a/lib/avl/service.ts
+++ b/lib/avl/service.ts
@@ -379,6 +379,59 @@ export async function getAvlListById(id: number): Promise<AvlListItem | null> {
}
/**
+ * AVL 상세 정보 전체 조회 (클라이언트 사이드 처리용)
+ */
+export const getAllAvlDetail = async (avlListId: number) => {
+ try {
+ debugLog('AVL 상세 전체 조회 시작', { avlListId });
+
+ // 모든 데이터 조회를 위해 page=1, perPage=10000(충분히 큰 수) 설정
+ // 필터 없이 ID로만 조회
+ return await getAvlDetail({
+ page: 1,
+ perPage: 10000,
+ sort: [{ id: "no", desc: false }],
+ filters: [],
+ joinOperator: "and",
+ search: "",
+ avlListId: avlListId,
+ // 선택적 필드들은 undefined로 전달하여 기본값(보통 "" 또는 무시됨)을 사용하게 함
+ equipBulkDivision: undefined,
+ disciplineCode: undefined,
+ disciplineName: undefined,
+ materialNameCustomerSide: undefined,
+ packageCode: undefined,
+ packageName: undefined,
+ materialGroupCode: undefined,
+ materialGroupName: undefined,
+ vendorName: undefined,
+ vendorCode: undefined,
+ avlVendorName: undefined,
+ tier: undefined,
+ faTarget: undefined,
+ faStatus: undefined,
+ isAgent: undefined,
+ contractSignerName: undefined,
+ headquarterLocation: undefined,
+ manufacturingLocation: undefined,
+ hasAvl: undefined,
+ isBlacklist: undefined,
+ isBcc: undefined,
+ techQuoteNumber: undefined,
+ quoteCode: undefined,
+ quoteCountry: undefined,
+ remark: undefined,
+ flags: []
+ } as any);
+ } catch (err) {
+ debugError('AVL 상세 전체 조회 실패', { error: err, avlListId });
+ console.error("Error in getAllAvlDetail:", err);
+ return { data: [], pageCount: 0 };
+ }
+};
+
+
+/**
* AVL Vendor Info 상세 정보 조회 (단일)
*/
export async function getAvlVendorInfoById(id: number): Promise<AvlDetailItem | null> {
diff --git a/lib/avl/table/avl-detail-virtual-columns.tsx b/lib/avl/table/avl-detail-virtual-columns.tsx
new file mode 100644
index 00000000..250ba8de
--- /dev/null
+++ b/lib/avl/table/avl-detail-virtual-columns.tsx
@@ -0,0 +1,280 @@
+import { Checkbox } from "@/components/ui/checkbox"
+import { Badge } from "@/components/ui/badge"
+import { type ColumnDef } from "@tanstack/react-table"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import type { AvlDetailItem } from "../types"
+
+
+// 테이블 컬럼 정의 (Virtual Table용 - 너비 증가)
+export const virtualColumns: ColumnDef<AvlDetailItem>[] = [
+ {
+ header: "기본 정보",
+ columns: [
+ {
+ accessorKey: "no",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="No." />
+ ),
+ size: 90, // 60 + 30
+ meta: {
+ excelHeader: "No.",
+ },
+ },
+ {
+ accessorKey: "equipBulkDivision",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Equip/Bulk 구분" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("equipBulkDivision") as string
+ return (
+ <Badge variant="outline">
+ {value || "-"}
+ </Badge>
+ )
+ },
+ size: 200, // 120 + 30
+ meta: {
+ excelHeader: "Equip/Bulk 구분",
+ },
+ },
+ {
+ accessorKey: "disciplineName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="설계공종" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("disciplineName") as string
+ return <span>{value || "-"}</span>
+ },
+ size: 150, // 120 + 30
+ meta: {
+ excelHeader: "설계공종",
+ },
+ },
+ {
+ accessorKey: "materialNameCustomerSide",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="고객사 AVL 자재명" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("materialNameCustomerSide") as string
+ return <span>{value || "-"}</span>
+ },
+ size: 250, // 150 + 30
+ meta: {
+ excelHeader: "고객사 AVL 자재명",
+ },
+ },
+ {
+ accessorKey: "packageName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="패키지 정보" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("packageName") as string
+ return <span>{value || "-"}</span>
+ },
+ size: 160, // 130 + 30
+ meta: {
+ excelHeader: "패키지 정보",
+ },
+ },
+ {
+ accessorKey: "materialGroupCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="자재그룹코드" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("materialGroupCode") as string
+ return <span>{value || "-"}</span>
+ },
+ size: 250, // 120 + 30
+ meta: {
+ excelHeader: "자재그룹코드",
+ },
+ },
+ {
+ accessorKey: "materialGroupName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="자재그룹명" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("materialGroupName") as string
+ return <span>{value || "-"}</span>
+ },
+ size: 360, // 130 + 30
+ meta: {
+ excelHeader: "자재그룹명",
+ },
+ },
+ {
+ accessorKey: "vendorCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="협력업체코드" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("vendorCode") as string
+ return <span>{value || "-"}</span>
+ },
+ size: 200, // 120 + 30
+ meta: {
+ excelHeader: "협력업체코드",
+ },
+ },
+ {
+ accessorKey: "vendorName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="협력업체명" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("vendorName") as string
+ return <span className="font-medium">{value || "-"}</span>
+ },
+ size: 400,
+ meta: {
+ excelHeader: "협력업체명",
+ },
+ },
+ {
+ accessorKey: "avlVendorName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="AVL 등재업체명" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("avlVendorName") as string
+ return <span>{value || "-"}</span>
+ },
+ size: 170, // 140 + 30
+ meta: {
+ excelHeader: "AVL 등재업체명",
+ },
+ },
+ {
+ accessorKey: "tier",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="등급 (Tier)" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("tier") as string
+ if (!value) return <span>-</span>
+
+ const tierColor = {
+ "Tier 1": "bg-green-100 text-green-800",
+ "Tier 2": "bg-yellow-100 text-yellow-800",
+ "Tier 3": "bg-red-100 text-red-800"
+ }[value] || "bg-gray-100 text-gray-800"
+
+ return (
+ <Badge className={tierColor}>
+ {value}
+ </Badge>
+ )
+ },
+ size: 130, // 100 + 30
+ meta: {
+ excelHeader: "등급 (Tier)",
+ },
+ },
+ ],
+ },
+ // FA 정보 그룹
+ {
+ header: "FA 정보",
+ columns: [
+ {
+ accessorKey: "faTarget",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="FA 대상" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("faTarget") as boolean
+ return (
+ <Badge variant={value ? "default" : "secondary"}>
+ {value ? "대상" : "비대상"}
+ </Badge>
+ )
+ },
+ size: 110, // 80 + 30
+ meta: {
+ excelHeader: "FA 대상",
+ },
+ },
+ {
+ accessorKey: "faStatus",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="FA 현황" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue("faStatus") as string
+ return <span>{value || "-"}</span>
+ },
+ size: 130, // 100 + 30
+ meta: {
+ excelHeader: "FA 현황",
+ },
+ },
+ ],
+ },
+ // SHI Qualification 그룹
+ {
+ header: "SHI Qualification",
+ columns: [
+ {
+ accessorKey: "shiAvl",
+ header: "AVL",
+ cell: ({ row }) => {
+ const value = row.getValue("shiAvl") as boolean
+ return (
+ <Checkbox
+ checked={value}
+ disabled
+ aria-label="SHI AVL 등재 여부"
+ />
+ )
+ },
+ size: 110, // 80 + 30
+ meta: {
+ excelHeader: "AVL",
+ },
+ },
+ {
+ accessorKey: "shiBlacklist",
+ header: "Blacklist",
+ cell: ({ row }) => {
+ const value = row.getValue("shiBlacklist") as boolean
+ return (
+ <Checkbox
+ checked={value}
+ disabled
+ aria-label="SHI Blacklist 등재 여부"
+ />
+ )
+ },
+ size: 130, // 100 + 30
+ meta: {
+ excelHeader: "Blacklist",
+ },
+ },
+ {
+ accessorKey: "shiBcc",
+ header: "BCC",
+ cell: ({ row }) => {
+ const value = row.getValue("shiBcc") as boolean
+ return (
+ <Checkbox
+ checked={value}
+ disabled
+ aria-label="SHI BCC 등재 여부"
+ />
+ )
+ },
+ size: 110, // 80 + 30
+ meta: {
+ excelHeader: "BCC",
+ },
+ },
+ ],
+ },
+]
+
diff --git a/lib/avl/table/avl-detail-virtual-table.tsx b/lib/avl/table/avl-detail-virtual-table.tsx
new file mode 100644
index 00000000..60d98c69
--- /dev/null
+++ b/lib/avl/table/avl-detail-virtual-table.tsx
@@ -0,0 +1,358 @@
+"use client"
+
+import * as React from "react"
+import {
+ useReactTable,
+ getCoreRowModel,
+ getSortedRowModel,
+ getFilteredRowModel,
+ type SortingState,
+ type ColumnFiltersState,
+ flexRender,
+ type Column,
+} from "@tanstack/react-table"
+import { useVirtualizer } from "@tanstack/react-virtual"
+import { toast } from "sonner"
+import { ChevronDown, ChevronUp, Search, Download } from "lucide-react"
+
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { BackButton } from "@/components/ui/back-button"
+import { virtualColumns } from "./avl-detail-virtual-columns"
+import type { AvlDetailItem } from "../types"
+import { exportTableToExcel } from "@/lib/export_all"
+
+interface AvlDetailVirtualTableProps {
+ data: AvlDetailItem[]
+ avlListId: number
+ avlType?: '프로젝트AVL' | '선종별표준AVL' | string
+ projectInfo?: {
+ code?: string
+ pspid?: string
+ OWN_NM?: string
+ kunnrNm?: string
+ }
+ shipOwnerName?: string
+ businessType?: string
+}
+
+function Filter({ column }: { column: Column<any, unknown> }) {
+ const columnFilterValue = column.getFilterValue()
+ const id = column.id
+
+ // Boolean 필터 (faTarget, shiBlacklist, shiBcc)
+ if (id === 'faTarget' || id === 'shiBlacklist' || id === 'shiBcc' || id === 'shiAvl') {
+ return (
+ <div onClick={(e) => e.stopPropagation()} className="mt-2">
+ <Select
+ value={(columnFilterValue as string) ?? "all"}
+ onValueChange={(value) => column.setFilterValue(value === "all" ? undefined : value === "true")}
+ >
+ <SelectTrigger className="h-8 w-full">
+ <SelectValue placeholder="All" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="all">All</SelectItem>
+ <SelectItem value="true">Yes</SelectItem>
+ <SelectItem value="false">No</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ )
+ }
+
+ // FA Status 필터 (O 또는 빈 값 - 데이터에 따라 조정 필요)
+ if (id === 'faStatus') {
+ return (
+ <div onClick={(e) => e.stopPropagation()} className="mt-2">
+ <Select
+ value={(columnFilterValue as string) ?? "all"}
+ onValueChange={(value) => column.setFilterValue(value === "all" ? undefined : value)}
+ >
+ <SelectTrigger className="h-8 w-full">
+ <SelectValue placeholder="All" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="all">All</SelectItem>
+ <SelectItem value="O">YES</SelectItem>
+ <SelectItem value="X">NO</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ )
+ }
+
+ // 일반 텍스트 검색
+ return (
+ <div onClick={(e) => e.stopPropagation()} className="mt-2">
+ <Input
+ type="text"
+ value={(columnFilterValue ?? '') as string}
+ onChange={(e) => column.setFilterValue(e.target.value)}
+ placeholder="Search..."
+ className="h-8 w-full font-normal bg-background"
+ />
+ </div>
+ )
+}
+
+export function AvlDetailVirtualTable({
+ data,
+ avlType,
+ projectInfo,
+ businessType,
+}: AvlDetailVirtualTableProps) {
+ // 상태 관리
+ const [sorting, setSorting] = React.useState<SortingState>([])
+ const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
+ const [globalFilter, setGlobalFilter] = React.useState("")
+
+ // 액션 핸들러
+ const handleAction = React.useCallback(async (action: string) => {
+ try {
+ switch (action) {
+ case 'vendor-pool':
+ window.open('/evcp/vendor-pool', '_blank')
+ break
+
+ case 'excel-export':
+ try {
+ toast.info("엑셀 파일을 생성 중입니다...")
+ await exportTableToExcel(table, {
+ filename: `AVL_상세내역_${new Date().toISOString().split('T')[0]}`,
+ allPages: true,
+ excludeColumns: ["select", "actions"],
+ useGroupHeader: true,
+ })
+ toast.success('Excel 파일이 다운로드되었습니다.')
+ } catch (error) {
+ console.error('Excel export 실패:', error)
+ toast.error('Excel 내보내기에 실패했습니다.')
+ }
+ break
+
+ default:
+ console.log('알 수 없는 액션:', action)
+ toast.error('알 수 없는 액션입니다.')
+ }
+ } catch (error) {
+ console.error('액션 처리 실패:', error)
+ toast.error('액션 처리 중 오류가 발생했습니다.')
+ }
+ }, [])
+
+ // TanStack Table 설정
+ const table = useReactTable({
+ data,
+ columns: virtualColumns,
+ state: {
+ sorting,
+ columnFilters,
+ globalFilter,
+ },
+ onSortingChange: setSorting,
+ onColumnFiltersChange: setColumnFilters,
+ onGlobalFilterChange: setGlobalFilter,
+ columnResizeMode: "onChange",
+ getCoreRowModel: getCoreRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ getRowId: (originalRow) => String(originalRow.id),
+ })
+
+ // Virtual Scrolling 설정
+ const tableContainerRef = React.useRef<HTMLDivElement>(null)
+
+ const { rows } = table.getRowModel()
+
+ const rowVirtualizer = useVirtualizer({
+ count: rows.length,
+ getScrollElement: () => tableContainerRef.current,
+ estimateSize: () => 50, // 행 높이 추정값
+ overscan: 10, // 화면 밖 렌더링할 행 수
+ })
+
+ const virtualRows = rowVirtualizer.getVirtualItems()
+ const totalSize = rowVirtualizer.getTotalSize()
+
+ const paddingTop = virtualRows.length > 0 ? virtualRows?.[0]?.start || 0 : 0
+ const paddingBottom = virtualRows.length > 0
+ ? totalSize - (virtualRows?.[virtualRows.length - 1]?.end || 0)
+ : 0
+
+ return (
+ <div className="flex flex-col h-full space-y-4">
+ {/* 상단 정보 표시 영역 */}
+ <div className="flex items-center justify-between p-4 border rounded-md bg-background">
+ <div className="flex items-center gap-4">
+ <h2 className="text-lg font-semibold">AVL 상세내역</h2>
+ <span className="px-3 py-1 bg-secondary-foreground text-secondary-foreground-foreground rounded-full text-sm font-medium">
+ {avlType}
+ </span>
+
+ <span className="text-sm text-muted-foreground">
+ [{businessType}] {projectInfo?.code || projectInfo?.pspid || '코드정보없음(표준AVL)'} ({projectInfo?.OWN_NM || projectInfo?.kunnrNm || '선주정보 없음'})
+ </span>
+ </div>
+
+ <div className="justify-end">
+ <BackButton>목록으로</BackButton>
+ </div>
+ </div>
+
+ {/* 툴바 */}
+ <div className="flex items-center justify-between gap-4">
+ <div className="flex items-center gap-2 flex-1">
+ <div className="relative flex-1 max-w-sm">
+ <Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
+ <Input
+ placeholder="전체 검색..."
+ value={globalFilter ?? ""}
+ onChange={(e) => setGlobalFilter(e.target.value)}
+ className="pl-8"
+ />
+ </div>
+ <div className="text-sm text-muted-foreground">
+ 전체 {data.length}건 중 {rows.length}건 표시
+ </div>
+ </div>
+
+ <div className="flex items-center gap-2">
+ <Button
+ onClick={() => handleAction('vendor-pool')}
+ variant="outline"
+ size="sm"
+ >
+ Vendor Pool
+ </Button>
+
+ <Button
+ onClick={() => handleAction('excel-export')}
+ variant="outline"
+ size="sm"
+ >
+ <Download className="mr-2 h-4 w-4" />
+ Excel Export
+ </Button>
+ </div>
+ </div>
+
+ {/* 테이블 */}
+ <div
+ ref={tableContainerRef}
+ className="relative flex-1 overflow-auto border rounded-md"
+ >
+ <table
+ className="table-fixed border-collapse"
+ style={{ width: table.getTotalSize() }}
+ >
+ {/* GroupHeader를 사용할 때 table-layout: fixed에서 열 너비가 올바르게 적용되도록 colgroup 추가 */}
+ <colgroup>
+ {table.getLeafHeaders().map((header) => (
+ <col key={header.id} style={{ width: header.getSize() }} />
+ ))}
+ </colgroup>
+ <thead className="sticky top-0 z-10 bg-muted">
+ {table.getHeaderGroups().map((headerGroup) => (
+ <tr key={headerGroup.id}>
+ {headerGroup.headers.map((header) => (
+ <th
+ key={header.id}
+ colSpan={header.colSpan}
+ className="border-b px-4 py-2 text-left text-sm font-medium relative group"
+ style={{ width: header.getSize() }}
+ >
+ {header.isPlaceholder ? null : (
+ <>
+ <div
+ className={
+ header.column.getCanSort()
+ ? "flex items-center gap-2 cursor-pointer select-none"
+ : ""
+ }
+ onClick={header.column.getToggleSortingHandler()}
+ >
+ {flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+ {header.column.getCanSort() && (
+ <div className="flex flex-col">
+ {header.column.getIsSorted() === "asc" ? (
+ <ChevronUp className="h-4 w-4" />
+ ) : header.column.getIsSorted() === "desc" ? (
+ <ChevronDown className="h-4 w-4" />
+ ) : (
+ <div className="h-4 w-4" />
+ )}
+ </div>
+ )}
+ </div>
+ {header.column.getCanFilter() && (
+ <Filter column={header.column} />
+ )}
+ <div
+ onMouseDown={header.getResizeHandler()}
+ onTouchStart={header.getResizeHandler()}
+ className={`absolute right-0 top-0 h-full w-1 cursor-col-resize select-none touch-none hover:bg-primary/50 ${
+ header.column.getIsResizing() ? 'bg-primary' : 'bg-transparent'
+ }`}
+ />
+ </>
+ )}
+ </th>
+ ))}
+ </tr>
+ ))}
+ </thead>
+ <tbody>
+ {paddingTop > 0 && (
+ <tr>
+ <td style={{ height: `${paddingTop}px` }} />
+ </tr>
+ )}
+ {virtualRows.map((virtualRow) => {
+ const row = rows[virtualRow.index]
+
+ return (
+ <tr
+ key={row.id}
+ data-index={virtualRow.index}
+ ref={rowVirtualizer.measureElement}
+ data-row-id={row.id}
+ className="hover:bg-muted/50"
+ >
+ {row.getVisibleCells().map((cell) => (
+ <td
+ key={cell.id}
+ className="border-b px-4 py-2 text-sm whitespace-normal break-words"
+ style={{ width: cell.column.getSize() }}
+ >
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )}
+ </td>
+ ))}
+ </tr>
+ )
+ })}
+ {paddingBottom > 0 && (
+ <tr>
+ <td style={{ height: `${paddingBottom}px` }} />
+ </tr>
+ )}
+ </tbody>
+ </table>
+ </div>
+ </div>
+ )
+}
+
diff --git a/lib/vendor-pool/enrichment-service.ts b/lib/vendor-pool/enrichment-service.ts
index 88694227..f492f7a6 100644
--- a/lib/vendor-pool/enrichment-service.ts
+++ b/lib/vendor-pool/enrichment-service.ts
@@ -1,6 +1,5 @@
"use server";
-import { getDisciplineCodeByCode } from "@/components/common/discipline/discipline-service";
import { getMaterialGroupByCode } from "@/lib/material/material-group-service";
import { getVendorByCode } from "@/components/common/vendor/vendor-service";
import { debugLog, debugWarn, debugSuccess } from "@/lib/debug-utils";
@@ -10,10 +9,6 @@ import { debugLog, debugWarn, debugSuccess } from "@/lib/debug-utils";
* 코드 필드를 기반으로 나머지 데이터를 자동으로 채웁니다.
*/
export interface VendorPoolEnrichmentInput {
- // 설계기능 관련
- designCategoryCode?: string;
- designCategory?: string;
-
// 자재그룹 관련
materialGroupCode?: string;
materialGroupName?: string;
@@ -21,10 +16,6 @@ export interface VendorPoolEnrichmentInput {
// 협력업체 관련
vendorCode?: string;
vendorName?: string;
-
- // 계약서명주체 관련
- contractSignerCode?: string;
- contractSignerName?: string;
}
export interface VendorPoolEnrichmentResult {
@@ -44,32 +35,11 @@ export async function enrichVendorPoolData(
const warnings: string[] = [];
debugLog('[Enrichment] 시작:', {
- designCategoryCode: data.designCategoryCode,
materialGroupCode: data.materialGroupCode,
vendorCode: data.vendorCode,
- contractSignerCode: data.contractSignerCode,
});
- // 1. 설계기능코드 → 설계기능명 자동완성
- if (data.designCategoryCode && !data.designCategory) {
- debugLog('[Enrichment] 설계기능명 조회 시도:', data.designCategoryCode);
- const discipline = await getDisciplineCodeByCode(data.designCategoryCode);
- if (discipline) {
- enriched.designCategory = discipline.USR_DF_CHAR_18;
- enrichedFields.push('designCategory');
- debugSuccess('[Enrichment] 설계기능명 자동완성:', {
- code: data.designCategoryCode,
- name: discipline.USR_DF_CHAR_18,
- });
- } else {
- debugWarn('[Enrichment] 설계기능코드를 찾을 수 없음:', data.designCategoryCode);
- warnings.push(
- `설계기능코드 '${data.designCategoryCode}'에 해당하는 설계기능명을 찾을 수 없습니다.`
- );
- }
- }
-
- // 2. 자재그룹코드 → 자재그룹명 자동완성
+ // 1. 자재그룹코드 → 자재그룹명 자동완성
if (data.materialGroupCode && !data.materialGroupName) {
debugLog('[Enrichment] 자재그룹명 조회 시도:', data.materialGroupCode);
const materialGroup = await getMaterialGroupByCode(data.materialGroupCode);
@@ -88,7 +58,7 @@ export async function enrichVendorPoolData(
}
}
- // 3. 협력업체코드 → 협력업체명 자동완성
+ // 2. 협력업체코드 → 협력업체명 자동완성
if (data.vendorCode && !data.vendorName) {
debugLog('[Enrichment] 협력업체명 조회 시도:', data.vendorCode);
const vendor = await getVendorByCode(data.vendorCode);
@@ -107,25 +77,6 @@ export async function enrichVendorPoolData(
}
}
- // 4. 계약서명주체코드 → 계약서명주체명 자동완성
- if (data.contractSignerCode && !data.contractSignerName) {
- debugLog('[Enrichment] 계약서명주체명 조회 시도:', data.contractSignerCode);
- const contractSigner = await getVendorByCode(data.contractSignerCode);
- if (contractSigner) {
- enriched.contractSignerName = contractSigner.vendorName;
- enrichedFields.push('contractSignerName');
- debugSuccess('[Enrichment] 계약서명주체명 자동완성:', {
- code: data.contractSignerCode,
- name: contractSigner.vendorName,
- });
- } else {
- debugWarn('[Enrichment] 계약서명주체코드를 찾을 수 없음:', data.contractSignerCode);
- warnings.push(
- `계약서명주체코드 '${data.contractSignerCode}'에 해당하는 계약서명주체명을 찾을 수 없습니다.`
- );
- }
- }
-
debugSuccess('[Enrichment] 완료:', {
enrichedFieldsCount: enrichedFields.length,
warningsCount: warnings.length,
@@ -137,4 +88,3 @@ export async function enrichVendorPoolData(
warnings,
};
}
-
diff --git a/lib/vendor-pool/excel-utils.ts b/lib/vendor-pool/excel-utils.ts
index 81426ebd..fe69aa8d 100644
--- a/lib/vendor-pool/excel-utils.ts
+++ b/lib/vendor-pool/excel-utils.ts
@@ -17,55 +17,24 @@ export interface ExcelColumnConfig {
export const vendorPoolExcelColumns: ExcelColumnConfig[] = [
{ accessorKey: 'constructionSector', header: '조선/해양', width: 15, required: true },
{ accessorKey: 'htDivision', header: 'H/T구분', width: 15, required: true },
- { accessorKey: 'designCategoryCode', header: '설계기능코드', width: 20, required: true },
- { accessorKey: 'designCategory', header: '설계기능(공종)', width: 25 }, // 코드로 자동완성 가능
+ { accessorKey: 'discipline', header: '설계공종', width: 25, required: true },
{ accessorKey: 'equipBulkDivision', header: 'Equip/Bulk', width: 15 },
- { accessorKey: 'packageCode', header: '패키지코드', width: 20 },
- { accessorKey: 'packageName', header: '패키지명', width: 25 },
{ accessorKey: 'materialGroupCode', header: '자재그룹코드', width: 20 },
{ accessorKey: 'materialGroupName', header: '자재그룹명', width: 30 }, // 코드로 자동완성 가능
- { accessorKey: 'smCode', header: 'SM Code', width: 15 },
{ accessorKey: 'similarMaterialNamePurchase', header: '유사자재명(구매)', width: 25 },
- { accessorKey: 'similarMaterialNameOther', header: '유사자재명(기타)', width: 25 },
{ accessorKey: 'vendorCode', header: '협력업체코드', width: 20 },
{ accessorKey: 'vendorName', header: '협력업체명', width: 25 }, // 코드로 자동완성 가능
+ { accessorKey: 'taxId', header: '사업자번호', width: 20 },
{ accessorKey: 'faTarget', header: 'FA대상', width: 15, type: 'boolean' },
{ accessorKey: 'faStatus', header: 'FA현황', width: 15 },
{ accessorKey: 'tier', header: '등급', width: 15, required: true },
- { accessorKey: 'isAgent', header: 'Agent여부', width: 15, type: 'boolean' },
- { accessorKey: 'contractSignerCode', header: '계약서명주체코드', width: 20 },
- { accessorKey: 'contractSignerName', header: '계약서명주체명', width: 25 }, // 코드로 자동완성 가능
{ accessorKey: 'headquarterLocation', header: '본사위치', width: 20, required: true },
{ accessorKey: 'manufacturingLocation', header: '제작/선적지', width: 20, required: true },
{ accessorKey: 'avlVendorName', header: 'AVL등재업체명', width: 25, required: true },
{ accessorKey: 'similarVendorName', header: '유사업체명', width: 25 },
- { accessorKey: 'hasAvl', header: 'AVL보유', width: 15, type: 'boolean' },
{ accessorKey: 'isBlacklist', header: '블랙리스트', width: 15, type: 'boolean' },
{ accessorKey: 'isBcc', header: 'BCC', width: 15, type: 'boolean' },
{ accessorKey: 'purchaseOpinion', header: '구매의견', width: 30 },
- // 선종
- { accessorKey: 'shipTypeCommon', header: '선종공통', width: 15, type: 'boolean' },
- { accessorKey: 'shipTypeAmax', header: 'A-MAX', width: 15, type: 'boolean' },
- { accessorKey: 'shipTypeSmax', header: 'S-MAX', width: 15, type: 'boolean' },
- { accessorKey: 'shipTypeVlcc', header: 'VLCC', width: 15, type: 'boolean' },
- { accessorKey: 'shipTypeLngc', header: 'LNGC', width: 15, type: 'boolean' },
- { accessorKey: 'shipTypeCont', header: '컨테이너선', width: 15, type: 'boolean' },
- // 해양플랜트
- { accessorKey: 'offshoreTypeCommon', header: '해양플랜트공통', width: 20, type: 'boolean' },
- { accessorKey: 'offshoreTypeFpso', header: 'FPSO', width: 15, type: 'boolean' },
- { accessorKey: 'offshoreTypeFlng', header: 'FLNG', width: 15, type: 'boolean' },
- { accessorKey: 'offshoreTypeFpu', header: 'FPU', width: 15, type: 'boolean' },
- { accessorKey: 'offshoreTypePlatform', header: '플랫폼', width: 15, type: 'boolean' },
- { accessorKey: 'offshoreTypeWtiv', header: 'WTIV', width: 15, type: 'boolean' },
- { accessorKey: 'offshoreTypeGom', header: 'GOM', width: 15, type: 'boolean' },
- // 담당자 정보
- { accessorKey: 'picName', header: '담당자명', width: 20 },
- { accessorKey: 'picEmail', header: '담당자이메일', width: 30 },
- { accessorKey: 'picPhone', header: '담당자연락처', width: 20 },
- // 대행사 정보
- { accessorKey: 'agentName', header: '대행사명', width: 20 },
- { accessorKey: 'agentEmail', header: '대행사이메일', width: 30 },
- { accessorKey: 'agentPhone', header: '대행사연락처', width: 20 },
// 최근 거래 정보
{ accessorKey: 'recentQuoteDate', header: '최근견적일', width: 20 },
{ accessorKey: 'recentQuoteNumber', header: '최근견적번호', width: 25 },
@@ -151,15 +120,13 @@ export async function createVendorPoolTemplate(filename?: string) {
const guideContent = [
'',
'■ 필수 입력 필드 (헤더가 빨간색)',
- ' - 조선/해양, H/T구분, 설계기능코드',
+ ' - 조선/해양, H/T구분, 설계공종',
' - 등급, 본사위치, 제작/선적지, AVL등재업체명',
'',
'■ 자동완성 기능 (코드 입력 시)',
' 1. 코드가 있는 경우 → 코드만 입력하면 명칭 자동완성',
- ' • 설계기능코드 → 설계기능명',
' • 자재그룹코드 → 자재그룹명',
' • 협력업체코드 → 협력업체명',
- ' • 계약서명주체코드 → 계약서명주체명',
'',
' 2. 코드가 없는 경우 → 명칭 직접 입력',
'',
@@ -170,7 +137,6 @@ export async function createVendorPoolTemplate(filename?: string) {
'■ 입력 규칙',
' - 조선/해양: "조선" 또는 "해양"',
' - H/T구분: "H", "T", "공통"',
- ' - 설계기능코드: 2자리 이하',
' - Equip/Bulk: "E", "B", "S" (1자리)',
' - 등급: "Tier 1", "Tier 2", "등급 외"'
]
diff --git a/lib/vendor-pool/service.ts b/lib/vendor-pool/service.ts
index 18d50ebd..f7637204 100644
--- a/lib/vendor-pool/service.ts
+++ b/lib/vendor-pool/service.ts
@@ -4,9 +4,12 @@ import { GetVendorPoolSchema } from "./validations";
import { VendorPool } from "./types";
import db from "@/db/db";
import { vendorPool } from "@/db/schema/avl/vendor-pool";
-import { eq, and, or, ilike, count, desc, sql } from "drizzle-orm";
+import { eq, and, or, ilike, count, desc, sql, inArray } from "drizzle-orm";
import { debugError } from "@/lib/debug-utils";
import { revalidateTag, unstable_cache } from "next/cache";
+import type { VendorPoolItem } from "./table/vendor-pool-table-columns";
+import { MATERIAL_GROUP_MASTER } from "@/db/schema/MDG/mdg";
+import { vendors } from "@/db/schema/vendors";
/**
* Vendor Pool 목록 조회
@@ -27,12 +30,10 @@ const _getVendorPools = async (input: GetVendorPoolSchema) => {
whereConditions.push(
or(
ilike(vendorPool.constructionSector, searchTerm),
- ilike(vendorPool.designCategoryCode, searchTerm),
- ilike(vendorPool.designCategory, searchTerm),
+ ilike(vendorPool.discipline, searchTerm),
ilike(vendorPool.vendorName, searchTerm),
ilike(vendorPool.materialGroupCode, searchTerm),
ilike(vendorPool.materialGroupName, searchTerm),
- ilike(vendorPool.packageName, searchTerm),
ilike(vendorPool.avlVendorName, searchTerm),
ilike(vendorPool.similarVendorName, searchTerm)
)
@@ -61,32 +62,11 @@ const _getVendorPools = async (input: GetVendorPoolSchema) => {
condition = ilike(vendorPool.htDivision, `%${filter.value}%`);
}
break;
- case 'designCategoryCode':
+ case 'discipline':
if (filter.operator === 'iLike') {
- condition = ilike(vendorPool.designCategoryCode, `%${filter.value}%`);
+ condition = ilike(vendorPool.discipline, `%${filter.value}%`);
} else if (filter.operator === 'eq') {
- condition = eq(vendorPool.designCategoryCode, filter.value as string);
- }
- break;
- case 'designCategory':
- if (filter.operator === 'iLike') {
- condition = ilike(vendorPool.designCategory, `%${filter.value}%`);
- } else if (filter.operator === 'eq') {
- condition = eq(vendorPool.designCategory, filter.value as string);
- }
- break;
- case 'packageCode':
- if (filter.operator === 'iLike') {
- condition = ilike(vendorPool.packageCode, `%${filter.value}%`);
- } else if (filter.operator === 'eq') {
- condition = eq(vendorPool.packageCode, filter.value as string);
- }
- break;
- case 'packageName':
- if (filter.operator === 'iLike') {
- condition = ilike(vendorPool.packageName, `%${filter.value}%`);
- } else if (filter.operator === 'eq') {
- condition = eq(vendorPool.packageName, filter.value as string);
+ condition = eq(vendorPool.discipline, filter.value as string);
}
break;
case 'materialGroupCode':
@@ -166,16 +146,6 @@ const _getVendorPools = async (input: GetVendorPoolSchema) => {
condition = eq(vendorPool.lastModifier, filter.value as string);
}
break;
- case 'hasAvl':
- if (filter.operator === 'eq') {
- condition = eq(vendorPool.hasAvl, filter.value === 'true');
- }
- break;
- case 'isAgent':
- if (filter.operator === 'eq') {
- condition = eq(vendorPool.isAgent, filter.value === 'true');
- }
- break;
case 'isBlacklist':
if (filter.operator === 'eq') {
condition = eq(vendorPool.isBlacklist, filter.value === 'true');
@@ -222,21 +192,12 @@ const _getVendorPools = async (input: GetVendorPoolSchema) => {
if (input.htDivision) {
whereConditions.push(eq(vendorPool.htDivision, input.htDivision));
}
- if (input.designCategoryCode) {
- whereConditions.push(ilike(vendorPool.designCategoryCode, `%${input.designCategoryCode}%`));
- }
- if (input.designCategory) {
- whereConditions.push(ilike(vendorPool.designCategory, `%${input.designCategory}%`));
+ if (input.discipline) {
+ whereConditions.push(ilike(vendorPool.discipline, `%${input.discipline}%`));
}
if (input.equipBulkDivision) {
whereConditions.push(eq(vendorPool.equipBulkDivision, input.equipBulkDivision));
}
- if (input.packageCode) {
- whereConditions.push(ilike(vendorPool.packageCode, `%${input.packageCode}%`));
- }
- if (input.packageName) {
- whereConditions.push(ilike(vendorPool.packageName, `%${input.packageName}%`));
- }
if (input.materialGroupCode) {
whereConditions.push(ilike(vendorPool.materialGroupCode, `%${input.materialGroupCode}%`));
}
@@ -255,16 +216,6 @@ const _getVendorPools = async (input: GetVendorPoolSchema) => {
if (input.tier) {
whereConditions.push(ilike(vendorPool.tier, `%${input.tier}%`));
}
- if (input.hasAvl === "true") {
- whereConditions.push(eq(vendorPool.hasAvl, true));
- } else if (input.hasAvl === "false") {
- whereConditions.push(eq(vendorPool.hasAvl, false));
- }
- if (input.isAgent === "true") {
- whereConditions.push(eq(vendorPool.isAgent, true));
- } else if (input.isAgent === "false") {
- whereConditions.push(eq(vendorPool.isAgent, false));
- }
if (input.isBlacklist === "true") {
whereConditions.push(eq(vendorPool.isBlacklist, true));
} else if (input.isBlacklist === "false") {
@@ -325,31 +276,20 @@ const _getVendorPools = async (input: GetVendorPoolSchema) => {
registrationDate: item.registrationDate ? item.registrationDate.toISOString().split('T')[0] : '',
lastModifiedDate: item.lastModifiedDate ? item.lastModifiedDate.toISOString().split('T')[0] : '',
// string 필드들의 null 처리
- packageCode: item.packageCode || '',
- packageName: item.packageName || '',
+ discipline: item.discipline || '',
materialGroupCode: item.materialGroupCode || '',
materialGroupName: item.materialGroupName || '',
- smCode: item.smCode || '',
similarMaterialNamePurchase: item.similarMaterialNamePurchase || '',
- similarMaterialNameOther: item.similarMaterialNameOther || '',
vendorCode: item.vendorCode || '',
vendorName: item.vendorName || '',
+ taxId: item.taxId || '',
faStatus: item.faStatus || '',
- faRemark: item.faRemark || '',
tier: item.tier || '',
- contractSignerCode: item.contractSignerCode || '',
- contractSignerName: item.contractSignerName || '',
headquarterLocation: item.headquarterLocation || '',
manufacturingLocation: item.manufacturingLocation || '',
avlVendorName: item.avlVendorName || '',
similarVendorName: item.similarVendorName || '',
purchaseOpinion: item.purchaseOpinion || '',
- picName: item.picName || '',
- picEmail: item.picEmail || '',
- picPhone: item.picPhone || '',
- agentName: item.agentName || '',
- agentEmail: item.agentEmail || '',
- agentPhone: item.agentPhone || '',
recentQuoteDate: item.recentQuoteDate || '',
recentQuoteNumber: item.recentQuoteNumber || '',
recentOrderDate: item.recentOrderDate || '',
@@ -358,24 +298,8 @@ const _getVendorPools = async (input: GetVendorPoolSchema) => {
lastModifier: item.lastModifier || '',
// boolean 필드들을 적절히 처리
faTarget: item.faTarget ?? false,
- hasAvl: item.hasAvl ?? false,
- isAgent: item.isAgent ?? false,
isBlacklist: item.isBlacklist ?? false,
isBcc: item.isBcc ?? false,
- // 선종 적용 정보
- shipTypeCommon: item.shipTypeCommon ?? false,
- shipTypeAmax: item.shipTypeAmax ?? false,
- shipTypeSmax: item.shipTypeSmax ?? false,
- shipTypeVlcc: item.shipTypeVlcc ?? false,
- shipTypeLngc: item.shipTypeLngc ?? false,
- shipTypeCont: item.shipTypeCont ?? false,
- offshoreTypeCommon: item.offshoreTypeCommon ?? false,
- offshoreTypeFpso: item.offshoreTypeFpso ?? false,
- offshoreTypeFlng: item.offshoreTypeFlng ?? false,
- offshoreTypeFpu: item.offshoreTypeFpu ?? false,
- offshoreTypePlatform: item.offshoreTypePlatform ?? false,
- offshoreTypeWtiv: item.offshoreTypeWtiv ?? false,
- offshoreTypeGom: item.offshoreTypeGom ?? false,
}));
const pageCount = Math.ceil(totalCount[0].count / input.perPage);
@@ -404,6 +328,59 @@ export const getVendorPools = unstable_cache(
);
/**
+ * Vendor Pool 전체 데이터 조회 (페이지네이션 없음)
+ * 클라이언트 사이드 필터링/정렬을 위한 전체 데이터 로드
+ */
+export async function getAllVendorPools(): Promise<VendorPoolItem[]> {
+ try {
+ // 전체 데이터 조회 (limit 없음)
+ const data = await db
+ .select()
+ .from(vendorPool)
+ .orderBy(desc(vendorPool.registrationDate));
+
+ // 데이터 변환 (timestamp -> string)
+ const transformedData = data.map((item, index) => ({
+ ...item,
+ no: index + 1,
+ selected: false,
+ registrationDate: item.registrationDate ? item.registrationDate.toISOString().split('T')[0] : '',
+ lastModifiedDate: item.lastModifiedDate ? item.lastModifiedDate.toISOString().split('T')[0] : '',
+ // string 필드들의 null 처리
+ discipline: item.discipline || '',
+ materialGroupCode: item.materialGroupCode || '',
+ materialGroupName: item.materialGroupName || '',
+ similarMaterialNamePurchase: item.similarMaterialNamePurchase || '',
+ vendorCode: item.vendorCode || '',
+ vendorName: item.vendorName || '',
+ taxId: item.taxId || '',
+ faStatus: item.faStatus || '',
+ tier: item.tier || '',
+ headquarterLocation: item.headquarterLocation || '',
+ manufacturingLocation: item.manufacturingLocation || '',
+ avlVendorName: item.avlVendorName || '',
+ similarVendorName: item.similarVendorName || '',
+ purchaseOpinion: item.purchaseOpinion || '',
+ recentQuoteDate: item.recentQuoteDate || '',
+ recentQuoteNumber: item.recentQuoteNumber || '',
+ recentOrderDate: item.recentOrderDate || '',
+ recentOrderNumber: item.recentOrderNumber || '',
+ registrant: item.registrant || '',
+ lastModifier: item.lastModifier || '',
+ // boolean 필드들을 적절히 처리
+ faTarget: item.faTarget ?? false,
+ isBlacklist: item.isBlacklist ?? false,
+ isBcc: item.isBcc ?? false,
+ }));
+
+ return transformedData;
+ } catch (err) {
+ console.error("Error in getAllVendorPools:", err);
+ return [];
+ }
+}
+
+/**
* Vendor Pool 상세 정보 조회
*/
export async function getVendorPoolById(id: number): Promise<VendorPool | null> {
@@ -427,31 +404,20 @@ export async function getVendorPoolById(id: number): Promise<VendorPool | null>
registrationDate: item.registrationDate ? item.registrationDate.toISOString().split('T')[0] : '',
lastModifiedDate: item.lastModifiedDate ? item.lastModifiedDate.toISOString().split('T')[0] : '',
// string 필드들의 null 처리
- packageCode: item.packageCode || '',
- packageName: item.packageName || '',
+ discipline: item.discipline || '',
materialGroupCode: item.materialGroupCode || '',
materialGroupName: item.materialGroupName || '',
- smCode: item.smCode || '',
similarMaterialNamePurchase: item.similarMaterialNamePurchase || '',
- similarMaterialNameOther: item.similarMaterialNameOther || '',
vendorCode: item.vendorCode || '',
vendorName: item.vendorName || '',
+ taxId: item.taxId || '',
faStatus: item.faStatus || '',
- faRemark: item.faRemark || '',
tier: item.tier || '',
- contractSignerCode: item.contractSignerCode || '',
- contractSignerName: item.contractSignerName || '',
headquarterLocation: item.headquarterLocation || '',
manufacturingLocation: item.manufacturingLocation || '',
avlVendorName: item.avlVendorName || '',
similarVendorName: item.similarVendorName || '',
purchaseOpinion: item.purchaseOpinion || '',
- picName: item.picName || '',
- picEmail: item.picEmail || '',
- picPhone: item.picPhone || '',
- agentName: item.agentName || '',
- agentEmail: item.agentEmail || '',
- agentPhone: item.agentPhone || '',
recentQuoteDate: item.recentQuoteDate || '',
recentQuoteNumber: item.recentQuoteNumber || '',
recentOrderDate: item.recentOrderDate || '',
@@ -460,24 +426,8 @@ export async function getVendorPoolById(id: number): Promise<VendorPool | null>
lastModifier: item.lastModifier || '',
// boolean 필드들을 적절히 처리
faTarget: item.faTarget ?? false,
- hasAvl: item.hasAvl ?? false,
- isAgent: item.isAgent ?? false,
isBlacklist: item.isBlacklist ?? false,
isBcc: item.isBcc ?? false,
- // 선종 적용 정보
- shipTypeCommon: item.shipTypeCommon ?? false,
- shipTypeAmax: item.shipTypeAmax ?? false,
- shipTypeSmax: item.shipTypeSmax ?? false,
- shipTypeVlcc: item.shipTypeVlcc ?? false,
- shipTypeLngc: item.shipTypeLngc ?? false,
- shipTypeCont: item.shipTypeCont ?? false,
- offshoreTypeCommon: item.offshoreTypeCommon ?? false,
- offshoreTypeFpso: item.offshoreTypeFpso ?? false,
- offshoreTypeFlng: item.offshoreTypeFlng ?? false,
- offshoreTypeFpu: item.offshoreTypeFpu ?? false,
- offshoreTypePlatform: item.offshoreTypePlatform ?? false,
- offshoreTypeWtiv: item.offshoreTypeWtiv ?? false,
- offshoreTypeGom: item.offshoreTypeGom ?? false,
};
return transformedData;
@@ -579,37 +529,25 @@ export async function createVendorPool(data: Omit<VendorPool, 'id' | 'registrati
htDivision: data.htDivision,
// 설계 정보
- designCategoryCode: data.designCategoryCode,
- designCategory: data.designCategory,
+ discipline: data.discipline,
equipBulkDivision: data.equipBulkDivision,
- // 패키지 정보
- packageCode: data.packageCode,
- packageName: data.packageName,
-
// 자재그룹 정보
materialGroupCode: data.materialGroupCode,
materialGroupName: data.materialGroupName,
// 자재 관련 정보
- smCode: data.smCode,
similarMaterialNamePurchase: data.similarMaterialNamePurchase,
- similarMaterialNameOther: data.similarMaterialNameOther,
// 협력업체 정보
vendorCode: data.vendorCode,
vendorName: data.vendorName,
+ taxId: data.taxId,
// 사업 및 인증 정보
faTarget: data.faTarget ?? false,
faStatus: data.faStatus,
- faRemark: data.faRemark,
tier: data.tier,
- isAgent: data.isAgent ?? false,
-
- // 계약 정보
- contractSignerCode: data.contractSignerCode,
- contractSignerName: data.contractSignerName,
// 위치 정보
headquarterLocation: data.headquarterLocation,
@@ -618,38 +556,12 @@ export async function createVendorPool(data: Omit<VendorPool, 'id' | 'registrati
// AVL 관련 정보
avlVendorName: data.avlVendorName,
similarVendorName: data.similarVendorName,
- hasAvl: data.hasAvl ?? false,
// 상태 정보
isBlacklist: data.isBlacklist ?? false,
isBcc: data.isBcc ?? false,
purchaseOpinion: data.purchaseOpinion,
- // AVL 적용 선종(조선)
- shipTypeCommon: data.shipTypeCommon ?? false,
- shipTypeAmax: data.shipTypeAmax ?? false,
- shipTypeSmax: data.shipTypeSmax ?? false,
- shipTypeVlcc: data.shipTypeVlcc ?? false,
- shipTypeLngc: data.shipTypeLngc ?? false,
- shipTypeCont: data.shipTypeCont ?? false,
-
- // AVL 적용 선종(해양)
- offshoreTypeCommon: data.offshoreTypeCommon ?? false,
- offshoreTypeFpso: data.offshoreTypeFpso ?? false,
- offshoreTypeFlng: data.offshoreTypeFlng ?? false,
- offshoreTypeFpu: data.offshoreTypeFpu ?? false,
- offshoreTypePlatform: data.offshoreTypePlatform ?? false,
- offshoreTypeWtiv: data.offshoreTypeWtiv ?? false,
- offshoreTypeGom: data.offshoreTypeGom ?? false,
-
- // eVCP 미등록 정보
- picName: data.picName,
- picEmail: data.picEmail,
- picPhone: data.picPhone,
- agentName: data.agentName,
- agentEmail: data.agentEmail,
- agentPhone: data.agentPhone,
-
// 업체 실적 현황
recentQuoteDate: data.recentQuoteDate,
recentQuoteNumber: data.recentQuoteNumber,
@@ -687,32 +599,20 @@ export async function createVendorPool(data: Omit<VendorPool, 'id' | 'registrati
registrationDate: createdItem.registrationDate ? createdItem.registrationDate.toISOString().split('T')[0] : '',
lastModifiedDate: createdItem.lastModifiedDate ? createdItem.lastModifiedDate.toISOString().split('T')[0] : '',
// string 필드들의 null 처리
- packageCode: createdItem.packageCode || '',
- packageName: createdItem.packageName || '',
+ discipline: createdItem.discipline || '',
materialGroupCode: createdItem.materialGroupCode || '',
materialGroupName: createdItem.materialGroupName || '',
- smCode: createdItem.smCode || '',
similarMaterialNamePurchase: createdItem.similarMaterialNamePurchase || '',
- similarMaterialNameOther: createdItem.similarMaterialNameOther || '',
vendorCode: createdItem.vendorCode || '',
vendorName: createdItem.vendorName || '',
taxId: createdItem.taxId || '',
faStatus: createdItem.faStatus || '',
- faRemark: createdItem.faRemark || '',
tier: createdItem.tier || '',
- contractSignerCode: createdItem.contractSignerCode || '',
- contractSignerName: createdItem.contractSignerName || '',
headquarterLocation: createdItem.headquarterLocation || '',
manufacturingLocation: createdItem.manufacturingLocation || '',
avlVendorName: createdItem.avlVendorName || '',
similarVendorName: createdItem.similarVendorName || '',
purchaseOpinion: createdItem.purchaseOpinion || '',
- picName: createdItem.picName || '',
- picEmail: createdItem.picEmail || '',
- picPhone: createdItem.picPhone || '',
- agentName: createdItem.agentName || '',
- agentEmail: createdItem.agentEmail || '',
- agentPhone: createdItem.agentPhone || '',
recentQuoteDate: createdItem.recentQuoteDate || '',
recentQuoteNumber: createdItem.recentQuoteNumber || '',
recentOrderDate: createdItem.recentOrderDate || '',
@@ -721,24 +621,8 @@ export async function createVendorPool(data: Omit<VendorPool, 'id' | 'registrati
lastModifier: createdItem.lastModifier || '',
// boolean 필드들을 적절히 처리
faTarget: createdItem.faTarget ?? false,
- hasAvl: createdItem.hasAvl ?? false,
- isAgent: createdItem.isAgent ?? false,
isBlacklist: createdItem.isBlacklist ?? false,
isBcc: createdItem.isBcc ?? false,
- // 선종 적용 정보
- shipTypeCommon: createdItem.shipTypeCommon ?? false,
- shipTypeAmax: createdItem.shipTypeAmax ?? false,
- shipTypeSmax: createdItem.shipTypeSmax ?? false,
- shipTypeVlcc: createdItem.shipTypeVlcc ?? false,
- shipTypeLngc: createdItem.shipTypeLngc ?? false,
- shipTypeCont: createdItem.shipTypeCont ?? false,
- offshoreTypeCommon: createdItem.offshoreTypeCommon ?? false,
- offshoreTypeFpso: createdItem.offshoreTypeFpso ?? false,
- offshoreTypeFlng: createdItem.offshoreTypeFlng ?? false,
- offshoreTypeFpu: createdItem.offshoreTypeFpu ?? false,
- offshoreTypePlatform: createdItem.offshoreTypePlatform ?? false,
- offshoreTypeWtiv: createdItem.offshoreTypeWtiv ?? false,
- offshoreTypeGom: createdItem.offshoreTypeGom ?? false,
};
// debugSuccess('Vendor Pool 생성 완료', { result: transformedData });
@@ -791,37 +675,25 @@ export async function updateVendorPool(id: number, data: Partial<VendorPool>): P
if (data.htDivision !== undefined) updateData.htDivision = data.htDivision;
// 설계 정보
- if (data.designCategoryCode !== undefined) updateData.designCategoryCode = data.designCategoryCode;
- if (data.designCategory !== undefined) updateData.designCategory = data.designCategory;
+ if (data.discipline !== undefined) updateData.discipline = data.discipline;
if (data.equipBulkDivision !== undefined) updateData.equipBulkDivision = data.equipBulkDivision;
- // 패키지 정보
- if (data.packageCode !== undefined) updateData.packageCode = data.packageCode;
- if (data.packageName !== undefined) updateData.packageName = data.packageName;
-
// 자재그룹 정보
if (data.materialGroupCode !== undefined) updateData.materialGroupCode = data.materialGroupCode;
if (data.materialGroupName !== undefined) updateData.materialGroupName = data.materialGroupName;
// 자재 관련 정보
- if (data.smCode !== undefined) updateData.smCode = data.smCode;
if (data.similarMaterialNamePurchase !== undefined) updateData.similarMaterialNamePurchase = data.similarMaterialNamePurchase;
- if (data.similarMaterialNameOther !== undefined) updateData.similarMaterialNameOther = data.similarMaterialNameOther;
// 협력업체 정보
if (data.vendorCode !== undefined) updateData.vendorCode = data.vendorCode;
if (data.vendorName !== undefined) updateData.vendorName = data.vendorName;
+ if (data.taxId !== undefined) updateData.taxId = data.taxId;
// 사업 및 인증 정보
if (data.faTarget !== undefined) updateData.faTarget = data.faTarget;
if (data.faStatus !== undefined) updateData.faStatus = data.faStatus;
- if (data.faRemark !== undefined) updateData.faRemark = data.faRemark;
if (data.tier !== undefined) updateData.tier = data.tier;
- if (data.isAgent !== undefined) updateData.isAgent = data.isAgent;
-
- // 계약 정보
- if (data.contractSignerCode !== undefined) updateData.contractSignerCode = data.contractSignerCode;
- if (data.contractSignerName !== undefined) updateData.contractSignerName = data.contractSignerName;
// 위치 정보
if (data.headquarterLocation !== undefined) updateData.headquarterLocation = data.headquarterLocation;
@@ -830,38 +702,12 @@ export async function updateVendorPool(id: number, data: Partial<VendorPool>): P
// AVL 관련 정보
if (data.avlVendorName !== undefined) updateData.avlVendorName = data.avlVendorName;
if (data.similarVendorName !== undefined) updateData.similarVendorName = data.similarVendorName;
- if (data.hasAvl !== undefined) updateData.hasAvl = data.hasAvl;
// 상태 정보
if (data.isBlacklist !== undefined) updateData.isBlacklist = data.isBlacklist;
if (data.isBcc !== undefined) updateData.isBcc = data.isBcc;
if (data.purchaseOpinion !== undefined) updateData.purchaseOpinion = data.purchaseOpinion;
- // AVL 적용 선종(조선)
- if (data.shipTypeCommon !== undefined) updateData.shipTypeCommon = data.shipTypeCommon;
- if (data.shipTypeAmax !== undefined) updateData.shipTypeAmax = data.shipTypeAmax;
- if (data.shipTypeSmax !== undefined) updateData.shipTypeSmax = data.shipTypeSmax;
- if (data.shipTypeVlcc !== undefined) updateData.shipTypeVlcc = data.shipTypeVlcc;
- if (data.shipTypeLngc !== undefined) updateData.shipTypeLngc = data.shipTypeLngc;
- if (data.shipTypeCont !== undefined) updateData.shipTypeCont = data.shipTypeCont;
-
- // AVL 적용 선종(해양)
- if (data.offshoreTypeCommon !== undefined) updateData.offshoreTypeCommon = data.offshoreTypeCommon;
- if (data.offshoreTypeFpso !== undefined) updateData.offshoreTypeFpso = data.offshoreTypeFpso;
- if (data.offshoreTypeFlng !== undefined) updateData.offshoreTypeFlng = data.offshoreTypeFlng;
- if (data.offshoreTypeFpu !== undefined) updateData.offshoreTypeFpu = data.offshoreTypeFpu;
- if (data.offshoreTypePlatform !== undefined) updateData.offshoreTypePlatform = data.offshoreTypePlatform;
- if (data.offshoreTypeWtiv !== undefined) updateData.offshoreTypeWtiv = data.offshoreTypeWtiv;
- if (data.offshoreTypeGom !== undefined) updateData.offshoreTypeGom = data.offshoreTypeGom;
-
- // eVCP 미등록 정보
- if (data.picName !== undefined) updateData.picName = data.picName;
- if (data.picEmail !== undefined) updateData.picEmail = data.picEmail;
- if (data.picPhone !== undefined) updateData.picPhone = data.picPhone;
- if (data.agentName !== undefined) updateData.agentName = data.agentName;
- if (data.agentEmail !== undefined) updateData.agentEmail = data.agentEmail;
- if (data.agentPhone !== undefined) updateData.agentPhone = data.agentPhone;
-
// 업체 실적 현황
if (data.recentQuoteDate !== undefined) updateData.recentQuoteDate = data.recentQuoteDate;
if (data.recentQuoteNumber !== undefined) updateData.recentQuoteNumber = data.recentQuoteNumber;
@@ -898,32 +744,20 @@ export async function updateVendorPool(id: number, data: Partial<VendorPool>): P
registrationDate: updatedItem.registrationDate ? updatedItem.registrationDate.toISOString().split('T')[0] : '',
lastModifiedDate: updatedItem.lastModifiedDate ? updatedItem.lastModifiedDate.toISOString().split('T')[0] : '',
// string 필드들의 null 처리
- packageCode: updatedItem.packageCode || '',
- packageName: updatedItem.packageName || '',
+ discipline: updatedItem.discipline || '',
materialGroupCode: updatedItem.materialGroupCode || '',
materialGroupName: updatedItem.materialGroupName || '',
- smCode: updatedItem.smCode || '',
similarMaterialNamePurchase: updatedItem.similarMaterialNamePurchase || '',
- similarMaterialNameOther: updatedItem.similarMaterialNameOther || '',
vendorCode: updatedItem.vendorCode || '',
vendorName: updatedItem.vendorName || '',
taxId: updatedItem.taxId || '',
faStatus: updatedItem.faStatus || '',
- faRemark: updatedItem.faRemark || '',
tier: updatedItem.tier || '',
- contractSignerCode: updatedItem.contractSignerCode || '',
- contractSignerName: updatedItem.contractSignerName || '',
headquarterLocation: updatedItem.headquarterLocation || '',
manufacturingLocation: updatedItem.manufacturingLocation || '',
avlVendorName: updatedItem.avlVendorName || '',
similarVendorName: updatedItem.similarVendorName || '',
purchaseOpinion: updatedItem.purchaseOpinion || '',
- picName: updatedItem.picName || '',
- picEmail: updatedItem.picEmail || '',
- picPhone: updatedItem.picPhone || '',
- agentName: updatedItem.agentName || '',
- agentEmail: updatedItem.agentEmail || '',
- agentPhone: updatedItem.agentPhone || '',
recentQuoteDate: updatedItem.recentQuoteDate || '',
recentQuoteNumber: updatedItem.recentQuoteNumber || '',
recentOrderDate: updatedItem.recentOrderDate || '',
@@ -932,24 +766,8 @@ export async function updateVendorPool(id: number, data: Partial<VendorPool>): P
lastModifier: updatedItem.lastModifier || 'system',
// boolean 필드들을 적절히 처리
faTarget: updatedItem.faTarget ?? false,
- hasAvl: updatedItem.hasAvl ?? false,
- isAgent: updatedItem.isAgent ?? false,
isBlacklist: updatedItem.isBlacklist ?? false,
isBcc: updatedItem.isBcc ?? false,
- // 선종 적용 정보
- shipTypeCommon: updatedItem.shipTypeCommon ?? false,
- shipTypeAmax: updatedItem.shipTypeAmax ?? false,
- shipTypeSmax: updatedItem.shipTypeSmax ?? false,
- shipTypeVlcc: updatedItem.shipTypeVlcc ?? false,
- shipTypeLngc: updatedItem.shipTypeLngc ?? false,
- shipTypeCont: updatedItem.shipTypeCont ?? false,
- offshoreTypeCommon: updatedItem.offshoreTypeCommon ?? false,
- offshoreTypeFpso: updatedItem.offshoreTypeFpso ?? false,
- offshoreTypeFlng: updatedItem.offshoreTypeFlng ?? false,
- offshoreTypeFpu: updatedItem.offshoreTypeFpu ?? false,
- offshoreTypePlatform: updatedItem.offshoreTypePlatform ?? false,
- offshoreTypeWtiv: updatedItem.offshoreTypeWtiv ?? false,
- offshoreTypeGom: updatedItem.offshoreTypeGom ?? false,
};
// debugSuccess('Vendor Pool 업데이트 완료', { id, result: transformedData });
@@ -994,7 +812,7 @@ export async function deleteVendorPool(id: number): Promise<boolean> {
// debugLog('Vendor Pool 삭제 시작', { id });
// 데이터베이스에서 삭제
- const result = await db
+ await db
.delete(vendorPool)
.where(eq(vendorPool.id, id));
@@ -1032,3 +850,360 @@ export async function deleteVendorPool(id: number): Promise<boolean> {
return false;
}
}
+
+export type ImportResultItem = {
+ rowNumber: number;
+ status: 'success' | 'error' | 'duplicate' | 'warning';
+ message: string;
+ data?: any;
+}
+
+export type ImportResult = {
+ totalRows: number;
+ successCount: number;
+ errorCount: number;
+ duplicateCount: number;
+ items: ImportResultItem[];
+}
+
+// Boolean 값 파싱 (서버 사이드용 - 클라이언트 유틸과 유사하게 동작)
+function parseBooleanServer(value: any): boolean {
+ if (typeof value === 'boolean') return value;
+ const strValue = String(value).toLowerCase().trim();
+ return strValue === 'true' || strValue === '1' || strValue === 'yes' ||
+ strValue === 'o' || strValue === 'y' || strValue === '참';
+}
+
+/**
+ * Vendor Pool 일괄 입력 처리 (Bulk Import)
+ * - 한 번에 여러 행을 입력받아 처리
+ * - Bulk Lookup으로 성능 최적화
+ */
+export async function processBulkImport(rows: Record<string, any>[], registrant: string): Promise<ImportResult> {
+ const result: ImportResult = {
+ totalRows: rows.length,
+ successCount: 0,
+ errorCount: 0,
+ duplicateCount: 0,
+ items: []
+ };
+
+ if (rows.length === 0) {
+ return result;
+ }
+
+ try {
+ // 1. Lookup을 위한 고유 코드 추출
+ const materialGroupCodes = new Set<string>();
+ const vendorCodes = new Set<string>();
+
+ rows.forEach(row => {
+ if (row.materialGroupCode) materialGroupCodes.add(String(row.materialGroupCode).trim());
+ if (row.vendorCode) vendorCodes.add(String(row.vendorCode).trim());
+ });
+
+ // 2. Bulk Fetch (DB 조회)
+ const materialGroupMap = new Map<string, string>();
+ if (materialGroupCodes.size > 0) {
+ const materialGroups = await db
+ .select({
+ code: MATERIAL_GROUP_MASTER.materialGroupCode,
+ name: MATERIAL_GROUP_MASTER.materialGroupDescription
+ })
+ .from(MATERIAL_GROUP_MASTER)
+ .where(inArray(MATERIAL_GROUP_MASTER.materialGroupCode, Array.from(materialGroupCodes)));
+
+ materialGroups.forEach(mg => {
+ if (mg.code && mg.name) {
+ materialGroupMap.set(mg.code.trim(), mg.name);
+ }
+ });
+ }
+
+ const vendorMap = new Map<string, string>();
+ if (vendorCodes.size > 0) {
+ const vendorList = await db
+ .select({
+ code: vendors.vendorCode,
+ name: vendors.vendorName
+ })
+ .from(vendors)
+ .where(inArray(vendors.vendorCode, Array.from(vendorCodes)));
+
+ vendorList.forEach(v => {
+ if (v.code && v.name) {
+ vendorMap.set(v.code.trim(), v.name);
+ }
+ });
+ }
+
+ // 2.5. 중복 검사를 위한 기존 데이터 조회
+ const targetVendorNames = new Set<string>();
+ rows.forEach(row => {
+ let vName = row.vendorName;
+ if (!vName && row.vendorCode) {
+ // vendorMap에서 조회
+ vName = vendorMap.get(String(row.vendorCode).trim());
+ }
+ if (vName) {
+ targetVendorNames.add(String(vName).trim());
+ }
+ });
+
+ const existingRecordsMap = new Map<string, number>();
+ if (targetVendorNames.size > 0) {
+ const existingRecords = await db
+ .select({
+ id: vendorPool.id,
+ constructionSector: vendorPool.constructionSector,
+ htDivision: vendorPool.htDivision,
+ discipline: vendorPool.discipline,
+ materialGroupCode: vendorPool.materialGroupCode,
+ vendorName: vendorPool.vendorName
+ })
+ .from(vendorPool)
+ .where(inArray(vendorPool.vendorName, Array.from(targetVendorNames)));
+
+ existingRecords.forEach(rec => {
+ // Key: constructionSector|htDivision|discipline|materialGroupCode|vendorName (trim 처리)
+ const cs = rec.constructionSector.trim();
+ const ht = rec.htDivision.trim();
+ const d = rec.discipline ? rec.discipline.trim() : '';
+ const m = rec.materialGroupCode ? rec.materialGroupCode.trim() : '';
+ const v = rec.vendorName ? rec.vendorName.trim() : '';
+ const key = `${cs}|${ht}|${d}|${m}|${v}`;
+ existingRecordsMap.set(key, rec.id);
+ });
+ }
+
+ // 3. 데이터 처리 및 검증
+ const validInsertRows: any[] = [];
+ const validUpdateRows: { id: number; data: any; rowNumber: number }[] = [];
+ const currentTimestamp = new Date();
+
+ for (let i = 0; i < rows.length; i++) {
+ const row = rows[i];
+ const rowNumber = i + 1;
+ const vendorPoolData: any = {};
+
+ // 기본 필드 매핑 및 타입 변환
+ const booleanFields = ['faTarget', 'isBlacklist', 'isBcc'];
+
+ Object.keys(row).forEach(key => {
+ const value = row[key];
+ if (booleanFields.includes(key)) {
+ vendorPoolData[key] = parseBooleanServer(value);
+ } else if (value === '' || value === undefined || value === null) {
+ vendorPoolData[key] = null;
+ } else {
+ vendorPoolData[key] = String(value);
+ }
+ });
+
+ // Enrichment (자동완성)
+ if (vendorPoolData.materialGroupCode && !vendorPoolData.materialGroupName) {
+ const mappedName = materialGroupMap.get(String(vendorPoolData.materialGroupCode).trim());
+ if (mappedName) {
+ vendorPoolData.materialGroupName = mappedName;
+ }
+ }
+
+ if (vendorPoolData.vendorCode && !vendorPoolData.vendorName) {
+ const mappedName = vendorMap.get(String(vendorPoolData.vendorCode).trim());
+ if (mappedName) {
+ vendorPoolData.vendorName = mappedName;
+ }
+ }
+
+ // 필수 필드 검증 (1차 검증)
+ // 키 필드가 null이면 실패 처리
+ const keyFields = [
+ { key: 'constructionSector', label: '공사부문' },
+ { key: 'htDivision', label: 'H/T구분' },
+ { key: 'discipline', label: '설계공종' },
+ { key: 'materialGroupCode', label: '자재그룹코드' }
+ ];
+
+ const missingKeyFields = keyFields
+ .filter(field => !vendorPoolData[field.key])
+ .map(field => field.label);
+
+ // vendorName은 vendorCode가 있으면 자동완성되므로, enrichment 후에 체크
+ if (!vendorPoolData.vendorName) {
+ missingKeyFields.push('협력업체명');
+ }
+
+ if (missingKeyFields.length > 0) {
+ result.errorCount++;
+ result.items.push({
+ rowNumber,
+ status: 'error',
+ message: `필수 키 필드 누락: ${missingKeyFields.join(', ')}`,
+ data: {
+ vendorName: vendorPoolData.vendorName,
+ materialGroupName: vendorPoolData.materialGroupName,
+ discipline: vendorPoolData.discipline,
+ }
+ });
+ continue;
+ }
+
+ // 데이터 형식 검증
+ const validationErrors: string[] = [];
+
+ if (vendorPoolData.equipBulkDivision && vendorPoolData.equipBulkDivision.length > 1) {
+ validationErrors.push(`Equip/Bulk 구분은 1자리여야 합니다: ${vendorPoolData.equipBulkDivision}`);
+ }
+ if (vendorPoolData.constructionSector && !['조선', '해양'].includes(vendorPoolData.constructionSector)) {
+ validationErrors.push(`공사부문은 '조선' 또는 '해양'이어야 합니다: ${vendorPoolData.constructionSector}`);
+ }
+ if (vendorPoolData.htDivision && !['H', 'T', '공통'].includes(vendorPoolData.htDivision)) {
+ validationErrors.push(`H/T구분은 'H', 'T' 또는 '공통'이어야 합니다: ${vendorPoolData.htDivision}`);
+ }
+
+ if (validationErrors.length > 0) {
+ result.errorCount++;
+ result.items.push({
+ rowNumber,
+ status: 'error',
+ message: `검증 실패: ${validationErrors.join(', ')}`,
+ data: {
+ vendorName: vendorPoolData.vendorName,
+ materialGroupName: vendorPoolData.materialGroupName,
+ discipline: vendorPoolData.discipline,
+ }
+ });
+ continue;
+ }
+
+ // 메타데이터 추가
+ vendorPoolData.lastModifier = registrant;
+ vendorPoolData.lastModifiedDate = currentTimestamp;
+
+ // 기본값 처리
+ if (vendorPoolData.faTarget === undefined) vendorPoolData.faTarget = false;
+ if (vendorPoolData.isBlacklist === undefined) vendorPoolData.isBlacklist = false;
+ if (vendorPoolData.isBcc === undefined) vendorPoolData.isBcc = false;
+
+ // 중복 검사 (2차 검증)
+ // [공사부문, H/T, 설계공종, 자재그룹코드, 협력업체명]
+ const checkConstructionSector = String(vendorPoolData.constructionSector).trim();
+ const checkHtDivision = String(vendorPoolData.htDivision).trim();
+ const checkDiscipline = String(vendorPoolData.discipline).trim();
+ const checkMaterialGroupCode = String(vendorPoolData.materialGroupCode).trim();
+ const checkVendorName = String(vendorPoolData.vendorName).trim();
+
+ const duplicateKey = `${checkConstructionSector}|${checkHtDivision}|${checkDiscipline}|${checkMaterialGroupCode}|${checkVendorName}`;
+
+ if (existingRecordsMap.has(duplicateKey)) {
+ const existingId = existingRecordsMap.get(duplicateKey)!;
+ validUpdateRows.push({
+ id: existingId,
+ data: vendorPoolData,
+ rowNumber
+ });
+ } else {
+ // 신규 등록 (3차 검증 - Insert)
+ vendorPoolData.registrant = registrant;
+ vendorPoolData.registrationDate = currentTimestamp;
+
+ validInsertRows.push({
+ rowNumber,
+ data: vendorPoolData
+ });
+ }
+ }
+
+ // 4. Bulk Execution
+
+ // 4.1 Updates (Sequential to avoid deadlock, though simple updates usually fine)
+ // 업데이트는 개별적으로 수행해야 함 (값들이 다를 수 있으므로)
+ for (const updateItem of validUpdateRows) {
+ try {
+ await db.update(vendorPool)
+ .set(updateItem.data)
+ .where(eq(vendorPool.id, updateItem.id));
+
+ result.duplicateCount++; // 업데이트된 건수를 중복(업데이트) 카운트로 처리
+ } catch (err) {
+ const errorMsg = err instanceof Error ? err.message : String(err);
+ result.errorCount++;
+ result.items.push({
+ rowNumber: updateItem.rowNumber,
+ status: 'error',
+ message: `데이터 업데이트 실패: ${errorMsg}`,
+ data: {
+ vendorName: updateItem.data.vendorName
+ }
+ });
+ }
+ }
+
+ // 4.2 Inserts (Batch)
+ if (validInsertRows.length > 0) {
+ // 500개씩 나누어 처리 (Batch Insert)
+ const BATCH_SIZE = 500;
+ for (let i = 0; i < validInsertRows.length; i += BATCH_SIZE) {
+ const batch = validInsertRows.slice(i, i + BATCH_SIZE);
+ const batchData = batch.map(item => item.data);
+
+ try {
+ await db.insert(vendorPool).values(batchData);
+ result.successCount += batch.length;
+ } catch (err) {
+ // 배치 실패 시 개별 재시도
+ console.error("Batch insert error, falling back to individual insert:", err);
+
+ // Fallback to individual insert for this batch to identify errors
+ for (const item of batch) {
+ try {
+ await db.insert(vendorPool).values(item.data);
+ result.successCount++;
+ } catch (innerErr) {
+ const innerErrorMsg = innerErr instanceof Error ? innerErr.message : String(innerErr);
+
+ if (innerErrorMsg.includes('unique_vendor_pool_combination') ||
+ innerErrorMsg.includes('duplicate key value')) {
+ // DB 레벨에서 중복 발생 시 (거의 발생 안해야 함, 위에서 체크했으므로)
+ // 하지만 동시성 이슈 등으로 발생 가능
+ result.errorCount++;
+ result.items.push({
+ rowNumber: item.rowNumber,
+ status: 'error',
+ message: `중복 데이터 발생 (동시성 이슈 가능성): ${innerErrorMsg}`,
+ data: {
+ vendorName: item.data.vendorName
+ }
+ });
+ } else {
+ result.errorCount++;
+ result.items.push({
+ rowNumber: item.rowNumber,
+ status: 'error',
+ message: `데이터 저장 실패: ${innerErrorMsg}`,
+ data: {
+ vendorName: item.data.vendorName,
+ materialGroupName: item.data.materialGroupName,
+ discipline: item.data.discipline,
+ }
+ });
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // 캐시 무효화
+ if (result.successCount > 0 || result.duplicateCount > 0) {
+ revalidateTag('vendor-pool-list');
+ revalidateTag('vendor-pool-stats');
+ }
+
+ return result;
+
+ } catch (error) {
+ console.error("Process bulk import error:", error);
+ throw error;
+ }
+}
diff --git a/lib/vendor-pool/table/bulk-import-dialog.tsx b/lib/vendor-pool/table/bulk-insert-dialog.tsx
index 50c20d08..ca32fd34 100644
--- a/lib/vendor-pool/table/bulk-import-dialog.tsx
+++ b/lib/vendor-pool/table/bulk-insert-dialog.tsx
@@ -20,27 +20,32 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
+import { DisciplineHardcodedSelector } from "@/components/common/discipline-hardcoded/discipline-hardcoded-selector"
+import { MaterialGroupSelectorDialogSingle } from "@/components/common/material/material-group-selector-dialog-single"
+import { VendorTierSelector } from "@/components/common/selectors/vendor-tier/vendor-tier-selector"
+import type { MaterialSearchItem } from "@/lib/material/material-group-service"
-interface BulkImportDialogProps {
+interface BulkInsertDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onSubmit: (data: Record<string, any>) => void
}
-export function BulkImportDialog({ open, onOpenChange, onSubmit }: BulkImportDialogProps) {
+export function BulkInsertDialog({ open, onOpenChange, onSubmit }: BulkInsertDialogProps) {
const [formData, setFormData] = React.useState<Record<string, any>>({
+ constructionSector: "",
+ discipline: "",
equipBulkDivision: "",
+ materialGroupCode: "",
+ materialGroupName: "",
similarMaterialNamePurchase: "",
faTarget: null,
tier: "",
- isAgent: null,
- headquarterLocation: "",
- manufacturingLocation: "",
- avlVendorName: "",
- isBlacklist: null,
- isBcc: null,
})
+ // 자재그룹 선택 상태 관리 (UI 표시용)
+ const [selectedMaterial, setSelectedMaterial] = React.useState<MaterialSearchItem | null>(null)
+
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
@@ -58,37 +63,46 @@ export function BulkImportDialog({ open, onOpenChange, onSubmit }: BulkImportDia
}
onSubmit(filteredData)
- // 폼 초기화
+ handleReset()
+ }
+
+ const handleReset = () => {
setFormData({
+ constructionSector: "",
+ discipline: "",
equipBulkDivision: "",
+ materialGroupCode: "",
+ materialGroupName: "",
similarMaterialNamePurchase: "",
faTarget: null,
tier: "",
- isAgent: null,
- headquarterLocation: "",
- manufacturingLocation: "",
- avlVendorName: "",
- isBlacklist: null,
- isBcc: null,
})
+ setSelectedMaterial(null)
}
const handleCancel = () => {
- setFormData({
- equipBulkDivision: "",
- similarMaterialNamePurchase: "",
- faTarget: null,
- tier: "",
- isAgent: null,
- headquarterLocation: "",
- manufacturingLocation: "",
- avlVendorName: "",
- isBlacklist: null,
- isBcc: null,
- })
+ handleReset()
onOpenChange(false)
}
+ // 자재그룹 선택 핸들러
+ const handleMaterialSelect = (material: MaterialSearchItem | null) => {
+ setSelectedMaterial(material)
+ if (material) {
+ setFormData(prev => ({
+ ...prev,
+ materialGroupCode: material.materialGroupCode,
+ materialGroupName: material.materialGroupDescription
+ }))
+ } else {
+ setFormData(prev => ({
+ ...prev,
+ materialGroupCode: "",
+ materialGroupName: ""
+ }))
+ }
+ }
+
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
@@ -101,6 +115,33 @@ export function BulkImportDialog({ open, onOpenChange, onSubmit }: BulkImportDia
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
+ {/* 공사부문 */}
+ <div className="space-y-2">
+ <Label htmlFor="constructionSector">공사부문</Label>
+ <Select
+ value={formData.constructionSector}
+ onValueChange={(value) => setFormData(prev => ({ ...prev, constructionSector: value }))}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="선택" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="조선">조선</SelectItem>
+ <SelectItem value="해양">해양</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+
+ {/* 설계공종 */}
+ <div className="space-y-2">
+ <Label htmlFor="discipline">설계공종</Label>
+ <DisciplineHardcodedSelector
+ selectedDiscipline={formData.discipline}
+ onDisciplineSelect={(value) => setFormData(prev => ({ ...prev, discipline: value }))}
+ placeholder="설계공종 선택"
+ />
+ </div>
+
{/* Equip/Bulk 구분 */}
<div className="space-y-2">
<Label htmlFor="equipBulkDivision">Equip/Bulk 구분</Label>
@@ -119,8 +160,29 @@ export function BulkImportDialog({ open, onOpenChange, onSubmit }: BulkImportDia
</Select>
</div>
- {/* 유사자재명(구매) */}
+ {/* 등급 */}
<div className="space-y-2">
+ <Label htmlFor="tier">등급</Label>
+ <VendorTierSelector
+ value={formData.tier}
+ onValueChange={(value) => setFormData(prev => ({ ...prev, tier: value }))}
+ placeholder="등급 선택"
+ />
+ </div>
+
+ {/* 자재그룹 - 전체 너비 사용 */}
+ <div className="space-y-2 col-span-2">
+ <Label>자재그룹</Label>
+ <MaterialGroupSelectorDialogSingle
+ selectedMaterial={selectedMaterial}
+ onMaterialSelect={handleMaterialSelect}
+ triggerLabel="자재그룹 선택"
+ placeholder="자재그룹 검색"
+ />
+ </div>
+
+ {/* 유사자재명(구매) */}
+ <div className="space-y-2 col-span-2">
<Label htmlFor="similarMaterialNamePurchase">유사자재명(구매)</Label>
<Input
id="similarMaterialNamePurchase"
@@ -131,7 +193,7 @@ export function BulkImportDialog({ open, onOpenChange, onSubmit }: BulkImportDia
</div>
{/* FA대상 */}
- <div className="space-y-2">
+ <div className="space-y-2 col-span-2">
<Label>FA대상</Label>
<div className="flex items-center space-x-2">
<Checkbox
@@ -142,89 +204,6 @@ export function BulkImportDialog({ open, onOpenChange, onSubmit }: BulkImportDia
<Label htmlFor="faTarget" className="text-sm">대상</Label>
</div>
</div>
-
- {/* 등급 */}
- <div className="space-y-2">
- <Label htmlFor="tier">등급</Label>
- <Input
- id="tier"
- value={formData.tier}
- onChange={(e) => setFormData(prev => ({ ...prev, tier: e.target.value }))}
- placeholder="등급 입력"
- />
- </div>
-
- {/* Agent 여부 */}
- <div className="space-y-2">
- <Label>Agent 여부</Label>
- <div className="flex items-center space-x-2">
- <Checkbox
- id="isAgent"
- checked={formData.isAgent === true}
- onCheckedChange={(checked) => setFormData(prev => ({ ...prev, isAgent: checked ? true : null }))}
- />
- <Label htmlFor="isAgent" className="text-sm">Agent</Label>
- </div>
- </div>
-
- {/* 본사위치(국가) */}
- <div className="space-y-2">
- <Label htmlFor="headquarterLocation">본사위치(국가)</Label>
- <Input
- id="headquarterLocation"
- value={formData.headquarterLocation}
- onChange={(e) => setFormData(prev => ({ ...prev, headquarterLocation: e.target.value }))}
- placeholder="국가명 입력"
- />
- </div>
-
- {/* 제작/선적지(국가) */}
- <div className="space-y-2">
- <Label htmlFor="manufacturingLocation">제작/선적지(국가)</Label>
- <Input
- id="manufacturingLocation"
- value={formData.manufacturingLocation}
- onChange={(e) => setFormData(prev => ({ ...prev, manufacturingLocation: e.target.value }))}
- placeholder="국가명 입력"
- />
- </div>
-
- {/* AVL등재업체명 */}
- <div className="space-y-2">
- <Label htmlFor="avlVendorName">AVL등재업체명</Label>
- <Input
- id="avlVendorName"
- value={formData.avlVendorName}
- onChange={(e) => setFormData(prev => ({ ...prev, avlVendorName: e.target.value }))}
- placeholder="업체명 입력"
- />
- </div>
-
- {/* Blacklist */}
- <div className="space-y-2">
- <Label>Blacklist</Label>
- <div className="flex items-center space-x-2">
- <Checkbox
- id="isBlacklist"
- checked={formData.isBlacklist === true}
- onCheckedChange={(checked) => setFormData(prev => ({ ...prev, isBlacklist: checked ? true : null }))}
- />
- <Label htmlFor="isBlacklist" className="text-sm">등록</Label>
- </div>
- </div>
-
- {/* BCC */}
- <div className="space-y-2">
- <Label>BCC</Label>
- <div className="flex items-center space-x-2">
- <Checkbox
- id="isBcc"
- checked={formData.isBcc === true}
- onCheckedChange={(checked) => setFormData(prev => ({ ...prev, isBcc: checked ? true : null }))}
- />
- <Label htmlFor="isBcc" className="text-sm">등록</Label>
- </div>
- </div>
</div>
<DialogFooter>
@@ -240,3 +219,4 @@ export function BulkImportDialog({ open, onOpenChange, onSubmit }: BulkImportDia
</Dialog>
)
}
+
diff --git a/lib/vendor-pool/table/import-result-dialog.tsx b/lib/vendor-pool/table/import-result-dialog.tsx
index 2e541271..db3d6282 100644
--- a/lib/vendor-pool/table/import-result-dialog.tsx
+++ b/lib/vendor-pool/table/import-result-dialog.tsx
@@ -21,7 +21,7 @@ export interface ImportResultItem {
data?: {
vendorName?: string
materialGroupName?: string
- designCategory?: string
+ discipline?: string
}
}
@@ -62,7 +62,7 @@ export function ImportResultDialog({ open, onOpenChange, result }: ImportResultD
case 'error':
return <Badge variant="destructive">실패</Badge>
case 'duplicate':
- return <Badge variant="secondary" className="bg-yellow-600 text-white">중복</Badge>
+ return <Badge variant="secondary" className="bg-yellow-600 text-white">중복(업데이트)</Badge>
case 'warning':
return <Badge variant="secondary" className="bg-orange-600 text-white">경고</Badge>
}
@@ -134,8 +134,8 @@ export function ImportResultDialog({ open, onOpenChange, result }: ImportResultD
{item.data.materialGroupName && (
<div>• 자재그룹: {item.data.materialGroupName}</div>
)}
- {item.data.designCategory && (
- <div>• 설계기능: {item.data.designCategory}</div>
+ {item.data.discipline && (
+ <div>• 설계공종: {item.data.discipline}</div>
)}
</div>
)}
diff --git a/lib/vendor-pool/table/vendor-pool-excel-import-button.tsx b/lib/vendor-pool/table/vendor-pool-excel-import-button.tsx
index cb39419d..3378c832 100644
--- a/lib/vendor-pool/table/vendor-pool-excel-import-button.tsx
+++ b/lib/vendor-pool/table/vendor-pool-excel-import-button.tsx
@@ -9,39 +9,26 @@ import ExcelJS from 'exceljs'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Upload, Loader } from 'lucide-react'
-import { createVendorPool } from '../service'
+import { processBulkImport } from '../service'
import { Input } from '@/components/ui/input'
import { useSession } from "next-auth/react"
import {
getCellValueAsString,
- parseBoolean,
getAccessorKeyByHeader,
- vendorPoolExcelColumns
} from '../excel-utils'
import { decryptWithServerAction } from '@/components/drm/drmUtils'
import { debugLog, debugError, debugWarn, debugSuccess, debugProcess } from '@/lib/debug-utils'
-import { enrichVendorPoolData } from '../enrichment-service'
-import { ImportResultDialog, ImportResult, ImportResultItem } from './import-result-dialog'
-import { ImportProgressDialog } from './import-progress-dialog'
+import { ImportResult } from './import-result-dialog'
interface ImportExcelProps {
- onSuccess?: () => void
+ onImportComplete: (result: ImportResult) => void
}
-export function ImportVendorPoolButton({ onSuccess }: ImportExcelProps) {
+export function ImportVendorPoolButton({ onImportComplete }: ImportExcelProps) {
const fileInputRef = useRef<HTMLInputElement>(null)
const [isImporting, setIsImporting] = React.useState(false)
const { data: session } = useSession()
- const [importResult, setImportResult] = React.useState<ImportResult | null>(null)
- const [showResultDialog, setShowResultDialog] = React.useState(false)
- // Progress 상태
- const [showProgressDialog, setShowProgressDialog] = React.useState(false)
- const [totalRows, setTotalRows] = React.useState(0)
- const [processedRows, setProcessedRows] = React.useState(0)
-
- // 헬퍼 함수들은 excel-utils에서 import
-
const handleImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) {
@@ -87,6 +74,7 @@ export function ImportVendorPoolButton({ onSuccess }: ImportExcelProps) {
if (!worksheet) {
debugError('[Import] 워크시트를 찾을 수 없습니다.')
toast.error("No worksheet found in the spreadsheet")
+ setIsImporting(false)
return
}
debugLog('[Import] 워크시트 확인 완료:', {
@@ -133,7 +121,7 @@ export function ImportVendorPoolButton({ onSuccess }: ImportExcelProps) {
// Process data rows
debugProcess('[Import] 데이터 행 처리 시작')
- const rows: any[] = [];
+ const rows: Record<string, any>[] = [];
const startRow = headerRowIndex + 1;
let skippedRows = 0;
@@ -191,337 +179,28 @@ export function ImportVendorPoolButton({ onSuccess }: ImportExcelProps) {
return
}
- // Progress Dialog 표시
- setTotalRows(rows.length)
- setProcessedRows(0)
- setShowProgressDialog(true)
-
- // Process each row
- debugProcess('[Import] 데이터베이스 저장 시작')
- let successCount = 0;
- let errorCount = 0;
- let duplicateCount = 0;
- const resultItems: ImportResultItem[] = []; // 실패한 건만 포함
-
- // Create promises for all vendor pool creation operations
- const promises = rows.map(async (row, rowIndex) => {
- // Excel 컬럼 설정을 기반으로 데이터 매핑 (catch 블록에서도 사용하기 위해 밖에서 선언)
- const vendorPoolData: any = {};
-
- try {
- debugLog(`[Import] 행 ${rowIndex + 1}/${rows.length} 처리 시작 - 원본 데이터:`, row)
-
- vendorPoolExcelColumns.forEach(column => {
- const { accessorKey, type } = column;
- const value = row[accessorKey] || '';
-
- if (type === 'boolean') {
- vendorPoolData[accessorKey] = parseBoolean(String(value));
- } else if (value === '') {
- // 빈 문자열은 null로 설정 (스키마에 맞게)
- vendorPoolData[accessorKey] = null;
- } else {
- vendorPoolData[accessorKey] = String(value);
- }
- });
-
- // 현재 사용자 정보 추가
- vendorPoolData.registrant = session?.user?.name || 'system';
- vendorPoolData.lastModifier = session?.user?.name || 'system';
-
- debugLog(`[Import] 행 ${rowIndex + 1} 데이터 매핑 완료 - 전체 필드:`, {
- '공사부문': vendorPoolData.constructionSector,
- 'H/T구분': vendorPoolData.htDivision,
- '설계기능코드': vendorPoolData.designCategoryCode,
- '설계기능': vendorPoolData.designCategory,
- 'Equip/Bulk구분': vendorPoolData.equipBulkDivision,
- '협력업체코드': vendorPoolData.vendorCode,
- '협력업체명': vendorPoolData.vendorName,
- '자재그룹코드': vendorPoolData.materialGroupCode,
- '자재그룹명': vendorPoolData.materialGroupName,
- '계약서명주체코드': vendorPoolData.contractSignerCode,
- '계약서명주체명': vendorPoolData.contractSignerName,
- '사업자번호': vendorPoolData.taxId,
- '패키지코드': vendorPoolData.packageCode,
- '패키지명': vendorPoolData.packageName
- })
-
- // 코드를 기반으로 자동완성 수행
- debugProcess(`[Import] 행 ${rowIndex + 1} enrichment 시작`);
- const enrichmentResult = await enrichVendorPoolData({
- designCategoryCode: vendorPoolData.designCategoryCode,
- designCategory: vendorPoolData.designCategory,
- materialGroupCode: vendorPoolData.materialGroupCode,
- materialGroupName: vendorPoolData.materialGroupName,
- vendorCode: vendorPoolData.vendorCode,
- vendorName: vendorPoolData.vendorName,
- contractSignerCode: vendorPoolData.contractSignerCode,
- contractSignerName: vendorPoolData.contractSignerName,
- });
-
- // enrichment 결과 적용
- if (enrichmentResult.enrichedFields.length > 0) {
- debugSuccess(`[Import] 행 ${rowIndex + 1} enrichment 완료 - 자동완성된 필드: ${enrichmentResult.enrichedFields.join(', ')}`);
-
- // enrichment된 데이터를 vendorPoolData에 반영
- if (enrichmentResult.enriched.designCategory) {
- vendorPoolData.designCategory = enrichmentResult.enriched.designCategory;
- }
- if (enrichmentResult.enriched.materialGroupName) {
- vendorPoolData.materialGroupName = enrichmentResult.enriched.materialGroupName;
- }
- if (enrichmentResult.enriched.vendorName) {
- vendorPoolData.vendorName = enrichmentResult.enriched.vendorName;
- }
- if (enrichmentResult.enriched.contractSignerName) {
- vendorPoolData.contractSignerName = enrichmentResult.enriched.contractSignerName;
- }
- }
-
- // enrichment 경고 메시지 로깅만 (resultItems에 추가하지 않음)
- if (enrichmentResult.warnings.length > 0) {
- debugWarn(`[Import] 행 ${rowIndex + 1} enrichment 경고:`, enrichmentResult.warnings);
- enrichmentResult.warnings.forEach(warning => {
- console.warn(`Row ${rowIndex + 1}: ${warning}`);
- });
- }
-
- // Validate required fields (필수 필드 검증)
- // 자동완성 가능한 필드는 코드가 있으면 명칭은 optional로 처리
- const requiredFieldsCheck = {
- constructionSector: { value: vendorPoolData.constructionSector, label: '공사부문' },
- htDivision: { value: vendorPoolData.htDivision, label: 'H/T구분' },
- designCategoryCode: { value: vendorPoolData.designCategoryCode, label: '설계기능코드' }
- };
-
- // 설계기능: 설계기능코드가 없으면 설계기능명 필수
- if (!vendorPoolData.designCategoryCode && !vendorPoolData.designCategory) {
- requiredFieldsCheck['designCategory'] = { value: vendorPoolData.designCategory, label: '설계기능' };
- }
-
- // 협력업체명: 협력업체코드가 없으면 협력업체명 필수
- if (!vendorPoolData.vendorCode && !vendorPoolData.vendorName) {
- requiredFieldsCheck['vendorName'] = { value: vendorPoolData.vendorName, label: '협력업체명' };
- }
-
- const missingRequiredFields = Object.entries(requiredFieldsCheck)
- .filter(([_, field]) => !field.value)
- .map(([key, field]) => `${field.label}(${key})`);
-
- if (missingRequiredFields.length > 0) {
- debugError(`[Import] 행 ${rowIndex + 1} 필수 필드 누락 [${missingRequiredFields.length}개]:`, {
- missingFields: missingRequiredFields,
- currentData: vendorPoolData
- });
- console.error(`Missing required fields in row ${rowIndex + 1}:`, missingRequiredFields.join(', '));
- errorCount++;
- resultItems.push({
- rowNumber: rowIndex + 1,
- status: 'error',
- message: `필수 필드 누락: ${missingRequiredFields.join(', ')}`,
- data: {
- vendorName: vendorPoolData.vendorName,
- materialGroupName: vendorPoolData.materialGroupName,
- designCategory: vendorPoolData.designCategory,
- }
- });
-
- // Progress 업데이트
- setProcessedRows(prev => prev + 1);
-
- return null;
- }
-
- debugSuccess(`[Import] 행 ${rowIndex + 1} 필수 필드 검증 통과`);
-
- // Validate field lengths and formats (필드 길이 및 형식 검증)
- const validationErrors: string[] = [];
-
- if (vendorPoolData.designCategoryCode && vendorPoolData.designCategoryCode.length > 2) {
- validationErrors.push(`설계기능코드는 2자리 이하여야 합니다: ${vendorPoolData.designCategoryCode}`);
- }
-
- if (vendorPoolData.equipBulkDivision && vendorPoolData.equipBulkDivision.length > 1) {
- validationErrors.push(`Equip/Bulk 구분은 1자리여야 합니다: ${vendorPoolData.equipBulkDivision}`);
- }
-
- if (vendorPoolData.constructionSector && !['조선', '해양'].includes(vendorPoolData.constructionSector)) {
- validationErrors.push(`공사부문은 '조선' 또는 '해양'이어야 합니다: ${vendorPoolData.constructionSector}`);
- }
-
- if (vendorPoolData.htDivision && !['H', 'T', '공통'].includes(vendorPoolData.htDivision)) {
- validationErrors.push(`H/T구분은 'H', 'T' 또는 '공통'이어야 합니다: ${vendorPoolData.htDivision}`);
- }
-
- if (validationErrors.length > 0) {
- debugError(`[Import] 행 ${rowIndex + 1} 검증 실패 [${validationErrors.length}개 오류]:`, {
- errors: validationErrors,
- problematicFields: {
- designCategoryCode: vendorPoolData.designCategoryCode,
- equipBulkDivision: vendorPoolData.equipBulkDivision,
- constructionSector: vendorPoolData.constructionSector,
- htDivision: vendorPoolData.htDivision
- }
- });
- console.error(`Validation errors in row ${rowIndex + 1}:`, validationErrors.join(' | '));
- errorCount++;
- resultItems.push({
- rowNumber: rowIndex + 1,
- status: 'error',
- message: `검증 실패: ${validationErrors.join(', ')}`,
- data: {
- vendorName: vendorPoolData.vendorName,
- materialGroupName: vendorPoolData.materialGroupName,
- designCategory: vendorPoolData.designCategory,
- }
- });
-
- // Progress 업데이트
- setProcessedRows(prev => prev + 1);
-
- return null;
- }
-
- debugSuccess(`[Import] 행 ${rowIndex + 1} 형식 검증 통과`);
-
- if (!session || !session.user || !session.user.id) {
- debugError(`[Import] 행 ${rowIndex + 1} 세션 오류: 로그인 정보 없음`);
- toast.error("인증 오류. 로그인 정보를 찾을 수 없습니다.")
- return
- }
-
- // Create the vendor pool entry
- debugProcess(`[Import] 행 ${rowIndex + 1} 데이터베이스 저장 시도`);
- const result = await createVendorPool(vendorPoolData as any)
-
- if (!result) {
- debugError(`[Import] 행 ${rowIndex + 1} 저장 실패: createVendorPool returned null`);
- console.error(`Failed to import row - createVendorPool returned null:`, vendorPoolData);
- errorCount++;
- resultItems.push({
- rowNumber: rowIndex + 1,
- status: 'error',
- message: '데이터 저장 실패',
- data: {
- vendorName: vendorPoolData.vendorName,
- materialGroupName: vendorPoolData.materialGroupName,
- designCategory: vendorPoolData.designCategory,
- }
- });
-
- // Progress 업데이트
- setProcessedRows(prev => prev + 1);
-
- return null;
- }
-
- debugSuccess(`[Import] 행 ${rowIndex + 1} 저장 성공 (ID: ${result.id})`);
- successCount++;
- // 성공한 건은 resultItems에 추가하지 않음
-
- // Progress 업데이트
- setProcessedRows(prev => prev + 1);
-
- return result;
- } catch (error) {
- debugError(`[Import] 행 ${rowIndex + 1} 처리 중 예외 발생:`, error);
- console.error("Error processing row:", error, row);
-
- // Unique 제약 조건 위반 감지 (중복 데이터)
- const errorMessage = error instanceof Error ? error.message : String(error);
- if (errorMessage === 'DUPLICATE_VENDOR_POOL') {
- debugWarn(`[Import] 행 ${rowIndex + 1} 중복 데이터 감지:`, {
- constructionSector: vendorPoolData.constructionSector,
- htDivision: vendorPoolData.htDivision,
- materialGroupCode: vendorPoolData.materialGroupCode,
- vendorName: vendorPoolData.vendorName
- });
- duplicateCount++;
- // 중복 건은 resultItems에 추가하지 않음
-
- // Progress 업데이트
- setProcessedRows(prev => prev + 1);
-
- return null;
- }
-
- // 다른 에러의 경우 에러 카운트 증가
- errorCount++;
- resultItems.push({
- rowNumber: rowIndex + 1,
- status: 'error',
- message: `처리 중 오류 발생: ${errorMessage}`,
- data: {
- vendorName: vendorPoolData.vendorName,
- materialGroupName: vendorPoolData.materialGroupName,
- designCategory: vendorPoolData.designCategory,
- }
- });
-
- // Progress 업데이트
- setProcessedRows(prev => prev + 1);
-
- return null;
- }
- });
-
- // Wait for all operations to complete
- debugProcess('[Import] 모든 Promise 완료 대기 중...')
- await Promise.all(promises);
- debugSuccess('[Import] 모든 데이터 처리 완료')
-
- debugLog('[Import] 최종 결과:', {
- totalRows: rows.length,
- successCount,
- errorCount,
- duplicateCount
+ // 서버로 Bulk Import 요청
+ debugProcess('[Import] 서버 Bulk Import 요청 시작')
+ toast.info(`${rows.length}개의 데이터를 처리하고 있습니다...`)
+
+ const registrant = session?.user?.name || 'system';
+ const result = await processBulkImport(rows, registrant);
+
+ debugSuccess('[Import] 서버 처리 완료', {
+ success: result.successCount,
+ error: result.errorCount
})
- // Progress Dialog 닫기
- setShowProgressDialog(false)
-
- // Import 결과 Dialog 데이터 생성 (실패한 건만 포함)
- const result: ImportResult = {
- totalRows: rows.length,
- successCount,
- errorCount,
- duplicateCount,
- items: resultItems // 실패한 건만 포함됨
- }
-
- // Show results
- if (successCount > 0) {
- debugSuccess(`[Import] 임포트 성공: ${successCount}개 항목`);
- toast.success(`${successCount}개 항목이 성공적으로 가져와졌습니다.`);
-
- if (errorCount > 0) {
- debugWarn(`[Import] 일부 실패: ${errorCount}개 항목`);
- }
- // Call the success callback to refresh data
- onSuccess?.();
- } else if (errorCount > 0) {
- debugError(`[Import] 모든 항목 실패: ${errorCount}개`);
- toast.error(`모든 ${errorCount}개 항목 가져오기에 실패했습니다. 데이터 형식을 확인하세요.`);
- }
-
- if (duplicateCount > 0) {
- debugWarn(`[Import] 중복 데이터: ${duplicateCount}개`);
- toast.warning(`${duplicateCount}개의 중복 데이터가 감지되었습니다.`);
- }
-
- // Import 결과 Dialog 표시
- setImportResult(result);
- setShowResultDialog(true);
+ // 결과 처리 및 콜백 호출
+ onImportComplete(result)
} catch (error) {
debugError('[Import] 전체 임포트 프로세스 실패:', error);
console.error("Import error:", error);
toast.error("Error importing data. Please check file format.");
- setShowProgressDialog(false);
} finally {
debugLog('[Import] 임포트 프로세스 종료');
setIsImporting(false);
- setShowProgressDialog(false);
// Reset the file input
if (fileInputRef.current) {
fileInputRef.current.value = '';
@@ -554,20 +233,6 @@ export function ImportVendorPoolButton({ onSuccess }: ImportExcelProps) {
{isImporting ? "Importing..." : "Import"}
</span>
</Button>
-
- {/* Import Progress Dialog */}
- <ImportProgressDialog
- open={showProgressDialog}
- totalRows={totalRows}
- processedRows={processedRows}
- />
-
- {/* Import 결과 Dialog */}
- <ImportResultDialog
- open={showResultDialog}
- onOpenChange={setShowResultDialog}
- result={importResult}
- />
</>
)
}
diff --git a/lib/vendor-pool/table/vendor-pool-table-columns.tsx b/lib/vendor-pool/table/vendor-pool-table-columns.tsx
index 5676250b..9d6c506f 100644
--- a/lib/vendor-pool/table/vendor-pool-table-columns.tsx
+++ b/lib/vendor-pool/table/vendor-pool-table-columns.tsx
@@ -23,7 +23,7 @@ interface VendorPoolTableMeta {
// Vendor Pool 데이터 타입 - 스키마 기반 + 테이블용 추가 필드
import type { VendorPool } from "@/db/schema/avl/vendor-pool"
-import { DisciplineCode, EngineeringDisciplineSelector } from "@/components/common/discipline"
+import { DisciplineHardcodedSelector } from "@/components/common/discipline-hardcoded"
import { MaterialGroupSelectorDialogSingle } from "@/components/common/material/material-group-selector-dialog-single"
import type { MaterialSearchItem } from "@/lib/material/material-group-service"
import { VendorTierSelector } from "@/components/common/selectors/vendor-tier/vendor-tier-selector"
@@ -64,10 +64,12 @@ export const columns: ColumnDef<VendorPoolItem>[] = [
),
enableSorting: false,
enableHiding: false,
+ enableColumnFilter: false,
size: 40,
},
{
accessorKey: "id",
+ accessorFn: (row) => String(row.id),
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="ID" />
),
@@ -82,7 +84,7 @@ export const columns: ColumnDef<VendorPoolItem>[] = [
// 실제 ID 표시
return <div className="text-sm font-mono">{id}</div>
},
- size: 60,
+ size: 120,
},
{
accessorKey: "constructionSector",
@@ -120,7 +122,7 @@ export const columns: ColumnDef<VendorPoolItem>[] = [
/>
)
},
- size: 100,
+ size: 160,
},
{
accessorKey: "htDivision",
@@ -156,46 +158,37 @@ export const columns: ColumnDef<VendorPoolItem>[] = [
/>
)
},
- size: 80,
+ size: 120,
},
{
- accessorKey: "designCategory",
+ accessorKey: "discipline",
header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title={<span className="text-red-600 font-medium">설계기능(공종) *</span>} />
+ <DataTableColumnHeaderSimple column={column} title={<span className="text-red-600 font-medium">설계공종 *</span>} />
),
cell: ({ row, table }) => {
- const designCategoryCode = row.original.designCategoryCode as string
- const designCategory = row.original.designCategory as string
+ const discipline = row.original.discipline as string
- // 현재 선택된 discipline 구성
- const selectedDiscipline: DisciplineCode | undefined = designCategoryCode && designCategory ? {
- CD: designCategoryCode,
- USR_DF_CHAR_18: designCategory
- } : undefined
-
- const onDisciplineSelect = async (discipline: DisciplineCode) => {
- console.log('선택된 설계공종:', discipline)
+ const onDisciplineSelect = async (newDiscipline: string) => {
+ console.log('선택된 설계공종:', newDiscipline)
console.log('행 ID:', row.original.id)
- // 설계기능코드와 설계기능(공종) 필드 모두 업데이트
if (table.options.meta?.onCellUpdate) {
- await table.options.meta.onCellUpdate(row.original.id, "designCategoryCode", discipline.CD)
- await table.options.meta.onCellUpdate(row.original.id, "designCategory", discipline.USR_DF_CHAR_18)
+ await table.options.meta.onCellUpdate(row.original.id, "discipline", newDiscipline)
} else {
console.error('onCellUpdate가 정의되지 않음')
}
}
return (
- <EngineeringDisciplineSelector
- selectedDiscipline={selectedDiscipline}
+ <DisciplineHardcodedSelector
+ selectedDiscipline={discipline}
onDisciplineSelect={onDisciplineSelect}
disabled={false}
placeholder="설계공종을 선택하세요"
/>
)
},
- size: 260,
+ size: 200,
},
{
accessorKey: "equipBulkDivision",
@@ -225,72 +218,17 @@ export const columns: ColumnDef<VendorPoolItem>[] = [
/>
)
},
- size: 120,
- },
- {
- accessorKey: "packageCode",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="패키지 코드" />
- ),
- cell: ({ row, table }) => {
- const value = row.getValue("packageCode")
- const onSave = async (newValue: any) => {
- if (table.options.meta?.onCellUpdate) {
- await table.options.meta.onCellUpdate(row.original.id, "packageCode", newValue)
- }
- }
-
- // 수정 여부 확인
- const isModified = getIsModified(table, row.original.id, "packageCode")
-
- return (
- <EditableCell
- value={value}
- type="text"
- onSave={onSave}
- placeholder="패키지 코드 입력"
- maxLength={50}
- autoSave={false}
- isModified={isModified}
- />
- )
- },
- size: 120,
+ size: 180,
},
{
- accessorKey: "packageName",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="패키지 명" />
- ),
- cell: ({ row, table }) => {
- const value = row.getValue("packageName")
- const isEmptyRow = String(row.original.id).startsWith('temp-')
- const onSave = async (newValue: any) => {
- if (table.options.meta?.onCellUpdate) {
- await table.options.meta.onCellUpdate(row.original.id, "packageName", newValue)
- }
+ // accessorKey: "materialGroupName",
+ id: "materialGroupName",
+ accessorFn: (row) => {
+ if (row.materialGroupCode && row.materialGroupName) {
+ return `${row.materialGroupCode} - ${row.materialGroupName}`
}
-
- // 수정 여부 확인
- const isModified = getIsModified(table, row.original.id, "packageName")
-
- return (
- <EditableCell
- value={value}
- type="text"
- onSave={onSave}
- placeholder="패키지 명 입력"
- maxLength={100}
- autoSave={false}
- initialEditMode={isEmptyRow}
- isModified={isModified}
- />
- )
+ return row.materialGroupName || row.materialGroupCode || ""
},
- size: 200,
- },
- {
- accessorKey: "materialGroupName",
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title={<span className="text-red-600 font-medium">자재그룹 *</span>} />
),
@@ -338,31 +276,6 @@ export const columns: ColumnDef<VendorPoolItem>[] = [
size: 400,
},
{
- accessorKey: "smCode",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="SM Code" />
- ),
- cell: ({ row, table }) => {
- const value = row.getValue("smCode")
- const onSave = async (newValue: any) => {
- if (table.options.meta?.onCellUpdate) {
- await table.options.meta.onCellUpdate(row.original.id, "smCode", newValue)
- }
- }
-
- return (
- <EditableCell
- value={value}
- type="text"
- onSave={onSave}
- placeholder="SM Code 입력"
- maxLength={50}
- />
- )
- },
- size: 200,
- },
- {
accessorKey: "similarMaterialNamePurchase",
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="자재명 (검색 키워드)" />
@@ -390,32 +303,6 @@ export const columns: ColumnDef<VendorPoolItem>[] = [
size: 250,
},
{
- accessorKey: "similarMaterialNameOther",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="유사자재명(구매외)" />
- ),
- cell: ({ row, table }) => {
- const value = row.getValue("similarMaterialNameOther")
- const onSave = async (newValue: any) => {
- if (table.options.meta?.onCellUpdate) {
- await table.options.meta.onCellUpdate(row.original.id, "similarMaterialNameOther", newValue)
- }
- }
-
- return (
- <EditableCell
- value={value}
- type="text"
- onSave={onSave}
- placeholder="유사자재명(구매외) 입력"
- maxLength={100}
- autoSave={false}
- />
- )
- },
- size: 140,
- },
- {
accessorKey: "vendorSelector",
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="협력업체 선택" />
@@ -465,7 +352,8 @@ export const columns: ColumnDef<VendorPoolItem>[] = [
/>
)
},
- size: 150,
+ enableColumnFilter: false,
+ size: 200,
},
{
accessorKey: "vendorCode",
@@ -495,15 +383,22 @@ export const columns: ColumnDef<VendorPoolItem>[] = [
/>
)
},
- size: 130,
+ size: 200,
},
{
- accessorKey: "vendorName",
+ // accessorKey: "vendorName",
+ id: "vendorName",
+ accessorFn: (row) => {
+ if (row.vendorCode && row.vendorName) {
+ return `${row.vendorCode} - ${row.vendorName}`
+ }
+ return row.vendorName || row.vendorCode || ""
+ },
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title={<span className="text-red-600 font-medium">협력업체 명 *</span>} />
),
cell: ({ row, table }) => {
- const value = row.getValue("vendorName")
+ const value = row.original.vendorName // accessorFn을 썼으므로 getValue() 대신 original 참조가 더 안전
const isEmptyRow = String(row.original.id).startsWith('temp-')
const onSave = async (newValue: any) => {
if (table.options.meta?.onCellUpdate) {
@@ -527,7 +422,7 @@ export const columns: ColumnDef<VendorPoolItem>[] = [
/>
)
},
- size: 130,
+ size: 200,
},
{
accessorKey: "faTarget",
@@ -554,53 +449,8 @@ export const columns: ColumnDef<VendorPoolItem>[] = [
)
},
enableSorting: false,
- size: 80,
- },
- {
- accessorKey: "faStatus",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="FA현황" />
- ),
- cell: ({ row }) => {
- const value = row.original.faStatus as string
-
- // 'O'인 경우에만 'O'를 표시, 그 외에는 빈 셀
- const displayValue = value === "O" ? "O" : ""
-
- return (
- <div className="px-2 py-1 text-sm text-center">
- {displayValue}
- </div>
- )
- },
size: 120,
},
- // {
- // accessorKey: "faRemark",
- // header: ({ column }) => (
- // <DataTableColumnHeaderSimple column={column} title="FA상세" />
- // ),
- // cell: ({ row, table }) => {
- // const value = row.getValue("faRemark")
- // const onSave = async (newValue: any) => {
- // if (table.options.meta?.onCellUpdate) {
- // await table.options.meta.onCellUpdate(row.original.id, "faRemark", newValue)
- // }
- // }
-
- // return (
- // <EditableCell
- // value={value}
- // type="textarea"
- // onSave={onSave}
- // placeholder="FA상세 입력"
- // maxLength={500}
- // autoSave={false}
- // />
- // )
- // },
- // size: 120,
- // },
{
accessorKey: "tier",
header: ({ column }) => (
@@ -624,144 +474,9 @@ export const columns: ColumnDef<VendorPoolItem>[] = [
/>
)
},
- size: 120,
- },
- {
- accessorKey: "isAgent",
- header: "Agent 여부",
- cell: ({ row, table }) => {
- const value = row.getValue("isAgent") as boolean
- const onSave = async (newValue: any) => {
- if (table.options.meta?.onCellUpdate) {
- await table.options.meta.onCellUpdate(row.original.id, "isAgent", newValue)
- }
- }
-
- return (
- <EditableCell
- value={value}
- type="checkbox"
- onSave={onSave}
- autoSave={false}
- />
- )
- },
- enableSorting: false,
- size: 100,
- },
- {
- accessorKey: "contractSignerCode",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="계약서명주체 코드" />
- ),
- cell: ({ row, table }) => {
- const value = row.getValue("contractSignerCode")
- const onSave = async (newValue: any) => {
- if (table.options.meta?.onCellUpdate) {
- await table.options.meta.onCellUpdate(row.original.id, "contractSignerCode", newValue)
- }
- }
-
- // 수정 여부 확인
- const isModified = getIsModified(table, row.original.id, "contractSignerCode")
-
- return (
- <EditableCell
- value={value}
- type="text"
- onSave={onSave}
- placeholder="계약서명주체 코드 입력"
- maxLength={50}
- autoSave={false}
- isModified={isModified}
- />
- )
- },
- size: 120,
- },
- {
- accessorKey: "contractSignerSelector",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="계약서명주체 선택" />
- ),
- cell: ({ row, table }) => {
- const contractSignerCode = row.original.contractSignerCode as string
- const contractSignerName = row.original.contractSignerName as string
-
- // 현재 선택된 contract signer 구성
- const selectedVendor: VendorSearchItem | null = contractSignerCode && contractSignerName ? {
- id: 0, // 실제로는 vendorId가 있어야 하지만 여기서는 임시로 0 사용
- vendorName: contractSignerName,
- vendorCode: contractSignerCode || null,
- taxId: null, // 사업자번호는 vendor-pool에서 관리하지 않음
- status: "ACTIVE", // 임시 값
- displayText: contractSignerName + (contractSignerCode ? ` (${contractSignerCode})` : "")
- } : null
-
- const onVendorSelect = async (vendor: VendorSearchItem | null) => {
- console.log('선택된 계약서명주체:', vendor)
-
- if (vendor) {
- // 계약서명주체코드와 계약서명주체명 필드 업데이트
- if (table.options.meta?.onCellUpdate) {
- await table.options.meta.onCellUpdate(row.original.id, "contractSignerCode", vendor.vendorCode || "")
- await table.options.meta.onCellUpdate(row.original.id, "contractSignerName", vendor.vendorName)
- }
- } else {
- // 선택 해제 시 빈 값으로 설정
- if (table.options.meta?.onCellUpdate) {
- await table.options.meta.onCellUpdate(row.original.id, "contractSignerCode", "")
- await table.options.meta.onCellUpdate(row.original.id, "contractSignerName", "")
- }
- }
- }
-
- return (
- <VendorSelectorDialogSingle
- selectedVendor={selectedVendor}
- onVendorSelect={onVendorSelect}
- disabled={false}
- triggerLabel="계약서명주체 선택"
- placeholder="계약서명주체를 검색하세요..."
- title="계약서명주체 선택"
- description="계약서명주체를 검색하고 선택해주세요."
- statusFilter="ACTIVE"
- />
- )
- },
size: 150,
},
{
- accessorKey: "contractSignerName",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title={<span className="text-red-600 font-medium">계약서명주체 명 *</span>} />
- ),
- cell: ({ row, table }) => {
- const value = row.getValue("contractSignerName")
- const onSave = async (newValue: any) => {
- if (table.options.meta?.onCellUpdate) {
- await table.options.meta.onCellUpdate(row.original.id, "contractSignerName", newValue)
- }
- }
-
- // 수정 여부 확인
- const isModified = getIsModified(table, row.original.id, "contractSignerName")
-
- return (
- <EditableCell
- value={value}
- type="text"
- onSave={onSave}
- placeholder="계약서명주체 명 입력"
- maxLength={100}
- autoSave={false}
- isModified={isModified}
- />
- )
- },
- size: 120,
- },
- {
accessorKey: "headquarterLocation",
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title={<span className="text-red-600 font-medium">본사 위치 *</span>} />
@@ -792,7 +507,7 @@ export const columns: ColumnDef<VendorPoolItem>[] = [
/>
)
},
- size: 180,
+ size: 220,
},
{
accessorKey: "manufacturingLocation",
@@ -850,7 +565,7 @@ export const columns: ColumnDef<VendorPoolItem>[] = [
/>
)
},
- size: 140,
+ size: 220,
},
{
accessorKey: "similarVendorName",
@@ -876,30 +591,7 @@ export const columns: ColumnDef<VendorPoolItem>[] = [
/>
)
},
- size: 130,
- },
- {
- accessorKey: "hasAvl",
- header: "AVL",
- cell: ({ row, table }) => {
- const value = row.getValue("hasAvl") as boolean
- const onSave = async (newValue: any) => {
- if (table.options.meta?.onCellUpdate) {
- await table.options.meta.onCellUpdate(row.original.id, "hasAvl", newValue)
- }
- }
-
- return (
- <EditableCell
- value={value}
- type="checkbox"
- onSave={onSave}
- autoSave={false}
- />
- )
- },
- enableSorting: false,
- size: 60,
+ size: 280,
},
{
accessorKey: "isBlacklist",
@@ -922,7 +614,7 @@ export const columns: ColumnDef<VendorPoolItem>[] = [
)
},
enableSorting: false,
- size: 60,
+ size: 100,
},
{
accessorKey: "isBcc",
@@ -945,7 +637,7 @@ export const columns: ColumnDef<VendorPoolItem>[] = [
)
},
enableSorting: false,
- size: 60,
+ size: 100,
},
{
accessorKey: "purchaseOpinion",
@@ -972,448 +664,7 @@ export const columns: ColumnDef<VendorPoolItem>[] = [
},
size: 300,
},
- {
- accessorKey: "shipTypeCommon",
- header: "공통",
- cell: ({ row, table }) => {
- const value = row.getValue("shipTypeCommon") as boolean
- const onSave = async (newValue: any) => {
- if (table.options.meta?.onCellUpdate) {
- await table.options.meta.onCellUpdate(row.original.id, "shipTypeCommon", newValue)
- }
- }
-
- return (
- <EditableCell
- value={value}
- type="checkbox"
- onSave={onSave}
- autoSave={false}
- />
- )
- },
- enableSorting: false,
- size: 80,
- },
- {
- accessorKey: "shipTypeAmax",
- header: "A-max",
- cell: ({ row, table }) => {
- const value = row.getValue("shipTypeAmax") as boolean
- const onSave = async (newValue: any) => {
- if (table.options.meta?.onCellUpdate) {
- await table.options.meta.onCellUpdate(row.original.id, "shipTypeAmax", newValue)
- }
- }
-
- return (
- <EditableCell
- value={value}
- type="checkbox"
- onSave={onSave}
- autoSave={false}
- />
- )
- },
- enableSorting: false,
- size: 60,
- },
- {
- accessorKey: "shipTypeSmax",
- header: "S-max",
- cell: ({ row, table }) => {
- const value = row.getValue("shipTypeSmax") as boolean
- const onSave = async (newValue: any) => {
- if (table.options.meta?.onCellUpdate) {
- await table.options.meta.onCellUpdate(row.original.id, "shipTypeSmax", newValue)
- }
- }
-
- return (
- <EditableCell
- value={value}
- type="checkbox"
- onSave={onSave}
- autoSave={false}
- />
- )
- },
- enableSorting: false,
- size: 60,
- },
- {
- accessorKey: "shipTypeVlcc",
- header: "VLCC",
- cell: ({ row, table }) => {
- const value = row.getValue("shipTypeVlcc") as boolean
- const onSave = async (newValue: any) => {
- if (table.options.meta?.onCellUpdate) {
- await table.options.meta.onCellUpdate(row.original.id, "shipTypeVlcc", newValue)
- }
- }
-
- return (
- <EditableCell
- value={value}
- type="checkbox"
- onSave={onSave}
- autoSave={false}
- />
- )
- },
- enableSorting: false,
- size: 60,
- },
- {
- accessorKey: "shipTypeLngc",
- header: "LNGC",
- cell: ({ row, table }) => {
- const value = row.getValue("shipTypeLngc") as boolean
- const onSave = async (newValue: any) => {
- if (table.options.meta?.onCellUpdate) {
- await table.options.meta.onCellUpdate(row.original.id, "shipTypeLngc", newValue)
- }
- }
-
- return (
- <EditableCell
- value={value}
- type="checkbox"
- onSave={onSave}
- autoSave={false}
- />
- )
- },
- size: 60,
- },
- {
- accessorKey: "shipTypeCont",
- header: "CONT",
- cell: ({ row, table }) => {
- const value = row.getValue("shipTypeCont") as boolean
- const onSave = async (newValue: any) => {
- if (table.options.meta?.onCellUpdate) {
- await table.options.meta.onCellUpdate(row.original.id, "shipTypeCont", newValue)
- }
- }
-
- return (
- <EditableCell
- value={value}
- type="checkbox"
- onSave={onSave}
- autoSave={false}
- />
- )
- },
- size: 60,
- },
- {
- accessorKey: "offshoreTypeCommon",
- header: "공통",
- cell: ({ row, table }) => {
- const value = row.getValue("offshoreTypeCommon") as boolean
- const onSave = async (newValue: any) => {
- if (table.options.meta?.onCellUpdate) {
- await table.options.meta.onCellUpdate(row.original.id, "offshoreTypeCommon", newValue)
- }
- }
-
- return (
- <EditableCell
- value={value}
- type="checkbox"
- onSave={onSave}
- autoSave={false}
- />
- )
- },
- size: 60,
- },
- {
- accessorKey: "offshoreTypeFpso",
- header: "FPSO",
- cell: ({ row, table }) => {
- const value = row.getValue("offshoreTypeFpso") as boolean
- const onSave = async (newValue: any) => {
- if (table.options.meta?.onCellUpdate) {
- await table.options.meta.onCellUpdate(row.original.id, "offshoreTypeFpso", newValue)
- }
- }
-
- return (
- <EditableCell
- value={value}
- type="checkbox"
- onSave={onSave}
- autoSave={false}
- />
- )
- },
- size: 60,
- },
- {
- accessorKey: "offshoreTypeFlng",
- header: "FLNG",
- cell: ({ row, table }) => {
- const value = row.getValue("offshoreTypeFlng") as boolean
- const onSave = async (newValue: any) => {
- if (table.options.meta?.onCellUpdate) {
- await table.options.meta.onCellUpdate(row.original.id, "offshoreTypeFlng", newValue)
- }
- }
-
- return (
- <EditableCell
- value={value}
- type="checkbox"
- onSave={onSave}
- autoSave={false}
- />
- )
- },
- size: 60,
- },
- {
- accessorKey: "offshoreTypeFpu",
- header: "FPU",
- cell: ({ row, table }) => {
- const value = row.getValue("offshoreTypeFpu") as boolean
- const onSave = async (newValue: any) => {
- if (table.options.meta?.onCellUpdate) {
- await table.options.meta.onCellUpdate(row.original.id, "offshoreTypeFpu", newValue)
- }
- }
-
- return (
- <EditableCell
- value={value}
- type="checkbox"
- onSave={onSave}
- autoSave={false}
- />
- )
- },
- size: 60,
- },
- {
- accessorKey: "offshoreTypePlatform",
- header: "Platform",
- cell: ({ row, table }) => {
- const value = row.getValue("offshoreTypePlatform") as boolean
- const onSave = async (newValue: any) => {
- if (table.options.meta?.onCellUpdate) {
- await table.options.meta.onCellUpdate(row.original.id, "offshoreTypePlatform", newValue)
- }
- }
-
- return (
- <EditableCell
- value={value}
- type="checkbox"
- onSave={onSave}
- autoSave={false}
- />
- )
- },
- size: 60,
- },
- {
- accessorKey: "offshoreTypeWtiv",
- header: "WTIV",
- cell: ({ row, table }) => {
- const value = row.getValue("offshoreTypeWtiv") as boolean
- const onSave = async (newValue: any) => {
- if (table.options.meta?.onCellUpdate) {
- await table.options.meta.onCellUpdate(row.original.id, "offshoreTypeWtiv", newValue)
- }
- }
-
- return (
- <EditableCell
- value={value}
- type="checkbox"
- onSave={onSave}
- autoSave={false}
- />
- )
- },
- size: 60,
- },
- {
- accessorKey: "offshoreTypeGom",
- header: "GOM",
- cell: ({ row, table }) => {
- const value = row.getValue("offshoreTypeGom") as boolean
- const onSave = async (newValue: any) => {
- if (table.options.meta?.onCellUpdate) {
- await table.options.meta.onCellUpdate(row.original.id, "offshoreTypeGom", newValue)
- }
- }
-
- return (
- <EditableCell
- value={value}
- type="checkbox"
- onSave={onSave}
- autoSave={false}
- />
- )
- },
- size: 60,
- },
- {
- accessorKey: "picName",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="협력업체담당자" />
- // 이전에는 컬럼명이 PIC(담당자) 였음.
- ),
- cell: ({ row, table }) => {
- const value = row.getValue("picName")
- const onSave = async (newValue: any) => {
- if (table.options.meta?.onCellUpdate) {
- await table.options.meta.onCellUpdate(row.original.id, "picName", newValue)
- }
- }
-
- return (
- <EditableCell
- value={value}
- type="text"
- onSave={onSave}
- placeholder="입력가능"
- maxLength={50}
- />
- )
- },
- size: 120,
- },
- {
- accessorKey: "picEmail",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="협력업체담당자(E-mail)" />
- // 이전에는 컬럼명이 PIC(E-mail) 였음.
- ),
- cell: ({ row, table }) => {
- const value = row.getValue("picEmail")
- const onSave = async (newValue: any) => {
- if (table.options.meta?.onCellUpdate) {
- await table.options.meta.onCellUpdate(row.original.id, "picEmail", newValue)
- }
- }
- return (
- <EditableCell
- value={value}
- type="text"
- onSave={onSave}
- placeholder="입력가능"
- maxLength={100}
- />
- )
- },
- size: 140,
- },
- {
- accessorKey: "picPhone",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="협력업체담당자(Phone)" />
- // 이전에는 컬럼명이 PIC(Phone) 였음.
- ),
- cell: ({ row, table }) => {
- const value = row.getValue("picPhone")
- const onSave = async (newValue: any) => {
- if (table.options.meta?.onCellUpdate) {
- await table.options.meta.onCellUpdate(row.original.id, "picPhone", newValue)
- }
- }
- return (
- <EditableCell
- value={value}
- type="text"
- onSave={onSave}
- placeholder="입력가능"
- maxLength={20}
- />
- )
- },
- size: 120,
- },
- {
- accessorKey: "agentName",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Agent(담당자)" />
- ),
- cell: ({ row, table }) => {
- const value = row.getValue("agentName")
- const onSave = async (newValue: any) => {
- if (table.options.meta?.onCellUpdate) {
- await table.options.meta.onCellUpdate(row.original.id, "agentName", newValue)
- }
- }
-
- return (
- <EditableCell
- value={value}
- type="text"
- onSave={onSave}
- placeholder="입력가능"
- maxLength={50}
- />
- )
- },
- size: 120,
- },
- {
- accessorKey: "agentEmail",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Agent(E-mail)" />
- ),
- cell: ({ row, table }) => {
- const value = row.getValue("agentEmail")
- const onSave = async (newValue: any) => {
- if (table.options.meta?.onCellUpdate) {
- await table.options.meta.onCellUpdate(row.original.id, "agentEmail", newValue)
- }
- }
-
- return (
- <EditableCell
- value={value}
- type="text"
- onSave={onSave}
- placeholder="입력가능"
- maxLength={100}
- />
- )
- },
- size: 140,
- },
- {
- accessorKey: "agentPhone",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="Agent(Phone)" />
- ),
- cell: ({ row, table }) => {
- const value = row.getValue("agentPhone")
- const onSave = async (newValue: any) => {
- if (table.options.meta?.onCellUpdate) {
- await table.options.meta.onCellUpdate(row.original.id, "agentPhone", newValue)
- }
- }
-
- return (
- <EditableCell
- value={value}
- type="text"
- onSave={onSave}
- placeholder="입력가능"
- maxLength={20}
- />
- )
- },
- size: 120,
- },
{
accessorKey: "recentQuoteDate",
header: ({ column }) => (
@@ -1427,7 +678,7 @@ export const columns: ColumnDef<VendorPoolItem>[] = [
</div>
)
},
- size: 120,
+ size: 200,
},
{
accessorKey: "recentQuoteNumber",
@@ -1442,7 +693,7 @@ export const columns: ColumnDef<VendorPoolItem>[] = [
</div>
)
},
- size: 130,
+ size: 200,
},
{
accessorKey: "recentOrderDate",
@@ -1457,7 +708,7 @@ export const columns: ColumnDef<VendorPoolItem>[] = [
</div>
)
},
- size: 120,
+ size: 150,
},
{
accessorKey: "recentOrderNumber",
@@ -1472,14 +723,14 @@ export const columns: ColumnDef<VendorPoolItem>[] = [
</div>
)
},
- size: 130,
+ size: 200,
},
{
accessorKey: "registrationDate",
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="등록일" />
),
- size: 120,
+ size: 150,
},
{
accessorKey: "registrant",
@@ -1490,14 +741,14 @@ export const columns: ColumnDef<VendorPoolItem>[] = [
const value = row.getValue("registrant") as string
return <div className="text-sm">{value || ""}</div>
},
- size: 100,
+ size: 150,
},
{
accessorKey: "lastModifiedDate",
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="최종변경일" />
),
- size: 120,
+ size: 150,
},
{
accessorKey: "lastModifier",
@@ -1508,7 +759,7 @@ export const columns: ColumnDef<VendorPoolItem>[] = [
const value = row.getValue("lastModifier") as string
return <div className="text-sm">{value || ""}</div>
},
- size: 120,
+ size: 150,
},
// 액션 그룹
{
@@ -1530,7 +781,7 @@ export const columns: ColumnDef<VendorPoolItem>[] = [
onSaveEmptyRow?.(data.id)
}}
title="저장"
- className="bg-green-600 hover:bg-green-700"
+ className="bg-green-600 hover:bg-green-700 whitespace-normal h-auto"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
@@ -1544,7 +795,7 @@ export const columns: ColumnDef<VendorPoolItem>[] = [
onCancelEmptyRow?.(data.id)
}}
title="취소"
- className="border-red-300 text-red-600 hover:bg-red-50"
+ className="border-red-300 text-red-600 hover:bg-red-50 whitespace-normal h-auto"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
@@ -1574,6 +825,6 @@ export const columns: ColumnDef<VendorPoolItem>[] = [
size: 120,
enableSorting: false,
enableHiding: false,
+ enableColumnFilter: false,
},
]
-
diff --git a/lib/vendor-pool/table/vendor-pool-table.tsx b/lib/vendor-pool/table/vendor-pool-table.tsx
index 336c93f5..e41c0fd5 100644
--- a/lib/vendor-pool/table/vendor-pool-table.tsx
+++ b/lib/vendor-pool/table/vendor-pool-table.tsx
@@ -12,7 +12,7 @@ import { DataTable } from "@/components/data-table/data-table"
import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
import { Button } from "@/components/ui/button"
import { toast } from "sonner"
-import { BulkImportDialog } from "./bulk-import-dialog"
+import { BulkInsertDialog } from "./bulk-insert-dialog"
import { columns, type VendorPoolItem } from "./vendor-pool-table-columns"
import { createVendorPool, updateVendorPool, deleteVendorPool } from "../service"
@@ -45,50 +45,22 @@ const createEmptyVendorPoolBase = (): Omit<VendorPool, 'id'> & { id?: string | n
designCategoryCode: "",
designCategory: "",
equipBulkDivision: "",
- packageCode: null,
- packageName: null,
materialGroupCode: null,
materialGroupName: null,
- smCode: null,
similarMaterialNamePurchase: null,
- similarMaterialNameOther: null,
vendorCode: null,
vendorName: "",
taxId: null,
faTarget: false,
faStatus: null,
- faRemark: null,
tier: null,
- isAgent: false,
- contractSignerCode: null,
- contractSignerName: "",
headquarterLocation: "",
manufacturingLocation: "",
avlVendorName: null,
similarVendorName: null,
- hasAvl: false,
isBlacklist: false,
isBcc: false,
purchaseOpinion: null,
- shipTypeCommon: false,
- shipTypeAmax: false,
- shipTypeSmax: false,
- shipTypeVlcc: false,
- shipTypeLngc: false,
- shipTypeCont: false,
- offshoreTypeCommon: false,
- offshoreTypeFpso: false,
- offshoreTypeFlng: false,
- offshoreTypeFpu: false,
- offshoreTypePlatform: false,
- offshoreTypeWtiv: false,
- offshoreTypeGom: false,
- picName: null,
- picEmail: null,
- picPhone: null,
- agentName: null,
- agentEmail: null,
- agentPhone: null,
recentQuoteDate: null,
recentQuoteNumber: null,
recentOrderDate: null,
@@ -111,7 +83,7 @@ export function VendorPoolTable({ data, pageCount, onRefresh }: VendorPoolTableP
const [isCreating, setIsCreating] = React.useState(false)
// 일괄입력 다이얼로그 상태
- const [bulkImportDialogOpen, setBulkImportDialogOpen] = React.useState(false)
+ const [bulkInsertDialogOpen, setBulkInsertDialogOpen] = React.useState(false)
@@ -309,7 +281,7 @@ export function VendorPoolTable({ data, pageCount, onRefresh }: VendorPoolTableP
const finalData = { ...rowData, ...changes }
// 필수 필드 검증 (최종 데이터 기준)
- const requiredFields = ['constructionSector', 'htDivision', 'designCategory', 'vendorName', 'materialGroupCode', 'materialGroupName', 'tier', 'contractSignerName', 'headquarterLocation', 'manufacturingLocation', 'avlVendorName']
+ const requiredFields = ['constructionSector', 'htDivision', 'designCategory', 'vendorName', 'materialGroupCode', 'materialGroupName', 'tier', 'headquarterLocation', 'manufacturingLocation', 'avlVendorName']
// 필드명과 한국어 레이블 매핑
const fieldLabels: Record<string, string> = {
@@ -320,7 +292,6 @@ export function VendorPoolTable({ data, pageCount, onRefresh }: VendorPoolTableP
materialGroupCode: '자재그룹코드',
materialGroupName: '자재그룹명',
tier: '등급(Tier)',
- contractSignerName: '계약서명주체명',
headquarterLocation: '위치(국가)',
manufacturingLocation: '제작/선적지(국가)',
avlVendorName: 'AVL등재업체명'
@@ -436,22 +407,6 @@ export function VendorPoolTable({ data, pageCount, onRefresh }: VendorPoolTableP
]
},
{
- id: "isAgent",
- label: "Agent 여부",
- options: [
- { label: "Agent", value: "true" },
- { label: "일반", value: "false" },
- ]
- },
- {
- id: "hasAvl",
- label: "AVL 존재",
- options: [
- { label: "있음", value: "true" },
- { label: "없음", value: "false" },
- ]
- },
- {
id: "isBlacklist",
label: "Blacklist",
options: [
@@ -500,16 +455,6 @@ export function VendorPoolTable({ data, pageCount, onRefresh }: VendorPoolTableP
type: "text",
},
{
- id: "packageCode",
- label: "패키지 코드",
- type: "text",
- },
- {
- id: "packageName",
- label: "패키지 명",
- type: "text",
- },
- {
id: "materialGroupCode",
label: "자재그룹 코드",
type: "text",
@@ -638,7 +583,7 @@ export function VendorPoolTable({ data, pageCount, onRefresh }: VendorPoolTableP
break
case 'bulk-import':
- setBulkImportDialogOpen(true)
+ setBulkInsertDialogOpen(true)
break
case 'save':
@@ -698,7 +643,7 @@ export function VendorPoolTable({ data, pageCount, onRefresh }: VendorPoolTableP
}, [table, onRefresh])
// 일괄입력 핸들러
- const handleBulkImport = React.useCallback(async (bulkData: Record<string, any>) => {
+ const handleBulkInsert = React.useCallback(async (bulkData: Record<string, any>) => {
const selectedRows = table.getFilteredSelectedRowModel().rows
if (selectedRows.length === 0) {
@@ -720,7 +665,7 @@ export function VendorPoolTable({ data, pageCount, onRefresh }: VendorPoolTableP
}
toast.success(`${selectedRows.length}개 행에 일괄 입력이 적용되었습니다.`)
- setBulkImportDialogOpen(false)
+ setBulkInsertDialogOpen(false)
} catch (error) {
console.error('일괄입력 처리 실패:', error)
toast.error('일괄입력 처리 중 오류가 발생했습니다.')
@@ -821,10 +766,10 @@ export function VendorPoolTable({ data, pageCount, onRefresh }: VendorPoolTableP
</DataTableAdvancedToolbar>
</DataTable>
- <BulkImportDialog
- open={bulkImportDialogOpen}
- onOpenChange={setBulkImportDialogOpen}
- onSubmit={handleBulkImport}
+ <BulkInsertDialog
+ open={bulkInsertDialogOpen}
+ onOpenChange={setBulkInsertDialogOpen}
+ onSubmit={handleBulkInsert}
/>
</>
)
diff --git a/lib/vendor-pool/table/vendor-pool-virtual-table.tsx b/lib/vendor-pool/table/vendor-pool-virtual-table.tsx
new file mode 100644
index 00000000..81ac804f
--- /dev/null
+++ b/lib/vendor-pool/table/vendor-pool-virtual-table.tsx
@@ -0,0 +1,779 @@
+"use client"
+
+import * as React from "react"
+import {
+ useReactTable,
+ getCoreRowModel,
+ getSortedRowModel,
+ getFilteredRowModel,
+ type ColumnDef,
+ type SortingState,
+ type ColumnFiltersState,
+ flexRender,
+ type Column,
+} from "@tanstack/react-table"
+import { useVirtualizer } from "@tanstack/react-virtual"
+import { useSession } from "next-auth/react"
+import { toast } from "sonner"
+import { ChevronDown, ChevronUp, Search, Download, FileSpreadsheet, Upload } from "lucide-react"
+
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { columns, type VendorPoolItem } from "./vendor-pool-table-columns"
+import { createVendorPool, updateVendorPool, deleteVendorPool } from "../service"
+import type { VendorPool } from "@/db/schema/avl/vendor-pool"
+import { BulkInsertDialog } from "./bulk-insert-dialog"
+import { ImportVendorPoolButton } from "./vendor-pool-excel-import-button"
+import { exportVendorPoolToExcel, createVendorPoolTemplate } from "../excel-utils"
+import { ImportResultDialog, type ImportResult } from "./import-result-dialog"
+
+// 테이블 메타 타입
+interface VendorPoolTableMeta {
+ onCellUpdate?: (id: string | number, field: string, newValue: any) => Promise<void>
+ onCellCancel?: (id: string | number, field: string) => void
+ onAction?: (action: string, data?: any) => void
+ onSaveEmptyRow?: (tempId: string) => Promise<void>
+ onCancelEmptyRow?: (tempId: string) => void
+ isEmptyRow?: (id: string) => boolean
+ getPendingChanges?: () => Record<string, Partial<VendorPoolItem>>
+}
+
+interface VendorPoolVirtualTableProps {
+ data: VendorPoolItem[]
+ onRefresh?: () => void
+}
+
+// 빈 행 기본값
+const createEmptyVendorPoolBase = (): Omit<VendorPool, 'id'> & { id?: string | number } => ({
+ constructionSector: "",
+ htDivision: "",
+ discipline: "",
+ equipBulkDivision: "",
+ materialGroupCode: null,
+ materialGroupName: null,
+ similarMaterialNamePurchase: null,
+ vendorCode: null,
+ vendorName: "",
+ taxId: null,
+ faTarget: false,
+ faStatus: null,
+ tier: null,
+ headquarterLocation: "",
+ manufacturingLocation: "",
+ avlVendorName: null,
+ similarVendorName: null,
+ isBlacklist: false,
+ isBcc: false,
+ purchaseOpinion: null,
+ recentQuoteDate: null,
+ recentQuoteNumber: null,
+ recentOrderDate: null,
+ recentOrderNumber: null,
+ registrationDate: null,
+ registrant: null,
+ lastModifiedDate: null,
+ lastModifier: null,
+})
+
+function Filter({ column }: { column: Column<any, unknown> }) {
+ const columnFilterValue = column.getFilterValue()
+ const id = column.id
+
+ // Boolean 필터 (faTarget, isBlacklist, isBcc 등)
+ if (id === 'faTarget' || id === 'isBlacklist' || id === 'isBcc') {
+ return (
+ <div onClick={(e) => e.stopPropagation()} className="mt-2">
+ <Select
+ value={(columnFilterValue as string) ?? "all"}
+ onValueChange={(value) => column.setFilterValue(value === "all" ? undefined : value === "true")}
+ >
+ <SelectTrigger className="h-8 w-full">
+ <SelectValue placeholder="All" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="all">All</SelectItem>
+ <SelectItem value="true">Yes</SelectItem>
+ <SelectItem value="false">No</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ )
+ }
+
+ // FA Status 필터 (O 또는 빈 값)
+ if (id === 'faStatus') {
+ return (
+ <div onClick={(e) => e.stopPropagation()} className="mt-2">
+ <Select
+ value={(columnFilterValue as string) ?? "all"}
+ onValueChange={(value) => column.setFilterValue(value === "all" ? undefined : value)}
+ >
+ <SelectTrigger className="h-8 w-full">
+ <SelectValue placeholder="All" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="all">All</SelectItem>
+ <SelectItem value="O">YES</SelectItem>
+ <SelectItem value="X">NO</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ )
+ }
+
+ // 일반 텍스트 검색
+ return (
+ <div onClick={(e) => e.stopPropagation()} className="mt-2">
+ <Input
+ type="text"
+ value={(columnFilterValue ?? '') as string}
+ onChange={(e) => column.setFilterValue(e.target.value)}
+ placeholder="Search..."
+ className="h-8 w-full font-normal bg-background"
+ />
+ </div>
+ )
+}
+
+export function VendorPoolVirtualTable({ data, onRefresh }: VendorPoolVirtualTableProps) {
+ const { data: session } = useSession()
+
+ // onRefresh를 ref로 관리하여 무한 루프 방지
+ const onRefreshRef = React.useRef(onRefresh)
+ React.useEffect(() => {
+ onRefreshRef.current = onRefresh
+ }, [onRefresh])
+
+ // 상태 관리
+ const [sorting, setSorting] = React.useState<SortingState>([])
+ const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
+ const [globalFilter, setGlobalFilter] = React.useState("")
+ const [pendingChanges, setPendingChanges] = React.useState<Record<string, Partial<VendorPoolItem>>>({})
+ const [isSaving, setIsSaving] = React.useState(false)
+ const [emptyRows, setEmptyRows] = React.useState<Record<string, VendorPoolItem>>({})
+ const [isCreating, setIsCreating] = React.useState(false)
+ const [bulkInsertDialogOpen, setBulkInsertDialogOpen] = React.useState(false)
+ const [importResult, setImportResult] = React.useState<ImportResult | null>(null)
+ const [showImportResultDialog, setShowImportResultDialog] = React.useState(false)
+
+ const handleImportComplete = React.useCallback((result: ImportResult) => {
+ setImportResult(result)
+ setShowImportResultDialog(true)
+ }, [])
+
+ const handleImportDialogClose = React.useCallback((open: boolean) => {
+ setShowImportResultDialog(open)
+ if (!open && importResult && importResult.successCount > 0) {
+ onRefreshRef.current?.()
+ }
+ }, [importResult])
+
+ // 인라인 편집 핸들러
+ const handleCellUpdate = React.useCallback(async (id: string | number, field: string, newValue: any) => {
+ const isEmptyRow = String(id).startsWith('temp-')
+
+ if (isEmptyRow) {
+ setEmptyRows(prev => ({
+ ...prev,
+ [id]: {
+ ...prev[id],
+ [field]: newValue
+ }
+ }))
+ }
+
+ setPendingChanges(prev => ({
+ ...prev,
+ [id]: {
+ ...prev[id],
+ [field]: newValue
+ }
+ }))
+ }, [])
+
+ // 편집 취소 핸들러
+ const handleCellCancel = React.useCallback((id: string | number, field: string) => {
+ const isEmptyRow = String(id).startsWith('temp-')
+
+ if (isEmptyRow) {
+ setEmptyRows(prev => ({
+ ...prev,
+ [id]: {
+ ...prev[id],
+ [field]: prev[id][field]
+ }
+ }))
+
+ setPendingChanges(prev => {
+ const itemChanges = { ...prev[id] }
+ delete itemChanges[field]
+
+ if (Object.keys(itemChanges).length === 0) {
+ const newChanges = { ...prev }
+ delete newChanges[id]
+ return newChanges
+ }
+
+ return {
+ ...prev,
+ [id]: itemChanges
+ }
+ })
+ } else {
+ setPendingChanges(prev => {
+ const itemChanges = { ...prev[id] }
+ delete itemChanges[field]
+
+ if (Object.keys(itemChanges).length === 0) {
+ const newChanges = { ...prev }
+ delete newChanges[id]
+ return newChanges
+ }
+
+ return {
+ ...prev,
+ [id]: itemChanges
+ }
+ })
+ }
+ }, [])
+
+ // 일괄 저장 핸들러
+ const handleBatchSave = React.useCallback(async () => {
+ if (Object.keys(pendingChanges).length === 0) return
+
+ setIsSaving(true)
+ let successCount = 0
+ let errorCount = 0
+ let duplicateErrors: string[] = []
+
+ try {
+ for (const [id, changes] of Object.entries(pendingChanges)) {
+ try {
+ const { id: _, no: __, selected: ___, ...updateData } = changes
+ const updateDataWithModifier: any = {
+ ...updateData,
+ lastModifier: session?.user?.name || null
+ }
+ const result = await updateVendorPool(Number(id), updateDataWithModifier)
+ if (result) {
+ successCount++
+ } else {
+ errorCount++
+ }
+ } catch (error) {
+ console.error(`항목 ${id} 저장 실패:`, error)
+
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ if (errorMessage === 'DUPLICATE_VENDOR_POOL') {
+ const changes = pendingChanges[id]
+ duplicateErrors.push(`항목 ${id}: 공사부문(${changes.constructionSector}), H/T(${changes.htDivision}), 자재그룹코드(${changes.materialGroupCode}), 협력업체명(${changes.vendorName})`)
+ }
+ errorCount++
+ }
+ }
+
+ setPendingChanges({})
+
+ if (successCount > 0) {
+ toast.success(`${successCount}개 항목이 저장되었습니다.`)
+ onRefreshRef.current?.()
+ }
+
+ if (duplicateErrors.length > 0) {
+ duplicateErrors.forEach(errorMsg => {
+ toast.error(`중복된 항목입니다. ${errorMsg}`)
+ })
+ }
+
+ const generalErrorCount = errorCount - duplicateErrors.length
+ if (generalErrorCount > 0) {
+ toast.error(`${generalErrorCount}개 항목 저장에 실패했습니다.`)
+ }
+ } catch (error) {
+ console.error("Batch save error:", error)
+ toast.error("저장 중 오류가 발생했습니다.")
+ } finally {
+ setIsSaving(false)
+ }
+ }, [pendingChanges, session]) // ✅ onRefresh 제거
+
+ // 빈 행 생성
+ const createEmptyRow = React.useCallback(() => {
+ if (isCreating) return
+
+ const tempId = `temp-${Date.now()}`
+ const userName = session?.user?.name || null
+
+ const emptyRow: VendorPoolItem = {
+ ...createEmptyVendorPoolBase(),
+ id: tempId,
+ no: 0,
+ selected: false,
+ registrationDate: "",
+ registrant: userName || "",
+ lastModifiedDate: "",
+ lastModifier: userName || "",
+ } as unknown as VendorPoolItem
+
+ setEmptyRows(prev => ({ ...prev, [tempId]: emptyRow }))
+ setIsCreating(true)
+
+ setPendingChanges(prev => ({
+ ...prev,
+ [tempId]: { ...emptyRow }
+ }))
+ }, [isCreating, session])
+
+ // 빈 행 저장
+ const saveEmptyRow = React.useCallback(async (tempId: string) => {
+ const rowData = emptyRows[tempId]
+ const changes = pendingChanges[tempId]
+
+ if (!rowData || !changes) {
+ console.error('rowData 또는 changes가 없음')
+ return
+ }
+
+ const finalData = { ...rowData, ...changes }
+
+ const requiredFields = ['constructionSector', 'htDivision', 'discipline', 'vendorName', 'materialGroupCode', 'materialGroupName', 'tier', 'headquarterLocation', 'manufacturingLocation', 'avlVendorName']
+
+ const fieldLabels: Record<string, string> = {
+ constructionSector: '공사부문',
+ htDivision: 'H/T구분',
+ discipline: '설계공종',
+ vendorName: '협력업체명',
+ materialGroupCode: '자재그룹코드',
+ materialGroupName: '자재그룹명',
+ tier: '등급(Tier)',
+ headquarterLocation: '위치(국가)',
+ manufacturingLocation: '제작/선적지(국가)',
+ avlVendorName: 'AVL등재업체명'
+ }
+
+ const missingFields = requiredFields.filter(field => {
+ const value = finalData[field as keyof VendorPoolItem]
+ return !value || value === ''
+ })
+
+ if (missingFields.length > 0) {
+ const missingFieldLabels = missingFields.map(field => fieldLabels[field]).join(', ')
+ toast.error(`필수 항목을 입력해주세요: ${missingFieldLabels}`)
+ return
+ }
+
+ try {
+ setIsSaving(true)
+
+ const { id: _, no: __, selected: ___, registrationDate: ____, lastModifiedDate: _____, ...createData } = finalData
+
+ const result = await createVendorPool(createData as any)
+
+ if (result) {
+ toast.success("새 항목이 추가되었습니다.")
+
+ setEmptyRows(prev => {
+ const newRows = { ...prev }
+ delete newRows[tempId]
+ return newRows
+ })
+
+ setPendingChanges(prev => {
+ const newChanges = { ...prev }
+ delete newChanges[tempId]
+ return newChanges
+ })
+
+ setIsCreating(false)
+ onRefreshRef.current?.()
+ }
+ } catch (error) {
+ console.error("빈 행 저장 실패:", error)
+
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ if (errorMessage === 'DUPLICATE_VENDOR_POOL') {
+ toast.error(`중복된 항목입니다. (공사부문: ${finalData.constructionSector}, H/T: ${finalData.htDivision}, 자재그룹코드: ${finalData.materialGroupCode}, 협력업체명: ${finalData.vendorName})`)
+ } else {
+ toast.error("저장 중 오류가 발생했습니다.")
+ }
+ } finally {
+ setIsSaving(false)
+ }
+ }, [emptyRows, pendingChanges]) // ✅ onRefresh 제거
+
+ // 빈 행 취소
+ const cancelEmptyRow = React.useCallback((tempId: string) => {
+ setEmptyRows(prev => {
+ const newRows = { ...prev }
+ delete newRows[tempId]
+ return newRows
+ })
+
+ setPendingChanges(prev => {
+ const newChanges = { ...prev }
+ delete newChanges[tempId]
+ return newChanges
+ })
+
+ setIsCreating(false)
+ toast.info("새 항목 추가가 취소되었습니다.")
+ }, [])
+
+ // 데이터 병합 (빈 행 + 기존 데이터)
+ const combinedData = React.useMemo(() => {
+ const emptyRowList = Object.values(emptyRows)
+
+ const updatedEmptyRows = emptyRowList.map((row, index) => ({
+ ...row,
+ no: -(emptyRowList.length - index)
+ }))
+
+ // 최적화: 변경사항이 없으면 기존 객체 재사용
+ const updatedExistingData = data.map((row) => {
+ const rowId = String(row.id)
+ const pendingChange = pendingChanges[rowId]
+
+ if (pendingChange) {
+ return { ...row, ...pendingChange }
+ }
+
+ return row
+ })
+
+ return [...updatedEmptyRows, ...updatedExistingData]
+ }, [data, emptyRows, pendingChanges])
+
+ // 액션 핸들러
+ const handleAction = React.useCallback(async (action: string, data?: any) => {
+ try {
+ switch (action) {
+ case 'new-registration':
+ createEmptyRow()
+ break
+
+ case 'bulk-import':
+ setBulkInsertDialogOpen(true)
+ break
+
+ case 'excel-export':
+ try {
+ await exportVendorPoolToExcel(
+ combinedData,
+ `vendor-pool-${new Date().toISOString().split('T')[0]}.xlsx`,
+ true
+ )
+ toast.success('Excel 파일이 다운로드되었습니다.')
+ } catch (error) {
+ console.error('Excel export 실패:', error)
+ toast.error('Excel 내보내기에 실패했습니다.')
+ }
+ break
+
+ case 'excel-template':
+ try {
+ await createVendorPoolTemplate(
+ `vendor-pool-template-${new Date().toISOString().split('T')[0]}.xlsx`
+ )
+ toast.success('Excel 템플릿이 다운로드되었습니다.')
+ } catch (error) {
+ console.error('Excel template export 실패:', error)
+ toast.error('Excel 템플릿 다운로드에 실패했습니다.')
+ }
+ break
+
+ case 'delete':
+ if (data?.id && confirm('정말 삭제하시겠습니까?')) {
+ const success = await deleteVendorPool(Number(data.id))
+ if (success) {
+ toast.success('삭제가 완료되었습니다.')
+ onRefreshRef.current?.()
+ } else {
+ toast.error('삭제에 실패했습니다.')
+ }
+ }
+ break
+
+ default:
+ console.log('알 수 없는 액션:', action)
+ toast.error('알 수 없는 액션입니다.')
+ }
+ } catch (error) {
+ console.error('액션 처리 실패:', error)
+ toast.error('액션 처리 중 오류가 발생했습니다.')
+ }
+ }, [createEmptyRow, combinedData]) // ✅ onRefresh 제거, combinedData 추가
+
+ // 테이블 메타
+ const tableMeta: VendorPoolTableMeta = {
+ onAction: handleAction,
+ onCellUpdate: handleCellUpdate,
+ onCellCancel: handleCellCancel,
+ onSaveEmptyRow: saveEmptyRow,
+ onCancelEmptyRow: cancelEmptyRow,
+ isEmptyRow: (id: string) => String(id).startsWith('temp-'),
+ getPendingChanges: () => pendingChanges
+ }
+
+ // TanStack Table 설정
+ const table = useReactTable({
+ data: combinedData,
+ columns,
+ state: {
+ sorting,
+ columnFilters,
+ globalFilter,
+ },
+ onSortingChange: setSorting,
+ onColumnFiltersChange: setColumnFilters,
+ onGlobalFilterChange: setGlobalFilter,
+ columnResizeMode: "onChange",
+ getCoreRowModel: getCoreRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ getRowId: (originalRow) => String(originalRow.id),
+ meta: tableMeta,
+ })
+
+ // 일괄입력 핸들러
+ const handleBulkInsert = React.useCallback(async (bulkData: Record<string, any>) => {
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+
+ if (selectedRows.length === 0) {
+ toast.error('일괄 입력할 행이 선택되지 않았습니다.')
+ return
+ }
+
+ try {
+ for (const row of selectedRows) {
+ const rowId = String(row.original.id)
+
+ Object.entries(bulkData).forEach(([field, value]) => {
+ if (value !== undefined && value !== null && value !== '') {
+ handleCellUpdate(rowId, field as keyof VendorPool, value)
+ }
+ })
+ }
+
+ toast.success(`${selectedRows.length}개 행에 일괄 입력이 적용되었습니다.`)
+ setBulkInsertDialogOpen(false)
+ } catch (error) {
+ console.error('일괄입력 처리 실패:', error)
+ toast.error('일괄입력 처리 중 오류가 발생했습니다.')
+ }
+ }, [table, handleCellUpdate]) // table dependency 추가
+
+ // Virtual Scrolling 설정
+ const tableContainerRef = React.useRef<HTMLDivElement>(null)
+
+ const { rows } = table.getRowModel()
+
+ const rowVirtualizer = useVirtualizer({
+ count: rows.length,
+ getScrollElement: () => tableContainerRef.current,
+ estimateSize: () => 50, // 행 높이 추정값
+ overscan: 10, // 화면 밖 렌더링할 행 수
+ })
+
+ const virtualRows = rowVirtualizer.getVirtualItems()
+ const totalSize = rowVirtualizer.getTotalSize()
+
+ const paddingTop = virtualRows.length > 0 ? virtualRows?.[0]?.start || 0 : 0
+ const paddingBottom = virtualRows.length > 0
+ ? totalSize - (virtualRows?.[virtualRows.length - 1]?.end || 0)
+ : 0
+
+ const hasPendingChanges = Object.keys(pendingChanges).length > 0
+
+ return (
+ <div className="flex flex-col flex-1 min-h-0 space-y-4">
+ {/* 툴바 */}
+ <div className="flex items-center justify-between gap-4">
+ <div className="flex items-center gap-2 flex-1">
+ <div className="relative flex-1 max-w-sm">
+ <Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
+ <Input
+ placeholder="전체 검색..."
+ value={globalFilter ?? ""}
+ onChange={(e) => setGlobalFilter(e.target.value)}
+ className="pl-8"
+ />
+ </div>
+ <div className="text-sm text-muted-foreground">
+ 전체 {combinedData.length}건 중 {rows.length}건 표시
+ </div>
+ </div>
+
+ <div className="flex items-center gap-2">
+ <Button
+ onClick={() => handleAction('new-registration')}
+ disabled={isCreating}
+ variant="outline"
+ size="sm"
+ >
+ 신규등록
+ </Button>
+
+ <Button
+ onClick={() => handleAction('bulk-import')}
+ variant="outline"
+ size="sm"
+ >
+ 일괄입력
+ </Button>
+
+ <ImportVendorPoolButton onImportComplete={handleImportComplete} />
+
+ <Button
+ onClick={() => handleAction('excel-export')}
+ variant="outline"
+ size="sm"
+ >
+ <Download className="mr-2 h-4 w-4" />
+ Excel Export
+ </Button>
+
+ <Button
+ onClick={() => handleAction('excel-template')}
+ variant="outline"
+ size="sm"
+ >
+ <FileSpreadsheet className="mr-2 h-4 w-4" />
+ Template
+ </Button>
+
+ <Button
+ onClick={handleBatchSave}
+ disabled={!hasPendingChanges || isSaving}
+ variant={hasPendingChanges && !isSaving ? "default" : "outline"}
+ size="sm"
+ >
+ {isSaving ? "저장 중..." : `저장${hasPendingChanges ? ` (${Object.keys(pendingChanges).length})` : ""}`}
+ </Button>
+ </div>
+ </div>
+
+ {/* 테이블 */}
+ <div
+ ref={tableContainerRef}
+ className="relative flex-1 overflow-auto border rounded-md"
+ >
+ <table
+ className="table-fixed border-collapse"
+ style={{ width: table.getTotalSize() }}
+ >
+ <thead className="sticky top-0 z-10 bg-muted">
+ {table.getHeaderGroups().map((headerGroup) => (
+ <tr key={headerGroup.id}>
+ {headerGroup.headers.map((header) => (
+ <th
+ key={header.id}
+ className="border-b px-4 py-2 text-left text-sm font-medium relative group"
+ style={{ width: header.getSize() }}
+ >
+ {header.isPlaceholder ? null : (
+ <>
+ <div
+ className={
+ header.column.getCanSort()
+ ? "flex items-center gap-2 cursor-pointer select-none"
+ : ""
+ }
+ onClick={header.column.getToggleSortingHandler()}
+ >
+ {flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+ {header.column.getCanSort() && (
+ <div className="flex flex-col">
+ {header.column.getIsSorted() === "asc" ? (
+ <ChevronUp className="h-4 w-4" />
+ ) : header.column.getIsSorted() === "desc" ? (
+ <ChevronDown className="h-4 w-4" />
+ ) : (
+ <div className="h-4 w-4" />
+ )}
+ </div>
+ )}
+ </div>
+ {header.column.getCanFilter() && (
+ <Filter column={header.column} />
+ )}
+ <div
+ onMouseDown={header.getResizeHandler()}
+ onTouchStart={header.getResizeHandler()}
+ className={`absolute right-0 top-0 h-full w-1 cursor-col-resize select-none touch-none hover:bg-primary/50 ${
+ header.column.getIsResizing() ? 'bg-primary' : 'bg-transparent'
+ }`}
+ />
+ </>
+ )}
+ </th>
+ ))}
+ </tr>
+ ))}
+ </thead>
+ <tbody>
+ {paddingTop > 0 && (
+ <tr>
+ <td style={{ height: `${paddingTop}px` }} />
+ </tr>
+ )}
+ {virtualRows.map((virtualRow) => {
+ const row = rows[virtualRow.index]
+ const isEmptyRow = String(row.original.id).startsWith('temp-')
+
+ return (
+ <tr
+ key={row.id}
+ data-index={virtualRow.index}
+ ref={rowVirtualizer.measureElement}
+ data-row-id={row.id}
+ className={isEmptyRow ? "bg-blue-50 border-blue-200" : "hover:bg-muted/50"}
+ >
+ {row.getVisibleCells().map((cell) => (
+ <td
+ key={cell.id}
+ className="border-b px-4 py-2 text-sm whitespace-normal break-words"
+ style={{ width: cell.column.getSize() }}
+ >
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )}
+ </td>
+ ))}
+ </tr>
+ )
+ })}
+ {paddingBottom > 0 && (
+ <tr>
+ <td style={{ height: `${paddingBottom}px` }} />
+ </tr>
+ )}
+ </tbody>
+ </table>
+ </div>
+
+ <BulkInsertDialog
+ open={bulkInsertDialogOpen}
+ onOpenChange={setBulkInsertDialogOpen}
+ onSubmit={handleBulkInsert}
+ />
+
+ <ImportResultDialog
+ open={showImportResultDialog}
+ onOpenChange={handleImportDialogClose}
+ result={importResult}
+ />
+ </div>
+ )
+}
diff --git a/lib/vendor-pool/types.ts b/lib/vendor-pool/types.ts
index 8a6e9881..56ae2bb3 100644
--- a/lib/vendor-pool/types.ts
+++ b/lib/vendor-pool/types.ts
@@ -12,37 +12,25 @@ export interface VendorPool {
htDivision: string // H/T구분: H 또는 T
// 설계 정보
- designCategoryCode: string // 설계기능(공종) 코드: 2자리 영문대문자
- designCategory: string // 설계기능(공종): 전장 등
- equipBulkDivision: string // Equip/Bulk 구분: E 또는 B
-
- // 패키지 정보
- packageCode: string
- packageName: string
+ discipline: string // 설계공종 (ARCHITECTURE 등)
+ equipBulkDivision: string | null // Equip/Bulk 구분: E 또는 B
// 자재그룹 정보
materialGroupCode: string
materialGroupName: string
// 자재 관련 정보
- smCode: string
similarMaterialNamePurchase: string // 유사자재명 (구매)
- similarMaterialNameOther: string // 유사자재명 (구매 외)
// 협력업체 정보
vendorCode: string
vendorName: string
+ taxId: string // 사업자번호
// 사업 및 인증 정보
faTarget: boolean // FA대상
faStatus: string // FA현황
- faRemark: string // FA상세
tier: string // 등급
- isAgent: boolean // Agent 여부
-
- // 계약 정보
- contractSignerCode: string
- contractSignerName: string
// 위치 정보
headquarterLocation: string // 본사 위치 (국가)
@@ -51,38 +39,12 @@ export interface VendorPool {
// AVL 관련 정보
avlVendorName: string // AVL 등재업체명
similarVendorName: string // 유사업체명(기술영업)
- hasAvl: boolean // AVL 존재여부
// 상태 정보
isBlacklist: boolean // Blacklist
isBcc: boolean // BCC
purchaseOpinion: string // 구매의견
- // AVL 적용 선종(조선)
- shipTypeCommon: boolean // 공통
- shipTypeAmax: boolean // A-max
- shipTypeSmax: boolean // S-max
- shipTypeVlcc: boolean // VLCC
- shipTypeLngc: boolean // LNGC
- shipTypeCont: boolean // CONT
-
- // AVL 적용 선종(해양)
- offshoreTypeCommon: boolean // 공통
- offshoreTypeFpso: boolean // FPSO
- offshoreTypeFlng: boolean // FLNG
- offshoreTypeFpu: boolean // FPU
- offshoreTypePlatform: boolean // Platform
- offshoreTypeWtiv: boolean // WTIV
- offshoreTypeGom: boolean // GOM
-
- // eVCP 미등록 정보
- picName: string // PIC(담당자)
- picEmail: string // PIC(E-mail)
- picPhone: string // PIC(Phone)
- agentName: string // Agent(담당자)
- agentEmail: string // Agent(E-mail)
- agentPhone: string // Agent(Phone)
-
// 업체 실적 현황
recentQuoteDate: string // 최근견적일
recentQuoteNumber: string // 최근견적번호
diff --git a/lib/vendor-pool/validations.ts b/lib/vendor-pool/validations.ts
index 60294edb..831e9299 100644
--- a/lib/vendor-pool/validations.ts
+++ b/lib/vendor-pool/validations.ts
@@ -24,22 +24,15 @@ export const vendorPoolSearchParamsCache = createSearchParamsCache({
]),
// Vendor Pool 관련 필드
- constructionSector: parseAsStringEnum(["조선", "해양"]).withDefault(""), // 공사부문
- htDivision: parseAsStringEnum(["H", "T"]).withDefault(""), // H/T구분
- designCategoryCode: parseAsString.withDefault(""), // 설계기능코드
- designCategory: parseAsString.withDefault(""), // 설계기능(공종)
- equipBulkDivision: parseAsStringEnum(["E", "B"]).withDefault(""), // Equip/Bulk 구분
+ constructionSector: parseAsString.withDefault(""), // 공사부문
+ htDivision: parseAsString.withDefault(""), // H/T구분
+ discipline: parseAsString.withDefault(""), // 설계공종
+ equipBulkDivision: parseAsString.withDefault(""), // Equip/Bulk 구분
- // 패키지/자재 정보
- packageCode: parseAsString.withDefault(""), // 패키지 코드
- packageName: parseAsString.withDefault(""), // 패키지 명
+ // 자재 정보
materialGroupCode: parseAsString.withDefault(""), // 자재그룹 코드
materialGroupName: parseAsString.withDefault(""), // 자재그룹 명
- smCode: parseAsString.withDefault(""), // SM Code
-
- // 유사자재명
similarMaterialNamePurchase: parseAsString.withDefault(""), // 유사자재명 (구매)
- similarMaterialNameOther: parseAsString.withDefault(""), // 유사자재명 (구매 외)
// 협력업체 정보
vendorCode: parseAsString.withDefault(""), // 협력업체 코드
@@ -48,11 +41,6 @@ export const vendorPoolSearchParamsCache = createSearchParamsCache({
// 인증/상태 정보
faStatus: parseAsString.withDefault(""), // FA현황
tier: parseAsString.withDefault(""), // 등급
- isAgent: parseAsStringEnum(["true", "false"]).withDefault(""), // Agent 여부
-
- // 계약 정보
- contractSignerCode: parseAsString.withDefault(""), // 계약서명주체 코드
- contractSignerName: parseAsString.withDefault(""), // 계약서명주체 명
// 위치 정보
headquarterLocation: parseAsString.withDefault(""), // 본사 위치
@@ -61,17 +49,10 @@ export const vendorPoolSearchParamsCache = createSearchParamsCache({
// AVL 정보
avlVendorName: parseAsString.withDefault(""), // AVL 등재업체명
similarVendorName: parseAsString.withDefault(""), // 유사업체명
- hasAvl: parseAsStringEnum(["true", "false"]).withDefault(""), // AVL 존재여부
// 상태 정보
- isBlacklist: parseAsStringEnum(["true", "false"]).withDefault(""), // Blacklist
- isBcc: parseAsStringEnum(["true", "false"]).withDefault(""), // BCC
-
- // eVCP 미등록 정보
- picName: parseAsString.withDefault(""), // PIC(담당자)
- picEmail: parseAsString.withDefault(""), // PIC(E-mail)
- agentName: parseAsString.withDefault(""), // Agent(담당자)
- agentEmail: parseAsString.withDefault(""), // Agent(E-mail)
+ isBlacklist: parseAsString.withDefault(""), // Blacklist
+ isBcc: parseAsString.withDefault(""), // BCC
// 실적 정보
recentQuoteNumber: parseAsString.withDefault(""), // 최근견적번호