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<
|
type BaseProps = Omit<
|
||||||
React.InputHTMLAttributes<HTMLInputElement>,
|
React.InputHTMLAttributes<HTMLInputElement>,
|
||||||
@@ -39,21 +39,32 @@ export default function CurrencyInput({
|
|||||||
|
|
||||||
if ("valueCents" in rest) {
|
if ("valueCents" in rest) {
|
||||||
const { valueCents, onChange, ...inputProps } = rest as CentsProps;
|
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 handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const formatted = formatString(e.target.value);
|
const formatted = formatString(e.target.value);
|
||||||
const parsed = Number.parseFloat(formatted || "0");
|
setDisplay(formatted);
|
||||||
onChange(Number.isFinite(parsed) ? Math.round(parsed * 100) : 0);
|
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 (
|
return (
|
||||||
<input
|
<input
|
||||||
{...inputProps}
|
{...inputProps}
|
||||||
className={mergedClass}
|
className={mergedClass}
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
value={value}
|
value={display}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
pattern="[0-9]*[.,]?[0-9]*"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -70,6 +81,7 @@ export default function CurrencyInput({
|
|||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
pattern="[0-9]*[.,]?[0-9]*"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,14 @@ const defaultSchedule = (): AutoPaySchedule => ({
|
|||||||
const DEFAULT_SAVINGS_PERCENT = 20;
|
const DEFAULT_SAVINGS_PERCENT = 20;
|
||||||
const MIN_SAVINGS_PERCENT = DEFAULT_SAVINGS_PERCENT;
|
const MIN_SAVINGS_PERCENT = DEFAULT_SAVINGS_PERCENT;
|
||||||
const normalizeCategoryName = (value: string) => value.trim().toLowerCase();
|
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 => ({
|
const createDefaultSavingsCategory = (): VariableCat => ({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
name: "savings",
|
name: "savings",
|
||||||
@@ -136,7 +144,13 @@ export default function OnboardingPage() {
|
|||||||
if (s.budgetConservatism) setBudgetConservatism(s.budgetConservatism);
|
if (s.budgetConservatism) setBudgetConservatism(s.budgetConservatism);
|
||||||
if (Number.isFinite(s.customFixedPercentage)) setCustomFixedPercentage(s.customFixedPercentage);
|
if (Number.isFinite(s.customFixedPercentage)) setCustomFixedPercentage(s.customFixedPercentage);
|
||||||
if (Array.isArray(s.vars) && s.vars.length > 0) {
|
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))
|
if (Array.isArray(s.fixeds))
|
||||||
setFixeds(
|
setFixeds(
|
||||||
@@ -181,11 +195,15 @@ export default function OnboardingPage() {
|
|||||||
|
|
||||||
// ── computed
|
// ── computed
|
||||||
const varsTotal = useMemo(
|
const varsTotal = useMemo(
|
||||||
() => vars.reduce((s, v) => s + (v.percent || 0), 0),
|
() => vars.reduce((s, v) => s + normalizePercentValue(v.percent), 0),
|
||||||
[vars]
|
[vars]
|
||||||
);
|
);
|
||||||
const savingsTotal = useMemo(
|
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]
|
[vars]
|
||||||
);
|
);
|
||||||
const varsRemaining = Math.max(0, 100 - varsTotal);
|
const varsRemaining = Math.max(0, 100 - varsTotal);
|
||||||
@@ -200,6 +218,10 @@ export default function OnboardingPage() {
|
|||||||
.filter(([, count]) => count > 1)
|
.filter(([, count]) => count > 1)
|
||||||
.map(([name]) => name);
|
.map(([name]) => name);
|
||||||
}, [vars]);
|
}, [vars]);
|
||||||
|
const hasMissingVarNames = useMemo(
|
||||||
|
() => vars.some((v) => !normalizeCategoryName(v.name)),
|
||||||
|
[vars]
|
||||||
|
);
|
||||||
const fixedTotal = useMemo(
|
const fixedTotal = useMemo(
|
||||||
() => fixeds.reduce((s, f) => s + (f.amountCents || 0), 0),
|
() => fixeds.reduce((s, f) => s + (f.amountCents || 0), 0),
|
||||||
[fixeds]
|
[fixeds]
|
||||||
@@ -241,7 +263,7 @@ export default function OnboardingPage() {
|
|||||||
const canNext4 =
|
const canNext4 =
|
||||||
vars.length > 0 &&
|
vars.length > 0 &&
|
||||||
varsTotal === 100 &&
|
varsTotal === 100 &&
|
||||||
vars.every((v) => v.name.trim()) &&
|
!hasMissingVarNames &&
|
||||||
vars.some((v) => v.isSavings) &&
|
vars.some((v) => v.isSavings) &&
|
||||||
savingsTotal >= MIN_SAVINGS_PERCENT &&
|
savingsTotal >= MIN_SAVINGS_PERCENT &&
|
||||||
duplicateVarNames.length === 0;
|
duplicateVarNames.length === 0;
|
||||||
@@ -520,7 +542,7 @@ export default function OnboardingPage() {
|
|||||||
dashboardSnapshot.variableCategories.map((c, i) => ({
|
dashboardSnapshot.variableCategories.map((c, i) => ({
|
||||||
id: c.id,
|
id: c.id,
|
||||||
name: normalizeCategoryName(c.name),
|
name: normalizeCategoryName(c.name),
|
||||||
percent: c.percent,
|
percent: normalizePercentValue(c.percent),
|
||||||
priority: i + 1,
|
priority: i + 1,
|
||||||
isSavings: !!c.isSavings,
|
isSavings: !!c.isSavings,
|
||||||
}))
|
}))
|
||||||
@@ -944,6 +966,12 @@ export default function OnboardingPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{hasMissingVarNames && (
|
||||||
|
<div className="toast-err">
|
||||||
|
Every category needs a name before you can continue.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{varsTotal !== 100 && varsTotal > 0 && (
|
{varsTotal !== 100 && varsTotal > 0 && (
|
||||||
<div className="toast-err">
|
<div className="toast-err">
|
||||||
Categories must total <strong>100%</strong>. Current: <strong>{varsTotal}%</strong>
|
Categories must total <strong>100%</strong>. Current: <strong>{varsTotal}%</strong>
|
||||||
|
|||||||
Reference in New Issue
Block a user