diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-08-06 04:23:40 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-08-06 04:23:40 +0000 |
| commit | de2ac5a2860bc25180971e7a11f852d9d44675b7 (patch) | |
| tree | b931c363f2cb19e177a0a7b17190d5de2a82d709 /lib/legal-review/status/legal-table.tsx | |
| parent | 6c549b0f264e9be4d60af38f9efc05b189d6849f (diff) | |
(대표님) 정기평가, 법적검토, 정책, 가입관련 처리 및 관련 컴포넌트 추가, 메뉴 변경
Diffstat (limited to 'lib/legal-review/status/legal-table.tsx')
| -rw-r--r-- | lib/legal-review/status/legal-table.tsx | 548 |
1 files changed, 548 insertions, 0 deletions
diff --git a/lib/legal-review/status/legal-table.tsx b/lib/legal-review/status/legal-table.tsx new file mode 100644 index 00000000..d68ffa4e --- /dev/null +++ b/lib/legal-review/status/legal-table.tsx @@ -0,0 +1,548 @@ +// ============================================================================ +// 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 { cn } from "@/lib/utils"; +import { useTablePresets } from "@/components/data-table/use-table-presets"; +import { TablePresetManager } from "@/components/data-table/data-table-preset"; +import { LegalWorksDetailView } from "@/db/schema"; +import { LegalWorksTableToolbarActions } from "./legal-works-toolbar-actions"; +import { getLegalWorks } from "../service"; +import { getLegalWorksColumns } from "./legal-works-columns"; +import { LegalWorkFilterSheet } from "./legal-work-filter-sheet"; +import { EditLegalWorkSheet } from "./update-legal-work-dialog"; +import { LegalWorkDetailDialog } from "./legal-work-detail-dialog"; +import { DeleteLegalWorksDialog } from "./delete-legal-works-dialog"; + + +/* -------------------------------------------------------------------------- */ +/* Stats Card */ +/* -------------------------------------------------------------------------- */ +function LegalWorksStats({ data }: { data: LegalWorksDetailView[] }) { + const stats = React.useMemo(() => { + const total = data.length; + const pending = data.filter(item => item.status === '검토요청').length; + const assigned = data.filter(item => item.status === '담당자배정').length; + const inProgress = data.filter(item => item.status === '검토중').length; + const completed = data.filter(item => item.status === '답변완료').length; + const urgent = data.filter(item => item.isUrgent).length; + + return { total, pending, assigned, inProgress, completed, urgent }; + }, [data]); + + if (stats.total === 0) { + return ( + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5 mb-6"> + <Card className="col-span-full"> + <CardContent className="pt-6 text-center text-sm text-muted-foreground"> + 등록된 법무업무가 없습니다. + </CardContent> + </Card> + </div> + ); + } + + return ( + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5 mb-6"> + <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">전체</Badge> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold">{stats.total.toLocaleString()}</div> + <div className="text-xs text-muted-foreground mt-1"> + 긴급 {stats.urgent}건 + </div> + </CardContent> + </Card> + + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">검토요청</CardTitle> + <Badge variant="secondary">대기</Badge> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-blue-600">{stats.pending.toLocaleString()}</div> + <div className="text-xs text-muted-foreground mt-1"> + {stats.total ? Math.round((stats.pending / stats.total) * 100) : 0}% of total + </div> + </CardContent> + </Card> + + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">담당자배정</CardTitle> + <Badge variant="secondary">진행</Badge> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-yellow-600">{stats.assigned.toLocaleString()}</div> + <div className="text-xs text-muted-foreground mt-1"> + {stats.total ? Math.round((stats.assigned / stats.total) * 100) : 0}% of total + </div> + </CardContent> + </Card> + + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">검토중</CardTitle> + <Badge variant="secondary">진행</Badge> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-orange-600">{stats.inProgress.toLocaleString()}</div> + <div className="text-xs text-muted-foreground mt-1"> + {stats.total ? Math.round((stats.inProgress / stats.total) * 100) : 0}% of total + </div> + </CardContent> + </Card> + + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">답변완료</CardTitle> + <Badge variant="default">완료</Badge> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-green-600">{stats.completed.toLocaleString()}</div> + <div className="text-xs text-muted-foreground mt-1"> + {stats.total ? Math.round((stats.completed / stats.total) * 100) : 0}% of total + </div> + </CardContent> + </Card> + </div> + ); +} + +/* -------------------------------------------------------------------------- */ +/* EvaluationTargetsTable */ +/* -------------------------------------------------------------------------- */ +interface LegalWorksTableProps { + promises: Promise<[Awaited<ReturnType<typeof getLegalWorks>>]>; + currentYear: number; + className?: string; +} + +export function LegalWorksTable({ promises, currentYear = new Date().getFullYear(), className }: LegalWorksTableProps) { + const [rowAction, setRowAction] = React.useState<DataTableRowAction<LegalWorksDetailView> | null>(null); + const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false); + const searchParams = useSearchParams(); + + // ✅ 외부 필터 상태 (폼에서 전달받은 필터) + const [externalFilters, setExternalFilters] = React.useState<any[]>([]); + 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, + currentYear: currentYear + }; + + console.log("=== 새 데이터 요청 ===", searchParams); + + // 서버 액션 직접 호출 + const newData = await getLegalWorks(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, currentYear, 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, + currentYear: currentYear + }; + + const newData = await getLegalWorks(searchParams); + setTableData(newData); + + console.log("=== 데이터 새로고침 완료 ===", newData.data.length, "건"); + } catch (error) { + console.error("데이터 새로고침 오류:", error); + } finally { + setIsDataLoading(false); + } + }, [currentYear, getSearchParam]); + + /* --------------------------- layout refs --------------------------- */ + const containerRef = React.useRef<HTMLDivElement>(null); + const [containerTop, setContainerTop] = React.useState(0); + + 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 + }) + } + }, []) + 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 = <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: 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<LegalWorksDetailView>( + "legal-review-table", + initialSettings + ); + + + + /* --------------------- 컬럼 ------------------------------ */ + const columns = React.useMemo(() => getLegalWorksColumns({ setRowAction }), [setRowAction]); + + /* 기본 필터 */ + const filterFields: DataTableFilterField<LegalWorksDetailView>[] = [ + { id: "vendorCode", label: "벤더 코드" }, + { id: "vendorName", label: "벤더명" }, + { id: "status", label: "상태" }, + ]; + + /* 고급 필터 */ + const advancedFilterFields: DataTableAdvancedFilterField<LegalWorksDetailView>[] = [ + ]; + + /* 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 */} + <div + className={cn( + "fixed left-0 bg-background border-r z-50 flex flex-col transition-all duration-300 ease-in-out overflow-hidden", + isFilterPanelOpen ? "border-r shadow-lg" : "border-r-0" + )} + style={{ + width: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : "0px", + top: `${containerTop}px`, + height: `calc(100vh - ${containerTop}px)` + }} + > + <LegalWorkFilterSheet + isOpen={isFilterPanelOpen} + onClose={() => setIsFilterPanelOpen(false)} + onFiltersApply={handleFiltersApply} // ✅ 필터 적용 콜백 전달 + isLoading={false} + /> + </div> + + {/* Main Container */} + <div ref={containerRef} className={cn("relative w-full overflow-hidden", className)}> + <div className="flex w-full h-full"> + <div + className="flex flex-col min-w-0 overflow-hidden transition-all duration-300 ease-in-out" + style={{ + width: isFilterPanelOpen ? `calc(100% - ${FILTER_PANEL_WIDTH}px)` : "100%", + marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : "0px", + }} + > + {/* Header */} + <div className="flex items-center justify-between p-4 bg-background shrink-0"> + <Button + variant="outline" + size="sm" + onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)} + className="flex items-center shadow-sm" + > + {isFilterPanelOpen ? <PanelLeftClose className="size-4" /> : <PanelLeftOpen className="size-4" />} + {getActiveFilterCount() > 0 && ( + <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs"> + {getActiveFilterCount()} + </span> + )} + </Button> + <div className="text-sm text-muted-foreground"> + 총 {tableData.total || tableData.data.length}건 + </div> + </div> + + {/* Stats */} + <div className="px-4"> + <LegalWorksStats data={tableData.data} /> + + </div> + + {/* Table */} + <div className="flex-1 overflow-hidden relative" style={{ height: "calc(100vh - 500px)" }}> + {isDataLoading && ( + <div className="absolute inset-0 bg-background/50 backdrop-blur-sm z-10 flex items-center justify-center"> + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <div className="w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin" /> + 필터링 중... + </div> + </div> + )} + <DataTable table={table} className="h-full"> + {/* ✅ 확장된 DataTableAdvancedToolbar 사용 */} + <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<LegalWorksDetailView> + presets={presets} + activePresetId={activePresetId} + currentSettings={currentSettings} + hasUnsavedChanges={hasUnsavedChanges} + isLoading={presetsLoading} + onCreatePreset={createPreset} + onUpdatePreset={updatePreset} + onDeletePreset={deletePreset} + onApplyPreset={applyPreset} + onSetDefaultPreset={setDefaultPreset} + onRenamePreset={renamePreset} + /> + + <LegalWorksTableToolbarActions table={table}onRefresh={refreshData} /> + </div> + </DataTableAdvancedToolbar> + </DataTable> + + {/* 다이얼로그들 */} + <EditLegalWorkSheet + open={rowAction?.type === "update"} + onOpenChange={() => setRowAction(null)} + work={rowAction?.row.original || null} + onSuccess={() => { + rowAction?.row.toggleSelected(false); + refreshData(); + }} + /> + + <LegalWorkDetailDialog + open={rowAction?.type === "view"} + onOpenChange={(open) => !open && setRowAction(null)} + work={rowAction?.row.original || null} + /> + + <DeleteLegalWorksDialog + open={rowAction?.type === "delete"} + onOpenChange={(open) => !open && setRowAction(null)} + legalWorks={rowAction?.row.original ? [rowAction.row.original] : []} + showTrigger={false} + onSuccess={() => { + setRowAction(null); + refreshData(); + }} + /> + + </div> + </div> + </div> + </div> + </> + ); +}
\ No newline at end of file |
