diff options
Diffstat (limited to 'lib/evaluation-target-list/table/evaluation-target-table.tsx')
| -rw-r--r-- | lib/evaluation-target-list/table/evaluation-target-table.tsx | 452 |
1 files changed, 452 insertions, 0 deletions
diff --git a/lib/evaluation-target-list/table/evaluation-target-table.tsx b/lib/evaluation-target-list/table/evaluation-target-table.tsx new file mode 100644 index 00000000..15837733 --- /dev/null +++ b/lib/evaluation-target-list/table/evaluation-target-table.tsx @@ -0,0 +1,452 @@ +"use client" + +import * as React from "react" +import { useRouter, useSearchParams } from "next/navigation" +import { Button } from "@/components/ui/button" +import { PanelLeftClose, PanelLeftOpen } from "lucide-react" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Skeleton } from "@/components/ui/skeleton" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { getEvaluationTargets, getEvaluationTargetsStats } from "../service" +import { cn } from "@/lib/utils" +import { useTablePresets } from "@/components/data-table/use-table-presets" +import { TablePresetManager } from "@/components/data-table/data-table-preset" +import { useMemo } from "react" +import { getEvaluationTargetsColumns } from "./evaluation-targets-columns" +import { EvaluationTargetsTableToolbarActions } from "./evaluation-targets-toolbar-actions" +import { EvaluationTargetFilterSheet } from "./evaluation-targets-filter-sheet" +import { EvaluationTargetWithDepartments } from "@/db/schema" + +interface EvaluationTargetsTableProps { + promises: Promise<[Awaited<ReturnType<typeof getEvaluationTargets>>]> + evaluationYear: number + className?: string +} + +// 통계 카드 컴포넌트 (클라이언트 컴포넌트용) +function EvaluationTargetsStats({ evaluationYear }: { evaluationYear: number }) { + const [stats, setStats] = React.useState<any>(null) + const [isLoading, setIsLoading] = React.useState(true) + const [error, setError] = React.useState<string | null>(null) + + React.useEffect(() => { + let isMounted = true + + async function fetchStats() { + try { + setIsLoading(true) + setError(null) + const statsData = await getEvaluationTargetsStats(evaluationYear) + + if (isMounted) { + setStats(statsData) + } + } catch (err) { + if (isMounted) { + setError(err instanceof Error ? err.message : 'Failed to fetch stats') + console.error('Error fetching evaluation targets stats:', err) + } + } finally { + if (isMounted) { + setIsLoading(false) + } + } + } + + fetchStats() + + return () => { + isMounted = false + } + }, []) + + if (isLoading) { + return ( + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 mb-6"> + {Array.from({ length: 4 }).map((_, i) => ( + <Card key={i}> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <Skeleton className="h-4 w-20" /> + </CardHeader> + <CardContent> + <Skeleton className="h-8 w-16" /> + </CardContent> + </Card> + ))} + </div> + ) + } + + if (error) { + return ( + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 mb-6"> + <Card className="col-span-full"> + <CardContent className="pt-6"> + <div className="text-center text-sm text-muted-foreground"> + 통계 데이터를 불러올 수 없습니다: {error} + </div> + </CardContent> + </Card> + </div> + ) + } + + if (!stats) { + return ( + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 mb-6"> + <Card className="col-span-full"> + <CardContent className="pt-6"> + <div className="text-center text-sm text-muted-foreground"> + 통계 데이터가 없습니다. + </div> + </CardContent> + </Card> + </div> + ) + } + + const totalTargets = stats.total || 0 + const pendingTargets = stats.pending || 0 + const confirmedTargets = stats.confirmed || 0 + const excludedTargets = stats.excluded || 0 + const consensusRate = totalTargets > 0 ? Math.round(((stats.consensusTrue || 0) / totalTargets) * 100) : 0 + + return ( + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 mb-6"> + {/* 총 평가 대상 */} + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">총 평가 대상</CardTitle> + <Badge variant="outline">{evaluationYear}년</Badge> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold">{totalTargets.toLocaleString()}</div> + <div className="text-xs text-muted-foreground mt-1"> + 해양 {stats.oceanDivision || 0}개 | 조선 {stats.shipyardDivision || 0}개 + </div> + </CardContent> + </Card> + + {/* 검토 중 */} + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">검토 중</CardTitle> + <Badge variant="secondary">대기</Badge> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-orange-600">{pendingTargets.toLocaleString()}</div> + <div className="text-xs text-muted-foreground mt-1"> + {totalTargets > 0 ? Math.round((pendingTargets / totalTargets) * 100) : 0}% of total + </div> + </CardContent> + </Card> + + {/* 확정 */} + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">확정</CardTitle> + <Badge variant="default" className="bg-green-600">완료</Badge> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-green-600">{confirmedTargets.toLocaleString()}</div> + <div className="text-xs text-muted-foreground mt-1"> + {totalTargets > 0 ? Math.round((confirmedTargets / totalTargets) * 100) : 0}% of total + </div> + </CardContent> + </Card> + + {/* 의견 일치율 */} + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">의견 일치율</CardTitle> + <Badge variant={consensusRate >= 80 ? "default" : consensusRate >= 60 ? "secondary" : "destructive"}> + {consensusRate}% + </Badge> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold">{consensusRate}%</div> + <div className="text-xs text-muted-foreground mt-1"> + 일치 {stats.consensusTrue || 0}개 | 불일치 {stats.consensusFalse || 0}개 + </div> + </CardContent> + </Card> + </div> + ) +} + +export function EvaluationTargetsTable({ promises, evaluationYear, className }: EvaluationTargetsTableProps) { + const [rowAction, setRowAction] = React.useState<DataTableRowAction<EvaluationTargetWithDepartments> | null>(null) + const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false) + console.count("E Targets render"); + const router = useRouter() + const searchParams = useSearchParams() + + const containerRef = React.useRef<HTMLDivElement>(null) + const [containerTop, setContainerTop] = React.useState(0) + + const updateContainerBounds = React.useCallback(() => { + if (containerRef.current) { + const rect = containerRef.current.getBoundingClientRect() + setContainerTop(rect.top) + } + }, []) + + React.useEffect(() => { + updateContainerBounds() + + const handleResize = () => { + updateContainerBounds() + } + + window.addEventListener('resize', handleResize) + window.addEventListener('scroll', updateContainerBounds) + + return () => { + window.removeEventListener('resize', handleResize) + window.removeEventListener('scroll', updateContainerBounds) + } + }, [updateContainerBounds]) + + const [promiseData] = React.use(promises) + const tableData = promiseData + + console.log("Evaluation Targets Table Data:", { + dataLength: tableData.data?.length, + pageCount: tableData.pageCount, + total: tableData.total, + sampleData: tableData.data?.[0] + }) + + const initialSettings = React.useMemo(() => ({ + page: parseInt(searchParams.get('page') || '1'), + perPage: parseInt(searchParams.get('perPage') || '10'), + sort: searchParams.get('sort') ? JSON.parse(searchParams.get('sort')!) : [{ id: "createdAt", desc: true }], + filters: searchParams.get('filters') ? JSON.parse(searchParams.get('filters')!) : [], + joinOperator: (searchParams.get('joinOperator') as "and" | "or") || "and", + basicFilters: searchParams.get('basicFilters') ? + JSON.parse(searchParams.get('basicFilters')!) : [], + basicJoinOperator: (searchParams.get('basicJoinOperator') as "and" | "or") || "and", + search: searchParams.get('search') || '', + columnVisibility: {}, + columnOrder: [], + pinnedColumns: { left: [], right: ["actions"] }, + groupBy: [], + expandedRows: [] + }), [searchParams]) + + const { + presets, + activePresetId, + hasUnsavedChanges, + isLoading: presetsLoading, + createPreset, + applyPreset, + updatePreset, + deletePreset, + setDefaultPreset, + renamePreset, + updateClientState, + getCurrentSettings, + } = useTablePresets<EvaluationTargetWithDepartments>('evaluation-targets-table', initialSettings) + + const columns = React.useMemo( + () => getEvaluationTargetsColumns(), + [] + ) + + const filterFields: DataTableFilterField<EvaluationTargetWithDepartments>[] = [ + { id: "vendorCode", label: "벤더 코드" }, + { id: "vendorName", label: "벤더명" }, + { id: "status", label: "상태" }, + ] + + const advancedFilterFields: DataTableAdvancedFilterField<EvaluationTargetWithDepartments>[] = [ + { id: "evaluationYear", label: "평가년도", type: "number" }, + { id: "division", label: "구분", type: "select", options: [ + { label: "해양", value: "OCEAN" }, + { label: "조선", value: "SHIPYARD" }, + ]}, + { id: "vendorCode", label: "벤더 코드", type: "text" }, + { id: "vendorName", label: "벤더명", type: "text" }, + { id: "domesticForeign", label: "내외자", type: "select", options: [ + { label: "내자", value: "DOMESTIC" }, + { label: "외자", value: "FOREIGN" }, + ]}, + { id: "materialType", label: "자재구분", type: "select", options: [ + { label: "기자재", value: "EQUIPMENT" }, + { label: "벌크", value: "BULK" }, + { label: "기자재/벌크", value: "EQUIPMENT_BULK" }, + ]}, + { id: "status", label: "상태", type: "select", options: [ + { label: "검토 중", value: "PENDING" }, + { label: "확정", value: "CONFIRMED" }, + { label: "제외", value: "EXCLUDED" }, + ]}, + { id: "consensusStatus", label: "의견 일치", type: "select", options: [ + { label: "의견 일치", value: "true" }, + { label: "의견 불일치", value: "false" }, + { label: "검토 중", value: "null" }, + ]}, + { id: "adminComment", label: "관리자 의견", type: "text" }, + { id: "consolidatedComment", label: "종합 의견", type: "text" }, + { id: "confirmedAt", label: "확정일", type: "date" }, + { id: "createdAt", label: "생성일", type: "date" }, + ] + + const currentSettings = useMemo(() => { + return getCurrentSettings() + }, [getCurrentSettings]) + + const initialState = useMemo(() => { + return { + sorting: initialSettings.sort.filter(sortItem => { + const columnExists = columns.some(col => col.accessorKey === sortItem.id || col.id === sortItem.id) + return columnExists + }) as any, + columnVisibility: currentSettings.columnVisibility, + columnPinning: currentSettings.pinnedColumns, + } + }, [currentSettings, initialSettings.sort, columns]) + + const { table } = useDataTable({ + data: tableData.data, + columns, + pageCount: tableData.pageCount, + rowCount: tableData.total || tableData.data.length, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + const handleSearch = () => { + setIsFilterPanelOpen(false) + } + + const getActiveBasicFilterCount = () => { + try { + const basicFilters = searchParams.get('basicFilters') + return basicFilters ? JSON.parse(basicFilters).length : 0 + } catch (e) { + return 0 + } + } + + const FILTER_PANEL_WIDTH = 400; + + return ( + <> + {/* Filter Panel */} + <div + className={cn( + "fixed left-0 bg-background border-r z-50 flex flex-col transition-all duration-300 ease-in-out overflow-hidden", + isFilterPanelOpen ? "border-r shadow-lg" : "border-r-0" + )} + style={{ + width: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px', + top: `${containerTop}px`, + height: `calc(100vh - ${containerTop}px)` + }} + > + <div className="h-full"> + <EvaluationTargetFilterSheet + isOpen={isFilterPanelOpen} + onClose={() => setIsFilterPanelOpen(false)} + onSearch={handleSearch} + isLoading={false} + /> + </div> + </div> + + {/* Main Content Container */} + <div + ref={containerRef} + className={cn("relative w-full overflow-hidden", className)} + > + <div className="flex w-full h-full"> + <div + className="flex flex-col min-w-0 overflow-hidden transition-all duration-300 ease-in-out" + style={{ + width: isFilterPanelOpen ? `calc(100% - ${FILTER_PANEL_WIDTH}px)` : '100%', + marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px' + }} + > + {/* Header Bar */} + <div className="flex items-center justify-between p-4 bg-background shrink-0"> + <div className="flex items-center gap-3"> + <Button + variant="outline" + size="sm" + type='button' + onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)} + className="flex items-center shadow-sm" + > + {isFilterPanelOpen ? <PanelLeftClose className="size-4"/> : <PanelLeftOpen className="size-4"/>} + {getActiveBasicFilterCount() > 0 && ( + <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs"> + {getActiveBasicFilterCount()} + </span> + )} + </Button> + </div> + + <div className="text-sm text-muted-foreground"> + {tableData && ( + <span>총 {tableData.total || tableData.data.length}건</span> + )} + </div> + </div> + + {/* 통계 카드들 */} + <div className="px-4"> + <EvaluationTargetsStats evaluationYear={evaluationYear} /> + </div> + + {/* Table Content Area */} + <div className="flex-1 overflow-hidden" style={{ height: 'calc(100vh - 500px)' }}> + <div className="h-full w-full"> + <DataTable table={table} className="h-full"> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <div className="flex items-center gap-2"> + <TablePresetManager<EvaluationTargetWithDepartments> + presets={presets} + activePresetId={activePresetId} + currentSettings={currentSettings} + hasUnsavedChanges={hasUnsavedChanges} + isLoading={presetsLoading} + onCreatePreset={createPreset} + onUpdatePreset={updatePreset} + onDeletePreset={deletePreset} + onApplyPreset={applyPreset} + onSetDefaultPreset={setDefaultPreset} + onRenamePreset={renamePreset} + /> + + <EvaluationTargetsTableToolbarActions table={table} /> + </div> + </DataTableAdvancedToolbar> + </DataTable> + </div> + </div> + </div> + </div> + </div> + </> + ) +}
\ No newline at end of file |
