summaryrefslogtreecommitdiff
path: root/lib/evaluation-target-list/table/evaluation-target-table copy.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'lib/evaluation-target-list/table/evaluation-target-table copy.tsx')
-rw-r--r--lib/evaluation-target-list/table/evaluation-target-table copy.tsx508
1 files changed, 508 insertions, 0 deletions
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 (
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button variant="ghost" size="icon" className="h-6 w-6">
+ <HelpCircle className="h-4 w-4 text-muted-foreground" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-96" align="start">
+ <div className="space-y-3">
+ <div className="space-y-1">
+ <h4 className="font-medium">평가 대상 확정 프로세스</h4>
+ <p className="text-sm text-muted-foreground">
+ 발주실적을 기반으로 평가 대상을 확정하는 절차입니다.
+ </p>
+ </div>
+ <div className="space-y-3 text-sm">
+ <div className="flex gap-3">
+ <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600">
+ 1
+ </div>
+ <div>
+ <p className="font-medium">발주실적 기반 자동 추출</p>
+ <p className="text-muted-foreground">전년도 10월 ~ 해당년도 9월 발주실적에서 업체 목록을 자동으로 생성합니다.</p>
+ </div>
+ </div>
+ <div className="flex gap-3">
+ <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600">
+ 2
+ </div>
+ <div>
+ <p className="font-medium">담당자 지정</p>
+ <p className="text-muted-foreground">각 평가 대상별로 5개 부서(발주/조달/품질/설계/CS)의 담당자를 지정합니다.</p>
+ </div>
+ </div>
+ <div className="flex gap-3">
+ <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600">
+ 3
+ </div>
+ <div>
+ <p className="font-medium">검토 및 의견 수렴</p>
+ <p className="text-muted-foreground">모든 담당자가 평가 대상 적합성을 검토하고 의견을 제출합니다.</p>
+ </div>
+ </div>
+ <div className="flex gap-3">
+ <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600">
+ 4
+ </div>
+ <div>
+ <p className="font-medium">최종 확정</p>
+ <p className="text-muted-foreground">모든 담당자 의견이 일치하면 평가 대상으로 최종 확정됩니다.</p>
+ </div>
+ </div>
+ </div>
+ </div>
+ </PopoverContent>
+ </Popover>
+ )
+}
+
+/* -------------------------------------------------------------------------- */
+/* Stats Card */
+/* -------------------------------------------------------------------------- */
+function EvaluationTargetsStats({ evaluationYear }: { evaluationYear: number }) {
+ const [stats, setStats] = React.useState<any>(null);
+ const [isLoading, setIsLoading] = React.useState(true);
+ const [error, setError] = React.useState<string | null>(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 (
+ <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 mb-6">
+ {Array.from({ length: 4 }).map((_, i) => (
+ <Card key={i}>
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <Skeleton className="h-4 w-20" />
+ </CardHeader>
+ <CardContent>
+ <Skeleton className="h-8 w-16" />
+ </CardContent>
+ </Card>
+ ))}
+ </div>
+ );
+ if (error)
+ return (
+ <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 mb-6">
+ <Card className="col-span-full">
+ <CardContent className="pt-6 text-center text-sm text-muted-foreground">
+ 통계 데이터를 불러올 수 없습니다: {error}
+ </CardContent>
+ </Card>
+ </div>
+ );
+ if (!stats)
+ return (
+ <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 mb-6">
+ <Card className="col-span-full">
+ <CardContent className="pt-6 text-center text-sm text-muted-foreground">
+ 통계 데이터가 없습니다.
+ </CardContent>
+ </Card>
+ </div>
+ );
+
+ 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 (
+ <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 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">{evaluationYear}년</Badge>
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">{total.toLocaleString()}</div>
+ <div className="text-xs text-muted-foreground mt-1">
+ 해양 {stats.oceanDivision || 0}개 | 조선 {stats.shipyardDivision || 0}개
+ </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">{pending.toLocaleString()}</div>
+ <div className="text-xs text-muted-foreground mt-1">
+ {total ? Math.round((pending / 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="success">완료</Badge>
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold text-green-600">{confirmed.toLocaleString()}</div>
+ <div className="text-xs text-muted-foreground mt-1">
+ {total ? Math.round((confirmed / 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={consensusRate >= 80 ? "default" : consensusRate >= 60 ? "secondary" : "destructive"}>{consensusRate}%</Badge>
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">{consensusRate}%</div>
+ <div className="text-xs text-muted-foreground mt-1">
+ 일치 {stats.consensusTrue || 0}개 | 불일치 {stats.consensusFalse || 0}개
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+ );
+}
+
+/* -------------------------------------------------------------------------- */
+/* EvaluationTargetsTable */
+/* -------------------------------------------------------------------------- */
+interface EvaluationTargetsTableProps {
+ promises: Promise<[Awaited<ReturnType<typeof getEvaluationTargets>>]>;
+ evaluationYear: number;
+ className?: string;
+}
+
+export function EvaluationTargetsTable({ promises, evaluationYear, className }: EvaluationTargetsTableProps) {
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<EvaluationTargetWithDepartments> | null>(null);
+ const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false);
+ const searchParams = useSearchParams();
+
+ /* --------------------------- layout refs --------------------------- */
+ const containerRef = React.useRef<HTMLDivElement>(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 = <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",
+ 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<EvaluationTargetWithDepartments>(
+ "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<EvaluationTargetWithDepartments>[] = [
+ { id: "vendorCode", label: "벤더 코드" },
+ { id: "vendorName", label: "벤더명" },
+ { id: "status", label: "상태" },
+ ];
+
+ /* 고급 필터 */
+ const advancedFilterFields: DataTableAdvancedFilterField<EvaluationTargetWithDepartments>[] = [
+ { 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 */}
+ <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)`
+ }}
+ >
+ <EvaluationTargetFilterSheet
+ isOpen={isFilterPanelOpen}
+ onClose={() => setIsFilterPanelOpen(false)}
+ onSearch={() => setIsFilterPanelOpen(false)}
+ 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" />}
+ {getActiveBasicFilterCount() > 0 && (
+ <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs">
+ {getActiveBasicFilterCount()}
+ </span>
+ )}
+ </Button>
+ <div className="text-sm text-muted-foreground">
+ 총 {tableData.total || tableData.data.length}건
+ </div>
+ </div>
+
+ {/* Stats */}
+ <div className="px-4">
+ <EvaluationTargetsStats evaluationYear={evaluationYear} />
+ </div>
+
+ {/* Table */}
+ <div className="flex-1 overflow-hidden" style={{ height: "calc(100vh - 500px)" }}>
+ <DataTable table={table} className="h-full">
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <div className="flex items-center gap-2">
+ <TablePresetManager<EvaluationTargetWithDepartments>
+ presets={presets}
+ activePresetId={activePresetId}
+ currentSettings={currentSettings}
+ hasUnsavedChanges={hasUnsavedChanges}
+ isLoading={presetsLoading}
+ onCreatePreset={createPreset}
+ onUpdatePreset={updatePreset}
+ onDeletePreset={deletePreset}
+ onApplyPreset={applyPreset}
+ onSetDefaultPreset={setDefaultPreset}
+ onRenamePreset={renamePreset}
+ />
+
+ <EvaluationTargetsTableToolbarActions table={table} />
+ </div>
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ {/* 편집 다이얼로그 */}
+ <EditEvaluationTargetSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ evaluationTarget={rowAction?.row.original ?? null}
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+ </>
+ );
+} \ No newline at end of file