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,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

View File

@@ -0,0 +1,9 @@
import { useAtomValue } from "jotai"
import { atomWithStorage } from "jotai/utils"
export const selectedCompanyAtom = atomWithStorage<string>('bank-rec-selected-company', window.frappe?.boot?.user?.defaults?.company || '')
export const useCurrentCompany = () => {
const selectedCompany = useAtomValue(selectedCompanyAtom)
return selectedCompany ? selectedCompany : (window.frappe?.boot?.user?.defaults?.company as string)
}

View File

@@ -0,0 +1,30 @@
import { useFrappeGetCall } from "frappe-react-sdk"
export const useDocType = (doctype: string, with_parent: 0 | 1 = 1, cached_timestamp?: Date) => {
// @ts-expect-error Locals is available in the FrappeContext
const localData = locals?.['DocType']?.[doctype] || null
const { data, error, isLoading } = useFrappeGetCall('frappe.desk.form.load.getdoctype', {
doctype: doctype,
with_parent: with_parent,
cached_timestamp: cached_timestamp ?? null,
}, localData || !doctype ? null : undefined, {
onSuccess: (data) => {
if (data) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data?.docs?.forEach((d: any) => {
// @ts-expect-error Frappe is available in the window
frappe.model.add_to_locals(d)
})
}
},
revalidateIfStale: false,
revalidateOnFocus: false,
})
return {
data: localData || (data?.docs?.[0] ?? null),
error,
isLoading: localData ? false : isLoading
}
}

View File

@@ -0,0 +1,13 @@
import { useFrappeGetCall } from "frappe-react-sdk"
const useFiscalYear = () => {
return useFrappeGetCall("erpnext.accounts.utils.get_fiscal_year", undefined, 'fiscal_year', {
revalidateOnFocus: false,
revalidateIfStale: false,
revalidateOnReconnect: false
})
}
export default useFiscalYear

View File

