/** * Drill-Through Modal for React * Displays source rows for a pivot cell in a paginated table */ import type { DrillThroughResult, PivotValueField } from '@smallwebco/tinypivot-core' import { exportToCSV, exportToXLSX, getAggregationLabel } from '@smallwebco/tinypivot-core' import React, { useCallback, useEffect, useMemo, useState } from 'react' import { createPortal } from 'react-dom' import { ExportMenu } from './ExportMenu' interface DrillThroughModalProps { show: boolean result: DrillThroughResult | null rowFields: string[] columnFields: string[] valueFields: PivotValueField[] onClose: () => void theme?: string } const PAGE_SIZE = 50 export function DrillThroughModal({ show, result, onClose, theme, }: DrillThroughModalProps) { const [currentPage, setCurrentPage] = useState(1) // Reset page when modal opens or result changes useEffect(() => { if (show) { setCurrentPage(1) } }, [show, result]) // Keyboard listener — only depends on show and onClose useEffect(() => { if (!show) return const handleKeydown = (event: KeyboardEvent) => { if (event.key === 'Escape') { onClose() } } document.addEventListener('keydown', handleKeydown) return () => document.removeEventListener('keydown', handleKeydown) }, [show, onClose]) // Column keys from the first row const columns = useMemo((): string[] => { if (!result || result.rows.length === 0) return [] return Object.keys(result.rows[0]) }, [result]) // Total pages const totalPages = useMemo(() => { if (!result) return 1 return Math.max(1, Math.ceil(result.rows.length / PAGE_SIZE)) }, [result]) // Rows for current page const pageRows = useMemo(() => { if (!result) return [] const start = (currentPage - 1) * PAGE_SIZE return result.rows.slice(start, start + PAGE_SIZE) }, [result, currentPage]) // Modal title const modalTitle = useMemo(() => { if (!result) return 'Drill Through' const { descriptor } = result const parts: string[] = [] if (descriptor.rowPath.length > 0) parts.push(descriptor.rowPath.join(' × ')) if (descriptor.columnPath.length > 0) parts.push(descriptor.columnPath.join(' × ')) const slice = parts.length > 0 ? parts.join(' × ') : 'Grand Total' const aggLabel = getAggregationLabel(descriptor.aggregation) const valueStr = descriptor.formattedValue !== '-' ? ` = ${descriptor.formattedValue}` : '' return `${slice} — ${aggLabel} of ${descriptor.valueField}${valueStr} · ${descriptor.rowCount} rows` }, [result]) const handleOverlayClick = useCallback((e: React.MouseEvent) => { if (e.target === e.currentTarget) { onClose() } }, [onClose]) const handleExport = useCallback(() => { if (!result || result.rows.length === 0) return exportToCSV(result.rows, columns, { filename: 'drill-through.csv' }) }, [result, columns]) const handleExportXLSX = useCallback(async () => { if (!result || result.rows.length === 0) return try { await exportToXLSX(result.rows, columns, { filename: 'drill-through.xlsx' }) } catch (err) { console.error('[TinyPivot] XLSX export failed:', err) } }, [result, columns]) const onExport = useCallback( (key: string) => { if (key === 'csv') { handleExport() } else { handleExportXLSX() } }, [handleExport, handleExportXLSX], ) const formatCellValue = (value: unknown): string => { if (value === null || value === undefined) return '' return String(value) } if (!show || typeof document === 'undefined') return null return createPortal(

{modalTitle}

{!result || result.rows.length === 0 ? (
No source rows found for this cell.
) : ( <>
{columns.map(col => ( ))} {pageRows.map((row, rowIdx) => ( {columns.map(col => ( ))} ))}
{col}
{formatCellValue(row[col])}
{totalPages > 1 && (
Page {' '} {currentPage} {' '} of {' '} {totalPages}
)} )}
{result && result.rows.length > 0 && ( )}
, document.body, ) }