diff options
Diffstat (limited to 'lib/evaluation/table/evaluation-table.tsx')
| -rw-r--r-- | lib/evaluation/table/evaluation-table.tsx | 341 |
1 files changed, 243 insertions, 98 deletions
diff --git a/lib/evaluation/table/evaluation-table.tsx b/lib/evaluation/table/evaluation-table.tsx index d4510eb5..257225c8 100644 --- a/lib/evaluation/table/evaluation-table.tsx +++ b/lib/evaluation/table/evaluation-table.tsx @@ -1,12 +1,21 @@ +// lib/evaluation/table/evaluation-table.tsx - 최종 정리된 버전 + "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 { PanelLeftClose, PanelLeftOpen, BarChart3, List, Info } 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 { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" import type { DataTableAdvancedFilterField, DataTableFilterField, @@ -19,22 +28,106 @@ import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-adv 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 { PeriodicEvaluationFilterSheet } from "./evaluation-filter-sheet" import { getPeriodicEvaluationsColumns } from "./evaluation-columns" -import { PeriodicEvaluationView } from "@/db/schema" -import { getPeriodicEvaluations, getPeriodicEvaluationsStats } from "../service" +import { PeriodicEvaluationView, PeriodicEvaluationAggregatedView } from "@/db/schema" +import { + getPeriodicEvaluationsWithAggregation, + getPeriodicEvaluationsStats +} from "../service" import { PeriodicEvaluationsTableToolbarActions } from "./periodic-evaluations-toolbar-actions" import { EvaluationDetailsDialog } from "./evaluation-details-dialog" +import { searchParamsEvaluationsCache } from "../validation" interface PeriodicEvaluationsTableProps { - promises: Promise<[Awaited<ReturnType<typeof getPeriodicEvaluations>>]> + promises: Promise<[Awaited<ReturnType<typeof getPeriodicEvaluationsWithAggregation>>]> evaluationYear: number + initialViewMode?: "detailed" | "aggregated" // ✅ 페이지에서 전달받는 초기 모드 className?: string } +// 뷰 모드 토글 컴포넌트 +function EvaluationViewToggle({ + value, + onValueChange, + detailedCount, + aggregatedCount, +}: { + value: "detailed" | "aggregated"; + onValueChange: (value: "detailed" | "aggregated") => void; + detailedCount?: number; + aggregatedCount?: number; +}) { + return ( + <div className="flex items-center gap-2"> + <ToggleGroup + type="single" + value={value} + onValueChange={(newValue) => { + if (newValue) onValueChange(newValue as "detailed" | "aggregated"); + }} + className="bg-muted p-1 rounded-lg" + > + <ToggleGroupItem + value="detailed" + aria-label="상세 뷰" + className="flex items-center gap-2 data-[state=on]:bg-background" + > + <List className="h-4 w-4" /> + <span>상세 뷰</span> + {detailedCount !== undefined && ( + <Badge variant="secondary" className="ml-1 text-xs"> + {detailedCount} + </Badge> + )} + </ToggleGroupItem> + + <ToggleGroupItem + value="aggregated" + aria-label="집계 뷰" + className="flex items-center gap-2 data-[state=on]:bg-background" + > + <BarChart3 className="h-4 w-4" /> + <span>집계 뷰</span> + {aggregatedCount !== undefined && ( + <Badge variant="secondary" className="ml-1 text-xs"> + {aggregatedCount} + </Badge> + )} + </ToggleGroupItem> + </ToggleGroup> + + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button variant="ghost" size="icon" className="h-8 w-8"> + <Info className="h-4 w-4" /> + </Button> + </TooltipTrigger> + <TooltipContent side="bottom" className="max-w-sm"> + <div className="space-y-2 text-sm"> + <div> + <strong>상세 뷰:</strong> 모든 평가 기록을 개별적으로 표시 + </div> + <div> + <strong>집계 뷰:</strong> 동일 벤더의 여러 division 평가를 평균으로 통합하여 표시 + </div> + </div> + </TooltipContent> + </Tooltip> + </TooltipProvider> + </div> + ); +} + // 통계 카드 컴포넌트 -function PeriodicEvaluationsStats({ evaluationYear }: { evaluationYear: number }) { +function PeriodicEvaluationsStats({ + evaluationYear, + viewMode +}: { + evaluationYear: number; + viewMode: "detailed" | "aggregated"; +}) { const [stats, setStats] = React.useState<any>(null) const [isLoading, setIsLoading] = React.useState(true) const [error, setError] = React.useState<string | null>(null) @@ -47,8 +140,11 @@ function PeriodicEvaluationsStats({ evaluationYear }: { evaluationYear: number } setIsLoading(true) setError(null) - // 실제 통계 함수 호출 - const statsData = await getPeriodicEvaluationsStats(evaluationYear) + // 뷰 모드에 따라 다른 통계 함수 호출 + const statsData = await getPeriodicEvaluationsStats( + evaluationYear, + viewMode === "aggregated" + ) if (isMounted) { setStats(statsData) @@ -70,7 +166,7 @@ function PeriodicEvaluationsStats({ evaluationYear }: { evaluationYear: number } return () => { isMounted = false } - }, [evaluationYear]) // evaluationYear 의존성 추가 + }, [evaluationYear, viewMode]) if (isLoading) { return ( @@ -114,13 +210,25 @@ function PeriodicEvaluationsStats({ evaluationYear }: { evaluationYear: number } {/* 총 평가 */} <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> + <CardTitle className="text-sm font-medium"> + 총 {viewMode === "aggregated" ? "벤더" : "평가"} + </CardTitle> + <div className="flex items-center gap-1"> + <Badge variant="outline">{evaluationYear}년</Badge> + {viewMode === "aggregated" && ( + <Badge variant="secondary" className="text-xs">집계</Badge> + )} + </div> </CardHeader> <CardContent> <div className="text-2xl font-bold">{totalEvaluations.toLocaleString()}</div> <div className="text-xs text-muted-foreground mt-1"> 평균점수 {stats.averageScore?.toFixed(1) || 0}점 + {viewMode === "aggregated" && stats.totalEvaluationCount && ( + <span className="ml-2"> + (총 {stats.totalEvaluationCount}개 평가) + </span> + )} </div> </CardContent> </Card> @@ -172,12 +280,55 @@ function PeriodicEvaluationsStats({ evaluationYear }: { evaluationYear: number } ) } -export function PeriodicEvaluationsTable({ promises, evaluationYear, className }: PeriodicEvaluationsTableProps) { - const [rowAction, setRowAction] = React.useState<DataTableRowAction<PeriodicEvaluationView> | null>(null) - const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false) +export function PeriodicEvaluationsTable({ + promises, + evaluationYear, + initialViewMode = "detailed", + className +}: PeriodicEvaluationsTableProps) { const router = useRouter() const searchParams = useSearchParams() - + + // ✅ URL에서 현재 집계 모드 상태 읽기 + const currentParams = searchParamsEvaluationsCache.parse(Object.fromEntries(searchParams.entries())) + const [viewMode, setViewMode] = React.useState<"detailed" | "aggregated">( + currentParams.aggregated ? "aggregated" : "detailed" + ) + + const [rowAction, setRowAction] = React.useState<DataTableRowAction<PeriodicEvaluationView | PeriodicEvaluationAggregatedView> | null>(null) + const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false) + const [detailedCount, setDetailedCount] = React.useState<number | undefined>(undefined) + const [aggregatedCount, setAggregatedCount] = React.useState<number | undefined>(undefined) + + const [externalFilters, setExternalFilters] = React.useState<any[]>([]); + const [externalJoinOperator, setExternalJoinOperator] = React.useState<"and" | "or">("and"); + + // ✅ 뷰 모드 변경 시 URL 업데이트 + const handleViewModeChange = React.useCallback((newMode: "detailed" | "aggregated") => { + setViewMode(newMode); + + // URL 파라미터 업데이트 + const newSearchParams = new URLSearchParams(searchParams.toString()) + if (newMode === "aggregated") { + newSearchParams.set("aggregated", "true") + } else { + newSearchParams.delete("aggregated") + } + + // 페이지를 1로 리셋 + newSearchParams.set("page", "1") + + router.push(`?${newSearchParams.toString()}`, { scroll: false }) + }, [router, searchParams]) + + const handleFiltersApply = React.useCallback((filters: any[], joinOperator: "and" | "or") => { + console.log("=== 폼에서 필터 전달받음 ===", filters, joinOperator); + setExternalFilters(filters); + setExternalJoinOperator(joinOperator); + setIsFilterPanelOpen(false); + }, []); + + // 컨테이너 위치 추적 const containerRef = React.useRef<HTMLDivElement>(null) const [containerTop, setContainerTop] = React.useState(0) @@ -185,7 +336,6 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className } if (containerRef.current) { const rect = containerRef.current.getBoundingClientRect() const newTop = rect.top - setContainerTop(prevTop => { if (Math.abs(prevTop - newTop) > 1) { return newTop @@ -195,69 +345,44 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className } } }, []) - const throttledUpdateBounds = React.useCallback(() => { - let timeoutId: NodeJS.Timeout - return () => { - clearTimeout(timeoutId) - timeoutId = setTimeout(updateContainerBounds, 16) - } - }, [updateContainerBounds]) - React.useEffect(() => { updateContainerBounds() - - const throttledHandler = throttledUpdateBounds() - - const handleResize = () => { - updateContainerBounds() + const throttledHandler = () => { + let timeoutId: NodeJS.Timeout + return () => { + clearTimeout(timeoutId) + timeoutId = setTimeout(updateContainerBounds, 16) + } } - window.addEventListener('resize', handleResize) - window.addEventListener('scroll', throttledHandler) + const handler = throttledHandler() + window.addEventListener('resize', updateContainerBounds) + window.addEventListener('scroll', handler) return () => { - window.removeEventListener('resize', handleResize) - window.removeEventListener('scroll', throttledHandler) + window.removeEventListener('resize', updateContainerBounds) + window.removeEventListener('scroll', handler) } - }, [updateContainerBounds, throttledUpdateBounds]) + }, [updateContainerBounds]) + // 데이터 로드 const [promiseData] = React.use(promises) const tableData = promiseData - - const getSearchParam = React.useCallback((key: string, defaultValue?: string): string => { - return searchParams?.get(key) ?? defaultValue ?? ""; - }, [searchParams]); - - const parseSearchParamHelper = React.useCallback((key: string, defaultValue: any): any => { - try { - const value = getSearchParam(key); - return value ? JSON.parse(value) : defaultValue; - } catch { - return defaultValue; - } - }, [getSearchParam]); - - const parseSearchParam = <T,>(key: string, defaultValue: T): T => { - return parseSearchParamHelper(key, defaultValue); - }; - + // 테이블 설정 const initialSettings = React.useMemo(() => ({ - page: parseInt(getSearchParam('page') || '1'), - perPage: parseInt(getSearchParam('perPage') || '10'), - sort: getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }], - filters: getSearchParam('filters') ? JSON.parse(getSearchParam('filters')!) : [], - joinOperator: (getSearchParam('joinOperator') as "and" | "or") || "and", - basicFilters: getSearchParam('basicFilters') ? - JSON.parse(getSearchParam('basicFilters')!) : [], - basicJoinOperator: (getSearchParam('basicJoinOperator') as "and" | "or") || "and", - search: getSearchParam('search') || '', + page: currentParams.page || 1, + perPage: currentParams.perPage || 10, + sort: currentParams.sort || [{ id: "createdAt", desc: true }], + filters: currentParams.filters || [], + joinOperator: currentParams.joinOperator || "and", + search: "", columnVisibility: {}, columnOrder: [], pinnedColumns: { left: [], right: ["actions"] }, groupBy: [], expandedRows: [] - }), [searchParams]) + }), [currentParams]) const { presets, @@ -270,28 +395,31 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className } deletePreset, setDefaultPreset, renamePreset, - updateClientState, getCurrentSettings, - } = useTablePresets<PeriodicEvaluationView>('periodic-evaluations-table', initialSettings) + } = useTablePresets<PeriodicEvaluationView | PeriodicEvaluationAggregatedView>('periodic-evaluations-table', initialSettings) - const columns = React.useMemo( - () => getPeriodicEvaluationsColumns({ setRowAction }), - [setRowAction] - ) + // 집계 모드에 따라 컬럼 수정 + const columns = React.useMemo(() => { + return getPeriodicEvaluationsColumns({ + setRowAction, + viewMode + }); + }, [viewMode, setRowAction]); - const filterFields: DataTableFilterField<PeriodicEvaluationView>[] = [ + const filterFields: DataTableFilterField<PeriodicEvaluationView | PeriodicEvaluationAggregatedView>[] = [ { id: "vendorCode", label: "벤더 코드" }, { id: "vendorName", label: "벤더명" }, { id: "status", label: "진행상태" }, ] - const advancedFilterFields: DataTableAdvancedFilterField<PeriodicEvaluationView>[] = [ + const advancedFilterFields: DataTableAdvancedFilterField<PeriodicEvaluationView | PeriodicEvaluationAggregatedView>[] = [ { id: "evaluationYear", label: "평가년도", type: "number" }, { id: "evaluationPeriod", label: "평가기간", type: "text" }, { id: "division", label: "구분", type: "select", options: [ { label: "해양", value: "PLANT" }, { label: "조선", value: "SHIP" }, + ...(viewMode === "aggregated" ? [{ label: "통합", value: "BOTH" }] : []), ] }, { id: "vendorCode", label: "벤더 코드", type: "text" }, @@ -311,7 +439,6 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className } { label: "미제출", value: "false" }, ] }, - { id: "totalScore", label: "총점", type: "number" }, { id: "finalScore", label: "최종점수", type: "number" }, { id: "submissionDate", label: "제출일", type: "date" }, { id: "reviewCompletedAt", label: "검토완료일", type: "date" }, @@ -326,7 +453,6 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className } columnPinning: currentSettings.pinnedColumns, }), [columns, currentSettings, initialSettings.sort]); - const { table } = useDataTable({ data: tableData.data, columns, @@ -341,18 +467,13 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className } clearOnDefault: true, }) - const handleSearch = () => { - setIsFilterPanelOpen(false) - } - - const getActiveBasicFilterCount = () => { + const getActiveFilterCount = React.useCallback(() => { try { - const basicFilters = getSearchParam('basicFilters') - return basicFilters ? JSON.parse(basicFilters).length : 0 - } catch (e) { - return 0 + return currentParams.filters?.length || 0; + } catch { + return 0; } - } + }, [currentParams.filters]); const FILTER_PANEL_WIDTH = 400; @@ -374,7 +495,7 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className } <PeriodicEvaluationFilterSheet isOpen={isFilterPanelOpen} onClose={() => setIsFilterPanelOpen(false)} - onSearch={handleSearch} + onFiltersApply={handleFiltersApply} isLoading={false} /> </div> @@ -393,35 +514,56 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className } marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px' }} > - {/* Header Bar */} + {/* Header Bar with View Toggle */} <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' + type="button" onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)} className="flex items-center shadow-sm" > {isFilterPanelOpen ? <PanelLeftClose className="size-4" /> : <PanelLeftOpen className="size-4" />} - {getActiveBasicFilterCount() > 0 && ( + {getActiveFilterCount() > 0 && ( <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs"> - {getActiveBasicFilterCount()} + {getActiveFilterCount()} </span> )} </Button> + + {/* ✅ 뷰 모드 토글 */} + <EvaluationViewToggle + value={viewMode} + onValueChange={handleViewModeChange} + detailedCount={detailedCount} + aggregatedCount={aggregatedCount} + /> </div> - <div className="text-sm text-muted-foreground"> - {tableData && ( - <span>총 {tableData.total || tableData.data.length}건</span> - )} + <div className="flex items-center gap-4"> + <div className="text-sm text-muted-foreground"> + {viewMode === "detailed" ? ( + <span>모든 평가 기록 표시</span> + ) : ( + <span>벤더별 통합 평가 표시</span> + )} + </div> + + <div className="text-sm text-muted-foreground"> + {tableData && ( + <span>총 {tableData.total || tableData.data.length}건</span> + )} + </div> </div> </div> {/* 통계 카드들 */} <div className="px-4"> - <PeriodicEvaluationsStats evaluationYear={evaluationYear} /> + <PeriodicEvaluationsStats + evaluationYear={evaluationYear} + viewMode={viewMode} + /> </div> {/* Table Content Area */} @@ -431,10 +573,16 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className } <DataTableAdvancedToolbar table={table} filterFields={advancedFilterFields} + debounceMs={300} shallow={false} + externalFilters={externalFilters} + externalJoinOperator={externalJoinOperator} + onFiltersChange={(filters, joinOperator) => { + console.log("=== 필터 변경 감지 ===", filters, joinOperator); + }} > <div className="flex items-center gap-2"> - <TablePresetManager<PeriodicEvaluationView> + <TablePresetManager<PeriodicEvaluationView | PeriodicEvaluationAggregatedView> presets={presets} activePresetId={activePresetId} currentSettings={currentSettings} @@ -448,9 +596,7 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className } onRenamePreset={renamePreset} /> - <PeriodicEvaluationsTableToolbarActions - table={table} - /> + <PeriodicEvaluationsTableToolbarActions table={table} /> </div> </DataTableAdvancedToolbar> </DataTable> @@ -459,12 +605,11 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className } open={rowAction?.type === "view"} onOpenChange={(open) => { if (!open) { - setRowAction(null) + setRowAction(null); } }} evaluation={rowAction?.row.original || null} /> - </div> </div> </div> |
