summaryrefslogtreecommitdiff
path: root/lib/evaluation/table/evaluation-table.tsx
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-07-24 11:06:32 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-07-24 11:06:32 +0000
commit1dc24d48e52f2e490f5603ceb02842586ecae533 (patch)
tree8fca2c5b5b52cc10557b5ba6e55b937ae3c57cf6 /lib/evaluation/table/evaluation-table.tsx
parented0d6fcc98f671280c2ccde797b50693da88152e (diff)
(대표님) 정기평가 피드백 반영, 설계 피드백 반영, (최겸) 기술영업 피드백 반영
Diffstat (limited to 'lib/evaluation/table/evaluation-table.tsx')
-rw-r--r--lib/evaluation/table/evaluation-table.tsx341
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>