// ============================================================================ // components/evaluation-targets-table.tsx (CLIENT COMPONENT) // ─ 정리된 버전 ─ // ============================================================================ "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"; import { useRouter } from "next/navigation"; // ✅ 라우터 추가 /* -------------------------------------------------------------------------- */ /* 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(); // ✅ 외부 필터 상태 (폼에서 전달받은 필터) const [externalFilters, setExternalFilters] = React.useState([]); const [externalJoinOperator, setExternalJoinOperator] = React.useState<"and" | "or">("and"); // ✅ 폼에서 전달받은 필터를 처리하는 핸들러 const handleFiltersApply = React.useCallback((filters: any[], joinOperator: "and" | "or") => { console.log("=== 폼에서 필터 전달받음 ===", filters, joinOperator); setExternalFilters(filters); setExternalJoinOperator(joinOperator); // 필터 적용 후 패널 닫기 setIsFilterPanelOpen(false); }, []); const searchString = React.useMemo( () => searchParams.toString(), [searchParams] ); const getSearchParam = React.useCallback( (key: string, def = "") => new URLSearchParams(searchString).get(key) ?? def, [searchString] ); // ✅ URL 필터 변경 감지 및 데이터 새로고침 React.useEffect(() => { const refetchData = async () => { try { setIsDataLoading(true); // 현재 URL 파라미터 기반으로 새 검색 파라미터 생성 const currentFilters = getSearchParam("filters"); const currentJoinOperator = getSearchParam("joinOperator", "and"); const currentPage = parseInt(getSearchParam("page", "1")); const currentPerPage = parseInt(getSearchParam("perPage", "10")); const currentSort = getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }]; const currentSearch = getSearchParam("search", ""); const searchParams = { filters: currentFilters ? JSON.parse(currentFilters) : [], joinOperator: currentJoinOperator as "and" | "or", page: currentPage, perPage: currentPerPage, sort: currentSort, search: currentSearch, evaluationYear: evaluationYear }; console.log("=== 새 데이터 요청 ===", searchParams); // 서버 액션 직접 호출 const newData = await getEvaluationTargets(searchParams); setTableData(newData); console.log("=== 데이터 업데이트 완료 ===", newData.data.length, "건"); } catch (error) { console.error("데이터 새로고침 오류:", error); } finally { setIsDataLoading(false); } }; /* ---------------------- 검색 파라미터 안전 처리 ---------------------- */ // 필터나 검색 파라미터가 변경되면 데이터 새로고침 (디바운스 적용) const timeoutId = setTimeout(() => { // 필터, 검색, 페이지네이션, 정렬 중 하나라도 변경되면 새로고침 const hasChanges = getSearchParam("filters") || getSearchParam("search") || getSearchParam("page") !== "1" || getSearchParam("perPage") !== "10" || getSearchParam("sort"); if (hasChanges) { refetchData(); } }, 300); // 디바운스 시간 단축 return () => clearTimeout(timeoutId); }, [searchString, evaluationYear, getSearchParam]); const refreshData = React.useCallback(async () => { try { setIsDataLoading(true); // 현재 URL 파라미터로 데이터 새로고침 const currentFilters = getSearchParam("filters"); const currentJoinOperator = getSearchParam("joinOperator", "and"); const currentPage = parseInt(getSearchParam("page", "1")); const currentPerPage = parseInt(getSearchParam("perPage", "10")); const currentSort = getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }]; const currentSearch = getSearchParam("search", ""); const searchParams = { filters: currentFilters ? JSON.parse(currentFilters) : [], joinOperator: currentJoinOperator as "and" | "or", page: currentPage, perPage: currentPerPage, sort: currentSort, search: currentSearch, evaluationYear: evaluationYear }; const newData = await getEvaluationTargets(searchParams); setTableData(newData); console.log("=== 데이터 새로고침 완료 ===", newData.data.length, "건"); } catch (error) { console.error("데이터 새로고침 오류:", error); } finally { setIsDataLoading(false); } }, [evaluationYear, getSearchParam]); /* --------------------------- layout refs --------------------------- */ const containerRef = React.useRef(null); const [containerTop, setContainerTop] = React.useState(0); 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 [initialPromiseData] = React.use(promises); // ✅ 테이블 데이터 상태 추가 const [tableData, setTableData] = React.useState(initialPromiseData); const [isDataLoading, setIsDataLoading] = React.useState(false); 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", search: getSearchParam("search", ""), columnVisibility: {}, columnOrder: [], pinnedColumns: { left: [], right: ["actions"] }, groupBy: [], expandedRows: [], }), [getSearchParam, parseSearchParam]); /* --------------------- 프리셋 훅 ------------------------------ */ 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 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: "PLANT" }, { label: "조선", value: "SHIP" } ]}, { 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: "orderReviewerName", label: "발주 담당자명", type: "text" }, { id: "procurementReviewerName", label: "조달 담당자명", type: "text" }, { id: "qualityReviewerName", label: "품질 담당자명", type: "text" }, { id: "designReviewerName", label: "설계 담당자명", type: "text" }, { id: "csReviewerName", label: "CS 담당자명", type: "text" }, { 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 getActiveFilterCount = React.useCallback(() => { try { // URL에서 현재 필터 수 확인 const filtersParam = getSearchParam("filters"); if (filtersParam) { const filters = JSON.parse(filtersParam); return Array.isArray(filters) ? filters.length : 0; } return 0; } catch { return 0; } }, [getSearchParam]); const FILTER_PANEL_WIDTH = 400; /* ---------------------------- JSX ---------------------------- */ return ( <> {/* Filter Panel */}
setIsFilterPanelOpen(false)} onFiltersApply={handleFiltersApply} // ✅ 필터 적용 콜백 전달 isLoading={false} />
{/* Main Container */}
{/* Header */}
총 {tableData.total || tableData.data.length}건
{/* Stats */}
{/* Table */}
{isDataLoading && (
필터링 중...
)} {/* ✅ 확장된 DataTableAdvancedToolbar 사용 */} { console.log("=== 필터 변경 감지 ===", filters, joinOperator); }} >
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} onDataChange={refreshData} />
); }