// 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, 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, 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 { cn } from "@/lib/utils" import { useTablePresets } from "@/components/data-table/use-table-presets" import { TablePresetManager } from "@/components/data-table/data-table-preset" import { PeriodicEvaluationFilterSheet } from "./evaluation-filter-sheet" // ✅ 올바른 컴포넌트 이름 import { getPeriodicEvaluationsColumns } from "./evaluation-columns" 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>]> 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 (
{ if (newValue) onValueChange(newValue as "detailed" | "aggregated"); }} className="bg-muted p-1 rounded-lg" > 상세 뷰 {detailedCount !== undefined && ( {detailedCount} )} 집계 뷰 {aggregatedCount !== undefined && ( {aggregatedCount} )}
상세 뷰: 모든 평가 기록을 개별적으로 표시
집계 뷰: 동일 벤더의 여러 division 평가를 평균으로 통합하여 표시
); } // 통계 카드 컴포넌트 function PeriodicEvaluationsStats({ evaluationYear, viewMode }: { evaluationYear: number; viewMode: "detailed" | "aggregated"; }) { 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 getPeriodicEvaluationsStats( evaluationYear, viewMode === "aggregated" ) if (isMounted) { setStats(statsData) } } catch (err) { if (isMounted) { setError(err instanceof Error ? err.message : 'Failed to fetch stats') console.error('Error fetching periodic evaluations stats:', err) } } finally { if (isMounted) { setIsLoading(false) } } } fetchStats() return () => { isMounted = false } }, [evaluationYear, viewMode]) if (isLoading) { return (
{Array.from({ length: 4 }).map((_, i) => ( ))}
) } if (error || !stats) { return (
{error ? `통계 데이터를 불러올 수 없습니다: ${error}` : "통계 데이터가 없습니다."}
) } const totalEvaluations = stats.total || 0 const pendingSubmission = stats.pendingSubmission || 0 const inProgress = (stats.submitted || 0) + (stats.inReview || 0) + (stats.reviewCompleted || 0) const finalized = stats.finalized || 0 const completionRate = stats.completionRate || 0 return (
{/* 총 평가 */} 총 {viewMode === "aggregated" ? "벤더" : "평가"}
{evaluationYear}년 {viewMode === "aggregated" && ( 집계 )}
{totalEvaluations.toLocaleString()}
평균점수 {stats.averageScore?.toFixed(1) || 0}점 {viewMode === "aggregated" && stats.totalEvaluationCount && ( (총 {stats.totalEvaluationCount}개 평가) )}
{/* 제출대기 */} 제출대기 대기
{pendingSubmission.toLocaleString()}
{totalEvaluations > 0 ? Math.round((pendingSubmission / totalEvaluations) * 100) : 0}% of total
{/* 진행중 */} 진행중 진행
{inProgress.toLocaleString()}
{totalEvaluations > 0 ? Math.round((inProgress / totalEvaluations) * 100) : 0}% of total
{/* 완료율 */} 완료율 = 80 ? "default" : completionRate >= 60 ? "secondary" : "destructive"}> {completionRate}%
{finalized.toLocaleString()}
최종확정 완료
) } 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 | null>(null) const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false) const [detailedCount, setDetailedCount] = React.useState(undefined) const [aggregatedCount, setAggregatedCount] = React.useState(undefined) // ✅ 외부 필터 상태 (폼에서 전달받은 필터) - EvaluationTargetsTable 패턴과 동일 const [externalFilters, setExternalFilters] = React.useState([]); const [externalJoinOperator, setExternalJoinOperator] = React.useState<"and" | "or">("and"); // ✅ 폼에서 전달받은 필터를 처리하는 핸들러 - EvaluationTargetsTable 패턴과 동일 const handleFiltersApply = React.useCallback((filters: any[], joinOperator: "and" | "or") => { console.log("=== 폼에서 필터 전달받음 ===", filters, joinOperator); setExternalFilters(filters); setExternalJoinOperator(joinOperator); // 필터 적용 후 패널 닫기 setIsFilterPanelOpen(false); }, []); // ✅ 뷰 모드 변경 시 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 searchString = React.useMemo( () => searchParams.toString(), [searchParams] ) const getSearchParam = React.useCallback( (key: string, def = "") => new URLSearchParams(searchString).get(key) ?? def, [searchString] ) // ✅ 초기 데이터 설정 - EvaluationTargetsTable 패턴과 동일 const [initialPromiseData] = React.use(promises) const [tableData, setTableData] = React.useState(initialPromiseData) const [isDataLoading, setIsDataLoading] = React.useState(false) // ✅ URL 필터 변경 감지 및 데이터 새로고침 - EvaluationTargetsTable 패턴과 동일 React.useEffect(() => { const refetchData = async () => { try { setIsDataLoading(true) // 현재 URL 파라미터 기반으로 새 검색 파라미터 생성 const currentFilters = getSearchParam("filters") const currentJoinOperator = getSearchParam("joinOperator", "and") const currentPage = parseInt(getSearchParam("page", "1")) const currentPerPage = parseInt(getSearchParam("perPage", "10")) const currentSort = getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }] const currentSearch = getSearchParam("search", "") const currentAggregated = getSearchParam("aggregated") === "true" const searchParams = { filters: currentFilters ? JSON.parse(currentFilters) : [], joinOperator: currentJoinOperator as "and" | "or", page: currentPage, perPage: currentPerPage, sort: currentSort, search: currentSearch, evaluationYear: evaluationYear, aggregated: currentAggregated } console.log("=== 새 데이터 요청 ===", searchParams) // 서버 액션 직접 호출 const newData = await getPeriodicEvaluationsWithAggregation(searchParams) setTableData(newData) console.log("=== 데이터 업데이트 완료 ===", newData.data.length, "건") } catch (error) { console.error("데이터 새로고침 오류:", error) } finally { setIsDataLoading(false) } } // 필터나 검색 파라미터가 변경되면 데이터 새로고침 (디바운스 적용) const timeoutId = setTimeout(() => { // 필터, 검색, 페이지네이션, 정렬 중 하나라도 변경되면 새로고침 const hasChanges = getSearchParam("filters") || getSearchParam("search") || getSearchParam("page") !== "1" || getSearchParam("perPage") !== "10" || getSearchParam("sort") || getSearchParam("aggregated") if (hasChanges) { refetchData() } }, 300) // 디바운스 시간 단축 return () => clearTimeout(timeoutId) }, [searchString, evaluationYear, getSearchParam]) const refreshData = React.useCallback(async () => { try { setIsDataLoading(true) // 현재 URL 파라미터로 데이터 새로고침 const currentFilters = getSearchParam("filters") const currentJoinOperator = getSearchParam("joinOperator", "and") const currentPage = parseInt(getSearchParam("page", "1")) const currentPerPage = parseInt(getSearchParam("perPage", "10")) const currentSort = getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }] const currentSearch = getSearchParam("search", "") const currentAggregated = getSearchParam("aggregated") === "true" const searchParams = { filters: currentFilters ? JSON.parse(currentFilters) : [], joinOperator: currentJoinOperator as "and" | "or", page: currentPage, perPage: currentPerPage, sort: currentSort, search: currentSearch, evaluationYear: evaluationYear, aggregated: currentAggregated } const newData = await getPeriodicEvaluationsWithAggregation(searchParams) setTableData(newData) console.log("=== 데이터 새로고침 완료 ===", newData.data.length, "건") } catch (error) { console.error("데이터 새로고침 오류:", error) } finally { setIsDataLoading(false) } }, [evaluationYear, getSearchParam]) // 컨테이너 위치 추적 - EvaluationTargetsTable 패턴과 동일 const containerRef = React.useRef(null) const [containerTop, setContainerTop] = React.useState(0) const updateContainerBounds = React.useCallback(() => { if (containerRef.current) { const rect = containerRef.current.getBoundingClientRect() const newTop = rect.top setContainerTop(prevTop => { if (Math.abs(prevTop - newTop) > 1) { return newTop } return prevTop }) } }, []) 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 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 = (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: parseSearchParam("filters", []), joinOperator: (getSearchParam("joinOperator") as "and" | "or") || "and", search: getSearchParam("search", ""), columnVisibility: {}, columnOrder: [], pinnedColumns: { left: [], right: ["actions"] }, groupBy: [], expandedRows: [] }), [getSearchParam, parseSearchParam]) const { presets, activePresetId, hasUnsavedChanges, isLoading: presetsLoading, createPreset, applyPreset, updatePreset, deletePreset, setDefaultPreset, renamePreset, getCurrentSettings, } = useTablePresets('periodic-evaluations-table', initialSettings) // 집계 모드에 따라 컬럼 수정 const columns = React.useMemo(() => { return getPeriodicEvaluationsColumns({ setRowAction, viewMode }); }, [viewMode, setRowAction]); const filterFields: DataTableFilterField[] = [ { id: "vendorCode", label: "벤더 코드" }, { id: "vendorName", label: "벤더명" }, { id: "status", label: "진행상태" }, ] const advancedFilterFields: DataTableAdvancedFilterField[] = [ { 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" }, { id: "vendorName", label: "벤더명", type: "text" }, { id: "status", label: "진행상태", type: "select", options: [ { label: "제출대기", value: "PENDING_SUBMISSION" }, { label: "제출완료", value: "SUBMITTED" }, { label: "검토중", value: "IN_REVIEW" }, { label: "검토완료", value: "REVIEW_COMPLETED" }, { label: "최종확정", value: "FINALIZED" }, ] }, { id: "documentsSubmitted", label: "문서제출", type: "select", options: [ { label: "제출완료", value: "true" }, { label: "미제출", value: "false" }, ] }, { id: "finalScore", label: "최종점수", type: "number" }, { id: "submissionDate", label: "제출일", type: "date" }, { id: "reviewCompletedAt", label: "검토완료일", type: "date" }, { id: "finalizedAt", label: "최종확정일", type: "date" }, ] const currentSettings = React.useMemo(() => getCurrentSettings(), [getCurrentSettings]); const initialState = React.useMemo(() => ({ sorting: initialSettings.sort.filter((s: any) => columns.some((c: any) => ("accessorKey" in c ? c.accessorKey : c.id) === 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 getActiveFilterCount = React.useCallback(() => { try { // URL에서 현재 필터 수 확인 const filtersParam = getSearchParam("filters") if (filtersParam) { const filters = JSON.parse(filtersParam) return Array.isArray(filters) ? filters.length : 0 } return 0 } catch { return 0 } }, [getSearchParam]) const FILTER_PANEL_WIDTH = 400; return ( <> {/* Filter Panel */}
{/* ✅ 올바른 컴포넌트 사용 */} setIsFilterPanelOpen(false)} onFiltersApply={handleFiltersApply} // ✅ 필터 적용 콜백 전달 isLoading={false} />
{/* Main Content Container */}
{/* Header Bar with View Toggle */}
{/* ✅ 뷰 모드 토글 */}
{viewMode === "detailed" ? ( 모든 평가 기록 표시 ) : ( 벤더별 통합 평가 표시 )}
{tableData && ( 총 {tableData.total || tableData.data.length}건 )}
{/* 통계 카드들 */}
{/* Table Content Area */}
{isDataLoading && (
필터링 중...
)}
{/* ✅ EvaluationTargetsTable 패턴과 동일하게 수정 */} { console.log("=== 필터 변경 감지 ===", filters, joinOperator); }} >
presets={presets} activePresetId={activePresetId} currentSettings={currentSettings} hasUnsavedChanges={hasUnsavedChanges} isLoading={presetsLoading} onCreatePreset={createPreset} onUpdatePreset={updatePreset} onDeletePreset={deletePreset} onApplyPreset={applyPreset} onSetDefaultPreset={setDefaultPreset} onRenamePreset={renamePreset} />
{ if (!open) { setRowAction(null); } }} evaluation={rowAction?.row.original || null} />
) }