diff options
27 files changed, 2346 insertions, 1974 deletions
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/avl/[id]/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/avl/[id]/page.tsx index b065919f..c0230013 100644 --- a/app/[lng]/evcp/(evcp)/(procurement)/avl/[id]/page.tsx +++ b/app/[lng]/evcp/(evcp)/(procurement)/avl/[id]/page.tsx @@ -4,9 +4,9 @@ import { notFound } from "next/navigation" import { getValidFilters } from "@/lib/data-table" import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" -import { getAvlLists, getAvlDetail } from "@/lib/avl/service" +import { getAllAvlDetail } from "@/lib/avl/service" import { avlDetailSearchParamsCache } from "@/lib/avl/validations" -import { AvlDetailTable } from "@/lib/avl/table/avl-detail-table" +import { AvlDetailVirtualTable } from "@/lib/avl/table/avl-detail-virtual-table" import { getAvlListById } from "@/lib/avl/service" import { getAllProjectInfoByProjectCode as getProjectInfoFromBiddingProjects } from "@/lib/bidding-projects/service" import { getAllProjectInfoByProjectCode as getProjectInfoFromProjects } from "@/lib/projects/service" @@ -36,21 +36,17 @@ export default async function AvlDetailPage(props: AvlDetailPageProps) { } // 프로젝트 테이블 먼저 - let projectInfo = await getProjectInfoFromProjects(avlListInfo.projectCode || '') + let projectInfo: any = await getProjectInfoFromProjects(avlListInfo.projectCode || '') // 없으면 견적프로젝트 테이블 조회 if (!projectInfo) { projectInfo = await getProjectInfoFromBiddingProjects(avlListInfo.projectCode || '') } // 배열로 오니 첫번째것만 - projectInfo = projectInfo[0] + projectInfo = projectInfo[0] as any const promises = Promise.all([ - getAvlDetail({ - ...search, - filters: validFilters, - avlListId: numericId, - }), + getAllAvlDetail(numericId), ]) return ( @@ -108,9 +104,8 @@ function AvlDetailTableWrapper({ const shipOwnerName = avlListInfo.shipOwnerName || undefined return ( - <AvlDetailTable + <AvlDetailVirtualTable data={data} - pageCount={pageCount} avlListId={avlListId} avlType={avlType} projectInfo={projectInfo} diff --git a/app/[lng]/evcp/(evcp)/(procurement)/vendor-pool/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/vendor-pool/page.tsx index 7426e069..f18716a3 100644 --- a/app/[lng]/evcp/(evcp)/(procurement)/vendor-pool/page.tsx +++ b/app/[lng]/evcp/(evcp)/(procurement)/vendor-pool/page.tsx @@ -1,25 +1,52 @@ "use client" import * as React from "react" -import { type SearchParams } from "@/types/table" - -import { getValidFilters } from "@/lib/data-table" -import { Skeleton } from "@/components/ui/skeleton" -import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" import { Shell } from "@/components/shell" -import { getVendorPools } from "@/lib/vendor-pool/service" -import { vendorPoolSearchParamsCache } from "@/lib/vendor-pool/validations" -import { VendorPoolTable } from "@/lib/vendor-pool/table/vendor-pool-table" import { InformationButton } from "@/components/information/information-button" -import { useSearchParams } from "next/navigation" +import { VendorPoolVirtualTable } from "@/lib/vendor-pool/table/vendor-pool-virtual-table" +import { Skeleton } from "@/components/ui/skeleton" +import type { VendorPoolItem } from "@/lib/vendor-pool/table/vendor-pool-table-columns" +import { toast } from "sonner" -interface VendorPoolPageProps { - searchParams: Promise<SearchParams> -} +export default function VendorPoolPage() { + const [data, setData] = React.useState<VendorPoolItem[]>([]) + const [isLoading, setIsLoading] = React.useState(true) + + // 전체 데이터 로드 + const loadData = React.useCallback(async () => { + setIsLoading(true) + try { + const response = await fetch('/api/vendor-pool/all') + if (!response.ok) { + throw new Error('Failed to fetch data') + } + const result = await response.json() + setData(result) + } catch (error) { + console.error('Failed to load vendor pool data:', error) + toast.error('데이터를 불러오는데 실패했습니다.') + } finally { + setIsLoading(false) + } + }, []) + + // 초기 로드 + React.useEffect(() => { + loadData() + }, []) // ✅ 빈 배열로 변경 - 마운트시에만 실행 + + // 새로고침 핸들러 - useRef를 사용하여 안정적인 참조 유지 + const loadDataRef = React.useRef(loadData) + React.useEffect(() => { + loadDataRef.current = loadData + }, [loadData]) + + const handleRefresh = React.useCallback(() => { + loadDataRef.current() + }, []) // ✅ 빈 배열로 변경 - 함수 재생성 방지 -export default function VendorPoolPage({ searchParams }: VendorPoolPageProps) { return ( - <Shell className="gap-2"> + <Shell variant="fullscreen" className="gap-2 h-[calc(100vh-150px)]"> <div className="flex items-center justify-between space-y-2"> <div className="flex items-center justify-between space-y-2"> <div> @@ -33,136 +60,14 @@ export default function VendorPoolPage({ searchParams }: VendorPoolPageProps) { </div> </div> - <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> - </React.Suspense> - <React.Suspense - fallback={ - <DataTableSkeleton - columnCount={30} - searchableColumnCount={1} - filterableColumnCount={5} - cellWidths={[ - "50px", "60px", "100px", "80px", "120px", "120px", "120px", "120px", - "120px", "100px", "140px", "140px", "130px", "120px", "80px", "100px", - "120px", "80px", "100px", "120px", "100px", "110px", "140px", "130px", - "60px", "100px", "80px", "120px", "80px", "80px" - ]} - shrinkZero - /> - } - > - <VendorPoolTableWrapperClient searchParamsPromise={searchParams} /> - </React.Suspense> + {isLoading ? ( + <div className="space-y-4 flex-1 flex flex-col"> + <Skeleton className="h-10 w-full" /> + <Skeleton className="h-full w-full flex-1" /> + </div> + ) : ( + <VendorPoolVirtualTable data={data} onRefresh={handleRefresh} /> + )} </Shell> ) } - -// 클라이언트 컴포넌트: 필터 변경을 감시하여 데이터 재조회 -function VendorPoolTableWrapperClient({ searchParamsPromise }: { searchParamsPromise: Promise<SearchParams> }) { - const searchParams = useSearchParams() - const [initialData, setInitialData] = React.useState<{ data: any[], pageCount: number } | null>(null) - const [data, setData] = React.useState<any[]>([]) - const [pageCount, setPageCount] = React.useState(0) - const [isLoading, setIsLoading] = React.useState(false) - - // 초기 데이터 로딩 - React.useEffect(() => { - const loadInitialData = async () => { - try { - const searchParamsData = await searchParamsPromise - const search = vendorPoolSearchParamsCache.parse(searchParamsData) - const validFilters = getValidFilters(search.filters) - - const result = await getVendorPools({ - ...search, - filters: validFilters, - }) - - setInitialData(result) - setData(result.data) - setPageCount(result.pageCount) - } catch (error) { - console.error('Failed to load initial data:', error) - } - } - loadInitialData() - }, [searchParamsPromise]) - - // 필터 상태 변경 감시 및 데이터 재조회 - React.useEffect(() => { - if (!initialData) return // 초기 데이터가 로드되기 전까지는 실행하지 않음 - - const refreshData = async () => { - setIsLoading(true) - try { - const currentParams = Object.fromEntries(searchParams.entries()) - const search = vendorPoolSearchParamsCache.parse(currentParams) - const validFilters = getValidFilters(search.filters) - - const result = await getVendorPools({ - ...search, - filters: validFilters, - }) - - setData(result.data) - setPageCount(result.pageCount) - } catch (error) { - console.error('Failed to refresh vendor pool data:', error) - } finally { - setIsLoading(false) - } - } - - // 필터 파라미터가 변경될 때마다 데이터 재조회 - const currentFilters = searchParams.get('filters') - const currentJoinOperator = searchParams.get('joinOperator') - const currentSearch = searchParams.get('search') - const currentPage = searchParams.get('page') - const currentPerPage = searchParams.get('perPage') - const currentSort = searchParams.get('sort') - - // 필터 관련 파라미터가 변경되면 재조회 - if (currentFilters !== '[]' || currentJoinOperator || currentSearch || currentPage || currentPerPage || currentSort) { - refreshData() - } else { - // 필터가 초기 상태면 초기 데이터로 복원 - setData(initialData.data) - setPageCount(initialData.pageCount) - } - }, [searchParams, initialData]) - - const handleRefresh = React.useCallback(async () => { - if (!initialData) return - - setIsLoading(true) - try { - const currentParams = Object.fromEntries(searchParams.entries()) - const search = vendorPoolSearchParamsCache.parse(currentParams) - const validFilters = getValidFilters(search.filters) - - const result = await getVendorPools({ - ...search, - filters: validFilters, - }) - - setData(result.data) - setPageCount(result.pageCount) - } catch (error) { - console.error('Failed to refresh vendor pool data:', error) - } finally { - setIsLoading(false) - } - }, [searchParams, initialData]) - - if (!initialData) { - return null // 초기 데이터 로딩 중 - } - - return ( - <VendorPoolTable - data={data} - pageCount={pageCount} - onRefresh={handleRefresh} - /> - ) -} diff --git a/app/api/vendor-pool/all/route.ts b/app/api/vendor-pool/all/route.ts new file mode 100644 index 00000000..c21461b8 --- /dev/null +++ b/app/api/vendor-pool/all/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from 'next/server'; +import { getAllVendorPools } from '@/lib/vendor-pool/service'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + try { + const data = await getAllVendorPools(); + return NextResponse.json(data); + } catch (error) { + console.error('Failed to fetch vendor pools:', error); + return NextResponse.json({ error: 'Failed to fetch data' }, { status: 500 }); + } +} + diff --git a/components/common/discipline-hardcoded/discipline-data.ts b/components/common/discipline-hardcoded/discipline-data.ts new file mode 100644 index 00000000..4910e272 --- /dev/null +++ b/components/common/discipline-hardcoded/discipline-data.ts @@ -0,0 +1,14 @@ +export const HARDCODED_DISCIPLINES = [ + 'ARCHITECTURE', + 'CCS', + 'ELECTRICAL', + 'INSTRUMENT', + 'INSULATION', + 'MACHINERY', + 'MECHANICAL', + 'PIPING', + 'STRUCTURE', + 'SURFACE PROTECTION', +] as const + +export type HardcodedDiscipline = typeof HARDCODED_DISCIPLINES[number] diff --git a/components/common/discipline-hardcoded/discipline-hardcoded-selector.tsx b/components/common/discipline-hardcoded/discipline-hardcoded-selector.tsx new file mode 100644 index 00000000..6de0a285 --- /dev/null +++ b/components/common/discipline-hardcoded/discipline-hardcoded-selector.tsx @@ -0,0 +1,48 @@ +'use client' + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { HARDCODED_DISCIPLINES, HardcodedDiscipline } from './discipline-data' + +export interface DisciplineHardcodedSelectorProps { + selectedDiscipline?: string + onDisciplineSelect: (discipline: string) => void + disabled?: boolean + placeholder?: string + className?: string +} + +export function DisciplineHardcodedSelector({ + selectedDiscipline, + onDisciplineSelect, + disabled, + placeholder = "설계공종 선택", + className +}: DisciplineHardcodedSelectorProps) { + + return ( + <Select + value={selectedDiscipline} + onValueChange={onDisciplineSelect} + disabled={disabled} + > + <SelectTrigger className={`w-full ${className || ''}`}> + <SelectValue placeholder={placeholder}> + {selectedDiscipline || <span className="text-muted-foreground">{placeholder}</span>} + </SelectValue> + </SelectTrigger> + <SelectContent> + {HARDCODED_DISCIPLINES.map((discipline) => ( + <SelectItem key={discipline} value={discipline}> + {discipline} + </SelectItem> + ))} + </SelectContent> + </Select> + ) +} diff --git a/components/common/discipline-hardcoded/index.ts b/components/common/discipline-hardcoded/index.ts new file mode 100644 index 00000000..bd55175f --- /dev/null +++ b/components/common/discipline-hardcoded/index.ts @@ -0,0 +1,3 @@ +export * from './discipline-data' +export * from './discipline-hardcoded-selector' + diff --git a/components/common/material/material-group-selector-dialog-single.tsx b/components/common/material/material-group-selector-dialog-single.tsx index bb039d0a..1aeaec33 100644 --- a/components/common/material/material-group-selector-dialog-single.tsx +++ b/components/common/material/material-group-selector-dialog-single.tsx @@ -130,9 +130,9 @@ export function MaterialGroupSelectorDialogSingle({ return ( <Dialog open={open} onOpenChange={handleOpenChange}> <DialogTrigger asChild> - <Button variant={triggerVariant} disabled={disabled}> + <Button variant={triggerVariant} disabled={disabled} className="h-auto whitespace-normal"> {selectedMaterial ? ( - <span className="truncate"> + <span className="whitespace-normal text-left break-words"> {selectedMaterial.displayText} </span> ) : ( diff --git a/components/common/selectors/place-of-shipping/place-of-shipping-selector.tsx b/components/common/selectors/place-of-shipping/place-of-shipping-selector.tsx index 2e9756a0..1d1aaa5e 100644 --- a/components/common/selectors/place-of-shipping/place-of-shipping-selector.tsx +++ b/components/common/selectors/place-of-shipping/place-of-shipping-selector.tsx @@ -236,27 +236,30 @@ export function PlaceOfShippingSelectorDialogSingle({ }, []) useEffect(() => { - const loadData = async () => { - try { - const data = await getPlaceOfShippingForSelection() - setPlaceOfShippingData(data) - } catch (error) { - console.error('선적지/하역지 데이터 로드 실패:', error) - setPlaceOfShippingData([]) - } finally { - setIsLoading(false) + if (open && placeOfShippingData.length === 0) { + const loadData = async () => { + setIsLoading(true) + try { + const data = await getPlaceOfShippingForSelection() + setPlaceOfShippingData(data) + } catch (error) { + console.error('선적지/하역지 데이터 로드 실패:', error) + setPlaceOfShippingData([]) + } finally { + setIsLoading(false) + } } - } - loadData() - }, []) + loadData() + } + }, [open, placeOfShippingData.length]) return ( <Dialog open={open} onOpenChange={handleOpenChange}> <DialogTrigger asChild> - <Button variant={triggerVariant} disabled={disabled}> + <Button variant={triggerVariant} disabled={disabled} className="h-auto whitespace-normal"> {selectedPlace ? ( - <span className="truncate"> + <span className="whitespace-normal text-left break-words"> {selectedPlace.code} - {selectedPlace.description} </span> ) : ( diff --git a/components/common/vendor/vendor-selector-dialog-single.tsx b/components/common/vendor/vendor-selector-dialog-single.tsx index 7bb4b14c..ba4243cf 100644 --- a/components/common/vendor/vendor-selector-dialog-single.tsx +++ b/components/common/vendor/vendor-selector-dialog-single.tsx @@ -135,9 +135,9 @@ export function VendorSelectorDialogSingle({ return ( <Dialog open={open} onOpenChange={handleOpenChange}> <DialogTrigger asChild> - <Button variant={triggerVariant} disabled={disabled}> + <Button variant={triggerVariant} disabled={disabled} className="h-auto whitespace-normal"> {selectedVendor ? ( - <span className="truncate"> + <span className="whitespace-normal text-left break-words"> {selectedVendor.displayText} </span> ) : ( diff --git a/components/data-table/editable-cell.tsx b/components/data-table/editable-cell.tsx index 05f5c4cb..aa43606e 100644 --- a/components/data-table/editable-cell.tsx +++ b/components/data-table/editable-cell.tsx @@ -59,13 +59,6 @@ export function EditableCell<T = any>({ const [isEditing, setIsEditing] = React.useState(initialEditMode) const [editValue, setEditValue] = React.useState<T>(value) const [error, setError] = React.useState<string | null>(null) - const handleStartEdit = useCallback((e: React.MouseEvent) => { - e.stopPropagation() - if (disabled) return - setIsEditing(true) - setEditValue(value) - setError(null) - }, [disabled, value]) const handleFinishEdit = useCallback((overrideValue?: T) => { const currentValue = overrideValue !== undefined ? overrideValue : editValue @@ -88,6 +81,35 @@ export function EditableCell<T = any>({ setError(null) }, [editValue, validation, value, onSave]) + const handleCheckboxChange = useCallback((checked: boolean) => { + const convertedValue = checked as T + setEditValue(convertedValue) + if (autoSave) { + // 체크박스 변경 시 자동 저장 - 값 직접 전달 + handleFinishEdit(convertedValue) + } else { + // 일괄 저장 모드에서는 실시간 표시를 위해 즉시 onSave 호출 + onSave(convertedValue) + } + }, [autoSave, handleFinishEdit, onSave]) + + const handleStartEdit = useCallback((e: React.MouseEvent) => { + e.stopPropagation() + if (disabled) return + + // 체크박스의 경우 즉시 토글 + if (type === "checkbox") { + // T가 boolean이라고 가정 + const newValue = !value + handleCheckboxChange(!!newValue) + return + } + + setIsEditing(true) + setEditValue(value) + setError(null) + }, [disabled, value, type, handleCheckboxChange]) + const handleCancelEdit = useCallback(() => { // 취소 시 원래 값으로 복원하되, pendingChanges에서도 제거 onCancel?.() @@ -165,18 +187,6 @@ export function EditableCell<T = any>({ } }, [type, value, autoSave, onSave, onChange]) - const handleCheckboxChange = useCallback((checked: boolean) => { - const convertedValue = checked as T - setEditValue(convertedValue) - if (autoSave) { - // 체크박스 변경 시 자동 저장 - 값 직접 전달 - handleFinishEdit(convertedValue) - } else { - // 일괄 저장 모드에서는 실시간 표시를 위해 즉시 onSave 호출 - onSave(convertedValue) - } - }, [autoSave, handleFinishEdit, onSave]) - // 읽기 전용 모드 if (!isEditing) { return ( @@ -192,7 +202,13 @@ export function EditableCell<T = any>({ > <div className="flex-1 truncate"> {type === "checkbox" ? ( - <Checkbox checked={!!value} disabled /> + <div onClick={(e) => e.stopPropagation()}> + <Checkbox + checked={!!value} + onCheckedChange={handleCheckboxChange} + disabled={disabled} + /> + </div> ) : ( <span className={cn( "block truncate", @@ -207,7 +223,7 @@ export function EditableCell<T = any>({ </span> )} </div> - {!disabled && ( + {!disabled && type !== "checkbox" && ( <Edit2 className="w-4 h-4 opacity-0 group-hover:opacity-50 transition-opacity flex-shrink-0 ml-2" /> )} </div> diff --git a/db/schema/avl/vendor-pool.ts b/db/schema/avl/vendor-pool.ts index 9f2cdd1a..53e09f34 100644 --- a/db/schema/avl/vendor-pool.ts +++ b/db/schema/avl/vendor-pool.ts @@ -1,4 +1,4 @@ -import { pgTable, text, boolean, integer, timestamp, varchar, uniqueIndex } from "drizzle-orm/pg-core"; +import { pgTable, boolean, integer, timestamp, varchar, uniqueIndex } from "drizzle-orm/pg-core"; import { sql } from 'drizzle-orm'; import { createInsertSchema, createSelectSchema } from "drizzle-zod"; @@ -13,22 +13,21 @@ export const vendorPool = pgTable("vendor_pool", { htDivision: varchar("ht_division", { length: 10 }).notNull(), // H 또는 T 또는 공통 // 설계 정보 - designCategoryCode: varchar("design_category_code", { length: 2 }).notNull(), // 2자리 영문대문자 - designCategory: varchar("design_category", { length: 50 }).notNull(), // 전장 등 + discipline: varchar("discipline", { length: 50 }), // 설계공종 (ARCHITECTURE 등) equipBulkDivision: varchar("equip_bulk_division", { length: 1 }), // E 또는 B // 패키지 정보 - packageCode: varchar("package_code", { length: 50 }), // 패키지 코드 - packageName: varchar("package_name", { length: 100 }), // 패키지 명 + // 삭제! packageCode: varchar("package_code", { length: 50 }), // 패키지 코드 + // 삭제! packageName: varchar("package_name", { length: 100 }), // 패키지 명 // 자재그룹 정보 materialGroupCode: varchar("material_group_code", { length: 50 }), // 자재그룹 코드 materialGroupName: varchar("material_group_name", { length: 100 }), // 자재그룹 명 // 자재 관련 정보 - smCode: varchar("sm_code", { length: 50 }), + // 삭제! smCode: varchar("sm_code", { length: 50 }), similarMaterialNamePurchase: varchar("similar_material_name_purchase", { length: 100 }), // 유사자재명 (구매) - similarMaterialNameOther: varchar("similar_material_name_other", { length: 100 }), // 유사자재명 (구매 외) + // 삭제! similarMaterialNameOther: varchar("similar_material_name_other", { length: 100 }), // 유사자재명 (구매 외) // 협력업체 정보 vendorCode: varchar("vendor_code", { length: 50 }), // 협력업체 코드 @@ -38,13 +37,13 @@ export const vendorPool = pgTable("vendor_pool", { taxId: varchar("tax_id", { length: 50 }), // 사업자번호 faTarget: boolean("fa_target").default(false), // FA대상 faStatus: varchar("fa_status", { length: 50 }), // FA현황 - faRemark: varchar("fa_remark", { length: 200 }), // FA상세 + // faRemark: varchar("fa_remark", { length: 200 }), // FA상세 tier: varchar("tier", { length: 20 }), // 등급 - isAgent: boolean("is_agent").default(false), // Agent 여부 + // 삭제! isAgent: boolean("is_agent").default(false), // Agent 여부 // 계약 정보 - contractSignerCode: varchar("contract_signer_code", { length: 50 }), // 계약서명주체 코드 - contractSignerName: varchar("contract_signer_name", { length: 100 }), // 계약서명주체 명 + // 삭제! contractSignerCode: varchar("contract_signer_code", { length: 50 }), // 계약서명주체 코드 + // 삭제! contractSignerName: varchar("contract_signer_name", { length: 100 }), // 계약서명주체 명 // 위치 정보 headquarterLocation: varchar("headquarter_location", { length: 50 }), // 본사 위치 (국가) @@ -53,7 +52,7 @@ export const vendorPool = pgTable("vendor_pool", { // AVL 관련 정보 avlVendorName: varchar("avl_vendor_name", { length: 100 }), // AVL 등재업체명 similarVendorName: varchar("similar_vendor_name", { length: 100 }), // 유사업체명(기술영업) - hasAvl: boolean("has_avl").default(false), // AVL 존재여부 + // 삭제! hasAvl: boolean("has_avl").default(false), // AVL 존재여부 // 상태 정보 isBlacklist: boolean("is_blacklist").default(false), // Blacklist @@ -61,29 +60,29 @@ export const vendorPool = pgTable("vendor_pool", { purchaseOpinion: varchar("purchase_opinion", { length: 500 }), // 구매의견 // AVL 적용 선종(조선) - shipTypeCommon: boolean("ship_type_common").default(false), // 공통 - shipTypeAmax: boolean("ship_type_amax").default(false), // A-max - shipTypeSmax: boolean("ship_type_smax").default(false), // S-max - shipTypeVlcc: boolean("ship_type_vlcc").default(false), // VLCC - shipTypeLngc: boolean("ship_type_lngc").default(false), // LNGC - shipTypeCont: boolean("ship_type_cont").default(false), // CONT + // 삭제! shipTypeCommon: boolean("ship_type_common").default(false), // 공통 + // 삭제! shipTypeAmax: boolean("ship_type_amax").default(false), // A-max + // 삭제! shipTypeSmax: boolean("ship_type_smax").default(false), // S-max + // 삭제! shipTypeVlcc: boolean("ship_type_vlcc").default(false), // VLCC + // 삭제! shipTypeLngc: boolean("ship_type_lngc").default(false), // LNGC + // 삭제! shipTypeCont: boolean("ship_type_cont").default(false), // CONT // AVL 적용 선종(해양) - offshoreTypeCommon: boolean("offshore_type_common").default(false), // 공통 - offshoreTypeFpso: boolean("offshore_type_fpso").default(false), // FPSO - offshoreTypeFlng: boolean("offshore_type_flng").default(false), // FLNG - offshoreTypeFpu: boolean("offshore_type_fpu").default(false), // FPU - offshoreTypePlatform: boolean("offshore_type_platform").default(false), // Platform - offshoreTypeWtiv: boolean("offshore_type_wtiv").default(false), // WTIV - offshoreTypeGom: boolean("offshore_type_gom").default(false), // GOM + // 삭제! offshoreTypeCommon: boolean("offshore_type_common").default(false), // 공통 + // 삭제! offshoreTypeFpso: boolean("offshore_type_fpso").default(false), // FPSO + // 삭제! offshoreTypeFlng: boolean("offshore_type_flng").default(false), // FLNG + // 삭제! offshoreTypeFpu: boolean("offshore_type_fpu").default(false), // FPU + // 삭제! offshoreTypePlatform: boolean("offshore_type_platform").default(false), // Platform + // 삭제! offshoreTypeWtiv: boolean("offshore_type_wtiv").default(false), // WTIV + // 삭제! offshoreTypeGom: boolean("offshore_type_gom").default(false), // GOM // eVCP 미등록 정보 - picName: varchar("pic_name", { length: 50 }), // PIC(담당자) - picEmail: varchar("pic_email", { length: 100 }), // PIC(E-mail) - picPhone: varchar("pic_phone", { length: 20 }), // PIC(Phone) - agentName: varchar("agent_name", { length: 50 }), // Agent(담당자) - agentEmail: varchar("agent_email", { length: 100 }), // Agent(E-mail) - agentPhone: varchar("agent_phone", { length: 20 }), // Agent(Phone) + // 삭제! picName: varchar("pic_name", { length: 50 }), // PIC(담당자) + // 삭제! picEmail: varchar("pic_email", { length: 100 }), // PIC(E-mail) + // 삭제! picPhone: varchar("pic_phone", { length: 20 }), // PIC(Phone) + // 삭제! agentName: varchar("agent_name", { length: 50 }), // Agent(담당자) + // 삭제! agentEmail: varchar("agent_email", { length: 100 }), // Agent(E-mail) + // 삭제! agentPhone: varchar("agent_phone", { length: 20 }), // Agent(Phone) // 업체 실적 현황 recentQuoteDate: varchar("recent_quote_date", { length: 10 }), // 최근견적일 (YYYY-MM-DD) @@ -98,10 +97,10 @@ export const vendorPool = pgTable("vendor_pool", { lastModifier: varchar("last_modifier", { length: 50 }), // 최종변경자 }, (table) => ({ - // 새로운 unique 제약조건: 공사부문 + H/T + 자재그룹코드 + 협력업체명 + // 새로운 unique 제약조건: 공사부문 + H/T + 설계공종 + 자재그룹코드 + 협력업체명 uniqueVendorPoolCombination: uniqueIndex("unique_vendor_pool_combination") - .on(table.constructionSector, table.htDivision, table.materialGroupCode, table.vendorName) - .where(sql`${table.materialGroupCode} IS NOT NULL AND ${table.vendorName} IS NOT NULL`) + .on(table.constructionSector, table.htDivision, table.discipline, table.materialGroupCode, table.vendorName) + .where(sql`${table.discipline} IS NOT NULL AND ${table.materialGroupCode} IS NOT NULL AND ${table.vendorName} IS NOT NULL`) })); // 복합키 인덱스 (자재그룹코드 + 벤더코드) 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(""), // 최근견적번호 diff --git a/package-lock.json b/package-lock.json index a02db5c6..b95cecae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -83,6 +83,7 @@ "@radix-ui/react-tooltip": "^1.1.6", "@t3-oss/env-nextjs": "^0.11.1", "@tanstack/react-table": "^8.20.6", + "@tanstack/react-virtual": "^3.13.12", "@tiptap/extension-blockquote": "^2.23.1", "@tiptap/extension-bullet-list": "^2.23.1", "@tiptap/extension-highlight": "^2.23.1", @@ -5113,6 +5114,23 @@ "react-dom": ">=16.8" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.12", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz", + "integrity": "sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@tanstack/table-core": { "version": "8.21.3", "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", @@ -5126,6 +5144,16 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.12", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz", + "integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tiptap/core": { "version": "2.26.1", "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.26.1.tgz", diff --git a/package.json b/package.json index 9dc98aac..6c9a85a5 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "@radix-ui/react-tooltip": "^1.1.6", "@t3-oss/env-nextjs": "^0.11.1", "@tanstack/react-table": "^8.20.6", + "@tanstack/react-virtual": "^3.13.12", "@tiptap/extension-blockquote": "^2.23.1", "@tiptap/extension-bullet-list": "^2.23.1", "@tiptap/extension-highlight": "^2.23.1", |
