From 4e63d8427d26d0d1b366ddc53650e15f3481fc75 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Tue, 24 Jun 2025 01:44:03 +0000 Subject: (대표님/최겸) 20250624 작업사항 10시43분 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../table/evaluation-target-table.tsx | 543 +++++++++------------ 1 file changed, 243 insertions(+), 300 deletions(-) (limited to 'lib/evaluation-target-list/table/evaluation-target-table.tsx') diff --git a/lib/evaluation-target-list/table/evaluation-target-table.tsx b/lib/evaluation-target-list/table/evaluation-target-table.tsx index fe0b3188..87be3589 100644 --- a/lib/evaluation-target-list/table/evaluation-target-table.tsx +++ b/lib/evaluation-target-list/table/evaluation-target-table.tsx @@ -1,76 +1,61 @@ -"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" +// ============================================================================ +// components/evaluation-targets-table.tsx (CLIENT COMPONENT) +// ─ 완전본 ─ evaluation-targets-columns.tsx 는 별도 +// ============================================================================ +"use client"; + +import * as React from "react"; +import { 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 { getEvaluationTargets, getEvaluationTargetsStats } from "../service" -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 { getEvaluationTargetsColumns } from "./evaluation-targets-columns" -import { EvaluationTargetsTableToolbarActions } from "./evaluation-targets-toolbar-actions" -import { EvaluationTargetFilterSheet } from "./evaluation-targets-filter-sheet" -import { EvaluationTargetWithDepartments } from "@/db/schema" -import { EditEvaluationTargetSheet } from "./update-evaluation-target" - -interface EvaluationTargetsTableProps { - promises: Promise<[Awaited>]> - evaluationYear: number - className?: string -} - -// 통계 카드 컴포넌트 (클라이언트 컴포넌트용) +} 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 { getEvaluationTargets, getEvaluationTargetsStats } from "../service"; +import { cn } from "@/lib/utils"; +import { useTablePresets } from "@/components/data-table/use-table-presets"; +import { TablePresetManager } from "@/components/data-table/data-table-preset"; +import { getEvaluationTargetsColumns } from "./evaluation-targets-columns"; +import { EvaluationTargetsTableToolbarActions } from "./evaluation-targets-toolbar-actions"; +import { EvaluationTargetFilterSheet } from "./evaluation-targets-filter-sheet"; +import { EvaluationTargetWithDepartments } from "@/db/schema"; +import { EditEvaluationTargetSheet } from "./update-evaluation-target"; + +/* -------------------------------------------------------------------------- */ +/* Stats Card */ +/* -------------------------------------------------------------------------- */ function EvaluationTargetsStats({ evaluationYear }: { evaluationYear: number }) { - const [stats, setStats] = React.useState(null) - const [isLoading, setIsLoading] = React.useState(true) - const [error, setError] = React.useState(null) + 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() { + let mounted = true; + (async () => { try { - setIsLoading(true) - setError(null) - const statsData = await getEvaluationTargetsStats(evaluationYear) - - if (isMounted) { - setStats(statsData) - } - } catch (err) { - if (isMounted) { - setError(err instanceof Error ? err.message : 'Failed to fetch stats') - console.error('Error fetching evaluation targets stats:', err) - } + setIsLoading(true); + const data = await getEvaluationTargetsStats(evaluationYear); + mounted && setStats(data); + } catch (e) { + mounted && setError(e instanceof Error ? e.message : "failed"); } finally { - if (isMounted) { - setIsLoading(false) - } + mounted && setIsLoading(false); } - } - - fetchStats() - + })(); return () => { - isMounted = false - } - }, []) + mounted = false; + }; + }, [evaluationYear]); - if (isLoading) { + if (isLoading) return (
{Array.from({ length: 4 }).map((_, i) => ( @@ -84,42 +69,32 @@ function EvaluationTargetsStats({ evaluationYear }: { evaluationYear: number }) ))}
- ) - } - - if (error) { + ); + if (error) return (
- -
- 통계 데이터를 불러올 수 없습니다: {error} -
+ + 통계 데이터를 불러올 수 없습니다: {error}
- ) - } - - if (!stats) { + ); + if (!stats) return (
- -
- 통계 데이터가 없습니다. -
+ + 통계 데이터가 없습니다.
- ) - } + ); - const totalTargets = stats.total || 0 - const pendingTargets = stats.pending || 0 - const confirmedTargets = stats.confirmed || 0 - const excludedTargets = stats.excluded || 0 - const consensusRate = totalTargets > 0 ? Math.round(((stats.consensusTrue || 0) / totalTargets) * 100) : 0 + const total = stats.total || 0; + const pending = stats.pending || 0; + const confirmed = stats.confirmed || 0; + const consensusRate = total ? Math.round(((stats.consensusTrue || 0) / total) * 100) : 0; return (
@@ -130,7 +105,7 @@ function EvaluationTargetsStats({ evaluationYear }: { evaluationYear: number }) {evaluationYear}년 -
{totalTargets.toLocaleString()}
+
{total.toLocaleString()}
해양 {stats.oceanDivision || 0}개 | 조선 {stats.shipyardDivision || 0}개
@@ -144,9 +119,9 @@ function EvaluationTargetsStats({ evaluationYear }: { evaluationYear: number }) 대기 -
{pendingTargets.toLocaleString()}
+
{pending.toLocaleString()}
- {totalTargets > 0 ? Math.round((pendingTargets / totalTargets) * 100) : 0}% of total + {total ? Math.round((pending / total) * 100) : 0}% of total
@@ -155,12 +130,12 @@ function EvaluationTargetsStats({ evaluationYear }: { evaluationYear: number }) 확정 - 완료 + 완료 -
{confirmedTargets.toLocaleString()}
+
{confirmed.toLocaleString()}
- {totalTargets > 0 ? Math.round((confirmedTargets / totalTargets) * 100) : 0}% of total + {total ? Math.round((confirmed / total) * 100) : 0}% of total
@@ -169,9 +144,7 @@ function EvaluationTargetsStats({ evaluationYear }: { evaluationYear: number }) 의견 일치율 - = 80 ? "default" : consensusRate >= 60 ? "secondary" : "destructive"}> - {consensusRate}% - + = 80 ? "default" : consensusRate >= 60 ? "secondary" : "destructive"}>{consensusRate}%
{consensusRate}%
@@ -181,83 +154,92 @@ function EvaluationTargetsStats({ evaluationYear }: { evaluationYear: number })
- ) + ); +} + +/* -------------------------------------------------------------------------- */ +/* EvaluationTargetsTable */ +/* -------------------------------------------------------------------------- */ +interface EvaluationTargetsTableProps { + promises: Promise<[Awaited>]>; + evaluationYear: number; + className?: string; } export function EvaluationTargetsTable({ promises, evaluationYear, className }: EvaluationTargetsTableProps) { - const [rowAction, setRowAction] = React.useState | null>(null) - const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false) - const router = useRouter() - const searchParams = useSearchParams() + const [rowAction, setRowAction] = React.useState | null>(null); + const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false); + const searchParams = useSearchParams(); - const containerRef = React.useRef(null) - const [containerTop, setContainerTop] = React.useState(0) + /* --------------------------- layout refs --------------------------- */ + const containerRef = React.useRef(null); + const [containerTop, setContainerTop] = React.useState(0); - // ✅ 스크롤 이벤트 throttling으로 성능 최적화 + // RFQ 패턴으로 변경: State를 통한 위치 관리 const updateContainerBounds = React.useCallback(() => { if (containerRef.current) { - const rect = containerRef.current.getBoundingClientRect() - const newTop = rect.top - - // ✅ 값이 실제로 변경될 때만 상태 업데이트 - setContainerTop(prevTop => { - if (Math.abs(prevTop - newTop) > 1) { // 1px 이상 차이날 때만 업데이트 - return newTop - } - return prevTop - }) + const rect = containerRef.current.getBoundingClientRect(); + setContainerTop(rect.top); } - }, []) - - // ✅ throttle 함수 추가 - const throttledUpdateBounds = React.useCallback(() => { - let timeoutId: NodeJS.Timeout - return () => { - clearTimeout(timeoutId) - timeoutId = setTimeout(updateContainerBounds, 16) // ~60fps - } - }, [updateContainerBounds]) + }, []); React.useEffect(() => { - updateContainerBounds() - - const throttledHandler = throttledUpdateBounds() - + updateContainerBounds(); + const handleResize = () => { - updateContainerBounds() - } - - window.addEventListener('resize', handleResize) - window.addEventListener('scroll', throttledHandler) // ✅ throttled 함수 사용 - + updateContainerBounds(); + }; + + window.addEventListener('resize', handleResize); + window.addEventListener('scroll', updateContainerBounds); + return () => { - window.removeEventListener('resize', handleResize) - window.removeEventListener('scroll', throttledHandler) + window.removeEventListener('resize', handleResize); + window.removeEventListener('scroll', updateContainerBounds); + }; + }, [updateContainerBounds]); + + /* ---------------------- 데이터 프리패치 ---------------------- */ + const [promiseData] = React.use(promises); + const tableData = promiseData; + + /* ---------------------- 검색 파라미터 안전 처리 ---------------------- */ + const getSearchParam = React.useCallback((key: string, defaultValue?: string): string => { + return searchParams?.get(key) ?? defaultValue ?? ""; + }, [searchParams]); + + // 제네릭 함수는 useCallback 밖에서 정의 + const parseSearchParamHelper = React.useCallback((key: string, defaultValue: any): any => { + try { + const value = getSearchParam(key); + return value ? JSON.parse(value) : defaultValue; + } catch { + return defaultValue; } - }, [updateContainerBounds, throttledUpdateBounds]) + }, [getSearchParam]); - const [promiseData] = React.use(promises) - const tableData = promiseData - - console.log(tableData) +const parseSearchParam = (key: string, defaultValue: T): T => { + return parseSearchParamHelper(key, defaultValue); +}; + /* ---------------------- 초기 설정 ---------------------------- */ 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') || '', + page: parseInt(getSearchParam("page", "1")), + perPage: parseInt(getSearchParam("perPage", "10")), + sort: parseSearchParam("sort", [{ id: "createdAt", desc: true }]), + filters: parseSearchParam("filters", []), + joinOperator: (getSearchParam("joinOperator") as "and" | "or") || "and", + basicFilters: parseSearchParam("basicFilters", []), + basicJoinOperator: (getSearchParam("basicJoinOperator") as "and" | "or") || "and", + search: getSearchParam("search", ""), columnVisibility: {}, columnOrder: [], pinnedColumns: { left: [], right: ["actions"] }, groupBy: [], - expandedRows: [] - }), [searchParams]) + expandedRows: [], + }), [getSearchParam, parseSearchParamHelper]); + /* --------------------- 프리셋 훅 ------------------------------ */ const { presets, activePresetId, @@ -269,83 +251,59 @@ export function EvaluationTargetsTable({ promises, evaluationYear, className }: deletePreset, setDefaultPreset, renamePreset, - updateClientState, getCurrentSettings, - } = useTablePresets('evaluation-targets-table', initialSettings) - - const columns = React.useMemo( - () => getEvaluationTargetsColumns({ setRowAction }), - [setRowAction] - ) - + } = useTablePresets( + "evaluation-targets-table", + initialSettings + ); + + /* --------------------- 컬럼 ------------------------------ */ + const columns = React.useMemo(() => getEvaluationTargetsColumns({ setRowAction }), [setRowAction]); +// const columns =[ +// { accessorKey: "vendorCode", header: "벤더 코드" }, +// { accessorKey: "vendorName", header: "벤더명" }, +// { accessorKey: "status", header: "상태" }, +// { accessorKey: "evaluationYear", header: "평가년도" }, +// { accessorKey: "division", header: "구분" } +// ]; + +window.addEventListener('beforeunload', () => { + console.trace('[beforeunload] 문서가 통째로 사라지려 합니다!'); +}); + + /* 기본 필터 */ const filterFields: DataTableFilterField[] = [ { id: "vendorCode", label: "벤더 코드" }, { id: "vendorName", label: "벤더명" }, { id: "status", label: "상태" }, - ] + ]; + /* 고급 필터 */ const advancedFilterFields: DataTableAdvancedFilterField[] = [ { id: "evaluationYear", label: "평가년도", type: "number" }, - { - id: "division", label: "구분", type: "select", options: [ - { label: "해양", value: "OCEAN" }, - { label: "조선", value: "SHIPYARD" }, - ] - }, + { id: "division", label: "구분", type: "select", options: [ { label: "해양", value: "OCEAN" }, { label: "조선", value: "SHIPYARD" } ] }, { id: "vendorCode", label: "벤더 코드", type: "text" }, { id: "vendorName", label: "벤더명", type: "text" }, - { - id: "domesticForeign", label: "내외자", type: "select", options: [ - { label: "내자", value: "DOMESTIC" }, - { label: "외자", value: "FOREIGN" }, - ] - }, - { - id: "materialType", label: "자재구분", type: "select", options: [ - { label: "기자재", value: "EQUIPMENT" }, - { label: "벌크", value: "BULK" }, - { label: "기자재/벌크", value: "EQUIPMENT_BULK" }, - ] - }, - { - id: "status", label: "상태", type: "select", options: [ - { label: "검토 중", value: "PENDING" }, - { label: "확정", value: "CONFIRMED" }, - { label: "제외", value: "EXCLUDED" }, - ] - }, - { - id: "consensusStatus", label: "의견 일치", type: "select", options: [ - { label: "의견 일치", value: "true" }, - { label: "의견 불일치", value: "false" }, - { label: "검토 중", value: "null" }, - ] - }, + { id: "domesticForeign", label: "내외자", type: "select", options: [ { label: "내자", value: "DOMESTIC" }, { label: "외자", value: "FOREIGN" } ] }, + { id: "materialType", label: "자재구분", type: "select", options: [ { label: "기자재", value: "EQUIPMENT" }, { label: "벌크", value: "BULK" }, { label: "기/벌", value: "EQUIPMENT_BULK" } ] }, + { id: "status", label: "상태", type: "select", options: [ { label: "검토 중", value: "PENDING" }, { label: "확정", value: "CONFIRMED" }, { label: "제외", value: "EXCLUDED" } ] }, + { id: "consensusStatus", label: "의견 일치", type: "select", options: [ { label: "일치", value: "true" }, { label: "불일치", value: "false" }, { label: "검토 중", value: "null" } ] }, { id: "adminComment", label: "관리자 의견", type: "text" }, { id: "consolidatedComment", label: "종합 의견", type: "text" }, { id: "confirmedAt", label: "확정일", type: "date" }, { id: "createdAt", 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]) + ]; + + /* current settings */ + 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]); + /* ----------------------- useDataTable ------------------------ */ const { table } = useDataTable({ data: tableData.data, columns, @@ -355,134 +313,119 @@ export function EvaluationTargetsTable({ promises, evaluationYear, className }: enablePinning: true, enableAdvancedFilter: true, initialState, - getRowId: (originalRow) => String(originalRow.id), + getRowId: (row) => String(row.id), shallow: false, clearOnDefault: true, - }) + }); - const handleSearch = () => { - setIsFilterPanelOpen(false) - } - - const getActiveBasicFilterCount = () => { + /* ---------------------- helper ------------------------------ */ + const getActiveBasicFilterCount = React.useCallback(() => { try { - const basicFilters = searchParams.get('basicFilters') - return basicFilters ? JSON.parse(basicFilters).length : 0 - } catch (e) { - return 0 + const f = getSearchParam("basicFilters"); + return f ? JSON.parse(f).length : 0; + } catch { + return 0; } - } + }, [getSearchParam]); const FILTER_PANEL_WIDTH = 400; + /* ---------------------------- JSX ---------------------------- */ return ( - <> + <> {/* Filter Panel */}
-
- setIsFilterPanelOpen(false)} - onSearch={handleSearch} - isLoading={false} - /> -
+ setIsFilterPanelOpen(false)} + onSearch={() => setIsFilterPanelOpen(false)} + isLoading={false} + />
- {/* Main Content Container */} -
+ {/* Main Container */} +
- {/* Header Bar */} + {/* Header */}
-
- -
- -
- {tableData && ( - 총 {tableData.total || tableData.data.length}건 + +
+ 총 {tableData.total || tableData.data.length}건
- {/* 통계 카드들 */} + {/* Stats */}
- {/* 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} - /> - - -
-
-
- - setRowAction(null)} - evaluationTarget={rowAction?.row.original ?? null} - /> - -
+ {/* Table */} +
+ + +
+ + presets={presets} + activePresetId={activePresetId} + currentSettings={currentSettings} + hasUnsavedChanges={hasUnsavedChanges} + isLoading={presetsLoading} + onCreatePreset={createPreset} + onUpdatePreset={updatePreset} + onDeletePreset={deletePreset} + onApplyPreset={applyPreset} + onSetDefaultPreset={setDefaultPreset} + onRenamePreset={renamePreset} + /> + + +
+
+
+ + {/* 편집 다이얼로그 */} + setRowAction(null)} + evaluationTarget={rowAction?.row.original ?? null} + />
- ) + ); } \ No newline at end of file -- cgit v1.2.3