added api logic, vitest, minimal testing ui
This commit is contained in:
31
web/src/components/CurrencyInput.tsx
Normal file
31
web/src/components/CurrencyInput.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from "react";
|
||||
|
||||
export default function CurrencyInput({
|
||||
value,
|
||||
onValue,
|
||||
placeholder = "0.00",
|
||||
}: {
|
||||
value: string;
|
||||
onValue: (v: string) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
function onChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const raw = e.target.value.replace(/[^0-9.]/g, "");
|
||||
// Keep only first dot, max 2 decimals
|
||||
const parts = raw.split(".");
|
||||
const cleaned =
|
||||
parts.length === 1
|
||||
? parts[0]
|
||||
: `${parts[0]}.${parts.slice(1).join("").slice(0, 2)}`;
|
||||
onValue(cleaned);
|
||||
}
|
||||
return (
|
||||
<input
|
||||
className="input"
|
||||
inputMode="decimal"
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
31
web/src/components/Pagination.tsx
Normal file
31
web/src/components/Pagination.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from "react";
|
||||
|
||||
export default function Pagination({
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
onPage,
|
||||
}: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
onPage: (p: number) => void;
|
||||
}) {
|
||||
const pages = Math.max(1, Math.ceil(total / Math.max(1, limit)));
|
||||
const prev = () => onPage(Math.max(1, page - 1));
|
||||
const next = () => onPage(Math.min(pages, page + 1));
|
||||
|
||||
return (
|
||||
<div className="row items-center mt-3">
|
||||
<button className="btn" onClick={prev} disabled={page <= 1}>
|
||||
← Prev
|
||||
</button>
|
||||
<div className="mx-3 text-sm">
|
||||
Page <strong>{page}</strong> of <strong>{pages}</strong> • {total} total
|
||||
</div>
|
||||
<button className="btn" onClick={next} disabled={page >= pages}>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
web/src/components/PercentGuard.tsx
Normal file
17
web/src/components/PercentGuard.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from "react";
|
||||
import { useDashboard } from "../hooks/useDashboard";
|
||||
|
||||
export default function PercentGuard() {
|
||||
const dash = useDashboard();
|
||||
const total = dash.data?.percentTotal ?? 0;
|
||||
|
||||
if (dash.isLoading) return null;
|
||||
if (total === 100) return null;
|
||||
|
||||
return (
|
||||
<div className="toast-err">
|
||||
Variable category percents must sum to <strong>100%</strong> (currently {total}%).
|
||||
Adjust them before recording income.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
web/src/components/Skeleton.tsx
Normal file
19
web/src/components/Skeleton.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
export function Skeleton({ className = "" }: { className?: string }) {
|
||||
return <div className={`animate-pulse rounded-[--radius-xl] bg-[color-mix(in_oklab,var(--color-ink) 70%,transparent)] ${className}`} />;
|
||||
}
|
||||
export function KPISkeleton() {
|
||||
return <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Skeleton className="h-24" />
|
||||
<Skeleton className="h-24" />
|
||||
<Skeleton className="h-24" />
|
||||
</div>;
|
||||
}
|
||||
export function ChartSkeleton({ tall=false }: { tall?: boolean }) {
|
||||
return <Skeleton className={tall ? "h-80" : "h-64"} />;
|
||||
}
|
||||
export function TableSkeleton() {
|
||||
return <div className="card">
|
||||
<div className="section-title">Loading…</div>
|
||||
<Skeleton className="h-40" />
|
||||
</div>;
|
||||
}
|
||||
39
web/src/components/Toast.tsx
Normal file
39
web/src/components/Toast.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React, { createContext, useContext, useState, useCallback, type PropsWithChildren } from "react";
|
||||
|
||||
type Toast = { id: string; kind: "ok" | "err"; message: string };
|
||||
type Ctx = { push: (kind: Toast["kind"], message: string) => void };
|
||||
|
||||
const ToastCtx = createContext<Ctx>({ push: () => {} });
|
||||
|
||||
export function ToastProvider({ children }: PropsWithChildren<{}>) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const push = useCallback((kind: Toast["kind"], message: string) => {
|
||||
const id = Math.random().toString(36).slice(2);
|
||||
setToasts((t) => [...t, { id, kind, message }]);
|
||||
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 3500);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastCtx.Provider value={{ push }}>
|
||||
{children}
|
||||
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 space-y-2 z-50">
|
||||
{toasts.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={
|
||||
"px-4 py-2 rounded-xl shadow " +
|
||||
(t.kind === "ok" ? "bg-green-600 text-white" : "bg-red-600 text-white")
|
||||
}
|
||||
>
|
||||
{t.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
return useContext(ToastCtx);
|
||||
}
|
||||
25
web/src/components/UserSwitcher.tsx
Normal file
25
web/src/components/UserSwitcher.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { setUserId, getUserId } from "../api/client";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function UserSwitcher() {
|
||||
const qc = useQueryClient();
|
||||
const [val, setVal] = useState(getUserId());
|
||||
const apply = () => {
|
||||
setUserId(val);
|
||||
qc.invalidateQueries(); // why: reload all data for new tenant
|
||||
};
|
||||
return (
|
||||
<div className="row">
|
||||
<input
|
||||
className="input w-20"
|
||||
type="number"
|
||||
min={1}
|
||||
value={val}
|
||||
onChange={(e) => setVal(e.target.value)}
|
||||
title="Dev User Id"
|
||||
/>
|
||||
<button className="btn" type="button" onClick={apply}>Use</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
web/src/components/charts/FixedFundingBars.tsx
Normal file
32
web/src/components/charts/FixedFundingBars.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend } from "recharts";
|
||||
|
||||
export type FixedItem = { name: string; funded: number; remaining: number };
|
||||
|
||||
export default function FixedFundingBars({ data }: { data: FixedItem[] }) {
|
||||
if (!data.length) {
|
||||
return (
|
||||
<div className="card">
|
||||
<h3 className="text-sm mb-2" style={{ color: "var(--color-sage)" }}>Fixed Plan Funding</h3>
|
||||
<div className="muted text-sm">No fixed plans yet.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="card">
|
||||
<h3 className="text-sm mb-2" style={{ color: "var(--color-sage)" }}>Fixed Plan Funding</h3>
|
||||
<div className="chart-lg">
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={data} stackOffset="expand">
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" />
|
||||
<XAxis dataKey="name" stroke="#94a3b8" />
|
||||
<YAxis tickFormatter={(v) => `${Math.round(Number(v) * 100)}%`} stroke="#94a3b8" />
|
||||
<Tooltip formatter={(v: number) => `${Math.round(Number(v) * 100)}%`} contentStyle={{ background: "#111827", border: "1px solid #111827", borderRadius: 12, color: "#E7E3D7" }} />
|
||||
<Legend />
|
||||
<Bar dataKey="funded" stackId="a" fill="#165F46" name="Funded" />
|
||||
<Bar dataKey="remaining" stackId="a" fill="#374151" name="Remaining" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
web/src/components/charts/VariableAllocationDonut.tsx
Normal file
35
web/src/components/charts/VariableAllocationDonut.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
// web/src/components/charts/VariableAllocationDonut.tsx
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from "recharts";
|
||||
|
||||
export type VariableSlice = { name: string; value: number; isSavings: boolean };
|
||||
|
||||
export default function VariableAllocationDonut({ data }: { data: VariableSlice[] }) {
|
||||
const total = data.reduce((s, d) => s + d.value, 0);
|
||||
if (!data.length || total === 0) {
|
||||
return (
|
||||
<div className="card">
|
||||
<h3 className="text-sm mb-2" style={{ color: "var(--color-sage)" }}>Variable Allocation</h3>
|
||||
<div className="muted text-sm">No categories configured yet.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fillFor = (s: boolean) => (s ? "#165F46" : "#374151");
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h3 className="text-sm mb-2" style={{ color: "var(--color-sage)" }}>Variable Allocation</h3>
|
||||
<div className="chart-md">
|
||||
<ResponsiveContainer>
|
||||
<PieChart>
|
||||
<Pie data={data} dataKey="value" nameKey="name" innerRadius={70} outerRadius={100} stroke="#0B1020" strokeWidth={2}>
|
||||
{data.map((d, i) => <Cell key={i} fill={fillFor(d.isSavings)} />)}
|
||||
</Pie>
|
||||
<Tooltip formatter={(v: number) => `${v}%`} contentStyle={{ background: "#111827", border: "1px solid #111827", borderRadius: 12, color: "#E7E3D7" }} />
|
||||
<Legend verticalAlign="bottom" height={24} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
web/src/components/ui.tsx
Normal file
36
web/src/components/ui.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React, { type PropsWithChildren } from "react";
|
||||
import { fmtMoney } from "../utils/money";
|
||||
|
||||
export function Money({ cents }: { cents: number }) {
|
||||
return <span className="font-mono">{fmtMoney(cents)}</span>;
|
||||
}
|
||||
|
||||
export function Field({
|
||||
label,
|
||||
children,
|
||||
}: PropsWithChildren<{ label: string }>) {
|
||||
return (
|
||||
<label className="stack">
|
||||
<span className="text-sm muted">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
disabled,
|
||||
onClick,
|
||||
type = "submit",
|
||||
}: PropsWithChildren<{ disabled?: boolean; onClick?: () => void; type?: "button" | "submit" }>) {
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className="btn"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user