From 90f79a7a691943a496f67f01c1e493256070e4de Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 7 Jul 2025 01:44:45 +0000 Subject: (대표님) 변경사항 20250707 10시 43분 - unstaged 변경사항 추가 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../table/evaluation-target-table copy.tsx | 508 +++++++++++++++++++++ .../table/evaluation-targets-columns.tsx | 405 ++++++++-------- .../table/evaluation-targets-toolbar-actions.tsx | 119 +++-- 3 files changed, 785 insertions(+), 247 deletions(-) create mode 100644 lib/evaluation-target-list/table/evaluation-target-table copy.tsx (limited to 'lib/evaluation-target-list/table') diff --git a/lib/evaluation-target-list/table/evaluation-target-table copy.tsx b/lib/evaluation-target-list/table/evaluation-target-table copy.tsx new file mode 100644 index 00000000..b140df0e --- /dev/null +++ b/lib/evaluation-target-list/table/evaluation-target-table copy.tsx @@ -0,0 +1,508 @@ +// ============================================================================ +// 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 { HelpCircle, 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 { 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"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; + +/* -------------------------------------------------------------------------- */ +/* Process Guide Popover */ +/* -------------------------------------------------------------------------- */ +function ProcessGuidePopover() { + return ( + + + + + +
+
+

평가 대상 확정 프로세스

+

+ 발주실적을 기반으로 평가 대상을 확정하는 절차입니다. +

+
+
+
+
+ 1 +
+
+

발주실적 기반 자동 추출

+

전년도 10월 ~ 해당년도 9월 발주실적에서 업체 목록을 자동으로 생성합니다.

+
+
+
+
+ 2 +
+
+

담당자 지정

+

각 평가 대상별로 5개 부서(발주/조달/품질/설계/CS)의 담당자를 지정합니다.

+
+
+
+
+ 3 +
+
+

검토 및 의견 수렴

+

모든 담당자가 평가 대상 적합성을 검토하고 의견을 제출합니다.

+
+
+
+
+ 4 +
+
+

최종 확정

+

모든 담당자 의견이 일치하면 평가 대상으로 최종 확정됩니다.

