// ============================================================================ // 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; console.log(tableData) /* ---------------------- 검색 파라미터 안전 처리 ---------------------- */ 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 */} setIsFilterPanelOpen(!isFilterPanelOpen)} className="flex items-center shadow-sm" > {isFilterPanelOpen ? : } {getActiveBasicFilterCount() > 0 && ( {getActiveBasicFilterCount()} )} 총 {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} /> > ); }
발주실적을 기반으로 평가 대상을 확정하는 절차입니다.
발주실적 기반 자동 추출
전년도 10월 ~ 해당년도 9월 발주실적에서 업체 목록을 자동으로 생성합니다.
담당자 지정
각 평가 대상별로 5개 부서(발주/조달/품질/설계/CS)의 담당자를 지정합니다.
검토 및 의견 수렴
모든 담당자가 평가 대상 적합성을 검토하고 의견을 제출합니다.
최종 확정
모든 담당자 의견이 일치하면 평가 대상으로 최종 확정됩니다.