From aa86729f9a2ab95346a2851e3837de1c367aae17 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Fri, 20 Jun 2025 11:37:31 +0000 Subject: (대표님) 20250620 작업사항 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/evaluation/table/evaluation-table.tsx | 462 ++++++++++++++++++++++++++++++ 1 file changed, 462 insertions(+) create mode 100644 lib/evaluation/table/evaluation-table.tsx (limited to 'lib/evaluation/table/evaluation-table.tsx') diff --git a/lib/evaluation/table/evaluation-table.tsx b/lib/evaluation/table/evaluation-table.tsx new file mode 100644 index 00000000..16f70592 --- /dev/null +++ b/lib/evaluation/table/evaluation-table.tsx @@ -0,0 +1,462 @@ +"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 { 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" + +interface PeriodicEvaluationsTableProps { + promises: Promise<[Awaited>]> + evaluationYear: number + className?: string +} + +// 통계 카드 컴포넌트 +function PeriodicEvaluationsStats({ 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) + // TODO: getPeriodicEvaluationsStats 구현 필요 + const statsData = { + total: 150, + pendingSubmission: 25, + submitted: 45, + inReview: 30, + reviewCompleted: 35, + finalized: 15, + averageScore: 82.5, + completionRate: 75 + } + + 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 + } + }, []) + + 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 ( +
+ {/* 총 평가 */} + + + 총 평가 + {evaluationYear}년 + + +
{totalEvaluations.toLocaleString()}
+
+ 평균점수 {stats.averageScore?.toFixed(1) || 0}점 +
+
+
+ + {/* 제출대기 */} + + + 제출대기 + 대기 + + +
{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, className }: PeriodicEvaluationsTableProps) { + 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) + + 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 + }) + } + }, []) + + 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() + } + + window.addEventListener('resize', handleResize) + window.addEventListener('scroll', throttledHandler) + + return () => { + window.removeEventListener('resize', handleResize) + window.removeEventListener('scroll', throttledHandler) + } + }, [updateContainerBounds, throttledUpdateBounds]) + + const [promiseData] = React.use(promises) + const tableData = promiseData + + 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('periodic-evaluations-table', initialSettings) + + const columns = React.useMemo( + () => getPeriodicEvaluationsColumns({ setRowAction }), + [setRowAction] + ) + + const filterFields: DataTableFilterField[] = [ + { id: "evaluationTarget.vendorCode", label: "벤더 코드" }, + { id: "evaluationTarget.vendorName", label: "벤더명" }, + { id: "status", label: "진행상태" }, + ] + + const advancedFilterFields: DataTableAdvancedFilterField[] = [ + { id: "evaluationTarget.evaluationYear", label: "평가년도", type: "number" }, + { id: "evaluationPeriod", label: "평가기간", type: "text" }, + { + id: "evaluationTarget.division", label: "구분", type: "select", options: [ + { label: "해양", value: "PLANT" }, + { label: "조선", value: "SHIP" }, + ] + }, + { id: "evaluationTarget.vendorCode", label: "벤더 코드", type: "text" }, + { id: "evaluationTarget.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: "totalScore", label: "총점", type: "number" }, + { id: "finalScore", label: "최종점수", type: "number" }, + { id: "submissionDate", label: "제출일", type: "date" }, + { id: "reviewCompletedAt", label: "검토완료일", type: "date" }, + { id: "finalizedAt", 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} + /> + + {/* TODO: PeriodicEvaluationsTableToolbarActions 구현 */} +
+
+
+ + {/* TODO: 수정/상세보기 모달 구현 */} + +
+
+
+
+
+ + ) +} \ No newline at end of file -- cgit v1.2.3