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

20
banking/src/lib/checks.ts Normal file
View File

@@ -0,0 +1,20 @@
/**
* Function to check if a string exists in a list
* @param list
* @param item
* @returns
*/
export const in_list = (list: string[], item?: string): boolean => {
if (item === undefined) return false
return list.includes(item)
}
/**
* Function to check if an object is empty
* @param obj
* @returns
*/
export const isEmpty = (obj: object) => {
return Object.keys(obj).length === 0;
}

View File

@@ -0,0 +1,15 @@
export const getCompanyCurrency = (company: string) => {
// @ts-expect-error - Locals is synced
return locals[':Company']?.[company]?.['default_currency']
}
export const getCompanyCostCenter = (company: string) => {
// @ts-expect-error - Locals is synced
return locals[':Company']?.[company]?.['cost_center']
}
export const getCompany = (company: string) => {
// @ts-expect-error - Locals is synced
return locals?.[':Company']?.[company]
}

View File

@@ -0,0 +1,24 @@
export const getCurrencySymbol = (currency: string) => {
// @ts-expect-error - Boot is available
if (frappe.boot) {
// @ts-expect-error - Boot is available
if (frappe.boot.sysdefaults && frappe.boot.sysdefaults.hide_currency_symbol == "Yes")
return "";
// @ts-expect-error - Boot is available
if (!currency) currency = frappe.boot.sysdefaults.currency;
return getCurrencyProperty(currency, 'symbol') || currency;
} else {
return getCurrencyProperty(currency, 'symbol') || currency
}
}
export const getCurrencyNumberFormat = (currency: string) => {
return getCurrencyProperty(currency, 'number_format')
}
export const getCurrencyProperty = (currency: string, property: 'symbol' | 'symbol_on_right' | 'number_format') => {
// @ts-expect-error - Locals is synced
return locals[':Currency']?.[currency]?.[property]
}

184
banking/src/lib/date.ts Normal file
View File

