summaryrefslogtreecommitdiff
path: root/lib/avl/table
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-11-25 22:04:56 +0900
committerjoonhoekim <26rote@gmail.com>2025-11-25 22:04:56 +0900
commit2b59582194fc5c23140f52c42c793c324856a35e (patch)
tree0db8ef0e913b3a44dfd6e3e20fe92b8e4984aeba /lib/avl/table
parent835df8ddc115ffa74414db2a4fab7efc0d0056a9 (diff)
(김준회) 벤더풀&AVL 구매 추가요청사항 반영
Diffstat (limited to 'lib/avl/table')
-rw-r--r--lib/avl/table/avl-detail-virtual-columns.tsx280
-rw-r--r--lib/avl/table/avl-detail-virtual-table.tsx358
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>
+ )
+}
+