diff options
Diffstat (limited to 'lib/techsales-rfq/table/rfq-table.tsx')
| -rw-r--r-- | lib/techsales-rfq/table/rfq-table.tsx | 524 |
1 files changed, 524 insertions, 0 deletions
diff --git a/lib/techsales-rfq/table/rfq-table.tsx b/lib/techsales-rfq/table/rfq-table.tsx new file mode 100644 index 00000000..3139b1a3 --- /dev/null +++ b/lib/techsales-rfq/table/rfq-table.tsx @@ -0,0 +1,524 @@ +"use client" + +import * as React from "react" +import { useSearchParams } from "next/navigation" +import { Button } from "@/components/ui/button" +import { PanelLeftClose, PanelLeftOpen } from "lucide-react" +import type { + DataTableAdvancedFilterField, + DataTableRowAction, +} from "@/types/table" +import { + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, +} from "@/components/ui/resizable" + +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { getColumns, EditingCellState } from "./rfq-table-column" +import { useEffect, useCallback, useMemo } from "react" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { RFQTableToolbarActions } from "./rfq-table-toolbar-actions" +import { getTechSalesRfqsWithJoin } from "@/lib/techsales-rfq/service" +import { toast } from "sonner" +import { useTablePresets } from "@/components/data-table/use-table-presets" +import { TablePresetManager } from "@/components/data-table/data-table-preset" +import { RfqDetailTables } from "./detail-table/rfq-detail-table" +import { cn } from "@/lib/utils" +import { ProjectDetailDialog } from "./project-detail-dialog" +import { RFQFilterSheet } from "./rfq-filter-sheet" + +// 기본적인 RFQ 타입 정의 (repository selectTechSalesRfqsWithJoin 반환 타입에 맞춤) +interface TechSalesRfq { + id: number + rfqCode: string | null + itemId: number + itemName: string | null + materialCode: string | null + dueDate: Date + rfqSendDate: Date | null + status: "RFQ Created" | "RFQ Vendor Assignned" | "RFQ Sent" | "Quotation Analysis" | "Closed" + picCode: string | null + remark: string | null + cancelReason: string | null + createdAt: Date + updatedAt: Date + createdBy: number | null + createdByName: string + updatedBy: number | null + updatedByName: string + sentBy: number | null + sentByName: string | null + projectSnapshot: any + seriesSnapshot: any + pspid: string + projNm: string + sector: string + projMsrm: number + ptypeNm: string + attachmentCount: number + quotationCount: number + // 필요에 따라 다른 필드들 추가 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any +} + +interface RFQListTableProps { + promises: Promise<[Awaited<ReturnType<typeof getTechSalesRfqsWithJoin>>]> + className?: string; + calculatedHeight?: string; // 계산된 높이 추가 +} + +export function RFQListTable({ + promises, + className, + calculatedHeight +}: RFQListTableProps) { + const searchParams = useSearchParams() + + // 필터 패널 상태 + const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false) + + // 선택된 RFQ 상태 + const [selectedRfq, setSelectedRfq] = React.useState<TechSalesRfq | null>(null) + + // 프로젝트 상세정보 다이얼로그 상태 + const [isProjectDetailOpen, setIsProjectDetailOpen] = React.useState(false) + const [projectDetailRfq, setProjectDetailRfq] = React.useState<TechSalesRfq | null>(null) + + // 패널 collapse 상태 + const [panelHeight, setPanelHeight] = React.useState<number>(55) + + // 고정 높이 설정을 위한 상수 (실제 측정값으로 조정 필요) + const LAYOUT_HEADER_HEIGHT = 64 // Layout Header 높이 + const LAYOUT_FOOTER_HEIGHT = 60 // Layout Footer 높이 (있다면 실제 값) + const LOCAL_HEADER_HEIGHT = 72 // 로컬 헤더 바 높이 (p-4 + border) + const FILTER_PANEL_WIDTH = 400 // 필터 패널 너비 + + // 높이 계산 + // 필터 패널 높이 - Layout Header와 Footer 사이 + const FIXED_FILTER_HEIGHT = `calc(100vh - ${LAYOUT_HEADER_HEIGHT*2}px)` + + console.log(calculatedHeight) + + // 테이블 컨텐츠 높이 - 전달받은 높이에서 로컬 헤더 제외 + const FIXED_TABLE_HEIGHT = calculatedHeight + ? `calc(${calculatedHeight} - ${LOCAL_HEADER_HEIGHT}px)` + : `calc(100vh - ${LAYOUT_HEADER_HEIGHT + LAYOUT_FOOTER_HEIGHT + LOCAL_HEADER_HEIGHT+76}px)` // fallback + + // Suspense 방식으로 데이터 처리 + const [promiseData] = React.use(promises) + const tableData = promiseData + + const [rowAction, setRowAction] = React.useState<DataTableRowAction<TechSalesRfq> | null>(null) + const [editingCell, setEditingCell] = React.useState<EditingCellState | null>(null) + + // 초기 설정 정의 + const initialSettings = React.useMemo(() => ({ + page: parseInt(searchParams?.get('page') || '1'), + perPage: parseInt(searchParams?.get('perPage') || '10'), + sort: searchParams?.get('sort') ? JSON.parse(searchParams.get('sort')!) : [{ id: "updatedAt", desc: true }], + filters: searchParams?.get('filters') ? JSON.parse(searchParams.get('filters')!) : [], + joinOperator: (searchParams?.get('joinOperator') as "and" | "or") || "and", + basicFilters: searchParams?.get('basicFilters') ? JSON.parse(searchParams.get('basicFilters')!) : [], + basicJoinOperator: (searchParams?.get('basicJoinOperator') as "and" | "or") || "and", + search: searchParams?.get('search') || '', + from: searchParams?.get('from') || undefined, + to: searchParams?.get('to') || undefined, + columnVisibility: {}, + columnOrder: [], + pinnedColumns: { left: [], right: [] }, + groupBy: [], + expandedRows: [] + }), [searchParams]) + + // DB 기반 프리셋 훅 사용 + const { + presets, + activePresetId, + hasUnsavedChanges, + isLoading: presetsLoading, + createPreset, + applyPreset, + updatePreset, + deletePreset, + setDefaultPreset, + renamePreset, + getCurrentSettings, + } = useTablePresets<TechSalesRfq>('rfq-list-table', initialSettings) + + // 비고 업데이트 함수 + const updateRemark = useCallback(async (rfqId: number, remark: string) => { + try { + // 기술영업 RFQ 비고 업데이트 함수 구현 필요 + // const result = await updateTechSalesRfqRemark(rfqId, remark); + console.log("Update remark for RFQ:", rfqId, "with:", remark); + + toast.success("비고가 업데이트되었습니다"); + } catch (error) { + console.error("비고 업데이트 오류:", error); + toast.error("업데이트 중 오류가 발생했습니다"); + } + }, []) + + // 조회 버튼 클릭 핸들러 + const handleSearch = () => { + setIsFilterPanelOpen(false) + } + + // 행 액션 처리 + useEffect(() => { + if (rowAction) { + switch (rowAction.type) { + case "select": + // 객체 참조 안정화를 위해 필요한 필드만 추출 + const rfqData = rowAction.row.original; + setSelectedRfq({ + id: rfqData.id, + rfqCode: rfqData.rfqCode, + itemId: rfqData.itemId, + itemName: rfqData.itemName, + materialCode: rfqData.materialCode, + dueDate: rfqData.dueDate, + rfqSendDate: rfqData.rfqSendDate, + status: rfqData.status, + picCode: rfqData.picCode, + remark: rfqData.remark, + cancelReason: rfqData.cancelReason, + createdAt: rfqData.createdAt, + updatedAt: rfqData.updatedAt, + createdBy: rfqData.createdBy, + createdByName: rfqData.createdByName, + updatedBy: rfqData.updatedBy, + updatedByName: rfqData.updatedByName, + sentBy: rfqData.sentBy, + sentByName: rfqData.sentByName, + projectSnapshot: rfqData.projectSnapshot, + seriesSnapshot: rfqData.seriesSnapshot, + pspid: rfqData.pspid, + projNm: rfqData.projNm, + sector: rfqData.sector, + projMsrm: rfqData.projMsrm, + ptypeNm: rfqData.ptypeNm, + attachmentCount: rfqData.attachmentCount, + quotationCount: rfqData.quotationCount, + }); + break; + case "project-detail": + // 프로젝트 상세정보 다이얼로그 열기 + const projectRfqData = rowAction.row.original; + setProjectDetailRfq({ + id: projectRfqData.id, + rfqCode: projectRfqData.rfqCode, + itemId: projectRfqData.itemId, + itemName: projectRfqData.itemName, + materialCode: projectRfqData.materialCode, + dueDate: projectRfqData.dueDate, + rfqSendDate: projectRfqData.rfqSendDate, + status: projectRfqData.status, + picCode: projectRfqData.picCode, + remark: projectRfqData.remark, + cancelReason: projectRfqData.cancelReason, + createdAt: projectRfqData.createdAt, + updatedAt: projectRfqData.updatedAt, + createdBy: projectRfqData.createdBy, + createdByName: projectRfqData.createdByName, + updatedBy: projectRfqData.updatedBy, + updatedByName: projectRfqData.updatedByName, + sentBy: projectRfqData.sentBy, + sentByName: projectRfqData.sentByName, + projectSnapshot: projectRfqData.projectSnapshot, + seriesSnapshot: projectRfqData.seriesSnapshot, + pspid: projectRfqData.pspid, + projNm: projectRfqData.projNm, + sector: projectRfqData.sector, + projMsrm: projectRfqData.projMsrm, + ptypeNm: projectRfqData.ptypeNm, + attachmentCount: projectRfqData.attachmentCount, + quotationCount: projectRfqData.quotationCount, + }); + setIsProjectDetailOpen(true); + break; + case "update": + console.log("Update rfq:", rowAction.row.original) + break; + case "delete": + console.log("Delete rfq:", rowAction.row.original) + break; + } + setRowAction(null) + } + }, [rowAction]) + + const columns = React.useMemo( + () => getColumns({ + setRowAction, + editingCell, + setEditingCell, + updateRemark + }), + [editingCell, setEditingCell, updateRemark] + ) + + // 고급 필터 필드 정의 + const advancedFilterFields: DataTableAdvancedFilterField<TechSalesRfq>[] = [ + { + id: "rfqCode", + label: "RFQ No.", + type: "text", + }, + { + id: "materialCode", + label: "자재코드", + type: "text", + }, + { + id: "itemName", + label: "자재명", + type: "text", + }, + { + id: "pspid", + label: "프로젝트 ID", + type: "text", + }, + { + id: "projNm", + label: "프로젝트명", + type: "text", + }, + { + id: "ptypeNm", + label: "선종명", + type: "text", + }, + { + id: "rfqSendDate", + label: "RFQ 전송일", + type: "date", + }, + { + id: "dueDate", + label: "RFQ 마감일", + type: "date", + }, + { + id: "createdByName", + label: "요청자", + type: "text", + }, + { + id: "status", + label: "상태", + type: "text", + }, + ] + + // 현재 설정 가져오기 + const currentSettings = useMemo(() => { + return getCurrentSettings() + }, [getCurrentSettings]) + + // useDataTable 초기 상태 설정 + const initialState = useMemo(() => { + return { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sorting: initialSettings.sort.filter((sortItem: any) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const columnExists = columns.some((col: any) => col.accessorKey === sortItem.id) + return columnExists + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any, + columnVisibility: currentSettings.columnVisibility, + columnPinning: currentSettings.pinnedColumns, + } + }, [currentSettings, initialSettings.sort, columns]) + + // useDataTable 훅 설정 + const { table } = useDataTable({ + data: tableData?.data || [], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + columns: columns as any, + pageCount: tableData?.pageCount || 0, + rowCount: tableData?.total || 0, + filterFields: [], + enablePinning: true, + enableAdvancedFilter: true, + initialState, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + columnResizeMode: "onEnd", + }) + + // Get active basic filter count + const getActiveBasicFilterCount = () => { + try { + const basicFilters = searchParams?.get('basicFilters') + return basicFilters ? JSON.parse(basicFilters).length : 0 + } catch { + return 0 + } + } + + console.log(panelHeight) + + return ( + <div + className={cn("flex flex-col relative", className)} + style={{ height: calculatedHeight }} + > + {/* Filter Panel - 계산된 높이 적용 */} + <div + className={cn( + "fixed left-0 bg-background border-r z-30 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: `${LAYOUT_HEADER_HEIGHT*2}px`, + height: FIXED_FILTER_HEIGHT + }} + > + {/* Filter Content */} + <div className="h-full"> + <RFQFilterSheet + isOpen={isFilterPanelOpen} + onClose={() => setIsFilterPanelOpen(false)} + onSearch={handleSearch} + isLoading={false} + /> + </div> + </div> + + {/* Main Content */} + <div + className="flex flex-col transition-all duration-300 ease-in-out" + style={{ + width: isFilterPanelOpen ? `calc(100% - ${FILTER_PANEL_WIDTH}px)` : '100%', + marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px', + height: '100%' + }} + > + {/* Header Bar - 고정 높이 */} + <div + className="flex items-center justify-between p-4 bg-background border-b" + style={{ + height: `${LOCAL_HEADER_HEIGHT}px`, + flexShrink: 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"/>} + {getActiveBasicFilterCount() > 0 && ( + <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs"> + {getActiveBasicFilterCount()} + </span> + )} + </Button> + </div> + + {/* Right side info */} + <div className="text-sm text-muted-foreground"> + {tableData && ( + <span>총 {tableData.total || 0}건</span> + )} + </div> + </div> + + {/* Table Content Area - 계산된 높이 사용 */} + <div + className="relative bg-background" + style={{ + height: FIXED_TABLE_HEIGHT, + display: 'grid', + gridTemplateRows: '1fr', + gridTemplateColumns: '1fr' + }} + > + <ResizablePanelGroup + direction="vertical" + className="w-full h-full" + > + <ResizablePanel + defaultSize={60} + minSize={25} + maxSize={75} + collapsible={false} + onResize={(size) => { + setPanelHeight(size) + }} + className="flex flex-col overflow-hidden" + > + {/* 상단 테이블 영역 */} + <div className="flex-1 min-h-0 overflow-hidden"> + <DataTable + table={table} + maxHeight={`${panelHeight*0.5}vh`} + > + <DataTableAdvancedToolbar + // eslint-disable-next-line @typescript-eslint/no-explicit-any + table={table as any} + filterFields={advancedFilterFields} + shallow={false} + > + <div className="flex items-center gap-2"> + <TablePresetManager<TechSalesRfq> + presets={presets} + activePresetId={activePresetId} + currentSettings={currentSettings} + hasUnsavedChanges={hasUnsavedChanges} + isLoading={presetsLoading} + onCreatePreset={createPreset} + onUpdatePreset={updatePreset} + onDeletePreset={deletePreset} + onApplyPreset={applyPreset} + onSetDefaultPreset={setDefaultPreset} + onRenamePreset={renamePreset} + /> + + <RFQTableToolbarActions + selection={table} + onRefresh={() => {}} + /> + </div> + </DataTableAdvancedToolbar> + </DataTable> + </div> + </ResizablePanel> + + <ResizableHandle withHandle /> + + <ResizablePanel + minSize={25} + defaultSize={40} + collapsible={false} + className="flex flex-col overflow-hidden" + > + {/* 하단 상세 테이블 영역 */} + <div className="flex-1 min-h-0 overflow-hidden bg-background"> + <RfqDetailTables selectedRfq={selectedRfq} maxHeight={`${(100-panelHeight)*0.4}vh`}/> + </div> + </ResizablePanel> + </ResizablePanelGroup> + </div> + </div> + + {/* 프로젝트 상세정보 다이얼로그 */} + <ProjectDetailDialog + open={isProjectDetailOpen} + onOpenChange={setIsProjectDetailOpen} + // eslint-disable-next-line @typescript-eslint/no-explicit-any + selectedRfq={projectDetailRfq as any} + /> + </div> + ) +}
\ No newline at end of file |