@@ -0,0 +1,184 @@
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import advancedFormat from 'dayjs/plugin/advancedFormat';
import relativeTime from 'dayjs/plugin/relativeTime';
import quarterOfYear from 'dayjs/plugin/quarterOfYear'
import customParseFormat from 'dayjs/plugin/customParseFormat';
import _ from '@/lib/translate';
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(advancedFormat);
dayjs.extend(relativeTime);
dayjs.extend(quarterOfYear);
dayjs.extend(customParseFormat);
const FRAPPE_DATE_FORMAT = "YYYY-MM-DD"
export const getUserDateFormat = () => {
return window?.frappe?.boot?.user?.defaults?.date_format.toUpperCase() || window?.frappe.boot.sysdefaults.date_format.toUpperCase()
}
// const FRAPPE_DATETIME_FORMAT = "YYYY-MM-DD HH:mm:ss"
export type TimePeriod = 'This Week' | 'This Month' | 'This Quarter' | 'This Year' | 'Last Week' | 'Last Month' | 'Last Quarter' | 'Last Year' | 'Date Range'
export const AVAILABLE_TIME_PERIODS: TimePeriod[] = [
'This Month',
'This Week',
'This Quarter',
'This Year',
'Last Week',
'Last Month',
'Last Quarter',
'Last Year',
];
/**
* Get the start and end dates for a given time period
* @param timePeriod - The time period to get the dates for
* @param format - The date format to use (defaults to FRAPPE_DATE_FORMAT)
* @param baseDate - Optional base date to use for calculations (defaults to current date)
* @returns The start and end dates in specified format, or empty strings if invalid
*/
export const getDatesForTimePeriod = (
timePeriod: TimePeriod,
format: string = FRAPPE_DATE_FORMAT,
baseDate?: string
) => {
const daysJSObject = baseDate ? dayjs(baseDate) : dayjs()
// Based on the time period, get the start and end dates
if (timePeriod === 'This Week') {
return {
fromDate: daysJSObject.startOf('week').format(format),
toDate: daysJSObject.endOf('week').format(format),
format: "Do MMM 'YY",
translatedLabel: _('This Week')
}
}
if (timePeriod === 'This Month' || timePeriod === 'Date Range') {
return {
fromDate: daysJSObject.startOf('month').format(format),
toDate: daysJSObject.endOf('month').format(format),
format: "Do MMM 'YY",
translatedLabel: _('This Month')
}
}
if (timePeriod === 'This Quarter') {
return {
fromDate: daysJSObject.startOf('quarter').format(format),
toDate: daysJSObject.endOf('quarter').format(format),
format: 'MMM YYYY',
translatedLabel: _('This Quarter')
}
}
if (timePeriod === 'This Year') {
return {
fromDate: daysJSObject.startOf('year').format(format),
toDate: daysJSObject.endOf('year').format(format),
format: 'MMM YYYY',
translatedLabel: _('This Year')
}
}
if (timePeriod === 'Last Week') {
const lastWeek = daysJSObject.subtract(1, 'week')
return {
fromDate: lastWeek.startOf('week').format(format),
toDate: lastWeek.endOf('week').format(format),
format: "Do MMM 'YY",
translatedLabel: _('Last Week')
}
}
if (timePeriod === 'Last Month') {
const lastMonth = daysJSObject.subtract(1, 'month')
return {
fromDate: lastMonth.startOf('month').format(format),
toDate: lastMonth.endOf('month').format(format),
format: "Do MMM 'YY",
translatedLabel: _('Last Month')
}
}
if (timePeriod === 'Last Quarter') {
const lastQuarter = daysJSObject.subtract(1, 'quarter')
return {
fromDate: lastQuarter.startOf('quarter').format(format),
toDate: lastQuarter.endOf('quarter').format(format),
format: 'MMM YYYY',
translatedLabel: _('Last Quarter')
}
}
if (timePeriod === 'Last Year') {
const lastYear = daysJSObject.subtract(1, 'year')
return {
fromDate: lastYear.startOf('year').format(format),
toDate: lastYear.endOf('year').format(format),
format: 'MMM YYYY',
translatedLabel: _('Last Year')
}
}
return {
fromDate: '',
toDate: '',
format: 'Do MMM YY',
translatedLabel: _('Date Range')
}
}
const toUserTimezone = (timestamp: string) => {
const systemTimezone = window.frappe?.boot?.time_zone?.system
const userTimezone = window.frappe?.boot?.time_zone?.user
if (systemTimezone && userTimezone) {
return dayjs.tz(timestamp, systemTimezone).clone().tz(userTimezone)
} else {
return dayjs(timestamp)
}
}
export const getTimeago = (date?: string) => {
if (date) {
const userDate = toUserTimezone(date)
return userDate.fromNow()
}
return ''
}
export const formatDate = (date?: string | Date, format?: string) => {
if (!format) {
format = getUserDateFormat()
}
if (date) {
return dayjs(date).format(format)
}
return ''
}
/**
* Utility function to convert a date string to a Date object
* @param date
* @returns
*/
export const toDate = (date: string, format: string = "YYYY-MM-DD") => {
return dayjs(date, format).toDate()
}
export const today = () => {
return dayjs().format(FRAPPE_DATE_FORMAT)
}

68
banking/src/lib/file.ts Normal file
View File

