fixed inputs to accept decimals
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
type BaseProps = Omit<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
@@ -39,21 +39,32 @@ export default function CurrencyInput({
|
||||
|
||||
if ("valueCents" in rest) {
|
||||
const { valueCents, onChange, ...inputProps } = rest as CentsProps;
|
||||
const [display, setDisplay] = useState<string>("");
|
||||
|
||||
// keep display in sync when valueCents prop changes externally
|
||||
useEffect(() => {
|
||||
const asNumber = (valueCents ?? 0) / 100;
|
||||
setDisplay(Number.isFinite(asNumber) ? asNumber.toString() : "");
|
||||
}, [valueCents]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const formatted = formatString(e.target.value);
|
||||
const parsed = Number.parseFloat(formatted || "0");
|
||||
onChange(Number.isFinite(parsed) ? Math.round(parsed * 100) : 0);
|
||||
setDisplay(formatted);
|
||||
const parsed = Number.parseFloat(formatted);
|
||||
if (Number.isFinite(parsed)) {
|
||||
onChange(Math.round(parsed * 100));
|
||||
}
|
||||
};
|
||||
const displayValue = (valueCents ?? 0) / 100;
|
||||
const value = Number.isFinite(displayValue) ? displayValue.toString() : "";
|
||||
|
||||
return (
|
||||
<input
|
||||
{...inputProps}
|
||||
className={mergedClass}
|
||||
inputMode="decimal"
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
value={display}
|
||||
onChange={handleChange}
|
||||
pattern="[0-9]*[.,]?[0-9]*"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -70,6 +81,7 @@ export default function CurrencyInput({
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
pattern="[0-9]*[.,]?[0-9]*"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,6 +75,14 @@ const defaultSchedule = (): AutoPaySchedule => ({
|
||||
const DEFAULT_SAVINGS_PERCENT = 20;
|
||||
const MIN_SAVINGS_PERCENT = DEFAULT_SAVINGS_PERCENT;
|
||||
const normalizeCategoryName = (value: string) => value.trim().toLowerCase();
|
||||
const normalizePercentValue = (value: unknown) => {
|
||||
const parsed =
|
||||
typeof value === "number"
|
||||
? value
|
||||
: Number.parseFloat(String(value ?? ""));
|
||||
if (!Number.isFinite(parsed)) return 0;
|
||||
return Math.max(0, Math.min(100, Math.round(parsed)));
|
||||
};
|
||||
const createDefaultSavingsCategory = (): VariableCat => ({
|
||||
id: crypto.randomUUID(),
|
||||
name: "savings",
|
||||
@@ -136,7 +144,13 @@ export default function OnboardingPage() {
|
||||
if (s.budgetConservatism) setBudgetConservatism(s.budgetConservatism);
|
||||
if (Number.isFinite(s.customFixedPercentage)) setCustomFixedPercentage(s.customFixedPercentage);
|
||||
if (Array.isArray(s.vars) && s.vars.length > 0) {
|
||||
setVars(s.vars.map((v: VariableCat) => ({ ...v, name: normalizeCategoryName(v.name) })));
|
||||
setVars(
|
||||
s.vars.map((v: VariableCat) => ({
|
||||
...v,
|
||||
name: normalizeCategoryName(v.name ?? ""),
|
||||
percent: normalizePercentValue(v.percent),
|
||||
}))
|
||||
);
|
||||
}
|
||||
if (Array.isArray(s.fixeds))
|
||||
setFixeds(
|
||||
@@ -181,11 +195,15 @@ export default function OnboardingPage() {
|
||||
|
||||
// ── computed
|
||||
const varsTotal = useMemo(
|
||||
() => vars.reduce((s, v) => s + (v.percent || 0), 0),
|
||||
() => vars.reduce((s, v) => s + normalizePercentValue(v.percent), 0),
|
||||
[vars]
|
||||
);
|
||||
const savingsTotal = useMemo(
|
||||
() => vars.reduce((s, v) => s + (v.isSavings ? v.percent || 0 : 0), 0),
|
||||
() =>
|
||||
vars.reduce(
|
||||
(s, v) => s + (v.isSavings ? normalizePercentValue(v.percent) : 0),
|
||||
0
|
||||
),
|
||||
[vars]
|
||||
);
|
||||
const varsRemaining = Math.max(0, 100 - varsTotal);
|
||||
@@ -200,6 +218,10 @@ export default function OnboardingPage() {
|
||||
.filter(([, count]) => count > 1)
|
||||
.map(([name]) => name);
|
||||
}, [vars]);
|
||||
const hasMissingVarNames = useMemo(
|
||||
() => vars.some((v) => !normalizeCategoryName(v.name)),
|
||||
[vars]
|
||||
);
|
||||
const fixedTotal = useMemo(
|
||||
() => fixeds.reduce((s, f) => s + (f.amountCents || 0), 0),
|
||||
[fixeds]
|
||||
@@ -241,7 +263,7 @@ export default function OnboardingPage() {
|
||||
const canNext4 =
|
||||
vars.length > 0 &&
|
||||
varsTotal === 100 &&
|
||||
vars.every((v) => v.name.trim()) &&
|
||||
!hasMissingVarNames &&
|
||||
vars.some((v) => v.isSavings) &&
|
||||
savingsTotal >= MIN_SAVINGS_PERCENT &&
|
||||
duplicateVarNames.length === 0;
|
||||
@@ -520,7 +542,7 @@ export default function OnboardingPage() {
|
||||
dashboardSnapshot.variableCategories.map((c, i) => ({
|
||||
id: c.id,
|
||||
name: normalizeCategoryName(c.name),
|
||||
percent: c.percent,
|
||||
percent: normalizePercentValue(c.percent),
|
||||
priority: i + 1,
|
||||
isSavings: !!c.isSavings,
|
||||
}))
|
||||
@@ -943,6 +965,12 @@ export default function OnboardingPage() {
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasMissingVarNames && (
|
||||
<div className="toast-err">
|
||||
Every category needs a name before you can continue.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{varsTotal !== 100 && varsTotal > 0 && (
|
||||
<div className="toast-err">
|
||||
|
||||
Reference in New Issue
Block a user