From aa86729f9a2ab95346a2851e3837de1c367aae17 Mon Sep 17 00:00:00 2001
From: dujinkim <dujin.kim@dtsolution.co.kr>
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<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
-- 
cgit v1.2.3

