"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, useState } 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" import UpdateSheet from "./update-rfq-sheet" import { deleteTechSalesRfq } from "@/lib/techsales-rfq/service" import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-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>]> 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(null) // 프로젝트 상세정보 다이얼로그 상태 const [isProjectDetailOpen, setIsProjectDetailOpen] = React.useState(false) const [projectDetailRfq, setProjectDetailRfq] = React.useState(null) // 첨부파일 시트 상태 const [attachmentsOpen, setAttachmentsOpen] = React.useState(false) const [selectedRfqForAttachments, setSelectedRfqForAttachments] = React.useState(null) const [attachmentsDefault, setAttachmentsDefault] = React.useState([]) // 아이템 다이얼로그 상태 const [itemsDialogOpen, setItemsDialogOpen] = React.useState(false) const [selectedRfqForItems, setSelectedRfqForItems] = React.useState(null) // 삭제 다이얼로그 상태 const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false) const [rfqToDelete, setRfqToDelete] = React.useState(null) const [isDeleting, setIsDeleting] = React.useState(false) // 패널 collapse 상태 const [panelHeight, setPanelHeight] = React.useState(55) // RFQListTable 컴포넌트 내부의 rowAction 처리 부분 수정 const [updateSheetOpen, setUpdateSheetOpen] = useState(false); const [selectedRfqIdForUpdate, setSelectedRfqIdForUpdate] = useState(null); // 고정 높이 설정을 위한 상수 (실제 측정값으로 조정 필요) 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 | 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('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": // RFQ 수정 시트 열기 setSelectedRfqIdForUpdate(rowAction.row.original.id); setUpdateSheetOpen(true); break; case "delete": // 삭제 다이얼로그 열기 setRfqToDelete(rowAction.row.original as TechSalesRfq) setDeleteDialogOpen(true) 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) }, []) // RFQ 삭제 처리 함수 const handleDeleteRfq = React.useCallback(async () => { if (!rfqToDelete) return try { setIsDeleting(true) const result = await deleteTechSalesRfq(rfqToDelete.id) if (result.error) { toast.error(`삭제 실패: ${result.error}`) return } toast.success("RFQ가 성공적으로 삭제되었습니다.") // 선택된 RFQ 초기화 setSelectedRfq(null) setRfqToDelete(null) setDeleteDialogOpen(false) // 테이블 새로고침을 위해 페이지 리로드 또는 데이터 재요청 필요 // 현재는 캐시 무효화가 되어 있으므로 자연스럽게 업데이트됨 } catch (error) { console.error("RFQ 삭제 오류:", error) toast.error("삭제 중 오류가 발생했습니다.") } finally { setIsDeleting(false) } }, [rfqToDelete]) const columns = React.useMemo( () => getColumns({ setRowAction, openAttachmentsSheet, openItemsDialog }), [openAttachmentsSheet, openItemsDialog, setRowAction] ) // 고급 필터 필드 정의 const advancedFilterFields: DataTableAdvancedFilterField[] = [ { 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" }, { label: "TA", value: "TA" }, // 해양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 } } return (
{/* Filter Panel - 계산된 높이 적용 */}
{/* Filter Content */}
setIsFilterPanelOpen(false)} onSearch={handleSearch} isLoading={false} />
{/* Main Content */}
{/* Header Bar - 고정 높이 */}
{/* Right side info */}
{tableData && ( 총 {tableData.total || 0}건 )}
{/* Table Content Area - 계산된 높이 사용 */}
{ setPanelHeight(size) }} className="flex flex-col overflow-hidden" > {/* 상단 테이블 영역 */}
{/* 아직 개발/테스트 전이라서 주석처리함. 지우지 말 것! 미사용 린터 에러는 무시. */} {/* presets={presets} activePresetId={activePresetId} currentSettings={currentSettings} hasUnsavedChanges={hasUnsavedChanges} isLoading={presetsLoading} onCreatePreset={createPreset} onUpdatePreset={updatePreset} onDeletePreset={deletePreset} onApplyPreset={applyPreset} onSetDefaultPreset={setDefaultPreset} onRenamePreset={renamePreset} /> */} {}} rfqType={rfqType} />
{/* 하단 상세 테이블 영역 */}
{/* 프로젝트 상세정보 다이얼로그 */} {/* 첨부파일 관리 시트 */} {/* 아이템 보기 다이얼로그 */} {updateSheetOpen && selectedRfqIdForUpdate && ( { // 테이블 새로고침 로직 // 필요한 경우 여기에 추가 }} /> )} {/* RFQ 삭제 다이얼로그 */} RFQ 삭제 확인 정말로 "{rfqToDelete?.rfqCode || rfqToDelete?.description}" RFQ를 삭제하시겠습니까?
주의: 이 작업은 되돌릴 수 없습니다. RFQ와 관련된 모든 데이터가 삭제됩니다.
취소 {isDeleting ? "삭제 중..." : "삭제하기"}
) }