summaryrefslogtreecommitdiff
path: root/lib/legal-review/status/legal-table.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'lib/legal-review/status/legal-table.tsx')
-rw-r--r--lib/legal-review/status/legal-table.tsx548
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