import { useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, FileTextIcon, Loader2Icon, TableIcon } from 'lucide-react' import _ from '@/lib/translate' import { cn } from '@/lib/utils' import { Button } from '@/components/ui/button' import { Switch } from '@/components/ui/switch' import { Label } from '@/components/ui/label' import { H3, Paragraph } from '@/components/ui/typography' import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' import ErrorBanner from '@/components/ui/error-banner' import RawTableGrid from '../RawTableGrid' import BBoxOverlay from './BBoxOverlay' import { applyColumnMappingChange, ColumnMapsTo, GetStatementDetailsResponse, PDFTable, useReextractPDFTable, useSetPDFTableHeader, useUpdatePDFTables, } from '../import_utils' type Props = { data: GetStatementDetailsResponse mutate: () => void } // Distinct overlay colours per table on a page. const OVERLAY_COLORS = [ { border: 'border-blue-500', bg: 'bg-blue-500/10', swatch: 'bg-blue-500' }, { border: 'border-purple-500', bg: 'bg-purple-500/10', swatch: 'bg-purple-500' }, { border: 'border-amber-500', bg: 'bg-amber-500/10', swatch: 'bg-amber-500' }, { border: 'border-teal-500', bg: 'bg-teal-500/10', swatch: 'bg-teal-500' }, ] const columnMappingRecord = (table: PDFTable): Record => { const map: Record = {} table.column_mapping?.forEach((col) => { map[col.index] = col.maps_to }) return map } const PDFTableEditor = ({ data, mutate }: Props) => { const isCompleted = data.doc.status === 'Completed' const [tables, setTables] = useState(() => data.pdf_tables ?? []) const [viewMode, setViewMode] = useState<'pdf' | 'table'>('pdf') const [pageIndex, setPageIndex] = useState(0) const [collapsed, setCollapsed] = useState>(new Set()) const toggleCollapsed = (tableIndex: number) => setCollapsed((prev) => { const next = new Set(prev) if (next.has(tableIndex)) { next.delete(tableIndex) } else { next.add(tableIndex) } return next }) const { call, loading, error } = useUpdatePDFTables() const { call: reextract, loading: reextracting } = useReextractPDFTable() const { call: setHeaderCall, loading: settingHeader } = useSetPDFTableHeader() const busy = loading || reextracting || settingHeader // Persist edits automatically (debounced) so the transaction preview updates in realtime. const tablesRef = useRef(tables) const saveTimer = useRef>(undefined) const reextractTimer = useRef>(undefined) const scheduleSave = () => { if (isCompleted) return clearTimeout(saveTimer.current) saveTimer.current = setTimeout(() => { call({ statement_import_id: data.doc.name, tables: tablesRef.current }) .then(() => mutate()) .catch(() => toast.error(_('Could not save the table settings.'))) }, 500) } // After a bbox change, re-extract that table's rows from the new region (debounced). // The target is read inside the timeout so it always reflects the committed bbox. const scheduleReextract = (tableIndex: number) => { if (isCompleted) return clearTimeout(reextractTimer.current) reextractTimer.current = setTimeout(() => { const target = tablesRef.current[tableIndex] reextract({ statement_import_id: data.doc.name, page: target.page, table_index: target.table_index, bbox: target.bbox, }) .then((res) => { commitTables(res?.message?.pdf_tables ?? []) mutate() }) .catch(() => toast.error(_('Could not re-extract the table.'))) }, 500) } useEffect(() => () => { clearTimeout(saveTimer.current) clearTimeout(reextractTimer.current) }, []) const pages = useMemo(() => Array.from(new Set(tables.map((t) => t.page))).sort((a, b) => a - b), [tables]) const currentPage = pages[pageIndex] // Keep the table's position in the flat array so edits target the right one. const pageTables = useMemo( () => tables.map((table, index) => ({ table, index })).filter((t) => t.table.page === currentPage), [tables, currentPage], ) // Keep tablesRef in sync synchronously so the debounced save/re-extract never read stale state. const commitTables = (next: PDFTable[]) => { tablesRef.current = next setTables(next) } const updateTable = (tableIndex: number, updater: (table: PDFTable) => PDFTable) => { commitTables(tablesRef.current.map((t, i) => (i === tableIndex ? updater(t) : t))) scheduleSave() } const onChangeMapping = (tableIndex: number, columnIndex: number, mapsTo: ColumnMapsTo) => { updateTable(tableIndex, (table) => ({ ...table, column_mapping: applyColumnMappingChange(table.column_mapping, columnIndex, mapsTo), })) } const onToggleIncluded = (tableIndex: number, included: boolean) => updateTable(tableIndex, (table) => ({ ...table, included })) const onBboxCommit = (tableIndex: number, bbox: [number, number, number, number]) => { commitTables(tablesRef.current.map((t, i) => (i === tableIndex ? { ...t, bbox } : t))) scheduleReextract(tableIndex) } // Set/clear the header row of a table; the backend re-derives the column mapping. const onSetHeader = (tableIndex: number, headerIndex: number | null) => { commitTables(tablesRef.current.map((t, i) => (i === tableIndex ? { ...t, header_index: headerIndex } : t))) const target = tablesRef.current[tableIndex] setHeaderCall({ statement_import_id: data.doc.name, page: target.page, table_index: target.table_index, header_index: headerIndex ?? -1, }) .then((res) => { commitTables(res?.message?.pdf_tables ?? []) mutate() }) .catch(() => toast.error(_('Could not update the header row.'))) } if (tables.length === 0) { return (
{_('No tables were extracted from this PDF.')}
) } return (

{_('Detected Tables')}

{_('Review each page. In the Table view, map each column, click a row number to set/clear the header row, and exclude anything that is not transactions (ads, summaries).')}
{error && }
setViewMode(v as 'pdf' | 'table')}> {_('PDF')} {_('Table')}
{busy && ( {reextracting ? _('Re-extracting') : _('Saving')} )} {_('Page {0} of {1}', [currentPage.toString(), pages.length.toString()])}
{viewMode === 'pdf' ? ( ) : (
{pageTables.map(({ table, index }, position) => { const isCollapsed = collapsed.has(index) return (
{_('Table {0}', [(position + 1).toString()])}
onToggleIncluded(index, c)} />
{!isCollapsed && (
onChangeMapping(index, columnIndex, mapsTo)} onSetHeader={(rowIndex) => onSetHeader(index, rowIndex)} />
)}
) })}
)}
) } type PageViewProps = { pageTables: { table: PDFTable; index: number }[] disabled: boolean onToggleIncluded: (tableIndex: number, included: boolean) => void onBboxCommit: (tableIndex: number, bbox: [number, number, number, number]) => void } const PageView = ({ pageTables, disabled, onToggleIncluded, onBboxCommit }: PageViewProps) => { const containerRef = useRef(null) const pageImage = pageTables[0]?.table.page_image const pageWidth = pageTables[0]?.table.page_width ?? 1 const pageHeight = pageTables[0]?.table.page_height ?? 1 if (!pageImage) { return ( {_('No page image is available for this page.')} ) } return (
{!disabled && ( {_('Drag a box to move it, or drag a corner to resize. The table is re-read from the new region automatically.')} )}
{_('Page {pageTables.map(({ table, index }, position) => { const color = OVERLAY_COLORS[position % OVERLAY_COLORS.length] return ( onBboxCommit(index, bbox)} /> ) })}
{pageTables.map(({ table, index }, position) => { const color = OVERLAY_COLORS[position % OVERLAY_COLORS.length] return (
{_('Table {0}', [(position + 1).toString()])}
onToggleIncluded(index, c)} />
) })}
) } const IncludeToggle = ({ id, checked, disabled, onCheckedChange, }: { id: string checked: boolean disabled: boolean onCheckedChange: (checked: boolean) => void }) => (
) export default PDFTableEditor