mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-23 20:16:40 +00:00
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:
20
banking/src/lib/checks.ts
Normal file
20
banking/src/lib/checks.ts
Normal 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;
|
||||
}
|
||||
15
banking/src/lib/company.ts
Normal file
15
banking/src/lib/company.ts
Normal 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]
|
||||
}
|
||||
24
banking/src/lib/currency.ts
Normal file
24
banking/src/lib/currency.ts
Normal 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
184
banking/src/lib/date.ts
Normal 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
68
banking/src/lib/file.ts
Normal 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
85
banking/src/lib/frappe.ts
Normal 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
|
||||
}
|
||||
12
banking/src/lib/namespace/defaults.js
Normal file
12
banking/src/lib/namespace/defaults.js
Normal 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;
|
||||
},
|
||||
};
|
||||
3
banking/src/lib/namespace/index.js
Normal file
3
banking/src/lib/namespace/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import "./namespace";
|
||||
import "./sync";
|
||||
import "./defaults";
|
||||
22
banking/src/lib/namespace/namespace.js
Normal file
22
banking/src/lib/namespace/namespace.js
Normal 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");
|
||||
187
banking/src/lib/namespace/sync.js
Normal file
187
banking/src/lib/namespace/sync.js
Normal 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
249
banking/src/lib/numbers.ts
Normal 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);
|
||||
};
|
||||
78
banking/src/lib/permissions.ts
Normal file
78
banking/src/lib/permissions.ts
Normal 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
|
||||
}
|
||||
45
banking/src/lib/translate.ts
Normal file
45
banking/src/lib/translate.ts
Normal 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
18
banking/src/lib/utils.ts
Normal 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))
|
||||
}
|
||||
Reference in New Issue
Block a user