diff --git a/package-lock.json b/package-lock.json
index ea10622..63951c9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,8 +15,10 @@
"@mui/x-date-pickers": "^8.5.3",
"formik": "^2.4.6",
"luxon": "^3.6.1",
+ "mathjs": "^14.5.2",
"react": "19.1.0",
"react-dom": "19.1.0",
+ "react-i18next": "^15.5.3",
"react-router-dom": "^7.6.2",
"tslib": "^2.8.1",
"zod": "^3.25.67"
@@ -12118,6 +12120,18 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/complex.js": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/complex.js/-/complex.js-2.4.2.tgz",
+ "integrity": "sha512-qtx7HRhPGSCBtGiST4/WGHuW+zeaND/6Ld+db6PbrulIB1i2Ev/2UPiqcmpQNPSyfBKraC0EOvOKCB5dGZKt3g==",
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/rawify"
+ }
+ },
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -12481,8 +12495,7 @@
"node_modules/decimal.js": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz",
- "integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==",
- "dev": true
+ "integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw=="
},
"node_modules/decompress-response": {
"version": "6.0.0",
@@ -13203,6 +13216,11 @@
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"dev": true
},
+ "node_modules/escape-latex": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/escape-latex/-/escape-latex-1.2.0.tgz",
+ "integrity": "sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw=="
+ },
"node_modules/escape-string-regexp": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
@@ -14543,6 +14561,18 @@
"node": ">= 0.6"
}
},
+ "node_modules/fraction.js": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.2.2.tgz",
+ "integrity": "sha512-uXBDv5knpYmv/2gLzWQ5mBHGBRk9wcKTeWu6GLTUEQfjCxO09uM/mHDrojlL+Q1mVGIIFo149Gba7od1XPgSzQ==",
+ "engines": {
+ "node": ">= 12"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/rawify"
+ }
+ },
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
@@ -15103,6 +15133,14 @@
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true
},
+ "node_modules/html-parse-stringify": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
+ "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
+ "dependencies": {
+ "void-elements": "3.1.0"
+ }
+ },
"node_modules/http-assert": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/http-assert/-/http-assert-1.5.0.tgz",
@@ -15270,6 +15308,37 @@
"node": ">=10.17.0"
}
},
+ "node_modules/i18next": {
+ "version": "25.2.1",
+ "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.2.1.tgz",
+ "integrity": "sha512-+UoXK5wh+VlE1Zy5p6MjcvctHXAhRwQKCxiJD8noKZzIXmnAX8gdHX5fLPA3MEVxEN4vbZkQFy8N0LyD9tUqPw==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://locize.com"
+ },
+ {
+ "type": "individual",
+ "url": "https://locize.com/i18next.html"
+ },
+ {
+ "type": "individual",
+ "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
+ }
+ ],
+ "peer": true,
+ "dependencies": {
+ "@babel/runtime": "^7.27.1"
+ },
+ "peerDependencies": {
+ "typescript": "^5"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -16105,6 +16174,11 @@
"node": "*"
}
},
+ "node_modules/javascript-natural-sort": {
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz",
+ "integrity": "sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw=="
+ },
"node_modules/jest": {
"version": "30.0.2",
"resolved": "https://registry.npmjs.org/jest/-/jest-30.0.2.tgz",
@@ -19251,6 +19325,28 @@
"node": ">= 0.4"
}
},
+ "node_modules/mathjs": {
+ "version": "14.5.2",
+ "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-14.5.2.tgz",
+ "integrity": "sha512-51U6hp7j4M4Rj+l+q2KbmXAV9EhQVQzUdw1wE67RnUkKKq5ibxdrl9Ky2YkSUEIc2+VU8/IsThZNu6QSHUoyTA==",
+ "dependencies": {
+ "@babel/runtime": "^7.26.10",
+ "complex.js": "^2.2.5",
+ "decimal.js": "^10.4.3",
+ "escape-latex": "^1.2.0",
+ "fraction.js": "^5.2.1",
+ "javascript-natural-sort": "^0.7.1",
+ "seedrandom": "^3.0.5",
+ "tiny-emitter": "^2.1.0",
+ "typed-function": "^4.2.1"
+ },
+ "bin": {
+ "mathjs": "bin/cli.js"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
"node_modules/mdn-data": {
"version": "2.0.30",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
@@ -20687,6 +20783,31 @@
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz",
"integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw=="
},
+ "node_modules/react-i18next": {
+ "version": "15.5.3",
+ "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.5.3.tgz",
+ "integrity": "sha512-ypYmOKOnjqPEJZO4m1BI0kS8kWqkBNsKYyhVUfij0gvjy9xJNoG/VcGkxq5dRlVwzmrmY1BQMAmpbbUBLwC4Kw==",
+ "dependencies": {
+ "@babel/runtime": "^7.27.6",
+ "html-parse-stringify": "^3.0.1"
+ },
+ "peerDependencies": {
+ "i18next": ">= 23.2.3",
+ "react": ">= 16.8.0",
+ "typescript": "^5"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ },
+ "react-native": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
@@ -21228,6 +21349,11 @@
"integrity": "sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==",
"dev": true
},
+ "node_modules/seedrandom": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz",
+ "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg=="
+ },
"node_modules/seek-bzip": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/seek-bzip/-/seek-bzip-2.0.0.tgz",
@@ -22277,6 +22403,11 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/tiny-emitter": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz",
+ "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q=="
+ },
"node_modules/tiny-warning": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
@@ -22752,11 +22883,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/typed-function": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/typed-function/-/typed-function-4.2.1.tgz",
+ "integrity": "sha512-EGjWssW7Tsk4DGfE+5yluuljS1OGYWiI1J6e8puZz9nTMM51Oug8CD5Zo4gWMsOhq5BI+1bF+rWTm4Vbj3ivRA==",
+ "engines": {
+ "node": ">= 18"
+ }
+ },
"node_modules/typescript": {
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
- "dev": true,
+ "devOptional": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -23294,6 +23433,14 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/void-elements": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
+ "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/vscode-uri": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz",
diff --git a/package.json b/package.json
index b42053a..a322026 100644
--- a/package.json
+++ b/package.json
@@ -14,8 +14,10 @@
"@mui/x-date-pickers": "^8.5.3",
"formik": "^2.4.6",
"luxon": "^3.6.1",
+ "mathjs": "^14.5.2",
"react": "19.1.0",
"react-dom": "19.1.0",
+ "react-i18next": "^15.5.3",
"react-router-dom": "^7.6.2",
"tslib": "^2.8.1",
"zod": "^3.25.67"
diff --git a/tsconfig.base.json b/tsconfig.base.json
index 9a14fd0..03249f7 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -10,7 +10,7 @@
"importHelpers": true,
"target": "es2015",
"module": "esnext",
- "lib": ["es2020", "dom"],
+ "lib": ["es2021", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"baseUrl": ".",
diff --git a/web/components/package.json b/web/components/package.json
index 7ec3d38..a3e2741 100644
--- a/web/components/package.json
+++ b/web/components/package.json
@@ -12,7 +12,11 @@
},
"peerDependencies": {
"react": "^19.1.0",
- "@mui/material": "^7.1.2"
+ "@mui/material": "^7.1.2",
+ "react-i18next": "^15.5.3"
+ },
+ "dependencies": {
+ "mathjs": "^14.5.2"
},
"repository": {
"type": "git",
diff --git a/web/components/src/index.ts b/web/components/src/index.ts
index eae73e9..da8451b 100644
--- a/web/components/src/index.ts
+++ b/web/components/src/index.ts
@@ -1,5 +1,4 @@
export * from "./lib/CashingTextField";
export * from "./lib/DataGridTitle";
-export * from "./lib/Loading";
-export * from "./lib/NumericInput";
+export * from "./lib/numeric-input";
export * from "./lib/Select";
diff --git a/web/components/src/lib/Loading.spec.tsx b/web/components/src/lib/Loading.spec.tsx
deleted file mode 100644
index dbb59f0..0000000
--- a/web/components/src/lib/Loading.spec.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-describe("Dummy test suite", () => {
- it("should work", () => {
- expect(true).toBeTruthy();
- });
-});
diff --git a/web/components/src/lib/Loading.tsx b/web/components/src/lib/Loading.tsx
deleted file mode 100644
index be9d410..0000000
--- a/web/components/src/lib/Loading.tsx
+++ /dev/null
@@ -1,10 +0,0 @@
-import * as React from "react";
-import { CircularProgress, Grid } from "@mui/material";
-
-export const Loading: React.FC = () => {
- return (
-
-
-
- );
-};
diff --git a/web/components/src/lib/NumericInput.tsx b/web/components/src/lib/NumericInput.tsx
deleted file mode 100644
index d05ee69..0000000
--- a/web/components/src/lib/NumericInput.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import * as React from "react";
-import { TextField, TextFieldProps } from "@mui/material";
-
-export type NumericInputProps = {
- onChange: (value: number | null) => void;
- value?: number | undefined | null;
-} & Omit;
-
-export const NumericInput: React.FC = ({ value, onChange, ...props }) => {
- const [internalValue, setInternalValue] = React.useState("");
-
- React.useEffect(() => {
- setInternalValue(String(value ?? ""));
- }, [value, setInternalValue]);
-
- const onInternalChange: React.ChangeEventHandler = (event) => {
- setInternalValue(event.target.value);
- };
-
- const propagateChange = () => {
- if (internalValue === "") {
- onChange(null);
- return;
- }
-
- const parsedValue = parseFloat(internalValue);
- if (!isNaN(parsedValue)) {
- setInternalValue(String(parsedValue));
- onChange(parsedValue);
- }
- };
-
- const onInternalBlur = () => {
- propagateChange();
- };
-
- const onKeyUp = (event: React.KeyboardEvent) => {
- if (event.key === "Enter") {
- propagateChange();
- }
- };
-
- return (
- event.target.select()}
- {...props}
- />
- );
-};
diff --git a/web/components/src/lib/numeric-input/NumericInput.tsx b/web/components/src/lib/numeric-input/NumericInput.tsx
new file mode 100644
index 0000000..ecf0463
--- /dev/null
+++ b/web/components/src/lib/numeric-input/NumericInput.tsx
@@ -0,0 +1,113 @@
+import * as React from "react";
+import { TextField, TextFieldProps } from "@mui/material";
+import { evaluateExpression } from "./mathExpression";
+import { useTranslation } from "react-i18next";
+
+export type NumericInputProps = {
+ onChange: (value: number) => void;
+ value?: number | undefined;
+ isCurrency?: boolean | false;
+} & Omit;
+
+const getDecimalSeparator = (locale: string) => {
+ const numberWithDecimalSeparator = 1.1;
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ const separator = Intl.NumberFormat(locale)
+ .formatToParts(numberWithDecimalSeparator)
+ .find((part) => part.type === "decimal")!.value;
+
+ return [separator, separator === "." ? "," : "."];
+};
+
+const adjustLocaleDecimalSeparators = (value: string, locale: string): string => {
+ const [decimalSeparator, thousandSeparator] = getDecimalSeparator(locale);
+ return value.replaceAll(thousandSeparator, "").replaceAll(decimalSeparator, ".");
+};
+
+const currencyFormatConfig = {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+};
+
+const numberFormatConfig = {
+ maximumFractionDigits: 12,
+};
+
+export const NumericInput: React.FC = ({
+ value,
+ isCurrency,
+ onChange,
+ error,
+ helperText,
+ ...props
+}) => {
+ const { i18n } = useTranslation();
+ const inputRef = React.useRef(null);
+
+ const [internalValue, setInternalValue] = React.useState("");
+ const [internalError, setInternalError] = React.useState(false);
+ const [internalHelperText, setInternalHelperText] = React.useState(undefined);
+
+ React.useEffect(() => {
+ if (value != null) {
+ setInternalValue(
+ Intl.NumberFormat(i18n.language, isCurrency ? currencyFormatConfig : numberFormatConfig).format(value)
+ );
+ }
+ }, [value, setInternalValue, isCurrency, i18n.language]);
+
+ const onInternalChange = (event: React.ChangeEvent) => {
+ setInternalValue(event.target.value);
+ };
+
+ const finalizeInput = () => {
+ const updateValue = (value: number) => {
+ setInternalValue(isCurrency ? value.toFixed(2) : String(value));
+ onChange(value);
+ setInternalError(false);
+ setInternalHelperText(undefined);
+ };
+
+ // first, preprocess the input to have commas / dots adhere to the current locale
+ const cleanedValue = adjustLocaleDecimalSeparators(internalValue, i18n.language);
+
+ // next, try to evaluate any math expression present in the input, plain numbers will remain unchanged
+ try {
+ const evaluated = evaluateExpression(cleanedValue);
+ updateValue(evaluated);
+ } catch (e) {
+ setInternalError(true);
+ setInternalHelperText(String(e));
+ }
+ };
+
+ const onInternalBlur = () => {
+ finalizeInput();
+ };
+
+ const onKeyUp = (event: React.KeyboardEvent) => {
+ if (event.key === "Enter") {
+ finalizeInput();
+ inputRef.current?.select();
+ }
+ };
+
+ const passedError = error || internalError;
+ const passedHelperText = internalHelperText ?? helperText;
+
+ return (
+ event.target.select()}
+ error={passedError}
+ helperText={passedHelperText}
+ inputRef={inputRef}
+ {...props}
+ />
+ );
+};
diff --git a/web/components/src/lib/numeric-input/index.ts b/web/components/src/lib/numeric-input/index.ts
new file mode 100644
index 0000000..0e3ba26
--- /dev/null
+++ b/web/components/src/lib/numeric-input/index.ts
@@ -0,0 +1 @@
+export * from "./NumericInput";
diff --git a/web/components/src/lib/numeric-input/mathExpression.spec.ts b/web/components/src/lib/numeric-input/mathExpression.spec.ts
new file mode 100644
index 0000000..ec94563
--- /dev/null
+++ b/web/components/src/lib/numeric-input/mathExpression.spec.ts
@@ -0,0 +1,13 @@
+import { evaluateExpression } from "./mathExpression";
+
+describe("mathExpressions", () => {
+ test("basic operations work", () => {
+ expect(evaluateExpression("(2 * 4 + 4 / 2) * 2")).toBe(20);
+ });
+ test("test invalid expression throws", () => {
+ expect(() => evaluateExpression("(2 * 4 + 4 / 2 * 2")).toThrow();
+ });
+ test("advanced operations are not allowed", () => {
+ expect(() => evaluateExpression("sqrt(10)")).toThrow();
+ });
+});
diff --git a/web/components/src/lib/numeric-input/mathExpression.ts b/web/components/src/lib/numeric-input/mathExpression.ts
new file mode 100644
index 0000000..b44342e
--- /dev/null
+++ b/web/components/src/lib/numeric-input/mathExpression.ts
@@ -0,0 +1,23 @@
+import { create, type ConfigOptions, evaluateDependencies, factory } from "mathjs";
+
+const config: ConfigOptions = {};
+
+const add = (a: number, b: number) => a + b;
+const subtract = (a: number, b: number) => a - b;
+const multiply = (a: number, b: number) => a * b;
+const divide = (a: number, b: number) => a / b;
+
+const math = create(
+ {
+ evaluateDependencies,
+ createAdd: factory("add", [], () => add),
+ createSubtract: factory("subtract", [], () => subtract),
+ createMultiply: factory("multiply", [], () => multiply),
+ createDivide: factory("divide", [], () => divide),
+ },
+ config
+);
+
+export const evaluateExpression = (expr: string): number => {
+ return math.evaluate(expr);
+};