@@ -0,0 +1,68 @@
/**
* Function to return extension of a file
* @param filename name of the file with extension
* @returns extension
*/
export const getFileExtension = (filename: string) => {
const fileNameWithoutQuery = filename?.split('?')[0]
const extension = fileNameWithoutQuery?.split('.').pop()?.toLocaleLowerCase() ?? ''
return extension;
}
/**
* Function to format bytes to human readable format
* @param bytes size in bytes
* @param decimals number of decimal places
* @returns string of human readable size
*/
export const formatBytes = (bytes: number, decimals = 2) => {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i]
}
export const imageExt = ["jpeg", "jpg", "png"]
export const excelExt = ['csv', 'xls', 'xlsx']
export const pptExt = ['ppt', 'pptx']
export const wordExt = ['doc', 'docx']
export const videoExt = ['mp4', 'mkv', 'webm', 'avi', 'mov']
export const audioExt = ['mp3', 'wav', 'ogg', 'flac']
export const getFileType = (ext: string) => {
switch (ext) {
case 'pdf': return 'pdf'
case 'doc': return 'word'
case 'docx': return 'word'
case 'xls': return 'excel'
case 'xlsx': return 'excel'
case 'ppt': return 'powerpoint'
case 'pptx': return 'powerpoint'
case 'mp3': return 'audio'
case 'wav': return 'audio'
case 'ogg': return 'audio'
case 'flac': return 'audio'
case 'mp4': return 'video'
case 'mkv': return 'video'
case 'webm': return 'video'
case 'avi': return 'video'
case 'mov': return 'video'
case 'jpeg': return 'image'
case 'jpg': return 'image'
case 'png': return 'image'
default: return 'file'
}
}

85
banking/src/lib/frappe.ts Normal file
View File

