summaryrefslogtreecommitdiff
path: root/lib/evaluation/table/evaluation-table.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'lib/evaluation/table/evaluation-table.tsx')
-rw-r--r--lib/evaluation/table/evaluation-table.tsx462
1 files changed, 462 insertions, 0 deletions
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<ReturnType<typeof getPeriodicEvaluations>>]>
+ evaluationYear: number
+ className?: string
+}
+
+// 통계 카드 컴포넌트
+function PeriodicEvaluationsStats({ 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)
+ // 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 (
+ <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 || !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">
+ {error ? `통계 데이터를 불러올 수 없습니다: ${error}` : "통계 데이터가 없습니다."}
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+ )
+ }
+
+ 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 (
+ <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">{totalEvaluations.toLocaleString()}</div>
+ <div className="text-xs text-muted-foreground mt-1">
+ 평균점수 {stats.averageScore?.toFixed(1) || 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="outline">대기</Badge>
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold text-orange-600">{pendingSubmission.toLocaleString()}</div>
+ <div className="text-xs text-muted-foreground mt-1">
+ {totalEvaluations > 0 ? Math.round((pendingSubmission / totalEvaluations) * 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="secondary">진행</Badge>
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold text-blue-600">{inProgress.toLocaleString()}</div>
+ <div className="text-xs text-muted-foreground mt-1">
+ {totalEvaluations > 0 ? Math.round((inProgress / totalEvaluations) * 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={completionRate >= 80 ? "default" : completionRate >= 60 ? "secondary" : "destructive"}>
+ {completionRate}%
+ </Badge>
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold text-green-600">{finalized.toLocaleString()}</div>
+ <div className="text-xs text-muted-foreground mt-1">
+ 최종확정 완료
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+ )
+}
+
+export function PeriodicEvaluationsTable({ promises, evaluationYear, className }: PeriodicEvaluationsTableProps) {
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<PeriodicEvaluationView> | null>(null)
+ const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false)
+ 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()
+ 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<PeriodicEvaluationView>('periodic-evaluations-table', initialSettings)
+
+ const columns = React.useMemo(
+ () => getPeriodicEvaluationsColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ const filterFields: DataTableFilterField<PeriodicEvaluationView>[] = [
+ { id: "evaluationTarget.vendorCode", label: "벤더 코드" },
+ { id: "evaluationTarget.vendorName", label: "벤더명" },
+ { id: "status", label: "진행상태" },
+ ]
+
+ const advancedFilterFields: DataTableAdvancedFilterField<PeriodicEvaluationView>[] = [
+ { 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<T>(c: ColumnDef<T>): 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 */}
+ <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">
+ <PeriodicEvaluationFilterSheet
+ 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">
+ <PeriodicEvaluationsStats 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<PeriodicEvaluationView>
+ presets={presets}
+ activePresetId={activePresetId}
+ currentSettings={currentSettings}
+ hasUnsavedChanges={hasUnsavedChanges}
+ isLoading={presetsLoading}
+ onCreatePreset={createPreset}
+ onUpdatePreset={updatePreset}
+ onDeletePreset={deletePreset}
+ onApplyPreset={applyPreset}
+ onSetDefaultPreset={setDefaultPreset}
+ onRenamePreset={renamePreset}
+ />
+
+ {/* TODO: PeriodicEvaluationsTableToolbarActions 구현 */}
+ </div>
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ {/* TODO: 수정/상세보기 모달 구현 */}
+
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </>
+ )
+} \ No newline at end of file