From 2b59582194fc5c23140f52c42c793c324856a35e Mon Sep 17 00:00:00 2001 From: joonhoekim <26rote@gmail.com> Date: Tue, 25 Nov 2025 22:04:56 +0900 Subject: (김준회) 벤더풀&AVL 구매 추가요청사항 반영 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/avl/service.ts | 53 ++++ lib/avl/table/avl-detail-virtual-columns.tsx | 280 +++++++++++++++++++++ lib/avl/table/avl-detail-virtual-table.tsx | 358 +++++++++++++++++++++++++++ 3 files changed, 691 insertions(+) create mode 100644 lib/avl/table/avl-detail-virtual-columns.tsx create mode 100644 lib/avl/table/avl-detail-virtual-table.tsx (limited to 'lib/avl') 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 @@ -378,6 +378,59 @@ export async function getAvlListById(id: number): Promise { } } +/** + * 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 상세 정보 조회 (단일) */ 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[] = [ + { + header: "기본 정보", + columns: [ + { + accessorKey: "no", + header: ({ column }) => ( + + ), + size: 90, // 60 + 30 + meta: { + excelHeader: "No.", + }, + }, + { + accessorKey: "equipBulkDivision", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const value = row.getValue("equipBulkDivision") as string + return ( + + {value || "-"} + + ) + }, + size: 200, // 120 + 30 + meta: { + excelHeader: "Equip/Bulk 구분", + }, + }, + { + accessorKey: "disciplineName", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const value = row.getValue("disciplineName") as string + return {value || "-"} + }, + size: 150, // 120 + 30 + meta: { + excelHeader: "설계공종", + }, + }, + { + accessorKey: "materialNameCustomerSide", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const value = row.getValue("materialNameCustomerSide") as string + return {value || "-"} + }, + size: 250, // 150 + 30 + meta: { + excelHeader: "고객사 AVL 자재명", + }, + }, + { + accessorKey: "packageName", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const value = row.getValue("packageName") as string + return {value || "-"} + }, + size: 160, // 130 + 30 + meta: { + excelHeader: "패키지 정보", + }, + }, + { + accessorKey: "materialGroupCode", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const value = row.getValue("materialGroupCode") as string + return {value || "-"} + }, + size: 250, // 120 + 30 + meta: { + excelHeader: "자재그룹코드", + }, + }, + { + accessorKey: "materialGroupName", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const value = row.getValue("materialGroupName") as string + return {value || "-"} + }, + size: 360, // 130 + 30 + meta: { + excelHeader: "자재그룹명", + }, + }, + { + accessorKey: "vendorCode", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const value = row.getValue("vendorCode") as string + return {value || "-"} + }, + size: 200, // 120 + 30 + meta: { + excelHeader: "협력업체코드", + }, + }, + { + accessorKey: "vendorName", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const value = row.getValue("vendorName") as string + return {value || "-"} + }, + size: 400, + meta: { + excelHeader: "협력업체명", + }, + }, + { + accessorKey: "avlVendorName", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const value = row.getValue("avlVendorName") as string + return {value || "-"} + }, + size: 170, // 140 + 30 + meta: { + excelHeader: "AVL 등재업체명", + }, + }, + { + accessorKey: "tier", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const value = row.getValue("tier") as string + if (!value) return - + + 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 ( + + {value} + + ) + }, + size: 130, // 100 + 30 + meta: { + excelHeader: "등급 (Tier)", + }, + }, + ], + }, + // FA 정보 그룹 + { + header: "FA 정보", + columns: [ + { + accessorKey: "faTarget", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const value = row.getValue("faTarget") as boolean + return ( + + {value ? "대상" : "비대상"} + + ) + }, + size: 110, // 80 + 30 + meta: { + excelHeader: "FA 대상", + }, + }, + { + accessorKey: "faStatus", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const value = row.getValue("faStatus") as string + return {value || "-"} + }, + 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 ( + + ) + }, + size: 110, // 80 + 30 + meta: { + excelHeader: "AVL", + }, + }, + { + accessorKey: "shiBlacklist", + header: "Blacklist", + cell: ({ row }) => { + const value = row.getValue("shiBlacklist") as boolean + return ( + + ) + }, + size: 130, // 100 + 30 + meta: { + excelHeader: "Blacklist", + }, + }, + { + accessorKey: "shiBcc", + header: "BCC", + cell: ({ row }) => { + const value = row.getValue("shiBcc") as boolean + return ( + + ) + }, + 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 }) { + const columnFilterValue = column.getFilterValue() + const id = column.id + + // Boolean 필터 (faTarget, shiBlacklist, shiBcc) + if (id === 'faTarget' || id === 'shiBlacklist' || id === 'shiBcc' || id === 'shiAvl') { + return ( +
e.stopPropagation()} className="mt-2"> + +
+ ) + } + + // FA Status 필터 (O 또는 빈 값 - 데이터에 따라 조정 필요) + if (id === 'faStatus') { + return ( +
e.stopPropagation()} className="mt-2"> + +
+ ) + } + + // 일반 텍스트 검색 + return ( +
e.stopPropagation()} className="mt-2"> + column.setFilterValue(e.target.value)} + placeholder="Search..." + className="h-8 w-full font-normal bg-background" + /> +
+ ) +} + +export function AvlDetailVirtualTable({ + data, + avlType, + projectInfo, + businessType, +}: AvlDetailVirtualTableProps) { + // 상태 관리 + const [sorting, setSorting] = React.useState([]) + const [columnFilters, setColumnFilters] = React.useState([]) + 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(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 ( +
+ {/* 상단 정보 표시 영역 */} +
+
+

AVL 상세내역

+ + {avlType} + + + + [{businessType}] {projectInfo?.code || projectInfo?.pspid || '코드정보없음(표준AVL)'} ({projectInfo?.OWN_NM || projectInfo?.kunnrNm || '선주정보 없음'}) + +
+ +
+ 목록으로 +
+
+ + {/* 툴바 */} +
+
+
+ + setGlobalFilter(e.target.value)} + className="pl-8" + /> +
+
+ 전체 {data.length}건 중 {rows.length}건 표시 +
+
+ +
+ + + +
+
+ + {/* 테이블 */} +
+ + {/* GroupHeader를 사용할 때 table-layout: fixed에서 열 너비가 올바르게 적용되도록 colgroup 추가 */} + + {table.getLeafHeaders().map((header) => ( + + ))} + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {paddingTop > 0 && ( + + + )} + {virtualRows.map((virtualRow) => { + const row = rows[virtualRow.index] + + return ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ) + })} + {paddingBottom > 0 && ( + + + )} + +
+ {header.isPlaceholder ? null : ( + <> +
+ {flexRender( + header.column.columnDef.header, + header.getContext() + )} + {header.column.getCanSort() && ( +
+ {header.column.getIsSorted() === "asc" ? ( + + ) : header.column.getIsSorted() === "desc" ? ( + + ) : ( +
+ )} +
+ )} +
+ {header.column.getCanFilter() && ( + + )} +
+ + )} +
+
+ {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} +
+
+
+
+ ) +} + -- cgit v1.2.3