diff options
| author | joonhoekim <26rote@gmail.com> | 2025-11-25 22:04:56 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-11-25 22:04:56 +0900 |
| commit | 2b59582194fc5c23140f52c42c793c324856a35e (patch) | |
| tree | 0db8ef0e913b3a44dfd6e3e20fe92b8e4984aeba /lib/avl/table | |
| parent | 835df8ddc115ffa74414db2a4fab7efc0d0056a9 (diff) | |
(김준회) 벤더풀&AVL 구매 추가요청사항 반영
Diffstat (limited to 'lib/avl/table')
| -rw-r--r-- | lib/avl/table/avl-detail-virtual-columns.tsx | 280 | ||||
| -rw-r--r-- | lib/avl/table/avl-detail-virtual-table.tsx | 358 |
2 files changed, 638 insertions, 0 deletions
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> + ) +} + |