@@ -0,0 +1,85 @@
import { FrappeError } from "frappe-react-sdk"
interface ParsedErrorMessage {
message: string,
title?: string,
indicator?: string,
}
export const getErrorMessage = (error?: FrappeError | null): string => {
const messages = getErrorMessages(error)
return messages.map(m => m.message).join('\n')
}
/**
* Standard function to parse the error messages from the FrappeError object
* @param error The FrappeError object to parse
* @returns An array of ParsedErrorMessage objects
*/
export const getErrorMessages = (error?: FrappeError | null): ParsedErrorMessage[] => {
if (!error) return []
let eMessages: ParsedErrorMessage[] = error?._server_messages ? JSON.parse(error?._server_messages) : []
eMessages = eMessages.map((m) => {
try {
// @ts-expect-error - it can sometimes be a string
return JSON.parse(m)
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
catch (e) {
return m
}
})
if (eMessages.length === 0) {
// Get the message from the exception by removing the exc_type
const indexOfFirstColon = error?.exception?.indexOf(':')
if (indexOfFirstColon) {
const exception = error?.exception?.slice(indexOfFirstColon + 1)
if (exception) {
eMessages = [{
message: exception,
title: "Error"
}]
}
}
if (eMessages.length === 0) {
eMessages = [{
message: error?.message,
title: "Error",
indicator: "red"
}]
}
}
return eMessages
}
export const slug = (name?: string) => {
return name?.toLowerCase().replace(/ /g, "-") ?? "";
}
export const scrub = (txt?: string) => {
return (txt || "").replace(/ /g, "_").toLowerCase(); // use
}
export const unscrub = (txt?: string) => {
return (txt || "").replace(/-|_/g, " ").replace(/\w*/g, function (keywords) {
return keywords.charAt(0).toUpperCase() + keywords.substring(1).toLowerCase();
});
}
export const getSystemDefault = (fieldName: string, fallback?: string) => {
return window.frappe?.boot?.sysdefaults?.[fieldName] ?? fallback
}
export const getUserDefault = (fieldName: string, fallback?: string) => {
return window.frappe?.boot?.user?.defaults?.[fieldName] ?? fallback
}
export const getBootFieldData = (fieldName: string, fallback?: string) => {
return window.frappe?.boot?.[fieldName] ?? fallback
}

View File

@@ -0,0 +1,12 @@
frappe.defaults = {
get_user_default: function (key) {
let defaults = frappe.boot.user.defaults;
let d = defaults[key];
if (!d) {
key = key.replace(/ /g, "_").toLowerCase();
d = defaults[key];
}
if (Array.isArray(d)) d = d[0];
return d;
},
};

View File

@@ -0,0 +1,3 @@
import "./namespace";
import "./sync";
import "./defaults";

View File

@@ -0,0 +1,22 @@
if (!window.frappe) window.frappe = {};
frappe.provide = function (namespace) {
// docs: create a namespace //
var nsl = namespace.split(".");
var parent = window;
for (var i = 0; i < nsl.length; i++) {
var n = nsl[i];
if (!parent[n]) {
parent[n] = {};
}
parent = parent[n];
}
return parent;
};
frappe.provide("locals");
frappe.provide("frappe.flags");
frappe.provide("frappe.settings");
frappe.provide("locals.DocType");
frappe.provide("frappe.model");
frappe.provide("frappe.defaults");

View File

@@ -0,0 +1,187 @@
import isPlainObject from "lodash.isplainobject";
Object.assign(frappe.model, {
docinfo: {},
sync: function (r) {
/* docs:
extract docs, docinfo (attachments, comments, assignments)
from incoming request and set in `locals` and `frappe.model.docinfo`
*/
var isPlain;
if (!r.docs && !r.docinfo) r = { docs: r };
isPlain = isPlainObject(r.docs);
if (isPlain) r.docs = [r.docs];
if (r.docs) {
for (var i = 0, l = r.docs.length; i < l; i++) {
var d = r.docs[i];
if (locals[d.doctype] && locals[d.doctype][d.name]) {
// update values
frappe.model.update_in_locals(d);
} else {
frappe.model.add_to_locals(d);
}
d.__last_sync_on = new Date();
}
}
frappe.model.sync_docinfo(r);
return r.docs;
},
sync_docinfo: (r) => {
// set docinfo (comments, assign, attachments)
if (r.docinfo) {
const { doctype, name } = r.docinfo;
if (!frappe.model.docinfo[doctype]) {
frappe.model.docinfo[doctype] = {};
}
frappe.model.docinfo[doctype][name] = r.docinfo;
// copy values to frappe.boot.user_info
Object.assign(frappe.boot.user_info, r.docinfo.user_info);
}
return r.docs;
},
add_to_locals: function (doc) {
if (!locals[doc.doctype]) locals[doc.doctype] = {};
if (!doc.name && doc.__islocal) {
// get name (local if required)
if (!doc.parentfield) frappe.model.clear_doc(doc);
doc.name = frappe.model.get_new_name(doc.doctype);
if (!doc.parentfield) frappe.provide("frappe.model.docinfo." + doc.doctype + "." + doc.name);
}
locals[doc.doctype][doc.name] = doc;
// let meta = frappe.get_meta(doc.doctype);
// let is_table = meta ? meta.istable : doc.parentfield;
// // add child docs to locals
// if (!is_table) {
// for (var i in doc) {
// var value = doc[i];
// if (isArray(value)) {
// for (var x = 0, y = value.length; x < y; x++) {
// var d = value[x];
// if (typeof d == "object" && !d.parent) d.parent = doc.name;
// frappe.model.add_to_locals(d);
// }
// }
// }
// }
},
update_in_locals: function (doc) {
// update values in the existing local doc instead of replacing
let local_doc = locals[doc.doctype][doc.name];
let clear_keys = function (source, target) {
Object.keys(target).map((key) => {
if (source[key] == undefined) delete target[key];
});
};
for (let fieldname in doc) {
let df = frappe.meta.get_field(doc.doctype, fieldname);
if (df && frappe.model.table_fields.includes(df.fieldtype)) {
// table
if (!(doc[fieldname] instanceof Array)) {
doc[fieldname] = [];
}
if (!(local_doc[fieldname] instanceof Array)) {
local_doc[fieldname] = [];
}
// child table, override each row and append new rows if required
for (let i = 0; i < doc[fieldname].length; i++) {
let d = doc[fieldname][i];
let local_d = local_doc[fieldname][i];
if (local_d) {
// deleted and added again
if (!locals[d.doctype]) locals[d.doctype] = {};
if (!d.name) {
// incoming row is new, find a new name
d.name = frappe.model.get_new_name(doc.doctype);
}
// if incoming row is not registered, register it
if (!locals[d.doctype][d.name]) {
// detach old key
delete locals[d.doctype][local_d.name];
// re-attach with new name
locals[d.doctype][d.name] = local_d;
}
// row exists, just copy the values
Object.assign(local_d, d);
clear_keys(d, local_d);
} else {
local_doc[fieldname].push(d);
if (!d.parent) d.parent = doc.name;
frappe.model.add_to_locals(d);
}
}
// remove extra rows
if (local_doc[fieldname].length > doc[fieldname].length) {
for (let i = doc[fieldname].length; i < local_doc[fieldname].length; i++) {
// clear from local
let d = local_doc[fieldname][i];
if (locals[d.doctype] && locals[d.doctype][d.name]) {
delete locals[d.doctype][d.name];
}
}
local_doc[fieldname].length = doc[fieldname].length;
}
} else {
// literal
local_doc[fieldname] = doc[fieldname];
}
}
// clear keys on parent
clear_keys(doc, local_doc);
},
remove_from_locals: function (doctype, name) {
let clear_doc = function (doctype, name) {
var doc = locals[doctype] && locals[doctype][name];
if (!doc) return;
var parent = null;
if (doc.parenttype) {
parent = doc.parent;
var parenttype = doc.parenttype,
parentfield = doc.parentfield;
}
delete locals[doctype][name];
if (parent) {
var parent_doc = locals[parenttype][parent];
var newlist = [],
idx = 1;
$.each(parent_doc[parentfield], function (i, d) {
if (d.name != name) {
newlist.push(d);
d.idx = idx;
idx++;
}
parent_doc[parentfield] = newlist;
});
}
};
clear_doc(doctype, name);
},
});

249
banking/src/lib/numbers.ts Normal file
View File

@@ -0,0 +1,249 @@
import { in_list } from "./checks";
import { getCurrencyNumberFormat, getCurrencyProperty, getCurrencySymbol } from "./currency";
import { getSystemDefault } from "./frappe";
import _ from "@/lib/translate";
export const formatCurrency = (value?: number, currency: string = '', decimals: number = 2) => {
if (!value) {
value = 0
}
if (!currency) {
currency = getSystemDefault('currency') ?? ''
}
const format = get_number_format(currency);
const symbol = getCurrencySymbol(currency);
const show_symbol_on_right = getCurrencyProperty(currency, 'symbol_on_right') ?? false;
if (decimals === undefined) {
decimals = getSystemDefault('currency_precision') || null;
}
if (symbol) {
if (show_symbol_on_right) {
return format_number(value, format, decimals) + " " + _(symbol);
}
return _(symbol) + " " + format_number(value, format, decimals);
} else {
return format_number(value, format, decimals);
}
}
const replace_all = (str: string, search: string, replace: string) => {
return str.split(search).join(replace);
};
const number_format_info = {
"#,###.##": { decimal_str: ".", group_sep: "," },
"#.###,##": { decimal_str: ",", group_sep: "." },
"# ###.##": { decimal_str: ".", group_sep: " " },
"# ###,##": { decimal_str: ",", group_sep: " " },
"#'###.##": { decimal_str: ".", group_sep: "'" },
"#, ###.##": { decimal_str: ".", group_sep: ", " },
"#,##,###.##": { decimal_str: ".", group_sep: "," },
"#,###.###": { decimal_str: ".", group_sep: "," },
"#.###": { decimal_str: "", group_sep: "." },
"#,###": { decimal_str: "", group_sep: "," },
};
const format_number = (v?: number, format?: string, decimals?: number | null) => {
if (!format) {
format = get_number_format();
if (decimals == null) decimals = cint(getSystemDefault("float_precision")) || 3;
}
const info = get_number_format_info(format);
// Fix the decimal first, toFixed will auto fill trailing zero.
if (decimals == null) decimals = info.precision;
v = flt(v, decimals, format);
let is_negative = false;
if (v < 0) is_negative = true;
v = Math.abs(v);
const val = v.toFixed(decimals)
const part = val.split(".");
// get group position and parts
let group_position = info.group_sep ? 3 : 0;
if (group_position) {
const integer = part[0];
let str = "";
for (let i = integer.length; i >= 0; i--) {
let l = replace_all(str, info.group_sep, "").length;
if (format == "#,##,###.##" && str.indexOf(",") != -1) {
// INR
group_position = 2;
l += 1;
}
str += integer.charAt(i);
if (l && !((l + 1) % group_position) && i != 0) {
str += info.group_sep;
}
}
part[0] = str.split("").reverse().join("");
}
if (part[0] + "" == "") {
part[0] = "0";
}
// join decimal
part[1] = part[1] && info.decimal_str ? info.decimal_str + part[1] : "";
// join
return (is_negative ? "-" : "") + part[0] + part[1];
};
function get_number_format_info(format: string) {
let info: { decimal_str: string, group_sep: string, precision?: number } = number_format_info[format as keyof typeof number_format_info];
if (!info) {
info = { decimal_str: ".", group_sep: "," };
}
// get the precision from the number format
info.precision = format.split(info.decimal_str).slice(1)[0].length;
return info;
}
function get_number_format(currency?: string): string {
return (
(cint(getSystemDefault("use_number_format_from_currency")) &&
currency &&
getCurrencyNumberFormat(currency)) ||
getSystemDefault("number_format") ||
"#,###.##"
)
}
export const flt = (value?: number | string | null, decimals?: number, number_format?: string, rounding_method?: string) => {
if (value === undefined || value === null || value === "") return 0
if (typeof value !== "number") {
value = value + "";
// strip currency symbol if exists
if (value.indexOf(" ") != -1) {
// using slice(1).join(" ") because space could also be a group separator
const parts = value.split(" ");
value = isNaN(parseFloat(parts[0])) ? parts.slice(parts.length - 1).join(" ") : value;
}
value = strip_number_groups(value, number_format);
value = parseFloat(value as string);
if (isNaN(value)) value = 0;
}
if (decimals != null) return _round(value, decimals, rounding_method);
return value;
}
function strip_number_groups(v: string, number_format?: string) {
if (!number_format) number_format = get_number_format();
const info = get_number_format_info(number_format);
// strip groups (,)
const group_regex = new RegExp(info.group_sep === "." ? "\\." : info.group_sep, "g");
v = v.replace(group_regex, "");
// replace decimal separator with (.)
if (info.decimal_str !== "." && info.decimal_str !== "") {
const decimal_regex = new RegExp(info.decimal_str, "g");
v = v.replace(decimal_regex, ".");
}
return v;
}
const _round = (num: number, precision: number, rounding_method?: string) => {
rounding_method = rounding_method || getSystemDefault('rounding_method') || "Banker's Rounding (legacy)";
const is_negative = num < 0 ? true : false;
if (rounding_method == "Banker's Rounding (legacy)") {
const d = cint(precision);
const m = Math.pow(10, d);
const n = +(d ? Math.abs(num) * m : Math.abs(num)).toFixed(8); // Avoid rounding errors
const i = Math.floor(n),
f = n - i;
let r = !precision && f == 0.5 ? (i % 2 == 0 ? i : i + 1) : Math.round(n);
r = d ? r / m : r;
return is_negative ? -r : r;
} else if (rounding_method == "Banker's Rounding") {
if (num == 0) return 0.0;
precision = cint(precision);
const multiplier = Math.pow(10, precision);
num = Math.abs(num) * multiplier;
const floor_num = Math.floor(num);
const decimal_part = num - floor_num;
// For explanation of this method read python flt implementation notes.
const epsilon = 2.0 ** (Math.log2(Math.abs(num)) - 52.0);
if (Math.abs(decimal_part - 0.5) < epsilon) {
num = floor_num % 2 == 0 ? floor_num : floor_num + 1;
} else {
num = Math.round(num);
}
num = num / multiplier;
return is_negative ? -num : num;
} else if (rounding_method == "Commercial Rounding") {
if (num == 0) return 0.0;
const digits = cint(precision);
const multiplier = Math.pow(10, digits);
num = num * multiplier;
// For explanation of this method read python flt implementation notes.
let epsilon = 2.0 ** (Math.log2(Math.abs(num)) - 52.0);
if (is_negative) {
epsilon = -1 * epsilon;
}
num = Math.round(num + epsilon);
return num / multiplier;
} else {
throw new Error(`Unknown rounding method ${rounding_method}`);
}
}
export const cint = (v: boolean | string | number, def?: boolean | string | number) => {
if (v === true) return 1;
if (v === false) return 0;
v = v + "";
if (v !== "0") v = lstrip(v, ["0"]);
v = parseInt(v); // eslint-ignore-line
if (isNaN(v)) v = def === undefined ? 0 : def;
return v as number;
};
export const lstrip = (s: string, chars?: string[]) => {
if (!chars) chars = ["\n", "\t", " "];
// strip left
let first_char = s.substring(0, 1);
while (chars.includes(first_char)) {
s = s.substring(1);
first_char = s.substring(0, 1);
}
return s;
};
export const getCurrencyFormatInfo = (currency?: string) => {
const format = get_number_format(currency);
return get_number_format_info(format);
};

View File

@@ -0,0 +1,78 @@
/**
* Check if user can read a document
* @param {string} doctype
**/
export const canReadDocument = (doctype: string) => {
return window.frappe?.boot?.user?.can_read?.includes(doctype) || false
}
/**
* Check if user can write a document
* @param {string} doctype
**/
export const canWriteDocument = (doctype: string) => {
return window.frappe?.boot?.user?.can_write?.includes(doctype) || false
}
/**
* Check if user can create a document
* @param {string} doctype
**/
export const canCreateDocument = (doctype: string) => {
return window.frappe?.boot?.user?.can_create?.includes(doctype) || false
}
/**
* Check if user can delete a document
* @param {string} doctype
**/
export const canDeleteDocument = (doctype: string) => {
return window.frappe?.boot?.user?.can_delete?.includes(doctype) || false
}
/**
* Check if user can cancel a document
* @param {string} doctype
**/
export const canCancelDocument = (doctype: string) => {
return window.frappe?.boot?.user?.can_cancel?.includes(doctype) || false
}
/**
* Check if user can search a document
* @param {string} doctype
**/
export const canSearchDocument = (doctype: string) => {
return window.frappe?.boot?.user?.can_search?.includes(doctype) || false
}
/**
* Check if user can import a document
* @param {string} doctype
**/
export const canImportDocument = (doctype: string) => {
return window.frappe?.boot?.user?.can_import?.includes(doctype) || false
}
/**
* Check if user can export a document
* @param {string} doctype
**/
export const canExportDocument = (doctype: string) => {
return window.frappe?.boot?.user?.can_export?.includes(doctype) || false
}
/**
* Check if the user has a role
* @param {string} role
* @returns boolean
*/
export const hasRole = (role: string) => {
return window.frappe?.boot?.user?.roles?.includes(role) || false
}

View File

@@ -0,0 +1,45 @@
function _(txt: string, replace?: string[], context = null) {
if (!txt) return txt;
if (typeof txt != "string") return txt;
let translated_text = "";
const key = txt; // txt.replace(/\n/g, "");
if (window.frappe) {
if (context) {
translated_text = window.frappe._messages[`${key}:${context}`];
}
if (!translated_text) {
translated_text = window.frappe?._messages?.[key] || txt;
}
} else {
translated_text = txt;
}
if (replace && typeof replace === "object") {
translated_text = format(translated_text, replace);
}
return translated_text;
};
function format(str: string, args: string[]) {
if (str == undefined) return str;
let unkeyed_index = 0;
return str.replace(
/\{(\w*)\}/g,
function (match, key) {
if (key === "") {
key = unkeyed_index;
unkeyed_index++;
}
if (key == +key) {
return args[key] ?? match;
}
return ""
}
);
}
export default _;

18
banking/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,18 @@
import { clsx, type ClassValue } from "clsx"
import { extendTailwindMerge } from "tailwind-merge"
const twMerge = extendTailwindMerge({
extend: {
classGroups: {
"font-size": [
{ text: ["p-base", "p-2xs", "p-xs", "p-sm", "p-lg", "p-xl", "p-2xl", "p-3xl"] }
]
}
}
})
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}