@@ -0,0 +1,138 @@
import { flt } from "@/lib/numbers"
import { PaymentEntryDeduction } from "@/types/Accounts/PaymentEntryDeduction"
import { PaymentEntryReference } from "@/types/Accounts/PaymentEntryReference"
import { useCallback } from "react"
import { useFormContext } from "react-hook-form"
export const usePaymentEntryCalculations = () => {
const { setValue, getValues, watch } = useFormContext()
const payment_type = watch('payment_type')
const allocatePartyAmount = (paid_amount: number) => {
const deductionsTable = getValues('deductions') ?? []
const party_type = getValues('party_type')
let total_positive_outstanding_including_order = 0
let total_negative_outstanding = 0
const total_deductions = deductionsTable.reduce((acc: number, row: PaymentEntryDeduction) => acc + row.amount, 0)
paid_amount -= total_deductions
const references = getValues('references') ?? []
references.forEach((ref: PaymentEntryReference) => {
if (flt(ref.outstanding_amount) > 0) {
total_positive_outstanding_including_order += flt(ref.outstanding_amount)
} else {
total_negative_outstanding += Math.abs(flt(ref.outstanding_amount))
}
})
let allocated_negative_outstanding = 0
let allocated_positive_outstanding = 0
if (
(payment_type == "Receive" && party_type == "Customer") ||
(payment_type == "Pay" && party_type == "Supplier")
) {
if (total_positive_outstanding_including_order > paid_amount) {
const remaining_outstanding = total_positive_outstanding_including_order - paid_amount
allocated_negative_outstanding = total_negative_outstanding < remaining_outstanding ? total_negative_outstanding : remaining_outstanding
}
allocated_positive_outstanding = paid_amount + allocated_negative_outstanding
} else {
total_negative_outstanding = flt(total_negative_outstanding)
if (paid_amount > total_negative_outstanding) {
allocated_positive_outstanding = total_negative_outstanding - paid_amount
allocated_negative_outstanding = paid_amount + (total_positive_outstanding_including_order < allocated_positive_outstanding ? total_positive_outstanding_including_order : allocated_positive_outstanding)
}
}
references.forEach((ref: PaymentEntryReference, index: number) => {
if (flt(ref.outstanding_amount) > 0 && allocated_positive_outstanding >= 0) {
setValue(`references.${index}.allocated_amount`, (flt(ref.outstanding_amount) >= allocated_positive_outstanding) ?
allocated_positive_outstanding : ref.outstanding_amount)
allocated_positive_outstanding -= flt(ref.allocated_amount)
} else if (flt(ref.outstanding_amount) < 0 && allocated_negative_outstanding) {
setValue(`references.${index}.allocated_amount`, (flt(ref.outstanding_amount) >= allocated_negative_outstanding) ?
-1 * allocated_negative_outstanding : ref.outstanding_amount)
allocated_negative_outstanding -= flt(ref.allocated_amount)
}
})
setTotalAllocatedAmount()
}
const setDifferenceAmount = useCallback((value: number) => {
const base_total_allocated_amount = getValues('base_total_allocated_amount') ?? 0
const base_received_amount = getValues('base_received_amount') ?? 0
const base_paid_amount = getValues('base_paid_amount') ?? 0
const deductionsTable = getValues('deductions') ?? []
const base_total_taxes_and_charges = getValues('base_total_taxes_and_charges') ?? 0
let difference_amount = 0
const base_party_amount = base_total_allocated_amount + flt(value)
if (payment_type == "Receive") {
difference_amount = base_party_amount - base_received_amount
}
else if (payment_type == "Pay") {
difference_amount = base_paid_amount - base_party_amount
}
else {
difference_amount = base_paid_amount - base_received_amount
}
const total_deductions = deductionsTable.reduce((acc: number, row: PaymentEntryDeduction) => acc + row.amount, 0)
setValue('difference_amount', difference_amount - total_deductions + base_total_taxes_and_charges)
}, [getValues, setValue, payment_type])
const setUnallocatedAmount = useCallback(() => {
const deductionsTable = getValues('deductions') ?? []
const base_total_allocated_amount = getValues('base_total_allocated_amount') ?? 0
const base_received_amount = getValues('base_received_amount') ?? 0
const total_allocated_amount = getValues('total_allocated_amount') ?? 0
const paid_amount = getValues('paid_amount') ?? 0
const base_total_taxes_and_charges = getValues('base_total_taxes_and_charges') ?? 0
const base_paid_amount = getValues('base_paid_amount') ?? 0
const received_amount = getValues('received_amount') ?? 0
const party = getValues('party')
let unallocated_amount = 0
const total_deductions = deductionsTable.reduce((acc: number, row: PaymentEntryDeduction) => acc + row.amount, 0)
if (party) {
if (payment_type == "Receive" && base_total_allocated_amount < base_received_amount + total_deductions
&& total_allocated_amount < paid_amount + total_deductions) {
unallocated_amount = base_received_amount + total_deductions + base_total_taxes_and_charges
- base_total_allocated_amount
} else if (payment_type == "Pay"
&& base_total_allocated_amount < base_paid_amount - total_deductions
&& total_allocated_amount < received_amount + total_deductions) {
unallocated_amount = base_paid_amount + base_total_taxes_and_charges - (total_deductions
+ base_total_allocated_amount)
}
}
setValue('unallocated_amount', unallocated_amount)
setDifferenceAmount(unallocated_amount)
}, [getValues, setValue, payment_type, setDifferenceAmount])
const setTotalAllocatedAmount = useCallback(() => {
let total_allocated_amount = 0
let base_total_allocated_amount = 0
const references = getValues('references')
references?.forEach((ref: PaymentEntryReference) => {
if (ref.allocated_amount) {
total_allocated_amount += flt(ref.allocated_amount)
base_total_allocated_amount += flt(ref.allocated_amount)
}
})
setValue('total_allocated_amount', Math.abs(total_allocated_amount))
setValue('base_total_allocated_amount', Math.abs(base_total_allocated_amount))
setUnallocatedAmount()
}, [getValues, setValue, setUnallocatedAmount])
return {
setTotalAllocatedAmount,
setUnallocatedAmount,
setDifferenceAmount,
allocatePartyAmount
}
}