diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-02 09:52:21 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-02 09:52:21 +0000 |
| commit | fccb00d15466cd0b2d861163663a5070c768ff77 (patch) | |
| tree | 4b14b27417ebeb873a9d4b4d7b5c64f6e1d78135 /lib/rfq-last/table/rfq-table.tsx | |
| parent | 72f212f717f136e875e7623404a5ddd4c5268901 (diff) | |
(대표님) OCR 박진석프로 요청 대응, rfq 변경된 요구사항 구현
Diffstat (limited to 'lib/rfq-last/table/rfq-table.tsx')
| -rw-r--r-- | lib/rfq-last/table/rfq-table.tsx | 457 |
1 files changed, 457 insertions, 0 deletions
diff --git a/lib/rfq-last/table/rfq-table.tsx b/lib/rfq-last/table/rfq-table.tsx new file mode 100644 index 00000000..199695a0 --- /dev/null +++ b/lib/rfq-last/table/rfq-table.tsx @@ -0,0 +1,457 @@ +"use client"; + +import * as React from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { 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 { RfqFilterSheet } from "./rfq-filter-sheet"; +import { getRfqColumns } from "./rfq-table-columns"; +import { RfqsLastView } from "@/db/schema"; +import { getRfqs } from "../service"; +import { RfqTableToolbarActions } from "./rfq-table-toolbar-actions"; + +interface RfqTableProps { + data: Awaited<ReturnType<typeof getRfqs>>; + rfqCategory?: "general" | "itb" | "rfq"; + className?: string; +} + +export function RfqTable({ + data, + rfqCategory = "itb", + className +}: RfqTableProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + + const [rowAction, setRowAction] = React.useState<DataTableRowAction<RfqsLastView> | null>(null); + const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false); + + // 외부 필터 상태 + 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] + ); + + // 초기 데이터 설정 +// const [initialPromiseData] = React.use(promises); + const [tableData, setTableData] = React.useState(data); + const [isDataLoading, setIsDataLoading] = React.useState(false); + + // URL 필터 변경 감지 및 데이터 새로고침 + React.useEffect(() => { + const refetchData = async () => { + try { + setIsDataLoading(true); + + 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, + rfqCategory: rfqCategory, + }; + + console.log("=== 새 데이터 요청 ===", searchParams); + + const newData = await getRfqs(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, rfqCategory, getSearchParam]); + + const refreshData = React.useCallback(async () => { + try { + setIsDataLoading(true); + + 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, + rfqCategory: rfqCategory, + }; + + const newData = await getRfqs(searchParams); + setTableData(newData); + + console.log("=== 데이터 새로고침 완료 ===", newData.data.length, "건"); + } catch (error) { + console.error("데이터 새로고침 오류:", error); + } finally { + setIsDataLoading(false); + } + }, [rfqCategory, getSearchParam]); + + // 컨테이너 위치 추적 + 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) { + 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 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<RfqsLastView>('rfq-table', initialSettings); + + // 컬럼 정의 + const columns = React.useMemo(() => { + return getRfqColumns({ + setRowAction, + rfqCategory + }); + }, [rfqCategory, setRowAction]); + + const filterFields: DataTableFilterField<RfqsLastView>[] = [ + { id: "rfqCode", label: "RFQ 코드" }, + { id: "projectName", label: "프로젝트명" }, + { id: "itemName", label: "자재명" }, + { id: "status", label: "상태" }, + ]; + + const advancedFilterFields: DataTableAdvancedFilterField<RfqsLastView>[] = [ + { id: "rfqCode", label: "RFQ 코드", type: "text" }, + { + id: "status", + label: "상태", + type: "select", + options: [ + { label: "RFQ 생성", value: "RFQ 생성" }, + { label: "구매담당지정", value: "구매담당지정" }, + { label: "견적요청문서 확정", value: "견적요청문서 확정" }, + { label: "Short List 확정", value: "Short List 확정" }, + { label: "TBE 완료", value: "TBE 완료" }, + { label: "RFQ 발송", value: "RFQ 발송" }, + { label: "견적접수", value: "견적접수" }, + { label: "최종업체선정", value: "최종업체선정" }, + ] + }, + { id: "projectCode", label: "프로젝트 코드", type: "text" }, + { id: "projectName", label: "프로젝트명", type: "text" }, + { id: "itemCode", label: "자재코드", type: "text" }, + { id: "itemName", label: "자재명", type: "text" }, + { id: "packageNo", label: "패키지 번호", type: "text" }, + { id: "picUserName", label: "구매담당자", type: "text" }, + { id: "vendorCount", label: "업체수", type: "number" }, + { id: "dueDate", label: "마감일", type: "date" }, + { id: "rfqSendDate", label: "발송일", type: "date" }, + ...(rfqCategory === "general" || rfqCategory === "all" ? [ + { id: "rfqType", label: "견적 유형", type: "text" }, + { id: "rfqTitle", label: "견적 제목", type: "text" }, + ] as DataTableAdvancedFilterField<RfqsLastView>[] : []), + ...(rfqCategory === "itb" || rfqCategory === "all" ? [ + { id: "projectCompany", label: "프로젝트 회사", type: "text" }, + { id: "projectSite", label: "프로젝트 사이트", type: "text" }, + { id: "smCode", label: "SM 코드", type: "text" }, + ] as DataTableAdvancedFilterField<RfqsLastView>[] : []), + ...(rfqCategory === "rfq" || rfqCategory === "all" ? [ + { id: "prNumber", label: "PR 번호", type: "text" }, + { id: "prIssueDate", label: "PR 발행일", type: "date" }, + { + id: "series", + label: "시리즈", + type: "select", + options: [ + { label: "시리즈 통합", value: "SS" }, + { label: "품목 통합", value: "II" }, + { label: "통합 없음", value: "" }, + ] + }, + ] as DataTableAdvancedFilterField<RfqsLastView>[] : []), + ]; + + const currentSettings = React.useMemo(() => getCurrentSettings(), [getCurrentSettings]); + + const initialState = React.useMemo(() => ({ + sorting: initialSettings.sort.filter((s: any) => columns.some((c: any) => ("accessorKey" in c ? c.accessorKey : c.id) === s.id)), + columnVisibility: currentSettings.columnVisibility, + columnPinning: currentSettings.pinnedColumns, + }), [columns, currentSettings, initialSettings.sort]); + + const { table } = useDataTable({ + data: tableData.data, + columns, + pageCount: tableData.pageCount, + rowCount: tableData.total || tableData.data.length, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }); + + const getActiveFilterCount = React.useCallback(() => { + try { + 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; + + 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)` + }} + > + <RfqFilterSheet + isOpen={isFilterPanelOpen} + onClose={() => setIsFilterPanelOpen(false)} + onFiltersApply={handleFiltersApply} + rfqCategory={rfqCategory} + isLoading={false} + /> + </div> + + {/* Main Content 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 Bar */} + <div className="flex items-center justify-between p-4 bg-background shrink-0"> + <div className="flex items-center gap-3"> + <Button + variant="outline" + size="sm" + type="button" + 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> + + {rfqCategory !== "all" && ( + <Badge variant="outline" className="text-sm"> + {rfqCategory === "general" ? "일반견적" : + rfqCategory === "itb" ? "ITB" : "RFQ"} + </Badge> + )} + </div> + + <div className="flex items-center gap-4"> + <div className="text-sm text-muted-foreground"> + {tableData && ( + <span>총 {tableData.total || tableData.data.length}건</span> + )} + </div> + </div> + </div> + + {/* Table Content Area */} + <div className="flex-1 overflow-hidden relative" style={{ height: 'calc(100vh - 200px)' }}> + {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> + )} + <div className="h-full w-full"> + <DataTable table={table} className="h-full"> + <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<RfqsLastView> + presets={presets} + activePresetId={activePresetId} + currentSettings={currentSettings} + hasUnsavedChanges={hasUnsavedChanges} + isLoading={presetsLoading} + onCreatePreset={createPreset} + onUpdatePreset={updatePreset} + onDeletePreset={deletePreset} + onApplyPreset={applyPreset} + onSetDefaultPreset={setDefaultPreset} + onRenamePreset={renamePreset} + /> + + <RfqTableToolbarActions + table={table} + onRefresh={refreshData} + /> + </div> + </DataTableAdvancedToolbar> + </DataTable> + </div> + </div> + </div> + </div> + </div> + </> + ); +}
\ No newline at end of file |