+
+
+
+
+
+
+ ) +} + +/* -------------------------------------------------------------------------- */ +/* 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); + + React.useEffect(() => { + let mounted = true; + (async () => { + try { + setIsLoading(true); + const data = await getEvaluationTargetsStats(evaluationYear); + mounted && setStats(data); + } catch (e) { + mounted && setError(e instanceof Error ? e.message : "failed"); + } finally { + mounted && setIsLoading(false); + } + })(); + return () => { + mounted = false; + }; + }, [evaluationYear]); + + if (isLoading) + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + + + + + + + + ))} +
+ ); + if (error) + return ( +
+ + + 통계 데이터를 불러올 수 없습니다: {error} + + +
+ ); + if (!stats) + return ( +
+ + + 통계 데이터가 없습니다. + + +
+ ); + + 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 ( +
+ {/* 총 평가 대상 */} + + + 총 평가 대상 + {evaluationYear}년 + + +
{total.toLocaleString()}
+
+ 해양 {stats.oceanDivision || 0}개 | 조선 {stats.shipyardDivision || 0}개 +
+
+
+ + {/* 검토 중 */} + + + 검토 중 + 대기 + + +
{pending.toLocaleString()}
+
+ {total ? Math.round((pending / total) * 100) : 0}% of total +
+
+
+ + {/* 확정 */} + + + 확정 + 완료 + + +
{confirmed.toLocaleString()}
+
+ {total ? Math.round((confirmed / total) * 100) : 0}% of total +
+
+
+ + {/* 의견 일치율 */} + + + 의견 일치율 + = 80 ? "default" : consensusRate >= 60 ? "secondary" : "destructive"}>{consensusRate}% + + +
{consensusRate}%
+
+ 일치 {stats.consensusTrue || 0}개 | 불일치 {stats.consensusFalse || 0}개 +
+
+
+
+ ); +} + +/* -------------------------------------------------------------------------- */ +/* 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 searchParams = useSearchParams(); + + /* --------------------------- layout refs --------------------------- */ + const containerRef = React.useRef(null); + const [containerTop, setContainerTop] = React.useState(0); + + // RFQ 패턴으로 변경: State를 통한 위치 관리 + const updateContainerBounds = React.useCallback(() => { + if (containerRef.current) { + const rect = containerRef.current.getBoundingClientRect(); + setContainerTop(rect.top); + } + }, []); + + React.useEffect(() => { + updateContainerBounds(); + + const handleResize = () => { + updateContainerBounds(); + }; + + window.addEventListener('resize', handleResize); + window.addEventListener('scroll', updateContainerBounds); + + return () => { + window.removeEventListener('resize', handleResize); + window.removeEventListener('scroll', updateContainerBounds); + }; + }, [updateContainerBounds]); + + /* ---------------------- 데이터 프리패치 ---------------------- */ + const [promiseData] = React.use(promises); + const tableData = promiseData; + + /* ---------------------- 검색 파라미터 안전 처리 ---------------------- */ + const searchString = React.useMemo( + () => searchParams.toString(), // query가 바뀔 때만 새로 계산 + [searchParams] + ); + + const getSearchParam = React.useCallback( + (key: string, def = "") => + new URLSearchParams(searchString).get(key) ?? def, + [searchString] + ); + + // 제네릭 함수는 useCallback 밖에서 정의 + 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 = (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: 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: [], + }), [getSearchParam]); + + /* --------------------- 프리셋 훅 ------------------------------ */ + const { + presets, + activePresetId, + hasUnsavedChanges, + isLoading: presetsLoading, + createPreset, + applyPreset, + updatePreset, + deletePreset, + setDefaultPreset, + renamePreset, + getCurrentSettings, + } = 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: "구분" } +// ]; + + + /* 기본 필터 */ + 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: "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: "adminComment", label: "관리자 의견", type: "text" }, + { id: "consolidatedComment", label: "종합 의견", type: "text" }, + { id: "confirmedAt", label: "확정일", type: "date" }, + { id: "createdAt", label: "생성일", type: "date" }, + ]; + + /* current settings */ + const currentSettings = React.useMemo(() => getCurrentSettings(), [getCurrentSettings]); + + const initialState = React.useMemo(() => { + return { + sorting: initialSettings.sort.filter(sortItem => { + const columnExists = columns.some(col => col.accessorKey === sortItem.id) + return columnExists + }) as any, + columnVisibility: currentSettings.columnVisibility, + columnPinning: currentSettings.pinnedColumns, + } + }, [currentSettings, initialSettings.sort, columns]) + + /* ----------------------- useDataTable ------------------------ */ + const { table } = useDataTable({ + data: tableData.data, + columns, + pageCount: tableData.pageCount, + rowCount: tableData.total || tableData.data.length, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState, + getRowId: (row) => String(row.id), + shallow: false, + clearOnDefault: true, + }); + + /* ---------------------- helper ------------------------------ */ + const getActiveBasicFilterCount = React.useCallback(() => { + try { + 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={() => setIsFilterPanelOpen(false)} + isLoading={false} + /> +
+ + {/* Main Container */} +
+
+
+ {/* Header */} +
+ +
+ 총 {tableData.total || tableData.data.length}건 +
+
+ + {/* Stats */} +
+ +
+ + {/* 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 diff --git a/lib/evaluation-target-list/table/evaluation-targets-columns.tsx b/lib/evaluation-target-list/table/evaluation-targets-columns.tsx index b6631f14..60f1af39 100644 --- a/lib/evaluation-target-list/table/evaluation-targets-columns.tsx +++ b/lib/evaluation-target-list/table/evaluation-targets-columns.tsx @@ -9,34 +9,22 @@ import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table- import { EvaluationTargetWithDepartments } from "@/db/schema"; import type { DataTableRowAction } from "@/types/table"; import { formatDate } from "@/lib/utils"; +import { vendortypeMap } from "@/types/evaluation"; interface GetColumnsProps { setRowAction: React.Dispatch | null>>; } -// ✅ 모든 헬퍼 함수들을 컴포넌트 외부로 이동 (매번 재생성 방지) +// ✅ 모든 헬퍼 함수들을 컴포넌트 외부로 이동 const getStatusBadgeVariant = (status: string) => { switch (status) { - case "PENDING": - return "secondary"; - case "CONFIRMED": - return "default"; - case "EXCLUDED": - return "destructive"; - default: - return "outline"; + case "PENDING": return "secondary"; + case "CONFIRMED": return "default"; + case "EXCLUDED": return "destructive"; + default: return "outline"; } }; -const getStatusText = (status: string) => { - const statusMap = { - PENDING: "검토 중", - CONFIRMED: "확정", - EXCLUDED: "제외" - }; - return statusMap[status] || status; -}; - const getConsensusBadge = (consensusStatus: boolean | null) => { if (consensusStatus === null) { return 검토 중; @@ -56,12 +44,7 @@ const getDivisionBadge = (division: string) => { }; const getMaterialTypeBadge = (materialType: string) => { - const typeMap = { - EQUIPMENT: "기자재", - BULK: "벌크", - EQUIPMENT_BULK: "기자재/벌크" - }; - return {typeMap[materialType] || materialType}; + return {vendortypeMap[materialType] || materialType}; }; const getDomesticForeignBadge = (domesticForeign: string) => { @@ -72,7 +55,6 @@ const getDomesticForeignBadge = (domesticForeign: string) => { ); }; -// ✅ 평가 대상 여부 표시 함수 const getEvaluationTargetBadge = (isTarget: boolean | null) => { if (isTarget === null) { return 미정; @@ -90,340 +72,335 @@ const getEvaluationTargetBadge = (isTarget: boolean | null) => { ); }; -export function getEvaluationTargetsColumns({ setRowAction }: GetColumnsProps): ColumnDef[] { +// ✅ 모든 cell 렌더러 함수들을 미리 정의 (매번 새로 생성 방지) +const renderEvaluationYear = ({ row }: any) => ( + {row.getValue("evaluationYear")} +); + +const renderDivision = ({ row }: any) => getDivisionBadge(row.getValue("division")); + +const renderStatus = ({ row }: any) => { + const status = row.getValue("status"); + return ( + + {status} + + ); +}; + +const renderConsensusStatus = ({ row }: any) => getConsensusBadge(row.getValue("consensusStatus")); + +const renderVendorCode = ({ row }: any) => ( + {row.getValue("vendorCode")} +); + +const renderVendorName = ({ row }: any) => ( +
("vendorName")!}> + {row.getValue("vendorName") as string} +
+); + +const renderDomesticForeign = ({ row }: any) => getDomesticForeignBadge(row.getValue("domesticForeign")); + +const renderMaterialType = ({ row }: any) => getMaterialTypeBadge(row.getValue("materialType")); + +const renderReviewerName = (fieldName: string) => ({ row }: any) => { + const reviewerName = row.getValue(fieldName); + return reviewerName ? ( +
+ {reviewerName} +
+ ) : ( + - + ); +}; + +const renderIsApproved = (fieldName: string) => ({ row }: any) => { + const isApproved = row.getValue(fieldName); + return getEvaluationTargetBadge(isApproved); +}; + +const renderComment = (maxWidth: string) => ({ row }: any) => { + const comment = row.getValue("adminComment") || row.getValue("consolidatedComment"); + return comment ? ( +
+ {comment} +
+ ) : ( + - + ); +}; + +const renderConfirmedAt = ({ row }: any) => { + const confirmedAt = row.getValue("confirmedAt"); + return {confirmedAt ? formatDate(confirmedAt, "KR") : '-'}; +}; + +const renderCreatedAt = ({ row }: any) => { + const createdAt = row.getValue("createdAt"); + return {formatDate(createdAt, "KR")}; +}; + +// ✅ 헤더 렌더러들도 미리 정의 +const createHeaderRenderer = (title: string) => ({ column }: any) => ( + +); + +// ✅ 체크박스 관련 함수들도 미리 정의 +const renderSelectAllCheckbox = ({ table }: any) => ( + table.toggleAllPageRowsSelected(!!v)} + aria-label="select all" + className="translate-y-0.5" + /> +); + +const renderRowCheckbox = ({ row }: any) => ( + row.toggleSelected(!!v)} + aria-label="select row" + className="translate-y-0.5" + /> +); + +// ✅ 정적 컬럼 정의 (setRowAction만 동적으로 주입) +function createStaticColumns(setRowAction: GetColumnsProps['setRowAction']): ColumnDef[] { + // Actions 컬럼의 클릭 핸들러를 미리 정의 + const renderActionsCell = ({ row }: any) => ( +
+ +
+ ); + return [ - // ✅ Checkbox + // Checkbox { id: "select", - header: ({ table }) => ( - table.toggleAllPageRowsSelected(!!v)} - aria-label="select all" - className="translate-y-0.5" - /> - ), - cell: ({ row }) => ( - row.toggleSelected(!!v)} - aria-label="select row" - className="translate-y-0.5" - /> - ), + header: renderSelectAllCheckbox, + cell: renderRowCheckbox, size: 40, enableSorting: false, enableHiding: false, }, - // ✅ 기본 정보 + // 기본 정보 { accessorKey: "evaluationYear", - header: ({ column }) => , - cell: ({ row }) => {row.getValue("evaluationYear")}, + header: createHeaderRenderer("평가년도"), + cell: renderEvaluationYear, size: 100, }, { accessorKey: "division", - header: ({ column }) => , - cell: ({ row }) => getDivisionBadge(row.getValue("division")), + header: createHeaderRenderer("구분"), + cell: renderDivision, size: 80, }, { accessorKey: "status", - header: ({ column }) => , - cell: ({ row }) => { - const status = row.getValue("status"); - return ( - - {getStatusText(status)} - - ); - }, + header: createHeaderRenderer("상태"), + cell: renderStatus, size: 100, }, { accessorKey: "consensusStatus", - header: ({ column }) => , - cell: ({ row }) => getConsensusBadge(row.getValue("consensusStatus")), + header: createHeaderRenderer("의견 일치"), + cell: renderConsensusStatus, size: 100, }, - // ✅ 벤더 정보 그룹 + // 벤더 정보 { id: "vendorInfo", header: "벤더 정보", columns: [ { accessorKey: "vendorCode", - header: ({ column }) => , - cell: ({ row }) => ( - {row.getValue("vendorCode")} - ), + header: createHeaderRenderer("벤더 코드"), + cell: renderVendorCode, size: 120, }, { accessorKey: "vendorName", - header: ({ column }) => , - cell: ({ row }) => ( -
("vendorName")!}> - {row.getValue("vendorName") as string} -
- ), + header: createHeaderRenderer("벤더명"), + cell: renderVendorName, size: 200, }, { accessorKey: "domesticForeign", - header: ({ column }) => , - cell: ({ row }) => getDomesticForeignBadge(row.getValue("domesticForeign")), + header: createHeaderRenderer("내외자"), + cell: renderDomesticForeign, size: 80, }, { accessorKey: "materialType", - header: ({ column }) => , - cell: ({ row }) => getMaterialTypeBadge(row.getValue("materialType")), + header: createHeaderRenderer("자재구분"), + cell: renderMaterialType, size: 120, }, ] }, - // ✅ 발주 담당자 + // 발주 담당자 { id: "orderReviewer", header: "발주 담당자", columns: [ { accessorKey: "orderReviewerName", - header: ({ column }) => , - cell: ({ row }) => { - const reviewerName = row.getValue("orderReviewerName"); - return reviewerName ? ( -
- {reviewerName} -
- ) : ( - - - ); - }, + header: createHeaderRenderer("담당자명"), + cell: renderReviewerName("orderReviewerName"), size: 120, }, { accessorKey: "orderIsApproved", - header: ({ column }) => , - cell: ({ row }) => { - const isApproved = row.getValue("orderIsApproved"); - return getEvaluationTargetBadge(isApproved); - }, + header: createHeaderRenderer("평가 대상"), + cell: renderIsApproved("orderIsApproved"), size: 120, }, ] }, - // ✅ 조달 담당자 + // 조달 담당자 { id: "procurementReviewer", header: "조달 담당자", columns: [ { accessorKey: "procurementReviewerName", - header: ({ column }) => , - cell: ({ row }) => { - const reviewerName = row.getValue("procurementReviewerName"); - return reviewerName ? ( -
- {reviewerName} -
- ) : ( - - - ); - }, + header: createHeaderRenderer("담당자명"), + cell: renderReviewerName("procurementReviewerName"), size: 120, }, { accessorKey: "procurementIsApproved", - header: ({ column }) => , - cell: ({ row }) => { - const isApproved = row.getValue("procurementIsApproved"); - return getEvaluationTargetBadge(isApproved); - }, + header: createHeaderRenderer("평가 대상"), + cell: renderIsApproved("procurementIsApproved"), size: 120, }, ] }, - // ✅ 품질 담당자 + // 품질 담당자 { id: "qualityReviewer", header: "품질 담당자", columns: [ { accessorKey: "qualityReviewerName", - header: ({ column }) => , - cell: ({ row }) => { - const reviewerName = row.getValue("qualityReviewerName"); - return reviewerName ? ( -
- {reviewerName} -
- ) : ( - - - ); - }, + header: createHeaderRenderer("담당자명"), + cell: renderReviewerName("qualityReviewerName"), size: 120, }, { accessorKey: "qualityIsApproved", - header: ({ column }) => , - cell: ({ row }) => { - const isApproved = row.getValue("qualityIsApproved"); - return getEvaluationTargetBadge(isApproved); - }, + header: createHeaderRenderer("평가 대상"), + cell: renderIsApproved("qualityIsApproved"), size: 120, }, ] }, - // ✅ 설계 담당자 + // 설계 담당자 { id: "designReviewer", header: "설계 담당자", columns: [ { accessorKey: "designReviewerName", - header: ({ column }) => , - cell: ({ row }) => { - const reviewerName = row.getValue("designReviewerName"); - return reviewerName ? ( -
- {reviewerName} -
- ) : ( - - - ); - }, + header: createHeaderRenderer("담당자명"), + cell: renderReviewerName("designReviewerName"), size: 120, }, { accessorKey: "designIsApproved", - header: ({ column }) => , - cell: ({ row }) => { - const isApproved = row.getValue("designIsApproved"); - return getEvaluationTargetBadge(isApproved); - }, + header: createHeaderRenderer("평가 대상"), + cell: renderIsApproved("designIsApproved"), size: 120, }, ] }, - // ✅ CS 담당자 + // CS 담당자 { id: "csReviewer", header: "CS 담당자", columns: [ { accessorKey: "csReviewerName", - header: ({ column }) => , - cell: ({ row }) => { - const reviewerName = row.getValue("csReviewerName"); - return reviewerName ? ( -
- {reviewerName} -
- ) : ( - - - ); - }, + header: createHeaderRenderer("담당자명"), + cell: renderReviewerName("csReviewerName"), size: 120, }, { accessorKey: "csIsApproved", - header: ({ column }) => , - cell: ({ row }) => { - const isApproved = row.getValue("csIsApproved"); - return getEvaluationTargetBadge(isApproved); - }, + header: createHeaderRenderer("평가 대상"), + cell: renderIsApproved("csIsApproved"), size: 120, }, ] }, - // ✅ 의견 및 결과 + // 의견 및 결과 { accessorKey: "adminComment", - header: ({ column }) => , - cell: ({ row }) => { - const comment = row.getValue("adminComment"); - return comment ? ( -
- {comment} -
- ) : ( - - - ); - }, + header: createHeaderRenderer("관리자 의견"), + cell: renderComment("max-w-[150px]"), size: 150, }, { accessorKey: "consolidatedComment", - header: ({ column }) => , - cell: ({ row }) => { - const comment = row.getValue("consolidatedComment"); - return comment ? ( -
- {comment} -
- ) : ( - - - ); - }, + header: createHeaderRenderer("종합 의견"), + cell: renderComment("max-w-[150px]"), size: 150, }, { accessorKey: "confirmedAt", - header: ({ column }) => , - cell: ({ row }) => { - const confirmedAt = row.getValue("confirmedAt"); - return { confirmedAt ? formatDate(confirmedAt, "KR") :'-'}; - }, + header: createHeaderRenderer("확정일"), + cell: renderConfirmedAt, size: 100, }, { accessorKey: "createdAt", - header: ({ column }) => , - cell: ({ row }) => { - const createdAt = row.getValue("createdAt"); - return {formatDate(createdAt, "KR")}; - }, + header: createHeaderRenderer("생성일"), + cell: renderCreatedAt, size: 100, }, - // ✅ Actions - 가장 안전하게 처리 + // Actions { id: "actions", enableHiding: false, size: 40, minSize: 40, - cell: ({ row }) => { - // ✅ 함수를 직접 정의해서 매번 새로 생성되지 않도록 처리 - const handleEdit = () => { - setRowAction({ row, type: "update" }); - }; - - return ( -
- -
- ); - }, + cell: renderActionsCell, }, ]; +} + +// ✅ WeakMap 캐시로 setRowAction별로 컬럼 캐싱 +const columnsCache = new WeakMap[]>(); + +export function getEvaluationTargetsColumns({ setRowAction }: GetColumnsProps): ColumnDef[] { + // 캐시 확인 + if (columnsCache.has(setRowAction)) { + console.log('✅ 캐시된 컬럼 사용'); + return columnsCache.get(setRowAction)!; + } + + console.log('🏗️ 새로운 컬럼 생성'); + const columns = createStaticColumns(setRowAction); + columnsCache.set(setRowAction, columns); + return columns; } \ No newline at end of file diff --git a/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx b/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx index 82b7c97c..8bc5254c 100644 --- a/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx +++ b/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx @@ -51,16 +51,69 @@ export function EvaluationTargetsTableToolbarActions({ // 선택된 행들 const selectedRows = table.getFilteredSelectedRowModel().rows const hasSelection = selectedRows.length > 0 - const selectedTargets = selectedRows.map(row => row.original) - // 선택된 항목들의 상태 분석 + // ✅ selectedTargets를 useMemo로 안정화 (VendorsTable 방식과 동일) + const selectedTargets = React.useMemo(() => { + return selectedRows.map(row => row.original) + }, [selectedRows]) + + // ✅ 각 상태별 타겟들을 개별적으로 메모이제이션 (VendorsTable 방식과 동일) + const pendingTargets = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(t => t.status === "PENDING"); + }, [table.getFilteredSelectedRowModel().rows]); + + const confirmedTargets = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(t => t.status === "CONFIRMED"); + }, [table.getFilteredSelectedRowModel().rows]); + + const excludedTargets = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(t => t.status === "EXCLUDED"); + }, [table.getFilteredSelectedRowModel().rows]); + + const consensusTrueTargets = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(t => t.consensusStatus === true); + }, [table.getFilteredSelectedRowModel().rows]); + + const consensusFalseTargets = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(t => t.consensusStatus === false); + }, [table.getFilteredSelectedRowModel().rows]); + + const consensusNullTargets = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(t => t.consensusStatus === null); + }, [table.getFilteredSelectedRowModel().rows]); + + // ✅ 선택된 항목들의 상태 분석 - 안정화된 개별 배열들 사용 const selectedStats = React.useMemo(() => { - const pending = selectedTargets.filter(t => t.status === "PENDING").length - const confirmed = selectedTargets.filter(t => t.status === "CONFIRMED").length - const excluded = selectedTargets.filter(t => t.status === "EXCLUDED").length - const consensusTrue = selectedTargets.filter(t => t.consensusStatus === true).length - const consensusFalse = selectedTargets.filter(t => t.consensusStatus === false).length - const consensusNull = selectedTargets.filter(t => t.consensusStatus === null).length + const pending = pendingTargets.length + const confirmed = confirmedTargets.length + const excluded = excludedTargets.length + const consensusTrue = consensusTrueTargets.length + const consensusFalse = consensusFalseTargets.length + const consensusNull = consensusNullTargets.length return { pending, @@ -73,12 +126,19 @@ export function EvaluationTargetsTableToolbarActions({ canExclude: pending > 0, canRequestReview: pending > 0 } - }, [selectedTargets]) + }, [ + pendingTargets.length, + confirmedTargets.length, + excludedTargets.length, + consensusTrueTargets.length, + consensusFalseTargets.length, + consensusNullTargets.length + ]) // ---------------------------------------------------------------- // 신규 평가 대상 생성 (자동) // ---------------------------------------------------------------- - const handleAutoGenerate = async () => { + const handleAutoGenerate = React.useCallback(async () => { setIsLoading(true) try { // TODO: 발주실적에서 자동 추출 API 호출 @@ -90,23 +150,33 @@ export function EvaluationTargetsTableToolbarActions({ } finally { setIsLoading(false) } - } + }, [router]) // ---------------------------------------------------------------- // 신규 평가 대상 생성 (수동) // ---------------------------------------------------------------- - const handleManualCreate = () => { + const handleManualCreate = React.useCallback(() => { setManualCreateDialogOpen(true) - } + }, []) // ---------------------------------------------------------------- // 다이얼로그 성공 핸들러 // ---------------------------------------------------------------- - const handleActionSuccess = () => { + const handleActionSuccess = React.useCallback(() => { table.resetRowSelection() onRefresh?.() router.refresh() - } + }, [table, onRefresh, router]) + + // ---------------------------------------------------------------- + // 내보내기 핸들러 + // ---------------------------------------------------------------- + const handleExport = React.useCallback(() => { + exportTableToExcel(table, { + filename: "vendor-target-list", + excludeColumns: ["select", "actions"], + }) + }, [table]) return ( <> @@ -141,12 +211,7 @@ export function EvaluationTargetsTableToolbarActions({