added api logic, vitest, minimal testing ui

This commit is contained in:
2025-11-15 23:26:57 -06:00
parent f4160b91db
commit 4eae966f96
95 changed files with 14155 additions and 469 deletions

View 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}
/>
);
}

View 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>
);
}

View 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>
);
}

View 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>;
}

View 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);
}

View 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>
);
}

View 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>
);
}

View 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
View 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>
);
}