mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-16 16:08:39 +00:00
This commit is contained in:
@@ -2,9 +2,7 @@ import CSVRawDataPreview from './CSVRawDataPreview'
|
||||
import StatementDetails from './StatementDetails'
|
||||
import { GetStatementDetailsResponse } from '../import_utils'
|
||||
|
||||
const CSVImport = ({ data }: { data: { message: GetStatementDetailsResponse } }) => {
|
||||
|
||||
|
||||
const CSVImport = ({ data, mutate }: { data: { message: GetStatementDetailsResponse }, mutate: () => void }) => {
|
||||
|
||||
return (
|
||||
<div className="w-full flex">
|
||||
@@ -12,7 +10,7 @@ const CSVImport = ({ data }: { data: { message: GetStatementDetailsResponse } })
|
||||
<StatementDetails data={data.message} />
|
||||
</div>
|
||||
<div className="w-[50%] border-s border-t pe-1 ps-0 border-outline-gray-2 h-[calc(100vh-72px)] overflow-scroll">
|
||||
<CSVRawDataPreview data={data.message} />
|
||||
<CSVRawDataPreview data={data.message} mutate={mutate} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,151 +1,104 @@
|
||||
import { Table, TableBody, TableCell, TableHead, TableRow } from "@/components/ui/table"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ArrowDownRightIcon, ArrowUpDownIcon, ArrowUpRightIcon, BanknoteIcon, CalendarIcon, DollarSignIcon, FileTextIcon, ListIcon, ReceiptIcon } from "lucide-react"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import _ from "@/lib/translate"
|
||||
import { GetStatementDetailsResponse } from "../import_utils"
|
||||
import { useMemo } from "react"
|
||||
import RawTableGrid from "../RawTableGrid"
|
||||
import {
|
||||
applyColumnMappingChange,
|
||||
ColumnMapsTo,
|
||||
GetStatementDetailsResponse,
|
||||
useSetHeaderIndex,
|
||||
useUpdateColumnMapping,
|
||||
} from "../import_utils"
|
||||
import { BankStatementImportLogColumnMap } from "@/types/Accounts/BankStatementImportLogColumnMap"
|
||||
|
||||
type Mapping = Pick<BankStatementImportLogColumnMap, "index" | "maps_to" | "header_text" | "variable">
|
||||
|
||||
const CSVRawDataPreview = ({ data }: { data: GetStatementDetailsResponse }) => {
|
||||
const toMapping = (columns?: BankStatementImportLogColumnMap[]): Mapping[] =>
|
||||
(columns ?? []).map((c) => ({
|
||||
index: c.index,
|
||||
maps_to: c.maps_to,
|
||||
header_text: c.header_text,
|
||||
variable: c.variable,
|
||||
}))
|
||||
|
||||
const column_mapping: Record<StandardColumnTypes, number> = useMemo(() => {
|
||||
const headerToState = (index?: number) => (index != null && index >= 0 ? index : null)
|
||||
|
||||
const col_map: Record<string, number> = {}
|
||||
const CSVRawDataPreview = ({
|
||||
data,
|
||||
mutate,
|
||||
}: {
|
||||
data: GetStatementDetailsResponse
|
||||
mutate: () => void
|
||||
}) => {
|
||||
const isCompleted = data.doc.status === "Completed"
|
||||
|
||||
data.doc.column_mapping?.forEach(col => {
|
||||
if (col.maps_to && col.maps_to !== "Do not import") {
|
||||
col_map[col.maps_to] = col.index;
|
||||
}
|
||||
})
|
||||
const [mapping, setMapping] = useState<Mapping[]>(() => toMapping(data.doc.column_mapping))
|
||||
const [headerIndex, setHeaderIndex] = useState<number | null>(() =>
|
||||
headerToState(data.doc.detected_header_index),
|
||||
)
|
||||
|
||||
return col_map
|
||||
const { call: updateMapping, loading: savingMapping } = useUpdateColumnMapping()
|
||||
const { call: setHeader, loading: savingHeader } = useSetHeaderIndex()
|
||||
|
||||
}, [data])
|
||||
const mappingRef = useRef(mapping)
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
|
||||
const validColumns = Object.values(column_mapping)
|
||||
useEffect(() => () => clearTimeout(saveTimer.current), [])
|
||||
|
||||
// Reverse the column mapping to get a map of column index to variable name
|
||||
const columnIndexMap: Record<number, StandardColumnTypes> = Object.fromEntries(Object.entries(column_mapping).map(([variable, columnIndex]) => [columnIndex, variable as StandardColumnTypes]))
|
||||
const columnMappingRecord: Record<number, ColumnMapsTo> = {}
|
||||
mapping.forEach((c) => {
|
||||
if (c.maps_to) columnMappingRecord[c.index] = c.maps_to as ColumnMapsTo
|
||||
})
|
||||
|
||||
const commitMapping = (next: Mapping[]) => {
|
||||
mappingRef.current = next
|
||||
setMapping(next)
|
||||
}
|
||||
|
||||
// Persist mapping edits (debounced) so the transaction preview updates in realtime.
|
||||
const scheduleSaveMapping = () => {
|
||||
if (isCompleted) return
|
||||
clearTimeout(saveTimer.current)
|
||||
saveTimer.current = setTimeout(() => {
|
||||
updateMapping({ statement_import_id: data.doc.name, column_mapping: mappingRef.current })
|
||||
.then(() => mutate())
|
||||
.catch(() => toast.error(_("Could not save the column mapping.")))
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const onChangeMapping = (columnIndex: number, mapsTo: ColumnMapsTo) => {
|
||||
if (isCompleted) return
|
||||
commitMapping(applyColumnMappingChange(mappingRef.current, columnIndex, mapsTo))
|
||||
scheduleSaveMapping()
|
||||
}
|
||||
|
||||
const onSetHeader = (rowIndex: number | null) => {
|
||||
if (isCompleted) return
|
||||
setHeaderIndex(rowIndex)
|
||||
setHeader({ statement_import_id: data.doc.name, header_index: rowIndex ?? -1 })
|
||||
.then((res) => {
|
||||
// The backend re-derives the mapping for the new header; sync local state.
|
||||
const doc = res?.message?.doc
|
||||
if (doc) {
|
||||
commitMapping(toMapping(doc.column_mapping))
|
||||
setHeaderIndex(headerToState(doc.detected_header_index))
|
||||
}
|
||||
mutate()
|
||||
})
|
||||
.catch(() => toast.error(_("Could not update the header row.")))
|
||||
}
|
||||
|
||||
// Loop over the contents of the CSV file and show a preview - highlight the header row and the transaction rows
|
||||
return (
|
||||
<Table containerClassName="rounded-none">
|
||||
<TableBody>
|
||||
{data.raw_data.map((row, index) => {
|
||||
|
||||
const isHeaderRow = index === data.doc.detected_header_index;
|
||||
const isTransactionRow = index >= (data.doc.detected_transaction_starting_index ?? 0) && index <= (data.doc.detected_transaction_ending_index ?? 0);
|
||||
|
||||
return <TableRow key={index}
|
||||
title={isHeaderRow ? "Header Row" : ""}
|
||||
className={cn({
|
||||
// "bg-yellow-100": isHeaderRow,
|
||||
// "hover:bg-yellow-100": isHeaderRow,
|
||||
"bg-green-50 hover:bg-green-50 dark:bg-green-700 dark:hover:bg-green-700": isTransactionRow,
|
||||
"text-ink-gray-5/70": !isTransactionRow && !isHeaderRow,
|
||||
})}>
|
||||
{isHeaderRow ? <TableHead className="bg-yellow-100 hover:bg-yellow-100 dark:bg-yellow-400 text-center font-semibold text-ink-gray-8">
|
||||
{index + 1}
|
||||
</TableHead> :
|
||||
<TableCell className="text-center px-1 py-0.5">
|
||||
{index + 1}
|
||||
</TableCell>
|
||||
}
|
||||
{row.map((cell, cellIndex) => {
|
||||
|
||||
const isValidColumn = validColumns.includes(cellIndex);
|
||||
const columnType = columnIndexMap[cellIndex];
|
||||
const isAmountColumn = ["Amount", "Withdrawal", "Deposit", "Balance"].includes(columnType);
|
||||
|
||||
if (isHeaderRow) {
|
||||
return <TableHead key={cellIndex} className={cn("max-w-[250px] w-fit overflow-hidden text-ellipsis py-0.5",
|
||||
isValidColumn ? "bg-yellow-100 hover:bg-yellow-100 dark:bg-yellow-400" : "bg-surface-gray-2",
|
||||
)}>
|
||||
<div className={cn("flex items-center text-xs gap-1 px-1 text-ink-gray-8 font-medium", {
|
||||
"justify-end": isAmountColumn && isValidColumn
|
||||
})}>
|
||||
{columnType && <Tooltip>
|
||||
<TooltipTrigger>
|
||||
<ColumnHeaderIcon columnType={columnType} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{_(columnType)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
}
|
||||
{cell}
|
||||
</div>
|
||||
</TableHead>
|
||||
} else {
|
||||
return <TableCell key={cellIndex} className={cn("max-w-[200px] w-fit overflow-hidden text-ellipsis py-0.5",
|
||||
{
|
||||
"bg-green-100 dark:bg-green-400 hover:bg-green-100 dark:hover:bg-green-400": isValidColumn && isTransactionRow,
|
||||
"text-ink-gray-5": !isValidColumn && isTransactionRow,
|
||||
}
|
||||
)} >
|
||||
<div className={cn("min-h-5 flex items-center text-xs px-1", {
|
||||
"justify-end": isAmountColumn && isValidColumn && isTransactionRow
|
||||
})} title={cell}>
|
||||
{cell}
|
||||
</div>
|
||||
</TableCell>
|
||||
}
|
||||
}
|
||||
|
||||
)}
|
||||
</TableRow>
|
||||
})}
|
||||
</TableBody>
|
||||
</Table >
|
||||
<RawTableGrid
|
||||
rows={data.raw_data}
|
||||
columnMapping={columnMappingRecord}
|
||||
headerIndex={headerIndex}
|
||||
editable={!isCompleted}
|
||||
disabled={isCompleted || savingMapping || savingHeader}
|
||||
onChangeMapping={onChangeMapping}
|
||||
onSetHeader={onSetHeader}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type StandardColumnTypes = BankStatementImportLogColumnMap['maps_to'];
|
||||
|
||||
const ColumnHeaderIcon = ({ columnType }: { columnType?: StandardColumnTypes }) => {
|
||||
if (!columnType) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (columnType === 'Amount') {
|
||||
return <DollarSignIcon className="w-4 h-4" />
|
||||
}
|
||||
|
||||
if (columnType === 'Withdrawal') {
|
||||
return <ArrowUpRightIcon className="w-4 h-4 text-ink-red-3" />
|
||||
}
|
||||
|
||||
if (columnType === 'Deposit') {
|
||||
return <ArrowDownRightIcon className="w-4 h-4 text-ink-green-3" />
|
||||
}
|
||||
|
||||
if (columnType === 'Balance') {
|
||||
return <BanknoteIcon className="w-4 h-4" />
|
||||
}
|
||||
|
||||
if (columnType === 'Date') {
|
||||
return <CalendarIcon className="w-4 h-4" />
|
||||
}
|
||||
|
||||
if (columnType === 'Description') {
|
||||
return <FileTextIcon className="w-4 h-4" />
|
||||
}
|
||||
|
||||
if (columnType === 'Reference') {
|
||||
return <ReceiptIcon className="w-4 h-4" />
|
||||
}
|
||||
|
||||
if (columnType === 'Transaction Type') {
|
||||
return <ListIcon className="w-4 h-4" />
|
||||
}
|
||||
|
||||
if (columnType === 'Debit/Credit') {
|
||||
return <ArrowUpDownIcon className="w-4 h-4" />
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default CSVRawDataPreview
|
||||
export default CSVRawDataPreview
|
||||
|
||||
@@ -142,11 +142,16 @@ const StatementDetails = ({ data }: Props) => {
|
||||
<TableCell>
|
||||
<div className='flex items-center gap-2'>
|
||||
<BankLogo bank={bank} />
|
||||
<span className="tracking-tight text-sm font-medium">{bank?.account_name}</span>
|
||||
<span title="GL Account" className="text-sm">{bank?.account}</span>
|
||||
<span className="text-sm">{bank?.account_name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>{_("Account")}</TableHead>
|
||||
<TableCell>
|
||||
<span title="GL Account" className="text-sm">{bank?.account}</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>{_("Statement File")}</TableHead>
|
||||
<TableCell>
|
||||
@@ -158,7 +163,11 @@ const StatementDetails = ({ data }: Props) => {
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>{_("Transaction Dates")}</TableHead>
|
||||
<TableCell>{_("{0} to {1}", [formatDate(data.doc.start_date, "Do MMMM YYYY"), formatDate(data.doc.end_date, "Do MMMM YYYY")])}</TableCell>
|
||||
{data.doc.start_date && data.doc.end_date ? (
|
||||
<TableCell>{_("{0} to {1}", [formatDate(data.doc.start_date, "Do MMMM YYYY"), formatDate(data.doc.end_date, "Do MMMM YYYY")])}</TableCell>
|
||||
) : (
|
||||
<TableCell>-</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>{_("Number of Transactions")}</TableHead>
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { RefObject, useEffect, useRef, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type Bbox = [number, number, number, number]
|
||||
|
||||
const MIN_SIZE = 8 // PDF points
|
||||
|
||||
// Keep the box valid: normalise flipped edges, enforce a min size, clamp to the page.
|
||||
const clampBbox = (bbox: Bbox, pageWidth: number, pageHeight: number): Bbox => {
|
||||
let [x0, top, x1, bottom] = bbox
|
||||
if (x1 < x0) [x0, x1] = [x1, x0]
|
||||
if (bottom < top) [top, bottom] = [bottom, top]
|
||||
x0 = Math.max(0, Math.min(x0, pageWidth - MIN_SIZE))
|
||||
top = Math.max(0, Math.min(top, pageHeight - MIN_SIZE))
|
||||
x1 = Math.min(pageWidth, Math.max(x1, x0 + MIN_SIZE))
|
||||
bottom = Math.min(pageHeight, Math.max(bottom, top + MIN_SIZE))
|
||||
return [x0, top, x1, bottom]
|
||||
}
|
||||
|
||||
const HANDLES = [
|
||||
{ id: 'nw', className: 'left-0 top-0 -translate-x-1/2 -translate-y-1/2 cursor-nwse-resize' },
|
||||
{ id: 'ne', className: 'right-0 top-0 translate-x-1/2 -translate-y-1/2 cursor-nesw-resize' },
|
||||
{ id: 'sw', className: 'left-0 bottom-0 -translate-x-1/2 translate-y-1/2 cursor-nesw-resize' },
|
||||
{ id: 'se', className: 'right-0 bottom-0 translate-x-1/2 translate-y-1/2 cursor-nwse-resize' },
|
||||
]
|
||||
|
||||
type Props = {
|
||||
bbox: Bbox
|
||||
pageWidth: number
|
||||
pageHeight: number
|
||||
color: { border: string; bg: string; swatch: string }
|
||||
label: string
|
||||
included: boolean
|
||||
disabled?: boolean
|
||||
containerRef: RefObject<HTMLDivElement | null>
|
||||
onCommit: (bbox: Bbox) => void
|
||||
}
|
||||
|
||||
/** A draggable + corner-resizable rectangle over a rendered PDF page. Coordinates are in PDF
|
||||
* points (top-left origin); pixel deltas are converted using the container's rendered size. */
|
||||
const BBoxOverlay = ({ bbox, pageWidth, pageHeight, color, label, included, disabled, containerRef, onCommit }: Props) => {
|
||||
const [draft, setDraft] = useState<Bbox>(bbox)
|
||||
const draftRef = useRef<Bbox>(bbox)
|
||||
const drag = useRef<{ mode: string; startX: number; startY: number; start: Bbox } | null>(null)
|
||||
|
||||
// Reset to the authoritative bbox whenever it changes (e.g. after a server re-extract).
|
||||
useEffect(() => {
|
||||
setDraft(bbox)
|
||||
draftRef.current = bbox
|
||||
}, [bbox])
|
||||
|
||||
const apply = (next: Bbox) => {
|
||||
draftRef.current = next
|
||||
setDraft(next)
|
||||
}
|
||||
|
||||
const onPointerDown = (e: React.PointerEvent) => {
|
||||
if (disabled) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const mode = (e.target as HTMLElement).dataset.handle ?? 'move'
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
drag.current = { mode, startX: e.clientX, startY: e.clientY, start: draftRef.current }
|
||||
}
|
||||
|
||||
const onPointerMove = (e: React.PointerEvent) => {
|
||||
if (!drag.current || !containerRef.current) return
|
||||
const rect = containerRef.current.getBoundingClientRect()
|
||||
const dx = ((e.clientX - drag.current.startX) / rect.width) * pageWidth
|
||||
const dy = ((e.clientY - drag.current.startY) / rect.height) * pageHeight
|
||||
let [x0, top, x1, bottom] = drag.current.start
|
||||
const m = drag.current.mode
|
||||
if (m === 'move') {
|
||||
x0 += dx
|
||||
x1 += dx
|
||||
top += dy
|
||||
bottom += dy
|
||||
} else {
|
||||
if (m.includes('w')) x0 += dx
|
||||
if (m.includes('e')) x1 += dx
|
||||
if (m.includes('n')) top += dy
|
||||
if (m.includes('s')) bottom += dy
|
||||
}
|
||||
apply(clampBbox([x0, top, x1, bottom], pageWidth, pageHeight))
|
||||
}
|
||||
|
||||
const onPointerUp = (e: React.PointerEvent) => {
|
||||
if (!drag.current) return
|
||||
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
|
||||
drag.current = null
|
||||
onCommit(draftRef.current)
|
||||
}
|
||||
|
||||
const [x0, top, x1, bottom] = draft
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute touch-none border-2',
|
||||
color.border,
|
||||
included ? color.bg : 'opacity-40',
|
||||
disabled ? 'pointer-events-none' : 'cursor-move',
|
||||
)}
|
||||
style={{
|
||||
left: `${(x0 / pageWidth) * 100}%`,
|
||||
top: `${(top / pageHeight) * 100}%`,
|
||||
width: `${((x1 - x0) / pageWidth) * 100}%`,
|
||||
height: `${((bottom - top) / pageHeight) * 100}%`,
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
<span className={cn('pointer-events-none absolute -top-5 left-0 rounded px-1 text-[10px] font-medium text-white', color.swatch)}>
|
||||
{label}
|
||||
</span>
|
||||
{!disabled &&
|
||||
HANDLES.map((handle) => (
|
||||
<span
|
||||
key={handle.id}
|
||||
data-handle={handle.id}
|
||||
className={cn('absolute size-2.5 rounded-sm border border-white', color.swatch, handle.className)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BBoxOverlay
|
||||
@@ -0,0 +1,23 @@
|
||||
import StatementDetails from '../CSV/StatementDetails'
|
||||
import PDFTableEditor from './PDFTableEditor'
|
||||
import { GetStatementDetailsResponse } from '../import_utils'
|
||||
|
||||
type Props = {
|
||||
data: { message: GetStatementDetailsResponse }
|
||||
mutate: () => void
|
||||
}
|
||||
|
||||
const PDFImport = ({ data, mutate }: Props) => {
|
||||
return (
|
||||
<div className="w-full flex">
|
||||
<div className="w-[45%] p-4 h-[calc(100vh-72px)] overflow-scroll">
|
||||
<StatementDetails data={data.message} />
|
||||
</div>
|
||||
<div className="w-[55%] border-s pe-1 ps-0 border-outline-gray-2 h-[calc(100vh-72px)] overflow-scroll">
|
||||
<PDFTableEditor data={data.message} mutate={mutate} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PDFImport
|
||||
@@ -0,0 +1,362 @@
|
||||
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<number, ColumnMapsTo> => {
|
||||
const map: Record<number, ColumnMapsTo> = {}
|
||||
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<PDFTable[]>(() => data.pdf_tables ?? [])
|
||||
const [viewMode, setViewMode] = useState<'pdf' | 'table'>('pdf')
|
||||
const [pageIndex, setPageIndex] = useState(0)
|
||||
const [collapsed, setCollapsed] = useState<Set<number>>(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<ReturnType<typeof setTimeout>>(undefined)
|
||||
const reextractTimer = useRef<ReturnType<typeof setTimeout>>(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 (
|
||||
<div className="p-4">
|
||||
<Paragraph className="text-p-sm text-ink-gray-5">
|
||||
{_('No tables were extracted from this PDF.')}
|
||||
</Paragraph>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<H3 className="text-base border-0 p-0">{_('Detected Tables')}</H3>
|
||||
<Paragraph className="text-p-sm">
|
||||
{_('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).')}
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
{error && <ErrorBanner error={error} />}
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as 'pdf' | 'table')}>
|
||||
<TabsList variant="subtle">
|
||||
<TabsTrigger value="pdf"><FileTextIcon />{_('PDF')}</TabsTrigger>
|
||||
<TabsTrigger value="table"><TableIcon />{_('Table')}</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{busy && (
|
||||
<span className="flex items-center gap-1 pe-1 text-xs text-ink-gray-5">
|
||||
<Loader2Icon className="size-3 animate-spin" />
|
||||
{reextracting ? _('Re-extracting') : _('Saving')}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
isIconButton
|
||||
disabled={pageIndex === 0}
|
||||
onClick={() => setPageIndex((i) => Math.max(0, i - 1))}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</Button>
|
||||
<span className="min-w-24 text-center text-sm text-ink-gray-7">
|
||||
{_('Page {0} of {1}', [currentPage.toString(), pages.length.toString()])}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
isIconButton
|
||||
disabled={pageIndex >= pages.length - 1}
|
||||
onClick={() => setPageIndex((i) => Math.min(pages.length - 1, i + 1))}
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{viewMode === 'pdf' ? (
|
||||
<PageView
|
||||
pageTables={pageTables}
|
||||
disabled={isCompleted}
|
||||
onToggleIncluded={onToggleIncluded}
|
||||
onBboxCommit={onBboxCommit}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{pageTables.map(({ table, index }, position) => {
|
||||
const isCollapsed = collapsed.has(index)
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn('flex flex-col rounded border border-outline-gray-2', !table.included && 'opacity-60')}
|
||||
>
|
||||
<div className="flex items-center justify-between p-2">
|
||||
<span className="ps-1 text-sm font-medium text-ink-gray-8">
|
||||
{_('Table {0}', [(position + 1).toString()])}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<IncludeToggle
|
||||
id={`tbl-${index}`}
|
||||
checked={table.included}
|
||||
disabled={isCompleted}
|
||||
onCheckedChange={(c) => onToggleIncluded(index, c)}
|
||||
/>
|
||||
<Button variant="ghost" size="sm" isIconButton onClick={() => toggleCollapsed(index)}>
|
||||
<ChevronDownIcon className={cn('transition-transform', isCollapsed && '-rotate-90')} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div className="overflow-auto border-t border-outline-gray-2">
|
||||
<RawTableGrid
|
||||
rows={table.rows}
|
||||
columnMapping={columnMappingRecord(table)}
|
||||
headerIndex={table.header_index}
|
||||
editable
|
||||
disabled={isCompleted}
|
||||
onChangeMapping={(columnIndex, mapsTo) => onChangeMapping(index, columnIndex, mapsTo)}
|
||||
onSetHeader={(rowIndex) => onSetHeader(index, rowIndex)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<HTMLDivElement>(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 (
|
||||
<Paragraph className="text-p-sm text-ink-gray-5">
|
||||
{_('No page image is available for this page.')}
|
||||
</Paragraph>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{!disabled && (
|
||||
<Paragraph className="text-xs text-ink-gray-5">
|
||||
{_('Drag a box to move it, or drag a corner to resize. The table is re-read from the new region automatically.')}
|
||||
</Paragraph>
|
||||
)}
|
||||
<div ref={containerRef} className="relative w-full overflow-auto rounded border border-outline-gray-2 bg-surface-gray-1">
|
||||
<img src={pageImage} alt={_('Page preview')} className="w-full" />
|
||||
{pageTables.map(({ table, index }, position) => {
|
||||
const color = OVERLAY_COLORS[position % OVERLAY_COLORS.length]
|
||||
return (
|
||||
<BBoxOverlay
|
||||
key={index}
|
||||
bbox={table.bbox}
|
||||
pageWidth={pageWidth}
|
||||
pageHeight={pageHeight}
|
||||
color={color}
|
||||
label={_('Table {0}', [(position + 1).toString()])}
|
||||
included={table.included}
|
||||
disabled={disabled}
|
||||
containerRef={containerRef}
|
||||
onCommit={(bbox) => onBboxCommit(index, bbox)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{pageTables.map(({ table, index }, position) => {
|
||||
const color = OVERLAY_COLORS[position % OVERLAY_COLORS.length]
|
||||
return (
|
||||
<div key={index} className="flex items-center justify-between rounded border border-outline-gray-2 px-2 py-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn('size-3 rounded-sm', color.swatch)} />
|
||||
<span className="text-xs">{_('Table {0}', [(position + 1).toString()])}</span>
|
||||
</div>
|
||||
<IncludeToggle
|
||||
id={`pdf-tbl-${index}`}
|
||||
checked={table.included}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(c) => onToggleIncluded(index, c)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const IncludeToggle = ({
|
||||
id,
|
||||
checked,
|
||||
disabled,
|
||||
onCheckedChange,
|
||||
}: {
|
||||
id: string
|
||||
checked: boolean
|
||||
disabled: boolean
|
||||
onCheckedChange: (checked: boolean) => void
|
||||
}) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor={id} className="text-xs text-ink-gray-6">{_('Include')}</Label>
|
||||
<Switch id={id} checked={checked} disabled={disabled} onCheckedChange={onCheckedChange} />
|
||||
</div>
|
||||
)
|
||||
|
||||
export default PDFTableEditor
|
||||
@@ -0,0 +1,222 @@
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
ArrowDownRightIcon,
|
||||
ArrowUpDownIcon,
|
||||
ArrowUpRightIcon,
|
||||
BanknoteIcon,
|
||||
CalendarIcon,
|
||||
DollarSignIcon,
|
||||
FileTextIcon,
|
||||
ListIcon,
|
||||
ReceiptIcon,
|
||||
} from 'lucide-react'
|
||||
import _ from '@/lib/translate'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Table, TableBody, TableCell, TableHead, TableRow } from '@/components/ui/table'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { COLUMN_MAPS_TO_OPTIONS, ColumnMapsTo } from './import_utils'
|
||||
|
||||
const AMOUNT_COLUMNS: ColumnMapsTo[] = ['Amount', 'Withdrawal', 'Deposit', 'Balance']
|
||||
const DATE_LIKE = /\d{1,4}[/\-.\s]\d{1,2}[/\-.\s]\d{1,4}|\d{1,2}[\s-][a-z]{3}/i
|
||||
|
||||
type Props = {
|
||||
rows: string[][]
|
||||
/** Column index -> mapped field */
|
||||
columnMapping: Record<number, ColumnMapsTo>
|
||||
headerIndex: number | null
|
||||
editable?: boolean
|
||||
disabled?: boolean
|
||||
onChangeMapping?: (columnIndex: number, mapsTo: ColumnMapsTo) => void
|
||||
/** Set the header row (or null to mark the table as having no header). */
|
||||
onSetHeader?: (rowIndex: number | null) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* A preview of extracted rows with CSV-style colour coding: the header row is highlighted,
|
||||
* detected transaction rows are green, and mapped columns are emphasised. When `editable`, a
|
||||
* compact row of column -> field dropdowns sits at the top, and row numbers can be clicked to
|
||||
* set/clear the header row.
|
||||
*/
|
||||
const RawTableGrid = ({ rows, columnMapping, headerIndex, editable, disabled, onChangeMapping, onSetHeader }: Props) => {
|
||||
// Tabular (XLSX) cells can be numbers/dates, not strings - coerce so .trim()/render are safe.
|
||||
const stringRows = useMemo(
|
||||
() => rows.map((row) => row.map((cell) => (cell == null ? '' : String(cell)))),
|
||||
[rows],
|
||||
)
|
||||
const numColumns = useMemo(() => stringRows.reduce((max, row) => Math.max(max, row.length), 0), [stringRows])
|
||||
|
||||
const validColumns = useMemo(
|
||||
() => Object.entries(columnMapping).filter(([, m]) => m && m !== 'Do not import').map(([i]) => Number(i)),
|
||||
[columnMapping],
|
||||
)
|
||||
const dateColumn = useMemo(() => Object.entries(columnMapping).find(([, m]) => m === 'Date')?.[0], [columnMapping])
|
||||
const amountColumns = useMemo(
|
||||
() => Object.entries(columnMapping).filter(([, m]) => ['Amount', 'Withdrawal', 'Deposit'].includes(m)).map(([i]) => Number(i)),
|
||||
[columnMapping],
|
||||
)
|
||||
|
||||
// Approximate the backend's transaction-row detection so the highlighting tracks edits live.
|
||||
const transactionRows = useMemo(() => {
|
||||
const set = new Set<number>()
|
||||
if (dateColumn === undefined) return set
|
||||
const dateIdx = Number(dateColumn)
|
||||
stringRows.forEach((row, index) => {
|
||||
if (index === headerIndex) return
|
||||
const dateCell = (row[dateIdx] ?? '').trim()
|
||||
if (!dateCell || !DATE_LIKE.test(dateCell)) return
|
||||
if (amountColumns.some((c) => (row[c] ?? '').trim() !== '')) set.add(index)
|
||||
})
|
||||
return set
|
||||
}, [stringRows, headerIndex, dateColumn, amountColumns])
|
||||
|
||||
return (
|
||||
<Table containerClassName="rounded-none">
|
||||
<TableBody>
|
||||
{editable && (
|
||||
<TableRow className="border-b border-outline-gray-2 bg-surface-white hover:bg-surface-white">
|
||||
<TableHead className="w-8 p-1" />
|
||||
{Array.from({ length: numColumns }).map((_unused, columnIndex) => (
|
||||
<TableHead key={columnIndex} className="p-1 align-top">
|
||||
<Select
|
||||
disabled={disabled}
|
||||
value={columnMapping[columnIndex] ?? 'Do not import'}
|
||||
onValueChange={(value) => onChangeMapping?.(columnIndex, value as ColumnMapsTo)}
|
||||
>
|
||||
<SelectTrigger variant="outline" inputSize="sm" className="h-7 w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{COLUMN_MAPS_TO_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<ColumnHeaderIcon columnType={option} />
|
||||
{_(option)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
{stringRows.map((row, index) => {
|
||||
const isHeaderRow = index === headerIndex
|
||||
const isTransactionRow = transactionRows.has(index)
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={index}
|
||||
className={cn({
|
||||
'bg-green-50 hover:bg-green-50 dark:bg-green-700 dark:hover:bg-green-700': isTransactionRow,
|
||||
'bg-yellow-100 hover:bg-yellow-100 dark:bg-yellow-400': isHeaderRow,
|
||||
'text-ink-gray-5/70': !isTransactionRow && !isHeaderRow,
|
||||
})}
|
||||
>
|
||||
{editable && onSetHeader ? (
|
||||
<TableCell className="h-px w-8 p-0 text-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onSetHeader(isHeaderRow ? null : index)}
|
||||
className={cn(
|
||||
'flex h-full w-full items-center justify-center px-1 text-ink-gray-6 hover:bg-surface-gray-3',
|
||||
isHeaderRow && 'font-semibold text-ink-gray-8',
|
||||
)}
|
||||
>
|
||||
{index + 1}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isHeaderRow
|
||||
? _('This is the header row. Click to mark the table as having no header.')
|
||||
: _('Click to set this as the header row.')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
) : (
|
||||
<TableCell className="w-8 px-1 py-0.5 text-center text-ink-gray-6">{index + 1}</TableCell>
|
||||
)}
|
||||
|
||||
{Array.from({ length: numColumns }).map((_unused, cellIndex) => {
|
||||
const columnType = columnMapping[cellIndex]
|
||||
const isValidColumn = validColumns.includes(cellIndex)
|
||||
const isAmountColumn = AMOUNT_COLUMNS.includes(columnType)
|
||||
const cellText = row[cellIndex] ?? ''
|
||||
|
||||
// Read-only header row: icon + label.
|
||||
if (isHeaderRow) {
|
||||
return (
|
||||
<TableCell key={cellIndex} className="max-w-[200px] overflow-hidden text-ellipsis py-1">
|
||||
<div className="flex items-center gap-1 px-1 text-xs font-medium text-ink-gray-8">
|
||||
{columnType && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<ColumnHeaderIcon columnType={columnType} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{_(columnType)}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{cellText}
|
||||
</div>
|
||||
</TableCell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TableCell
|
||||
key={cellIndex}
|
||||
className={cn('max-w-[200px] overflow-hidden text-ellipsis py-0.5', {
|
||||
'bg-green-100 dark:bg-green-400 hover:bg-green-100 dark:hover:bg-green-400': isValidColumn && isTransactionRow,
|
||||
'text-ink-gray-5': !isValidColumn && isTransactionRow,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={cn('min-h-5 flex items-center px-1 text-xs', {
|
||||
'justify-end': isAmountColumn && isValidColumn && isTransactionRow,
|
||||
})}
|
||||
title={cellText}
|
||||
>
|
||||
{cellText}
|
||||
</div>
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
const ColumnHeaderIcon = ({ columnType }: { columnType?: ColumnMapsTo }) => {
|
||||
switch (columnType) {
|
||||
case 'Amount':
|
||||
return <DollarSignIcon className="size-4" />
|
||||
case 'Withdrawal':
|
||||
return <ArrowUpRightIcon className="size-4 text-ink-red-3" />
|
||||
case 'Deposit':
|
||||
return <ArrowDownRightIcon className="size-4 text-ink-green-3" />
|
||||
case 'Balance':
|
||||
return <BanknoteIcon className="size-4" />
|
||||
case 'Date':
|
||||
return <CalendarIcon className="size-4" />
|
||||
case 'Description':
|
||||
return <FileTextIcon className="size-4" />
|
||||
case 'Reference':
|
||||
return <ReceiptIcon className="size-4" />
|
||||
case 'Transaction Type':
|
||||
return <ListIcon className="size-4" />
|
||||
case 'Debit/Credit':
|
||||
return <ArrowUpDownIcon className="size-4" />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export default RawTableGrid
|
||||
@@ -1,6 +1,97 @@
|
||||
import { BankStatementImportLog } from "@/types/Accounts/BankStatementImportLog"
|
||||
import { useFrappeGetCall } from "frappe-react-sdk"
|
||||
import { useFrappeGetCall, useFrappePostCall } from "frappe-react-sdk"
|
||||
|
||||
export type ColumnMapsTo =
|
||||
| "Do not import"
|
||||
| "Date"
|
||||
| "Withdrawal"
|
||||
| "Deposit"
|
||||
| "Amount"
|
||||
| "Description"
|
||||
| "Reference"
|
||||
| "Transaction Type"
|
||||
| "Debit/Credit"
|
||||
| "Balance"
|
||||
| "Included Fee"
|
||||
| "Excluded Fee"
|
||||
| "Party Name/Account Holder"
|
||||
| "Party Account No."
|
||||
| "Party IBAN"
|
||||
|
||||
export type ColumnMappingEntry = {
|
||||
index: number
|
||||
maps_to: ColumnMapsTo | string
|
||||
header_text?: string
|
||||
variable?: string
|
||||
}
|
||||
|
||||
/** Apply a column mapping change, clearing the same mapping from any other column. */
|
||||
export function applyColumnMappingChange<T extends ColumnMappingEntry>(
|
||||
columns: T[],
|
||||
columnIndex: number,
|
||||
mapsTo: ColumnMapsTo,
|
||||
): T[] {
|
||||
const previous = columns.find((c) => c.index === columnIndex)
|
||||
const cleared =
|
||||
mapsTo === "Do not import"
|
||||
? columns
|
||||
: columns.map((c) =>
|
||||
c.index !== columnIndex && c.maps_to === mapsTo
|
||||
? { ...c, maps_to: "Do not import" as ColumnMapsTo }
|
||||
: c,
|
||||
)
|
||||
|
||||
return [
|
||||
...cleared.filter((c) => c.index !== columnIndex),
|
||||
{
|
||||
index: columnIndex,
|
||||
maps_to: mapsTo,
|
||||
header_text: previous?.header_text ?? "",
|
||||
variable: previous?.variable ?? `column_${columnIndex}`,
|
||||
} as T,
|
||||
].sort((a, b) => a.index - b.index)
|
||||
}
|
||||
|
||||
export const COLUMN_MAPS_TO_OPTIONS: ColumnMapsTo[] = [
|
||||
"Do not import",
|
||||
"Date",
|
||||
"Description",
|
||||
"Reference",
|
||||
"Withdrawal",
|
||||
"Deposit",
|
||||
"Amount",
|
||||
"Balance",
|
||||
"Debit/Credit",
|
||||
"Transaction Type",
|
||||
"Included Fee",
|
||||
"Excluded Fee",
|
||||
"Party Name/Account Holder",
|
||||
"Party Account No.",
|
||||
"Party IBAN",
|
||||
]
|
||||
|
||||
export interface PDFTableColumn {
|
||||
index: number
|
||||
header_text: string
|
||||
variable?: string
|
||||
maps_to: ColumnMapsTo
|
||||
}
|
||||
|
||||
export interface PDFTable {
|
||||
page: number
|
||||
table_index: number
|
||||
bbox: [number, number, number, number]
|
||||
page_width: number
|
||||
page_height: number
|
||||
page_image: string | null
|
||||
render_scale: number | null
|
||||
rows: string[][]
|
||||
header_index: number | null
|
||||
column_mapping: PDFTableColumn[]
|
||||
date_format?: string
|
||||
amount_format?: string
|
||||
included: boolean
|
||||
}
|
||||
|
||||
export interface GetStatementDetailsResponse {
|
||||
doc: BankStatementImportLog,
|
||||
@@ -30,6 +121,7 @@ export interface GetStatementDetailsResponse {
|
||||
date_format: string,
|
||||
raw_data: Array<Array<string>>,
|
||||
currency: string,
|
||||
pdf_tables?: PDFTable[],
|
||||
}
|
||||
|
||||
export const useGetStatementDetails = (id: string) => {
|
||||
@@ -39,4 +131,24 @@ export const useGetStatementDetails = (id: string) => {
|
||||
revalidateOnFocus: false
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
export const useUpdatePDFTables = () => {
|
||||
return useFrappePostCall<{ message: GetStatementDetailsResponse }>("erpnext.accounts.doctype.bank_statement_import_log.bank_statement_import_log.update_pdf_tables")
|
||||
}
|
||||
|
||||
export const useReextractPDFTable = () => {
|
||||
return useFrappePostCall<{ message: GetStatementDetailsResponse }>("erpnext.accounts.doctype.bank_statement_import_log.bank_statement_import_log.reextract_pdf_table")
|
||||
}
|
||||
|
||||
export const useSetPDFTableHeader = () => {
|
||||
return useFrappePostCall<{ message: GetStatementDetailsResponse }>("erpnext.accounts.doctype.bank_statement_import_log.bank_statement_import_log.set_pdf_table_header")
|
||||
}
|
||||
|
||||
export const useUpdateColumnMapping = () => {
|
||||
return useFrappePostCall<{ message: GetStatementDetailsResponse }>("erpnext.accounts.doctype.bank_statement_import_log.bank_statement_import_log.update_column_mapping")
|
||||
}
|
||||
|
||||
export const useSetHeaderIndex = () => {
|
||||
return useFrappePostCall<{ message: GetStatementDetailsResponse }>("erpnext.accounts.doctype.bank_statement_import_log.bank_statement_import_log.set_header_index")
|
||||
}
|
||||
Reference in New Issue
Block a user