diff options
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.tsx | 508 |
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 |
