summaryrefslogtreecommitdiff
path: root/lib/techsales-rfq/table/rfq-table.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'lib/techsales-rfq/table/rfq-table.tsx')
-rw-r--r--lib/techsales-rfq/table/rfq-table.tsx1223
1 files changed, 635 insertions, 588 deletions
diff --git a/lib/techsales-rfq/table/rfq-table.tsx b/lib/techsales-rfq/table/rfq-table.tsx
index 615753cd..e3551625 100644
--- a/lib/techsales-rfq/table/rfq-table.tsx
+++ b/lib/techsales-rfq/table/rfq-table.tsx
@@ -1,589 +1,636 @@
-"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 } from "./rfq-table-column"
-import { useEffect, useMemo } from "react"
-import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
-import { RFQTableToolbarActions } from "./rfq-table-toolbar-actions"
-import { getTechSalesRfqsWithJoin, getTechSalesRfqAttachments } from "@/lib/techsales-rfq/service"
-import { toast } from "sonner"
-import { useTablePresets } from "@/components/data-table/use-table-presets"
-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"
-import { TechSalesRfqAttachmentsSheet, ExistingTechSalesAttachment } from "./tech-sales-rfq-attachments-sheet"
-import { RfqItemsViewDialog } from "./rfq-items-view-dialog"
-// 기본적인 RFQ 타입 정의 (repository selectTechSalesRfqsWithJoin 반환 타입에 맞춤)
-interface TechSalesRfq {
- id: number
- rfqCode: string | null
- biddingProjectId: number | null
- materialCode: string | null
- dueDate: Date
- rfqSendDate: Date | null
- status: "RFQ Created" | "RFQ Vendor Assignned" | "RFQ Sent" | "Quotation Analysis" | "Closed"
- description: 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
- // 조인된 프로젝트 정보
- pspid: string
- projNm: string
- sector: string
- projMsrm: number
- ptypeNm: string
- attachmentCount: number
- quotationCount: number
- rfqType: "SHIP" | "TOP" | "HULL" | null
- // 필요에 따라 다른 필드들 추가
- [key: string]: unknown
-}
-
-interface RFQListTableProps {
- promises: Promise<[Awaited<ReturnType<typeof getTechSalesRfqsWithJoin>>]>
- className?: string;
- calculatedHeight?: string; // 계산된 높이 추가
- rfqType: "SHIP" | "TOP" | "HULL";
-}
-
-export function RFQListTable({
- promises,
- className,
- calculatedHeight,
- rfqType
-}: 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)
-
- // 첨부파일 시트 상태
- const [attachmentsOpen, setAttachmentsOpen] = React.useState(false)
- const [selectedRfqForAttachments, setSelectedRfqForAttachments] = React.useState<TechSalesRfq | null>(null)
- const [attachmentsDefault, setAttachmentsDefault] = React.useState<ExistingTechSalesAttachment[]>([])
-
- // 아이템 다이얼로그 상태
- const [itemsDialogOpen, setItemsDialogOpen] = React.useState(false)
- const [selectedRfqForItems, setSelectedRfqForItems] = 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 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: ["items", "attachments", "tbe-attachments", "cbe-attachments"] },
- groupBy: [],
- expandedRows: []
- }), [searchParams])
-
- // DB 기반 프리셋 훅 사용
- const {
- // presets,
- // activePresetId,
- // hasUnsavedChanges,
- // isLoading: presetsLoading,
- // createPreset,
- // applyPreset,
- // updatePreset,
- // deletePreset,
- // setDefaultPreset,
- // renamePreset,
- getCurrentSettings,
- } = useTablePresets<TechSalesRfq>('rfq-list-table', initialSettings)
-
- // 조회 버튼 클릭 핸들러
- const handleSearch = () => {
- setIsFilterPanelOpen(false)
- }
-
- // 행 액션 처리
- useEffect(() => {
- if (rowAction) {
- switch (rowAction.type) {
- case "select":
- // 객체 참조 안정화를 위해 필요한 필드만 추출
- const rfqData = rowAction.row.original;
- setSelectedRfq({
- id: rfqData.id,
- rfqCode: rfqData.rfqCode,
- rfqType: rfqData.rfqType, // 빠뜨린 rfqType 필드 추가
- biddingProjectId: rfqData.biddingProjectId,
- materialCode: rfqData.materialCode,
- dueDate: rfqData.dueDate,
- rfqSendDate: rfqData.rfqSendDate,
- status: rfqData.status,
- description: rfqData.description,
- 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,
- pspid: rfqData.pspid,
- projNm: rfqData.projNm,
- sector: rfqData.sector,
- projMsrm: rfqData.projMsrm,
- ptypeNm: rfqData.ptypeNm,
- attachmentCount: rfqData.attachmentCount,
- quotationCount: rfqData.quotationCount,
- });
- break;
- case "view":
- // 프로젝트 상세정보 다이얼로그 열기
- const projectRfqData = rowAction.row.original;
- setProjectDetailRfq({
- id: projectRfqData.id,
- rfqCode: projectRfqData.rfqCode,
- rfqType: projectRfqData.rfqType, // 빠뜨린 rfqType 필드 추가
- biddingProjectId: projectRfqData.biddingProjectId,
- materialCode: projectRfqData.materialCode,
- dueDate: projectRfqData.dueDate,
- rfqSendDate: projectRfqData.rfqSendDate,
- status: projectRfqData.status,
- description: projectRfqData.description,
- 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,
- 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 [attachmentType, setAttachmentType] = React.useState<"RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT">("RFQ_COMMON")
-
- // 첨부파일 시트 열기 함수
- const openAttachmentsSheet = React.useCallback(async (rfqId: number, attachmentType: 'RFQ_COMMON' | 'TBE_RESULT' | 'CBE_RESULT' = 'RFQ_COMMON') => {
- try {
- // 선택된 RFQ 찾기
- const rfq = tableData?.data?.find(r => r.id === rfqId)
- if (!rfq) {
- toast.error("RFQ를 찾을 수 없습니다.")
- return
- }
-
- // attachmentType을 RFQ_COMMON, TBE_RESULT, CBE_RESULT 중 하나로 변환
- const validAttachmentType=attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT"
-
- // 실제 첨부파일 목록 조회 API 호출
- const result = await getTechSalesRfqAttachments(rfqId)
-
- if (result.error) {
- toast.error(result.error)
- return
- }
-
- // 해당 타입의 첨부파일만 필터링
- const filteredAttachments = result.data.filter(att => att.attachmentType === validAttachmentType)
-
- // API 응답을 ExistingTechSalesAttachment 형식으로 변환
- const attachments: ExistingTechSalesAttachment[] = filteredAttachments.map(att => ({
- id: att.id,
- techSalesRfqId: att.techSalesRfqId || rfqId, // null인 경우 rfqId 사용
- fileName: att.fileName,
- originalFileName: att.originalFileName,
- filePath: att.filePath,
- fileSize: att.fileSize || undefined,
- fileType: att.fileType || undefined,
- attachmentType: att.attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT",
- description: att.description || undefined,
- createdBy: att.createdBy,
- createdAt: att.createdAt,
- }))
-
- setAttachmentType(validAttachmentType)
- setAttachmentsDefault(attachments)
- setSelectedRfqForAttachments(rfq as unknown as TechSalesRfq)
- setAttachmentsOpen(true)
- } catch (error) {
- console.error("첨부파일 조회 오류:", error)
- toast.error("첨부파일 조회 중 오류가 발생했습니다.")
- }
- }, [tableData?.data])
-
- // 첨부파일 업데이트 콜백
- const handleAttachmentsUpdated = React.useCallback((rfqId: number, newAttachmentCount: number) => {
- // Service에서 이미 revalidateTag와 revalidatePath로 캐시 무효화 처리됨
- console.log(`RFQ ${rfqId}의 첨부파일 개수가 ${newAttachmentCount}개로 업데이트됨`)
-
- // 성공 피드백
- setTimeout(() => {
- toast.success(`첨부파일 개수가 업데이트되었습니다. (${newAttachmentCount}개)`, {
- duration: 3000
- })
- }, 500)
- }, [])
-
- // 아이템 다이얼로그 열기 함수
- const openItemsDialog = React.useCallback((rfq: TechSalesRfq) => {
- console.log("Opening items dialog for RFQ:", rfq.id, rfq)
- setSelectedRfqForItems(rfq as unknown as TechSalesRfq)
- setItemsDialogOpen(true)
- }, [])
-
- const columns = React.useMemo(
- () => getColumns({
- setRowAction,
- openAttachmentsSheet,
- openItemsDialog
- }),
- [openAttachmentsSheet, openItemsDialog]
- )
-
- // 고급 필터 필드 정의
- const advancedFilterFields: DataTableAdvancedFilterField<TechSalesRfq>[] = [
- {
- id: "rfqCode",
- label: "RFQ No.",
- type: "text",
- },
- {
- id: "description",
- label: "설명",
- type: "text",
- },
- {
- id: "projNm",
- 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={() => {}}
- rfqType={rfqType}
- />
- </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}
- />
-
- {/* 첨부파일 관리 시트 */}
- <TechSalesRfqAttachmentsSheet
- open={attachmentsOpen}
- onOpenChange={setAttachmentsOpen}
- defaultAttachments={attachmentsDefault}
- rfq={selectedRfqForAttachments}
- attachmentType={attachmentType}
- onAttachmentsUpdated={handleAttachmentsUpdated}
- />
-
- {/* 아이템 보기 다이얼로그 */}
- <RfqItemsViewDialog
- open={itemsDialogOpen}
- onOpenChange={setItemsDialogOpen}
- rfq={selectedRfqForItems}
- />
- </div>
- )
+"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 } from "./rfq-table-column"
+import { useEffect, useMemo } from "react"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import { RFQTableToolbarActions } from "./rfq-table-toolbar-actions"
+import { getTechSalesRfqsWithJoin, getTechSalesRfqAttachments } from "@/lib/techsales-rfq/service"
+import { toast } from "sonner"
+import { useTablePresets } from "@/components/data-table/use-table-presets"
+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"
+import { TechSalesRfqAttachmentsSheet, ExistingTechSalesAttachment } from "./tech-sales-rfq-attachments-sheet"
+import { RfqItemsViewDialog } from "./rfq-items-view-dialog"
+// 기본적인 RFQ 타입 정의 (repository selectTechSalesRfqsWithJoin 반환 타입에 맞춤)
+export interface TechSalesRfq {
+ id: number
+ rfqCode: string | null
+ biddingProjectId: number | null
+ materialCode: string | null
+ dueDate: Date
+ rfqSendDate: Date | null
+ status: "RFQ Created" | "RFQ Vendor Assignned" | "RFQ Sent" | "Quotation Analysis" | "Closed"
+ description: string | null
+ 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
+ // 조인된 프로젝트 정보
+ pspid: string
+ projNm: string
+ sector: string
+ projMsrm: number
+ ptypeNm: string
+ attachmentCount: number
+ hasTbeAttachments: boolean
+ hasCbeAttachments: boolean
+ quotationCount: number
+ rfqType: "SHIP" | "TOP" | "HULL" | null
+ itemCount: number
+ workTypes: string | null
+ // 필요에 따라 다른 필드들 추가
+ [key: string]: unknown
+}
+
+interface RFQListTableProps {
+ promises: Promise<[Awaited<ReturnType<typeof getTechSalesRfqsWithJoin>>]>
+ className?: string;
+ calculatedHeight?: string; // 계산된 높이 추가
+ rfqType: "SHIP" | "TOP" | "HULL";
+}
+
+export function RFQListTable({
+ promises,
+ className,
+ calculatedHeight,
+ rfqType
+}: 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)
+
+ // 첨부파일 시트 상태
+ const [attachmentsOpen, setAttachmentsOpen] = React.useState(false)
+ const [selectedRfqForAttachments, setSelectedRfqForAttachments] = React.useState<TechSalesRfq | null>(null)
+ const [attachmentsDefault, setAttachmentsDefault] = React.useState<ExistingTechSalesAttachment[]>([])
+
+ // 아이템 다이얼로그 상태
+ const [itemsDialogOpen, setItemsDialogOpen] = React.useState(false)
+ const [selectedRfqForItems, setSelectedRfqForItems] = 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 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: ["items", "attachments", "tbe-attachments", "cbe-attachments"] },
+ groupBy: [],
+ expandedRows: []
+ }), [searchParams])
+
+ // DB 기반 프리셋 훅 사용
+ const {
+ // presets,
+ // activePresetId,
+ // hasUnsavedChanges,
+ // isLoading: presetsLoading,
+ // createPreset,
+ // applyPreset,
+ // updatePreset,
+ // deletePreset,
+ // setDefaultPreset,
+ // renamePreset,
+ getCurrentSettings,
+ } = useTablePresets<TechSalesRfq>('rfq-list-table', initialSettings)
+
+ // 조회 버튼 클릭 핸들러
+ const handleSearch = () => {
+ setIsFilterPanelOpen(false)
+ }
+
+ // 행 액션 처리
+ useEffect(() => {
+ if (rowAction) {
+ switch (rowAction.type) {
+ case "select":
+ // 객체 참조 안정화를 위해 필요한 필드만 추출
+ const rfqData = rowAction.row.original;
+ setSelectedRfq({
+ id: rfqData.id,
+ rfqCode: rfqData.rfqCode,
+ rfqType: rfqData.rfqType, // 빠뜨린 rfqType 필드 추가
+ biddingProjectId: rfqData.biddingProjectId,
+ hasTbeAttachments: rfqData.hasTbeAttachments,
+ hasCbeAttachments: rfqData.hasCbeAttachments,
+ materialCode: rfqData.materialCode,
+ dueDate: rfqData.dueDate,
+ rfqSendDate: rfqData.rfqSendDate,
+ status: rfqData.status,
+ description: rfqData.description,
+ 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,
+ pspid: rfqData.pspid,
+ projNm: rfqData.projNm,
+ sector: rfqData.sector,
+ projMsrm: rfqData.projMsrm,
+ ptypeNm: rfqData.ptypeNm,
+ attachmentCount: rfqData.attachmentCount,
+ quotationCount: rfqData.quotationCount,
+ itemCount: rfqData.itemCount,
+ workTypes: rfqData.workTypes,
+ });
+ break;
+ case "view":
+ // 프로젝트 상세정보 다이얼로그 열기
+ const projectRfqData = rowAction.row.original;
+ setProjectDetailRfq({
+ id: projectRfqData.id,
+ rfqCode: projectRfqData.rfqCode,
+ rfqType: projectRfqData.rfqType, // 빠뜨린 rfqType 필드 추가
+ biddingProjectId: projectRfqData.biddingProjectId,
+ hasTbeAttachments: projectRfqData.hasTbeAttachments,
+ hasCbeAttachments: projectRfqData.hasCbeAttachments,
+ materialCode: projectRfqData.materialCode,
+ dueDate: projectRfqData.dueDate,
+ rfqSendDate: projectRfqData.rfqSendDate,
+ status: projectRfqData.status,
+ description: projectRfqData.description,
+ 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,
+ pspid: projectRfqData.pspid,
+ projNm: projectRfqData.projNm,
+ sector: projectRfqData.sector,
+ projMsrm: projectRfqData.projMsrm,
+ ptypeNm: projectRfqData.ptypeNm,
+ attachmentCount: projectRfqData.attachmentCount,
+ quotationCount: projectRfqData.quotationCount,
+ itemCount: projectRfqData.itemCount,
+ workTypes: projectRfqData.workTypes,
+ });
+ 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 [attachmentType, setAttachmentType] = React.useState<"RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT">("RFQ_COMMON")
+
+ // 첨부파일 시트 열기 함수
+ const openAttachmentsSheet = React.useCallback(async (rfqId: number, attachmentType: 'RFQ_COMMON' | 'TBE_RESULT' | 'CBE_RESULT' = 'RFQ_COMMON') => {
+ try {
+ // 선택된 RFQ 찾기
+ const rfq = tableData?.data?.find(r => r.id === rfqId)
+ if (!rfq) {
+ toast.error("RFQ를 찾을 수 없습니다.")
+ return
+ }
+
+ // attachmentType을 RFQ_COMMON, TBE_RESULT, CBE_RESULT 중 하나로 변환
+ const validAttachmentType=attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT"
+
+ // 실제 첨부파일 목록 조회 API 호출
+ const result = await getTechSalesRfqAttachments(rfqId)
+
+ if (result.error) {
+ toast.error(result.error)
+ return
+ }
+
+ // 해당 타입의 첨부파일만 필터링
+ const filteredAttachments = result.data.filter(att => att.attachmentType === validAttachmentType)
+
+ // API 응답을 ExistingTechSalesAttachment 형식으로 변환
+ const attachments: ExistingTechSalesAttachment[] = filteredAttachments.map(att => ({
+ id: att.id,
+ techSalesRfqId: att.techSalesRfqId || rfqId, // null인 경우 rfqId 사용
+ fileName: att.fileName,
+ originalFileName: att.originalFileName,
+ filePath: att.filePath,
+ fileSize: att.fileSize || undefined,
+ fileType: att.fileType || undefined,
+ attachmentType: att.attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT",
+ description: att.description || undefined,
+ createdBy: att.createdBy,
+ createdAt: att.createdAt,
+ }))
+
+ setAttachmentType(validAttachmentType)
+ setAttachmentsDefault(attachments)
+ setSelectedRfqForAttachments(rfq as unknown as TechSalesRfq)
+ setAttachmentsOpen(true)
+ } catch (error) {
+ console.error("첨부파일 조회 오류:", error)
+ toast.error("첨부파일 조회 중 오류가 발생했습니다.")
+ }
+ }, [tableData?.data])
+
+ // // 첨부파일 업데이트 콜백
+ // const handleAttachmentsUpdated = React.useCallback((rfqId: number, newAttachmentCount: number) => {
+ // // Service에서 이미 revalidateTag와 revalidatePath로 캐시 무효화 처리됨
+ // console.log(`RFQ ${rfqId}의 첨부파일 개수가 ${newAttachmentCount}개로 업데이트됨`)
+
+ // // 성공 피드백
+ // setTimeout(() => {
+ // toast.success(`첨부파일 개수가 업데이트되었습니다. (${newAttachmentCount}개)`, {
+ // duration: 3000
+ // })
+ // }, 500)
+ // }, [])
+
+ // 아이템 다이얼로그 열기 함수
+ const openItemsDialog = React.useCallback((rfq: TechSalesRfq) => {
+ console.log("Opening items dialog for RFQ:", rfq.id, rfq)
+ setSelectedRfqForItems(rfq)
+ setItemsDialogOpen(true)
+ }, [])
+
+ const columns = React.useMemo(
+ () => getColumns({
+ setRowAction,
+ openAttachmentsSheet,
+ openItemsDialog
+ }),
+ [openAttachmentsSheet, openItemsDialog, setRowAction]
+ )
+
+ // 고급 필터 필드 정의
+ const advancedFilterFields: DataTableAdvancedFilterField<TechSalesRfq>[] = [
+ {
+ id: "rfqCode",
+ label: "RFQ No.",
+ type: "text",
+ },
+ {
+ id: "description",
+ label: "설명",
+ type: "text",
+ },
+ {
+ id: "projNm",
+ 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",
+ },
+ {
+ id: "workTypes",
+ label: "Work Type",
+ type: "multi-select",
+ options: [
+ // 조선 workTypes
+ { label: "기장", value: "기장" },
+ { label: "전장", value: "전장" },
+ { label: "선실", value: "선실" },
+ { label: "배관", value: "배관" },
+ { label: "철의", value: "철의" },
+ { label: "선체", value: "선체" },
+ // 해양TOP workTypes
+ { label: "TM", value: "TM" },
+ { label: "TS", value: "TS" },
+ { label: "TE", value: "TE" },
+ { label: "TP", value: "TP" },
+ // 해양HULL workTypes
+ { label: "HA", value: "HA" },
+ { label: "HE", value: "HE" },
+ { label: "HH", value: "HH" },
+ { label: "HM", value: "HM" },
+ { label: "NC", value: "NC" },
+ { label: "HO", value: "HO" },
+ { label: "HP", value: "HP" },
+ ],
+ },
+ ]
+
+ // 현재 설정 가져오기
+ 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={() => {}}
+ rfqType={rfqType}
+ />
+ </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}
+ />
+
+ {/* 첨부파일 관리 시트 */}
+ <TechSalesRfqAttachmentsSheet
+ open={attachmentsOpen}
+ onOpenChange={setAttachmentsOpen}
+ defaultAttachments={attachmentsDefault}
+ rfq={selectedRfqForAttachments}
+ attachmentType={attachmentType}
+ />
+
+ {/* 아이템 보기 다이얼로그 */}
+ <RfqItemsViewDialog
+ open={itemsDialogOpen}
+ onOpenChange={setItemsDialogOpen}
+ rfq={selectedRfqForItems ? {
+ id: selectedRfqForItems.id,
+ rfqCode: selectedRfqForItems.rfqCode,
+ status: selectedRfqForItems.status,
+ description: selectedRfqForItems.description || undefined,
+ rfqType: selectedRfqForItems.rfqType
+ } : null}
+ />
+ </div>
+ )
} \ No newline at end of file