feat: new banking module (#54720)

* feat: initial SPA setup for banking

* wip: bring over new banking module

* feat: added Espresso design tokens

* feat: button styles

* fix: add all ink colors

* wip: espresso design system changes

* feat: button and badge espresso components

* fix: button styling for reconcile

* feat: Espresso progress bar

* feat: Espresso toggle switch

* feat: Espresso tabs design

* fix: vertical tab support

* fix: button sizing across modals

* feat: Espresso style table layout

* feat: Espresso tooltip

* feat: Espresso elevations and checkbox

* feat: Dialog with Espresso styles

* feat: Espresso textarea

* fix: input styles

* fix: colors on bank picker

* fix: breadcrumb styling

* fix: bank picker styling

* feat: create doctypes and fields for bank reconciliation

* feat: APIs for banking

* fix: use date format parser

* fix: font styling to match Espresso

* wip: settings modal

* feat: settings dialog component

* fix: icons and invalid requests

* feat: preferences tab

* fix: adjust icon stroke width to 1.5

* feat: rule configuration in settings

* fix: remove sheet component

* feat: alert and error banner component

* feat: dropdown in Espresso

* feat: popover and select in Espresso

* fix: cleanup more styles

* fix: match size of link fields

* feat: command styling

* fix: remove unused style tokens

* fix: styles for global date picker dropdown

* fix: styles for match and reconcile

* feat: table Espresso component

* feat: remove all other design tokens

* fix: remove unused tokens

* fix: form elements

* fix: remove unused styles and fix filters in bank transaction list

* feat: fetch bank rec doctypes for filtering

* fix: record payment modal

* feat: support for dark mode switching

* fix: move bank logos to public folder

* feat: add support for RTL

* feat: support for RTL

* chore: send layout direction in dev boot

* fix: make checkbox work in RTL

* feat: dark mode support

* fix: dark mode style

* feat: bank logos in dark mode

* feat: dark mode bank logos

* chore: use dark mode bank logos everywhere

* chore: move rule evaluation to controller

* chore: add tests for bank transaction rules

* fix: move deps to fix actions errors

* fix: move tw-animate-css to deps

* fix: remove shadcn

* fix: do not open modal if no transactions selected

* fix: add translation strings

* feat: add banner on existing bank reconciliation tool

* feat: bank statement import

* fix: translations and layout directions

* fix: validation for transaction matching rule

* fix: styles

* fix: show conflicting transactions in alert

* fix: show help text for new banking module forms

* feat: show total debits and credits

* fix: dark mode colors in automatic config

* feat: add keyboard shortcuts help

* feat: added keyboard shortcut for settings

* fix: decrease size of progress bar

* chore: bump packages

* feat: add tests for statement import

* fix: settings dialog

* fix: show banner on small screens

* fix: show banner when no bank account set
This commit is contained in:
Nikhil Kothari
2026-05-09 23:14:58 +05:30
committed by GitHub
parent 332026fe5e
commit 6de5367f12
262 changed files with 39467 additions and 14 deletions

View File

@@ -0,0 +1,510 @@
import * as React from "react"
import {
type Cell,
type ColumnDef,
type ColumnSizingState,
type Header,
type OnChangeFn,
type Row,
type RowSelectionState,
flexRender,
functionalUpdate,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table"
import { useVirtualizer } from "@tanstack/react-virtual"
import { useDebounceCallback } from "usehooks-ts"
import { Checkbox } from "@/components/ui/checkbox"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import { useDirection } from "./direction"
/** Optional per-column layout hints for `ListView`. */
export type ListViewColumnMeta = {
/** CSS grid track (`1fr`, `2fr`, `minmax(0,1fr)`). When set, used instead of TanStack pixel `size` in `grid-template-columns`. */
gridWidth?: string
align?: "left" | "center" | "right"
/**
* Tabular figures for stable digit width. Default: on when `align` is `right` (amounts); set `false` to opt out, or `true` for dates/IDs.
*/
tabularNums?: boolean
/**
* Full text for an overflow tooltip (shown only when the cell truncates). If omitted, a string `accessorKey` value is used when available.
*/
getTooltipText?: (row: unknown) => string | null | undefined
/** `false` disables the overflow tooltip for this column. */
truncateTooltip?: boolean
/**
* `false` skips single-line truncation for cells with custom layouts (e.g. action buttons). Default `true`.
*/
truncate?: boolean
}
function alignClass(meta: ListViewColumnMeta | undefined) {
switch (meta?.align) {
case "center":
return "justify-center text-center"
case "right":
return "justify-end text-end"
default:
return "justify-start text-start"
}
}
function tabularNumsClass(meta: ListViewColumnMeta | undefined) {
if (meta?.tabularNums === false) return ""
if (meta?.tabularNums === true) return "tabular-nums"
if (meta?.align === "right") return "tabular-nums"
return ""
}
function resolveTooltipLabel<TData>(
row: Row<TData>,
meta: ListViewColumnMeta | undefined,
columnDef: ColumnDef<TData, unknown>,
): string | undefined {
if (meta?.truncateTooltip === false) return undefined
const fromMeta = meta?.getTooltipText?.(row.original as unknown)
if (fromMeta != null && String(fromMeta).length > 0) {
return String(fromMeta)
}
const key = "accessorKey" in columnDef ? columnDef.accessorKey : undefined
if (key !== undefined && key !== null && key !== "") {
try {
const v = row.getValue(String(key))
if (v != null && v !== "") return String(v)
} catch {
/* column may not expose a value */
}
}
return undefined
}
function ListViewCellBody<TData>({
cell,
row,
meta,
children,
}: {
cell: Cell<TData, unknown>
row: Row<TData>
meta: ListViewColumnMeta | undefined
children: React.ReactNode
}) {
const ref = React.useRef<HTMLDivElement>(null)
const [overflowing, setOverflowing] = React.useState(false)
const direction = useDirection()
const tooltipLabel = resolveTooltipLabel(row, meta, cell.column.columnDef)
const tooltipAlign = meta?.align === "right" && direction === "ltr" ? "end" : "start"
const measure = React.useCallback(() => {
const el = ref.current
if (!el) return
setOverflowing(el.scrollWidth > el.clientWidth + 1)
}, [])
React.useLayoutEffect(() => {
measure()
}, [measure, children, tooltipLabel])
React.useEffect(() => {
const el = ref.current
if (!el || typeof ResizeObserver === "undefined") return
const ro = new ResizeObserver(measure)
ro.observe(el)
return () => ro.disconnect()
}, [measure])
if (meta?.truncate === false) {
return <div className="min-w-0 flex-1 overflow-visible">{children}</div>
}
const inner = (
<div
ref={ref}
className={cn(
"min-h-0 min-w-0 flex-1 truncate",
)}
>
{children}
</div>
)
if (!tooltipLabel || !overflowing) {
return inner
}
return (
<Tooltip delayDuration={400}>
<TooltipTrigger asChild>{inner}</TooltipTrigger>
<TooltipContent
side="bottom"
align={tooltipAlign}
className="max-w-sm text-balance wrap-break-word"
>
{tooltipLabel}
</TooltipContent>
</Tooltip>
)
}
function gridTemplateFromHeaders<TData>(headers: Header<TData, unknown>[]) {
return headers
.map((header) => {
const meta = header.column.columnDef.meta as ListViewColumnMeta | undefined
if (meta?.gridWidth) {
return meta.gridWidth
}
return `${header.getSize()}px`
})
.join(" ")
}
function defaultGetRowId<TData>(row: TData, index: number) {
const r = row as Record<string, unknown>
if (r && typeof r.name === "string") return r.name
if (r && typeof r.id === "string") return r.id
return String(index)
}
export type ListViewProps<TData> = {
data: TData[]
columns: ColumnDef<TData, unknown>[]
/**
* Stable row id for selection and keys. Defaults to `name`, then `id`, then row index (index is fragile if data order changes).
*/
getRowId?: (originalRow: TData, index: number) => string
/** Pixel height of each body row (default 40, matches frappe-ui ListView). */
rowHeight?: number
className?: string
/** Classes for the scrollable viewport (default includes max-height). */
scrollAreaClassName?: string
/** Max height of the scroll area; number is pixels. Default `420`. */
maxHeight?: number | string
emptyState?: React.ReactNode
enableColumnResizing?: boolean
columnSizing?: ColumnSizingState
onColumnSizingChange?: OnChangeFn<ColumnSizingState>
/** Debounced callback for persisting widths (e.g. localStorage). */
onColumnSizingCommit?: (sizing: ColumnSizingState) => void
columnSizingCommitDelayMs?: number
enableRowSelection?: boolean
rowSelection?: RowSelectionState
onRowSelectionChange?: OnChangeFn<RowSelectionState>
onRowClick?: (row: TData, event: React.MouseEvent) => void
}
function ListViewInner<TData>({
data,
columns: userColumns,
getRowId: getRowIdProp,
rowHeight = 40,
className,
scrollAreaClassName,
maxHeight = 420,
emptyState,
enableColumnResizing = true,
columnSizing: controlledColumnSizing,
onColumnSizingChange: controlledOnColumnSizingChange,
onColumnSizingCommit,
columnSizingCommitDelayMs = 250,
enableRowSelection = false,
rowSelection: controlledRowSelection,
onRowSelectionChange: controlledOnRowSelectionChange,
onRowClick,
}: ListViewProps<TData>) {
const parentRef = React.useRef<HTMLDivElement>(null)
const [internalColumnSizing, setInternalColumnSizing] = React.useState<ColumnSizingState>({})
const columnSizing = controlledColumnSizing ?? internalColumnSizing
const [internalRowSelection, setInternalRowSelection] = React.useState<RowSelectionState>({})
const rowSelection = controlledRowSelection ?? internalRowSelection
const setRowSelection = controlledOnRowSelectionChange ?? setInternalRowSelection
const debouncedSizingCommit = useDebounceCallback(
(sizing: ColumnSizingState) => {
onColumnSizingCommit?.(sizing)
},
columnSizingCommitDelayMs,
)
const selectionColumn = React.useMemo<ColumnDef<TData, unknown>>(
() => ({
id: "__list_view_select__",
size: 36,
minSize: 36,
maxSize: 36,
enableResizing: false,
meta: {
truncate: false,
truncateTooltip: false,
} satisfies ListViewColumnMeta,
header: ({ table }) => (
<div className="flex size-full items-center justify-center">
<Checkbox
aria-label="Select all rows"
checked={
table.getIsAllRowsSelected()
? true
: table.getIsSomeRowsSelected()
? "indeterminate"
: false
}
onCheckedChange={(value) => table.toggleAllRowsSelected(value === true)}
onClick={(e) => e.stopPropagation()}
/>
</div>
),
cell: ({ row }) => (
<div className="flex size-full items-center justify-center">
<Checkbox
aria-label="Select row"
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(value === true)}
onClick={(e) => e.stopPropagation()}
/>
</div>
),
}),
[],
)
const columns = React.useMemo(() => {
if (!enableRowSelection) return userColumns
return [selectionColumn, ...userColumns]
}, [enableRowSelection, selectionColumn, userColumns])
const getRowId = React.useCallback(
(originalRow: TData, index: number) =>
(getRowIdProp ?? defaultGetRowId)(originalRow, index),
[getRowIdProp],
)
const onColumnSizingChangeInternal = React.useCallback<OnChangeFn<ColumnSizingState>>(
(updater) => {
if (controlledOnColumnSizingChange) {
controlledOnColumnSizingChange(updater)
return
}
setInternalColumnSizing((old) => {
const next = functionalUpdate(updater, old)
debouncedSizingCommit(next)
return next
})
},
[controlledOnColumnSizingChange, debouncedSizingCommit],
)
const direction = useDirection()
const table = useReactTable({
data,
columns,
defaultColumn: {
minSize: 50,
size: 150,
},
columnResizeMode: "onChange",
columnResizeDirection: direction,
enableColumnResizing,
getCoreRowModel: getCoreRowModel(),
getRowId,
onColumnSizingChange: onColumnSizingChangeInternal,
onRowSelectionChange: setRowSelection,
state: {
columnSizing,
rowSelection,
},
enableRowSelection,
})
const headerGroup = table.getHeaderGroups()[0]
const gridTemplateColumns = headerGroup
? gridTemplateFromHeaders(headerGroup.headers)
: ""
const { rows } = table.getRowModel()
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => rowHeight,
overscan: 10,
})
const maxHeightStyle =
typeof maxHeight === "number" ? `${maxHeight}px` : maxHeight
if (data.length === 0) {
return (
<div
className={cn(
"bg-surface-gray-2 text-ink-gray-5 flex min-h-32 items-center justify-center rounded-md px-4 text-sm",
className,
)}
>
{emptyState ?? "No data"}
</div>
)
}
/** Tracks + column gaps + horizontal padding (`px-2` × 2) so header and body share one scroll width. */
const colCount = headerGroup?.headers.length ?? 0
const minTableOuterWidth =
table.getCenterTotalSize() +
Math.max(0, colCount - 1) * 16 +
16
return (
<div className={cn("flex min-w-0 flex-col", className)} role="grid">
<div
ref={parentRef}
className={cn("min-h-0 overflow-auto", scrollAreaClassName)}
style={{ maxHeight: maxHeightStyle }}
>
{headerGroup ? (
<div
className="bg-surface-gray-2 sticky top-0 z-10 mb-2 grid w-full items-center gap-x-4 rounded p-2"
role="row"
style={{
display: "grid",
gridTemplateColumns,
minWidth: `max(100%, ${minTableOuterWidth}px)`,
boxSizing: "border-box",
}}
>
{headerGroup.headers.map((header) => {
const meta = header.column.columnDef.meta as ListViewColumnMeta | undefined
return (
<div
key={header.id}
className={cn(
"text-ink-gray-5 group relative flex min-w-0 items-center px-1 text-sm",
alignClass(meta),
)}
role="columnheader"
>
<div className="min-w-0 flex-1 truncate">
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</div>
{enableColumnResizing && header.column.getCanResize() ? (
<>
<span
aria-hidden
className={cn(
"pointer-events-none absolute ltr:right-0 rtl:left-0 z-1 w-0.5 bg-gray-400",
"opacity-0 transition-[opacity,background-color] ease-in-out duration-150",
"group-hover:opacity-100 group-hover:bg-gray-400",
header.column.getIsResizing() && "bg-outline-gray-6 opacity-100",
)}
style={{ height: "100%" }}
/>
<div
role="separator"
aria-orientation="vertical"
aria-label="Resize column"
onMouseDown={(e) => {
e.preventDefault()
document.body.classList.add("select-none", "cursor-col-resize")
const end = () => {
document.body.classList.remove("select-none", "cursor-col-resize")
window.removeEventListener("mouseup", end)
window.removeEventListener("touchend", end)
}
window.addEventListener("mouseup", end)
window.addEventListener("touchend", end)
header.getResizeHandler()(e)
}}
onTouchStart={header.getResizeHandler()}
className="absolute top-0 ltr:right-0 rtl:left-0 z-10 h-full w-2 max-w-[12px] cursor-col-resize touch-none select-none bg-transparent"
/>
</>
) : null}
</div>
)
})}
</div>
) : null}
<div
className="relative w-full"
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
minWidth: `max(100%, ${minTableOuterWidth}px)`,
boxSizing: "border-box",
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const row = rows[virtualRow.index]
if (!row) return null
const leadDataColumnIndex = enableRowSelection ? 1 : 0
return (
<div
key={row.id}
data-index={virtualRow.index}
role="row"
className={cn(
"ease-in-out absolute top-0 ltr:left-0 rtl:right-0 w-full min-w-0 rounded px-2 transition-all duration-300",
// virtualRow.index > 0 && "border-t border-outline-gray-1",
!row.getIsSelected() && "hover:bg-surface-menu-bar",
row.getIsSelected() && "bg-surface-gray-2 hover:bg-surface-gray-3",
onRowClick && "cursor-pointer",
)}
style={{
display: "grid",
gridTemplateColumns,
boxSizing: "border-box",
columnGap: "1rem",
height: `${rowHeight}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
onClick={(e) => {
if (onRowClick) onRowClick(row.original, e)
}}
>
{virtualRow.index > 0 && <div className="absolute top-0 inset-s-2 inset-e-2 h-px bg-outline-gray-1" />}
{row.getVisibleCells().map((cell, cellIndex) => {
const meta = cell.column.columnDef.meta as ListViewColumnMeta | undefined
return (
<div
key={cell.id}
role="gridcell"
className={cn(
"flex min-w-0 items-center overflow-hidden text-sm",
cellIndex === leadDataColumnIndex
? "text-ink-gray-8"
: "text-ink-gray-7",
alignClass(meta),
tabularNumsClass(meta),
)}
>
<ListViewCellBody cell={cell} row={row} meta={meta}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</ListViewCellBody>
</div>
)
})}
</div>
)
})}
</div>
</div>
</div>
)
}
/**
* Div-based list with CSS Grid columns, optional resize handles, row virtualization, and frappe-uialigned Espresso tokens.
*/
export function ListView<TData>(props: ListViewProps<TData>) {
return <ListViewInner {...props} />
}
export type { ColumnSizingState, RowSelectionState }