frontend: What-If page with fan chart driven by /simulate
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was canceled
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was canceled
New /what-if route. Sticky form on the left (jurisdiction, strategy, glide, NW seed, spending, savings, horizon, optional floor for vpw_floor, MC paths) submits to POST /simulate; results panel renders summary stats + the new FanChart. FanChart component layers seven series: - p10 invisible baseline (line, transparent) - p10→p25 stacked area (low opacity) - p25→p75 stacked area (IQR, mid opacity) - p75→p90 stacked area (low opacity) - p50 solid median line (drawn last, prominent) - p10 + p90 dashed lines on top of the bands Stacking deltas keeps the band fills clean — plotting raw quantiles each as their own area would overlap badly. Reusable by scenario detail in the next chunk (same ProjectionPoint[] shape). 5 tests pass (was 2). 470 KB gzipped (ECharts). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
5d2b9e931a
commit
bb74bc0add
4 changed files with 550 additions and 4 deletions
|
|
@ -1,8 +1,9 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Route, Routes, Link } from 'react-router-dom';
|
||||
import { NavLink, Route, Routes, Link } from 'react-router-dom';
|
||||
|
||||
import { api } from '@/api/client';
|
||||
import { Dashboard } from '@/pages/Dashboard';
|
||||
import { WhatIf } from '@/pages/WhatIf';
|
||||
|
||||
export function App() {
|
||||
const health = useQuery({ queryKey: ['health'], queryFn: api.health });
|
||||
|
|
@ -11,9 +12,15 @@ export function App() {
|
|||
<div className="min-h-screen flex flex-col">
|
||||
<header className="border-b border-slate-200 bg-white">
|
||||
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<Link to="/" className="text-xl font-semibold tracking-tight">
|
||||
fire-planner
|
||||
</Link>
|
||||
<div className="flex items-center gap-6">
|
||||
<Link to="/" className="text-xl font-semibold tracking-tight">
|
||||
fire-planner
|
||||
</Link>
|
||||
<nav className="flex gap-4 text-sm">
|
||||
<NavTab to="/">Dashboard</NavTab>
|
||||
<NavTab to="/what-if">What if</NavTab>
|
||||
</nav>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
api:{' '}
|
||||
<span className={health.data ? 'text-emerald-600' : 'text-amber-600'}>
|
||||
|
|
@ -29,8 +36,23 @@ export function App() {
|
|||
<main className="flex-1 max-w-7xl w-full mx-auto px-6 py-8">
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/what-if" element={<WhatIf />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavTab({ to, children }: { to: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
end
|
||||
className={({ isActive }) =>
|
||||
`${isActive ? 'text-slate-900 font-medium' : 'text-slate-500 hover:text-slate-800'}`
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
41
frontend/src/components/FanChart.test.tsx
Normal file
41
frontend/src/components/FanChart.test.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
import { FanChart } from './FanChart';
|
||||
import type { ProjectionPoint } from '@/api/client';
|
||||
|
||||
vi.mock('echarts-for-react', () => ({
|
||||
default: ({ option }: { option: { series?: unknown[] } }) => (
|
||||
<div data-testid="chart" data-series-count={option.series?.length ?? 0} />
|
||||
),
|
||||
}));
|
||||
|
||||
const point = (year: number, p50 = 1_000_000): ProjectionPoint => ({
|
||||
year_idx: year,
|
||||
p10_portfolio_gbp: String(p50 * 0.7),
|
||||
p25_portfolio_gbp: String(p50 * 0.85),
|
||||
p50_portfolio_gbp: String(p50),
|
||||
p75_portfolio_gbp: String(p50 * 1.15),
|
||||
p90_portfolio_gbp: String(p50 * 1.3),
|
||||
p50_withdrawal_gbp: '60000',
|
||||
p50_tax_gbp: '8000',
|
||||
survival_rate: '1.0',
|
||||
});
|
||||
|
||||
describe('FanChart', () => {
|
||||
it('shows empty state when there are no points', () => {
|
||||
render(<FanChart yearly={[]} />);
|
||||
expect(screen.getByText('No projection data.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders 7 base series for fan + median + p10/p90 lines', () => {
|
||||
render(<FanChart yearly={[point(0), point(1), point(2)]} />);
|
||||
const chart = screen.getByTestId('chart');
|
||||
expect(chart.dataset.seriesCount).toBe('7');
|
||||
});
|
||||
|
||||
it('adds an 8th series when showWithdrawal is on', () => {
|
||||
render(<FanChart yearly={[point(0), point(1)]} showWithdrawal />);
|
||||
expect(screen.getByTestId('chart').dataset.seriesCount).toBe('8');
|
||||
});
|
||||
});
|
||||
202
frontend/src/components/FanChart.tsx
Normal file
202
frontend/src/components/FanChart.tsx
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
/**
|
||||
* Monte Carlo fan chart — confidence bands (p10/p25/p75/p90) + median line.
|
||||
*
|
||||
* Renders five layered series in ECharts:
|
||||
* 1. p10 invisible baseline (line, transparent, no fill)
|
||||
* 2. (p25 - p10) band stacks on p10 with a low-opacity fill
|
||||
* 3. (p75 - p25) band stacks on top with mid-opacity (the inter-quartile)
|
||||
* 4. (p90 - p75) band stacks on top with low-opacity (upper tail)
|
||||
* 5. p50 median standalone solid line, drawn last so it sits above
|
||||
*
|
||||
* Stacking the deltas instead of plotting raw quantiles keeps the band
|
||||
* fills clean — ECharts area renderers fill to the *baseline* by default,
|
||||
* which would overlap badly if we just plotted p10/p25/p50/p75/p90 lines
|
||||
* each with their own area.
|
||||
*/
|
||||
import { useMemo } from 'react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import type { EChartsOption } from 'echarts';
|
||||
|
||||
import type { ProjectionPoint } from '@/api/client';
|
||||
import { gbpCompact } from '@/lib/format';
|
||||
|
||||
interface Props {
|
||||
yearly: ProjectionPoint[];
|
||||
height?: number;
|
||||
showWithdrawal?: boolean;
|
||||
}
|
||||
|
||||
export function FanChart({ yearly, height = 360, showWithdrawal = false }: Props) {
|
||||
const option = useMemo<EChartsOption>(() => buildFan(yearly, showWithdrawal), [
|
||||
yearly,
|
||||
showWithdrawal,
|
||||
]);
|
||||
if (yearly.length === 0) {
|
||||
return <p className="text-sm text-slate-500">No projection data.</p>;
|
||||
}
|
||||
return <ReactECharts option={option} style={{ height, width: '100%' }} notMerge lazyUpdate />;
|
||||
}
|
||||
|
||||
function buildFan(yearly: ProjectionPoint[], showWithdrawal: boolean): EChartsOption {
|
||||
const years = yearly.map((p) => p.year_idx);
|
||||
const p10 = yearly.map((p) => num(p.p10_portfolio_gbp));
|
||||
const p25 = yearly.map((p) => num(p.p25_portfolio_gbp));
|
||||
const p50 = yearly.map((p) => num(p.p50_portfolio_gbp));
|
||||
const p75 = yearly.map((p) => num(p.p75_portfolio_gbp));
|
||||
const p90 = yearly.map((p) => num(p.p90_portfolio_gbp));
|
||||
const wd = yearly.map((p) => num(p.p50_withdrawal_gbp));
|
||||
|
||||
// Deltas — what each band stacks on top of the previous.
|
||||
const d_25_10 = p25.map((v, i) => Math.max(0, v - p10[i]!));
|
||||
const d_75_25 = p75.map((v, i) => Math.max(0, v - p25[i]!));
|
||||
const d_90_75 = p90.map((v, i) => Math.max(0, v - p75[i]!));
|
||||
|
||||
const series: EChartsOption['series'] = [
|
||||
{
|
||||
name: 'p10',
|
||||
type: 'line',
|
||||
stack: 'fan',
|
||||
data: p10,
|
||||
lineStyle: { opacity: 0 },
|
||||
itemStyle: { opacity: 0 },
|
||||
symbol: 'none',
|
||||
tooltip: { show: false },
|
||||
},
|
||||
{
|
||||
name: 'p10–p25',
|
||||
type: 'line',
|
||||
stack: 'fan',
|
||||
data: d_25_10,
|
||||
lineStyle: { opacity: 0 },
|
||||
symbol: 'none',
|
||||
areaStyle: { color: 'rgba(80, 110, 230, 0.12)' },
|
||||
tooltip: { show: false },
|
||||
},
|
||||
{
|
||||
name: 'p25–p75 (IQR)',
|
||||
type: 'line',
|
||||
stack: 'fan',
|
||||
data: d_75_25,
|
||||
lineStyle: { opacity: 0 },
|
||||
symbol: 'none',
|
||||
areaStyle: { color: 'rgba(80, 110, 230, 0.28)' },
|
||||
tooltip: { show: false },
|
||||
},
|
||||
{
|
||||
name: 'p75–p90',
|
||||
type: 'line',
|
||||
stack: 'fan',
|
||||
data: d_90_75,
|
||||
lineStyle: { opacity: 0 },
|
||||
symbol: 'none',
|
||||
areaStyle: { color: 'rgba(80, 110, 230, 0.12)' },
|
||||
tooltip: { show: false },
|
||||
},
|
||||
{
|
||||
name: 'median',
|
||||
type: 'line',
|
||||
data: p50,
|
||||
lineStyle: { width: 2, color: 'rgb(40, 70, 200)' },
|
||||
itemStyle: { color: 'rgb(40, 70, 200)' },
|
||||
symbol: 'none',
|
||||
smooth: true,
|
||||
z: 10,
|
||||
},
|
||||
{
|
||||
name: 'p10',
|
||||
type: 'line',
|
||||
data: p10,
|
||||
lineStyle: { width: 1, color: 'rgba(40, 70, 200, 0.5)', type: 'dashed' },
|
||||
symbol: 'none',
|
||||
smooth: true,
|
||||
z: 5,
|
||||
},
|
||||
{
|
||||
name: 'p90',
|
||||
type: 'line',
|
||||
data: p90,
|
||||
lineStyle: { width: 1, color: 'rgba(40, 70, 200, 0.5)', type: 'dashed' },
|
||||
symbol: 'none',
|
||||
smooth: true,
|
||||
z: 5,
|
||||
},
|
||||
];
|
||||
|
||||
if (showWithdrawal) {
|
||||
series.push({
|
||||
name: 'median withdrawal',
|
||||
type: 'line',
|
||||
yAxisIndex: 1,
|
||||
data: wd,
|
||||
lineStyle: { width: 1, color: 'rgb(190, 90, 60)' },
|
||||
symbol: 'none',
|
||||
smooth: true,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
grid: { left: 60, right: showWithdrawal ? 70 : 24, top: 30, bottom: 40 },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: (params) => {
|
||||
const arr = Array.isArray(params) ? params : [params];
|
||||
const year = arr[0]?.name ?? '';
|
||||
const idx = years.indexOf(Number(year));
|
||||
if (idx < 0) return '';
|
||||
return [
|
||||
`<b>Year ${year}</b>`,
|
||||
`p10: ${gbpCompact(p10[idx]!)}`,
|
||||
`p25: ${gbpCompact(p25[idx]!)}`,
|
||||
`<b>p50: ${gbpCompact(p50[idx]!)}</b>`,
|
||||
`p75: ${gbpCompact(p75[idx]!)}`,
|
||||
`p90: ${gbpCompact(p90[idx]!)}`,
|
||||
showWithdrawal ? `withdrawal: ${gbpCompact(wd[idx]!)}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('<br/>');
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
bottom: 0,
|
||||
data: [
|
||||
'median',
|
||||
'p10',
|
||||
'p90',
|
||||
'p25–p75 (IQR)',
|
||||
...(showWithdrawal ? ['median withdrawal'] : []),
|
||||
],
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: years,
|
||||
name: 'years from now',
|
||||
nameLocation: 'middle',
|
||||
nameGap: 24,
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: 'portfolio (real GBP)',
|
||||
nameLocation: 'middle',
|
||||
nameGap: 50,
|
||||
axisLabel: { formatter: (v: number) => gbpCompact(v) },
|
||||
},
|
||||
...(showWithdrawal
|
||||
? [
|
||||
{
|
||||
type: 'value' as const,
|
||||
name: 'withdrawal',
|
||||
nameLocation: 'middle' as const,
|
||||
nameGap: 50,
|
||||
axisLabel: { formatter: (v: number) => gbpCompact(v) },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
series,
|
||||
};
|
||||
}
|
||||
|
||||
function num(s: string): number {
|
||||
return Number(s);
|
||||
}
|
||||
281
frontend/src/pages/WhatIf.tsx
Normal file
281
frontend/src/pages/WhatIf.tsx
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
/**
|
||||
* What-If — interactive Monte Carlo. Form on the left, fan chart on the
|
||||
* right. Hits POST /simulate (no DB write); ~1-3s for 5k paths.
|
||||
*/
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { api, type SimulateRequest, type SimulateResult } from '@/api/client';
|
||||
import { FanChart } from '@/components/FanChart';
|
||||
import { gbp, pct } from '@/lib/format';
|
||||
|
||||
const JURISDICTIONS = ['uk', 'cyprus', 'bulgaria', 'malaysia', 'thailand', 'uae', 'nomad'];
|
||||
const STRATEGIES = ['trinity', 'guyton_klinger', 'vpw', 'vpw_floor'];
|
||||
const GLIDES = ['rising', 'static_60_40'];
|
||||
|
||||
const DEFAULTS: SimulateRequest = {
|
||||
jurisdiction: 'cyprus',
|
||||
strategy: 'guyton_klinger',
|
||||
leave_uk_year: 2,
|
||||
glide_path: 'rising',
|
||||
spending_gbp: '60000',
|
||||
nw_seed_gbp: '1500000',
|
||||
savings_per_year_gbp: '0',
|
||||
horizon_years: 60,
|
||||
floor_gbp: null,
|
||||
n_paths: 5000,
|
||||
seed: 42,
|
||||
};
|
||||
|
||||
export function WhatIf() {
|
||||
const [form, setForm] = useState<SimulateRequest>(DEFAULTS);
|
||||
const sim = useMutation({
|
||||
mutationFn: (req: SimulateRequest) => api.simulate(req),
|
||||
});
|
||||
|
||||
const onSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
sim.mutate({
|
||||
...form,
|
||||
floor_gbp: form.strategy === 'vpw_floor' ? form.floor_gbp : null,
|
||||
});
|
||||
};
|
||||
|
||||
const update = <K extends keyof SimulateRequest>(key: K, value: SimulateRequest[K]) =>
|
||||
setForm((f) => ({ ...f, [key]: value }));
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<header>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">What if…</h1>
|
||||
<p className="text-sm text-slate-500">
|
||||
Run a single Monte Carlo against the engine. No data persisted.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[320px_1fr] gap-6 items-start">
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="rounded-lg border border-slate-200 bg-white p-5 space-y-4 sticky top-4"
|
||||
>
|
||||
<Field label="Jurisdiction">
|
||||
<Select
|
||||
value={form.jurisdiction}
|
||||
onChange={(v) => update('jurisdiction', v)}
|
||||
options={JURISDICTIONS}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Strategy">
|
||||
<Select
|
||||
value={form.strategy}
|
||||
onChange={(v) => update('strategy', v)}
|
||||
options={STRATEGIES}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Glide path">
|
||||
<Select
|
||||
value={form.glide_path}
|
||||
onChange={(v) => update('glide_path', v)}
|
||||
options={GLIDES}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Years until leaving UK">
|
||||
<NumberInput
|
||||
value={form.leave_uk_year}
|
||||
onChange={(v) => update('leave_uk_year', v)}
|
||||
min={0}
|
||||
max={60}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Annual spending (£)">
|
||||
<NumberInput
|
||||
value={Number(form.spending_gbp)}
|
||||
onChange={(v) => update('spending_gbp', String(v))}
|
||||
min={0}
|
||||
step={1000}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="NW seed (£)">
|
||||
<NumberInput
|
||||
value={Number(form.nw_seed_gbp)}
|
||||
onChange={(v) => update('nw_seed_gbp', String(v))}
|
||||
min={0}
|
||||
step={10000}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Annual savings (£)">
|
||||
<NumberInput
|
||||
value={Number(form.savings_per_year_gbp ?? 0)}
|
||||
onChange={(v) => update('savings_per_year_gbp', String(v))}
|
||||
min={0}
|
||||
step={1000}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Horizon (years)">
|
||||
<NumberInput
|
||||
value={form.horizon_years ?? 60}
|
||||
onChange={(v) => update('horizon_years', v)}
|
||||
min={5}
|
||||
max={100}
|
||||
/>
|
||||
</Field>
|
||||
{form.strategy === 'vpw_floor' && (
|
||||
<Field label="Floor (£/yr)">
|
||||
<NumberInput
|
||||
value={Number(form.floor_gbp ?? 40000)}
|
||||
onChange={(v) => update('floor_gbp', String(v))}
|
||||
min={0}
|
||||
step={1000}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<Field label="Monte Carlo paths">
|
||||
<NumberInput
|
||||
value={form.n_paths ?? 5000}
|
||||
onChange={(v) => update('n_paths', v)}
|
||||
min={100}
|
||||
max={50000}
|
||||
step={500}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={sim.isPending}
|
||||
className="w-full rounded-md bg-slate-900 text-white text-sm font-medium px-4 py-2 hover:bg-slate-800 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{sim.isPending ? 'Running…' : 'Run simulation'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="space-y-4 min-w-0">
|
||||
{sim.isError && (
|
||||
<div className="rounded-md border border-red-200 bg-red-50 p-4 text-red-800 text-sm">
|
||||
{String((sim.error as Error)?.message ?? sim.error)}
|
||||
</div>
|
||||
)}
|
||||
{!sim.data && !sim.isPending && !sim.isError && (
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-12 text-center text-slate-500">
|
||||
Set parameters on the left and run a simulation.
|
||||
</div>
|
||||
)}
|
||||
{sim.isPending && (
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-12 text-center text-slate-500">
|
||||
Running Monte Carlo…
|
||||
</div>
|
||||
)}
|
||||
{sim.data && <Results result={sim.data} horizon={form.horizon_years ?? 60} />}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Results({ result, horizon }: { result: SimulateResult; horizon: number }) {
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Stat label="Success rate" value={pct(result.success_rate)} accent />
|
||||
<Stat label="Median ending NW" value={gbp(result.p50_ending_gbp)} />
|
||||
<Stat label="P10 ending" value={gbp(result.p10_ending_gbp)} />
|
||||
<Stat label="P90 ending" value={gbp(result.p90_ending_gbp)} />
|
||||
<Stat label="Median lifetime tax" value={gbp(result.median_lifetime_tax_gbp)} />
|
||||
<Stat
|
||||
label="Median years to ruin"
|
||||
value={result.median_years_to_ruin ?? 'never'}
|
||||
/>
|
||||
<Stat label="Horizon" value={`${horizon} years`} />
|
||||
<Stat label="Engine time" value={`${result.elapsed_seconds}s`} />
|
||||
</div>
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-5">
|
||||
<h2 className="text-lg font-semibold mb-2">Portfolio fan</h2>
|
||||
<FanChart yearly={result.yearly} height={420} showWithdrawal />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({
|
||||
label,
|
||||
value,
|
||||
accent,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
accent?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border border-slate-200 bg-white p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-slate-500">{label}</div>
|
||||
<div
|
||||
className={`text-xl font-semibold tabular-nums mt-1 ${
|
||||
accent ? 'text-emerald-700' : ''
|
||||
}`}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="text-xs uppercase tracking-wide text-slate-500">{label}</span>
|
||||
<div className="mt-1">{children}</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function Select({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
options: string[];
|
||||
}) {
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400"
|
||||
>
|
||||
{options.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{o}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberInput({
|
||||
value,
|
||||
onChange,
|
||||
min,
|
||||
max,
|
||||
step = 1,
|
||||
}: {
|
||||
value: number;
|
||||
onChange: (v: number) => void;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
onChange={(e) => {
|
||||
const n = Number(e.target.value);
|
||||
if (Number.isFinite(n)) onChange(n);
|
||||
}}
|
||||
className="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm tabular-nums focus:outline-none focus:ring-2 focus:ring-slate-400"
|
||||
/>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue