"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" import { EditEvaluationTargetSheet } from "./update-evaluation-target" interface EvaluationTargetsTableProps { promises: Promise<[Awaited>]> evaluationYear: number className?: string } // 통계 카드 컴포넌트 (클라이언트 컴포넌트용) function EvaluationTargetsStats({ evaluationYear }: { evaluationYear: number }) { const [stats, setStats] = React.useState(null) const [isLoading, setIsLoading] = React.useState(true) const [error, setError] = React.useState(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 (
{Array.from({ length: 4 }).map((_, i) => ( ))}
) } if (error) { return (
통계 데이터를 불러올 수 없습니다: {error}
) } if (!stats) { return (
통계 데이터가 없습니다.
) } 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 (
{/* 총 평가 대상 */} 총 평가 대상 {evaluationYear}년
{totalTargets.toLocaleString()}
해양 {stats.oceanDivision || 0}개 | 조선 {stats.shipyardDivision || 0}개
{/* 검토 중 */} 검토 중 대기
{pendingTargets.toLocaleString()}
{totalTargets > 0 ? Math.round((pendingTargets / totalTargets) * 100) : 0}% of total
{/* 확정 */} 확정 완료
{confirmedTargets.toLocaleString()}
{totalTargets > 0 ? Math.round((confirmedTargets / totalTargets) * 100) : 0}% of total
{/* 의견 일치율 */} 의견 일치율 = 80 ? "default" : consensusRate >= 60 ? "secondary" : "destructive"}> {consensusRate}%
{consensusRate}%
일치 {stats.consensusTrue || 0}개 | 불일치 {stats.consensusFalse || 0}개
) } export function EvaluationTargetsTable({ promises, evaluationYear, className }: EvaluationTargetsTableProps) { const [rowAction, setRowAction] = React.useState | null>(null) const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false) const router = useRouter() const searchParams = useSearchParams() const containerRef = React.useRef(null) const [containerTop, setContainerTop] = React.useState(0) // ✅ 스크롤 이벤트 throttling으로 성능 최적화 const updateContainerBounds = React.useCallback(() => { if (containerRef.current) { const rect = containerRef.current.getBoundingClientRect() const newTop = rect.top // ✅ 값이 실제로 변경될 때만 상태 업데이트 setContainerTop(prevTop => { if (Math.abs(prevTop - newTop) > 1) { // 1px 이상 차이날 때만 업데이트 return newTop } return prevTop }) } }, []) // ✅ throttle 함수 추가 const throttledUpdateBounds = React.useCallback(() => { let timeoutId: NodeJS.Timeout return () => { clearTimeout(timeoutId) timeoutId = setTimeout(updateContainerBounds, 16) // ~60fps } }, [updateContainerBounds]) React.useEffect(() => { updateContainerBounds() const throttledHandler = throttledUpdateBounds() const handleResize = () => { updateContainerBounds() } window.addEventListener('resize', handleResize) window.addEventListener('scroll', throttledHandler) // ✅ throttled 함수 사용 return () => { window.removeEventListener('resize', handleResize) window.removeEventListener('scroll', throttledHandler) } }, [updateContainerBounds, throttledUpdateBounds]) const [promiseData] = React.use(promises) const tableData = promiseData console.log(tableData) 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('evaluation-targets-table', initialSettings) const columns = React.useMemo( () => getEvaluationTargetsColumns({ setRowAction }), [setRowAction] ) const filterFields: DataTableFilterField[] = [ { id: "vendorCode", label: "벤더 코드" }, { id: "vendorName", label: "벤더명" }, { id: "status", label: "상태" }, ] const advancedFilterFields: DataTableAdvancedFilterField[] = [ { 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]) function getColKey(c: ColumnDef): string | undefined { if ("accessorKey" in c && c.accessorKey) return c.accessorKey as string if ("id" in c && c.id) return c.id as string return undefined } const initialState = useMemo(() => { return { sorting: initialSettings.sort.filter(s => columns.some(c => getColKey(c) === s.id)), columnVisibility: currentSettings.columnVisibility, columnPinning: currentSettings.pinnedColumns, } }, [columns, currentSettings, initialSettings.sort]) 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 */}
setIsFilterPanelOpen(false)} onSearch={handleSearch} isLoading={false} />
{/* Main Content Container */}
{/* Header Bar */}
{tableData && ( 총 {tableData.total || tableData.data.length}건 )}
{/* 통계 카드들 */}
{/* Table Content Area */}
presets={presets} activePresetId={activePresetId} currentSettings={currentSettings} hasUnsavedChanges={hasUnsavedChanges} isLoading={presetsLoading} onCreatePreset={createPreset} onUpdatePreset={updatePreset} onDeletePreset={deletePreset} onApplyPreset={applyPreset} onSetDefaultPreset={setDefaultPreset} onRenamePreset={renamePreset} />
setRowAction(null)} evaluationTarget={rowAction?.row.original ?? null} />
) }