added api logic, vitest, minimal testing ui
This commit is contained in:
2
web/.env.development
Normal file
2
web/.env.development
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_API_URL=http://localhost:8080/api
|
||||
VITE_APP_NAME=SkyMoney
|
||||
24
web/.gitignore
vendored
Normal file
24
web/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
73
web/README.md
Normal file
73
web/README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
23
web/eslint.config.js
Normal file
23
web/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
web/index.html
Normal file
13
web/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>web</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
5859
web/package-lock.json
generated
Normal file
5859
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
web/package.json
Normal file
40
web/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest --run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@tanstack/react-query": "^5.90.9",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.9.6",
|
||||
"recharts": "^3.4.1",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@tanstack/react-query-devtools": "^5.90.2",
|
||||
"@types/node": "^20.12.12",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@vitejs/plugin-react": "^5.1.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.3",
|
||||
"vite": "^7.2.2",
|
||||
"vitest": "^2.1.3"
|
||||
}
|
||||
}
|
||||
42
web/src/App.css
Normal file
42
web/src/App.css
Normal file
@@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
63
web/src/App.tsx
Normal file
63
web/src/App.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Link, NavLink, Route, Routes } from "react-router-dom";
|
||||
import DashboardPage from "./pages/DashboardPage";
|
||||
import IncomePage from "./pages/IncomePage";
|
||||
import TransactionsPage from "./pages/TransactionsPage";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[--color-bg] text-[--color-fg]">
|
||||
<TopNav />
|
||||
<main className="container">
|
||||
<Routes>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/income" element={<IncomePage />} />
|
||||
<Route path="/transactions" element={<TransactionsPage />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</main>
|
||||
<footer className="container py-8 text-center text-sm muted">
|
||||
SkyMoney • {new Date().getFullYear()}
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TopNav() {
|
||||
return (
|
||||
<header className="border-b border-[--color-ink] bg-[--color-panel]">
|
||||
<div className="container h-14 flex items-center gap-4">
|
||||
<Link to="/" className="font-bold">
|
||||
SkyMoney
|
||||
</Link>
|
||||
<Nav to="/">Dashboard</Nav>
|
||||
<Nav to="/income">Income</Nav>
|
||||
<Nav to="/transactions">Transactions</Nav>
|
||||
<div className="ml-auto text-xs muted">demo user</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function Nav({ to, children }: { to: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
"px-3 py-1 rounded-xl hover:bg-[--color-ink]/60 " +
|
||||
(isActive ? "bg-[--color-ink]" : "")
|
||||
}
|
||||
end
|
||||
>
|
||||
{children}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
function NotFound() {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-xl font-bold mb-2">404</h1>
|
||||
<p className="muted">This page got lost in the budget cuts.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
web/src/api/categories.ts
Normal file
30
web/src/api/categories.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { request } from "./client";
|
||||
|
||||
export type NewCategory = {
|
||||
name: string;
|
||||
percent: number; // 0..100
|
||||
isSavings: boolean;
|
||||
priority: number;
|
||||
};
|
||||
export type UpdateCategory = Partial<NewCategory>;
|
||||
|
||||
export const categoriesApi = {
|
||||
create: (body: NewCategory) =>
|
||||
request<{ id: number }>("/variable-categories", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" }
|
||||
}),
|
||||
|
||||
update: (id: number, body: UpdateCategory) =>
|
||||
request(`/variable-categories/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" }
|
||||
}),
|
||||
|
||||
delete: (id: number) =>
|
||||
request(`/variable-categories/${id}`, {
|
||||
method: "DELETE"
|
||||
})
|
||||
};
|
||||
30
web/src/api/client.ts
Normal file
30
web/src/api/client.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export type ApiError = { status: number; message: string };
|
||||
|
||||
const base = "/api";
|
||||
const KEY = "skymoney:userId";
|
||||
|
||||
export function getUserId(): string {
|
||||
let id = localStorage.getItem(KEY);
|
||||
if (!id) { id = "1"; localStorage.setItem(KEY, id); }
|
||||
return id;
|
||||
}
|
||||
export function setUserId(id: string) {
|
||||
localStorage.setItem(KEY, String(id || "1"));
|
||||
}
|
||||
|
||||
export async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${base}${path}`, {
|
||||
headers: { "Content-Type": "application/json", "x-user-id": getUserId(), ...(init?.headers || {}) },
|
||||
...init
|
||||
});
|
||||
const text = await res.text();
|
||||
const data = text ? JSON.parse(text) : null;
|
||||
if (!res.ok) throw { status: res.status, message: data?.message || res.statusText } as ApiError;
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T,>(path: string) => request<T>(path),
|
||||
post: <T,>(path: string, body: unknown) =>
|
||||
request<T>(path, { method: "POST", body: JSON.stringify(body) }),
|
||||
};
|
||||
27
web/src/api/fixedPlans.ts
Normal file
27
web/src/api/fixedPlans.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { request } from "./client";
|
||||
|
||||
export type NewPlan = {
|
||||
name: string;
|
||||
totalCents: number; // >= 0
|
||||
fundedCents?: number; // optional, default 0
|
||||
priority: number; // int
|
||||
dueOn: string; // ISO date
|
||||
};
|
||||
export type UpdatePlan = Partial<NewPlan>;
|
||||
|
||||
export const fixedPlansApi = {
|
||||
create: (body: NewPlan) =>
|
||||
request<{ id: number }>("/fixed-plans", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
update: (id: number, body: UpdatePlan) =>
|
||||
request(`/fixed-plans/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
delete: (id: number) =>
|
||||
request(`/fixed-plans/${id}`, { method: "DELETE" }),
|
||||
};
|
||||
65
web/src/api/http.ts
Normal file
65
web/src/api/http.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
// Lightweight fetch wrappers with sensible defaults
|
||||
|
||||
const BASE =
|
||||
(typeof import.meta !== "undefined" && import.meta.env && import.meta.env.VITE_API_URL) ||
|
||||
""; // e.g. "http://localhost:8080" or proxy
|
||||
|
||||
type FetchOpts = {
|
||||
method?: "GET" | "POST" | "PATCH" | "DELETE";
|
||||
headers?: Record<string, string>;
|
||||
body?: any;
|
||||
query?: Record<string, string | number | boolean | undefined>;
|
||||
};
|
||||
|
||||
function toQS(q?: FetchOpts["query"]) {
|
||||
if (!q) return "";
|
||||
const sp = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(q)) {
|
||||
if (v === undefined || v === null || v === "") continue;
|
||||
sp.set(k, String(v));
|
||||
}
|
||||
const s = sp.toString();
|
||||
return s ? `?${s}` : "";
|
||||
}
|
||||
|
||||
async function http<T>(path: string, opts: FetchOpts = {}): Promise<T> {
|
||||
const { method = "GET", headers = {}, body, query } = opts;
|
||||
const url = `${BASE}${path}${toQS(query)}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
// The API defaults x-user-id if missing; add your own if you want:
|
||||
// "x-user-id": localStorage.getItem("userId") ?? "demo-user-1",
|
||||
...headers,
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
// Try to parse JSON either way
|
||||
const text = await res.text();
|
||||
const json = text ? JSON.parse(text) : null;
|
||||
|
||||
if (!res.ok) {
|
||||
const msg =
|
||||
(json && (json.message || json.error)) ||
|
||||
`${res.status} ${res.statusText}` ||
|
||||
"Request failed";
|
||||
throw new Error(msg);
|
||||
}
|
||||
return json as T;
|
||||
}
|
||||
|
||||
export async function apiGet<T>(path: string, query?: FetchOpts["query"]) {
|
||||
return http<T>(path, { method: "GET", query });
|
||||
}
|
||||
export async function apiPost<T>(path: string, body?: any) {
|
||||
return http<T>(path, { method: "POST", body });
|
||||
}
|
||||
export async function apiPatch<T>(path: string, body?: any) {
|
||||
return http<T>(path, { method: "PATCH", body });
|
||||
}
|
||||
export async function apiDelete<T>(path: string) {
|
||||
return http<T>(path, { method: "DELETE" });
|
||||
}
|
||||
67
web/src/api/schemas.ts
Normal file
67
web/src/api/schemas.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const money = z.number().int().nonnegative();
|
||||
|
||||
export const VariableCategory = z.object({
|
||||
id: z.number().int(),
|
||||
name: z.string(),
|
||||
percent: z.number().int(),
|
||||
isSavings: z.boolean(),
|
||||
priority: z.number().int(),
|
||||
balanceCents: money
|
||||
}).passthrough();
|
||||
|
||||
export const FixedPlan = z.object({
|
||||
id: z.number().int(),
|
||||
name: z.string(),
|
||||
totalCents: money,
|
||||
fundedCents: money,
|
||||
priority: z.number().int(),
|
||||
dueOn: z.string() // ISO
|
||||
}).passthrough();
|
||||
|
||||
export const Transaction = z.object({
|
||||
id: z.number().int(),
|
||||
kind: z.enum(["variable_spend", "fixed_payment"]).or(z.string()),
|
||||
amountCents: money,
|
||||
occurredAt: z.string()
|
||||
}).passthrough();
|
||||
|
||||
export const Dashboard = z.object({
|
||||
totals: z.object({
|
||||
incomeCents: money,
|
||||
variableBalanceCents: money,
|
||||
fixedRemainingCents: money
|
||||
}).passthrough(),
|
||||
variableCategories: z.array(VariableCategory),
|
||||
fixedPlans: z.array(FixedPlan),
|
||||
recentTransactions: z.array(Transaction)
|
||||
}).passthrough();
|
||||
|
||||
export const IncomeResult = z.object({
|
||||
incomeEventId: z.number().int(),
|
||||
fixedAllocations: z.array(z.object({
|
||||
id: z.number().int(),
|
||||
amountCents: money,
|
||||
fixedPlanId: z.number().int().nullable()
|
||||
}).passthrough()),
|
||||
variableAllocations: z.array(z.object({
|
||||
id: z.number().int(),
|
||||
amountCents: money,
|
||||
variableCategoryId: z.number().int().nullable()
|
||||
}).passthrough()),
|
||||
remainingUnallocatedCents: money
|
||||
}).passthrough();
|
||||
|
||||
export const TransactionsList = z.object({
|
||||
items: z.array(z.object({
|
||||
id: z.number().int(),
|
||||
kind: z.enum(["variable_spend", "fixed_payment"]).or(z.string()),
|
||||
amountCents: z.number().int().nonnegative(),
|
||||
occurredAt: z.string()
|
||||
}).passthrough()),
|
||||
page: z.number().int().nonnegative(),
|
||||
limit: z.number().int().positive(),
|
||||
total: z.number().int().nonnegative()
|
||||
}).passthrough();
|
||||
export type TransactionsListT = z.infer<typeof TransactionsList>;
|
||||
20
web/src/api/transactions.ts
Normal file
20
web/src/api/transactions.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { request } from "./client";
|
||||
import { TransactionsList, type TransactionsListT } from "./schemas";
|
||||
|
||||
export type TxQuery = {
|
||||
from?: string; // YYYY-MM-DD
|
||||
to?: string; // YYYY-MM-DD
|
||||
kind?: "variable_spend" | "fixed_payment";
|
||||
q?: string;
|
||||
page?: number; // 1-based
|
||||
limit?: number; // default 20
|
||||
};
|
||||
|
||||
export async function listTransactions(params: TxQuery): Promise<TransactionsListT> {
|
||||
const u = new URL("/api/transactions", location.origin);
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v !== undefined && v !== "") u.searchParams.set(k, String(v));
|
||||
}
|
||||
const data = await request<unknown>(u.pathname + "?" + u.searchParams.toString());
|
||||
return TransactionsList.parse(data);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
78
web/src/hooks/useCategories.ts
Normal file
78
web/src/hooks/useCategories.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { categoriesApi, type NewCategory, type UpdateCategory } from "../api/categories";
|
||||
|
||||
const DASHBOARD_KEY = ["dashboard"];
|
||||
|
||||
export function useCategories() {
|
||||
const qc = useQueryClient();
|
||||
const snap = qc.getQueryData<any>(DASHBOARD_KEY);
|
||||
return snap?.variableCategories ?? [];
|
||||
}
|
||||
|
||||
export function useCreateCategory() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: NewCategory) => categoriesApi.create(body),
|
||||
onMutate: async (vars) => {
|
||||
await qc.cancelQueries({ queryKey: DASHBOARD_KEY });
|
||||
const prev = qc.getQueryData<any>(DASHBOARD_KEY);
|
||||
if (prev) {
|
||||
const optimistic = {
|
||||
...prev,
|
||||
variableCategories: [
|
||||
...prev.variableCategories,
|
||||
{ id: -Math.floor(Math.random() * 1e9), balanceCents: 0, ...vars }
|
||||
]
|
||||
};
|
||||
qc.setQueryData(DASHBOARD_KEY, optimistic);
|
||||
}
|
||||
return { prev };
|
||||
},
|
||||
onError: (_err, _vars, ctx) => ctx?.prev && qc.setQueryData(DASHBOARD_KEY, ctx.prev),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: DASHBOARD_KEY })
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateCategory() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, body }: { id: number; body: UpdateCategory }) => categoriesApi.update(id, body),
|
||||
onMutate: async ({ id, body }) => {
|
||||
await qc.cancelQueries({ queryKey: DASHBOARD_KEY });
|
||||
const prev = qc.getQueryData<any>(DASHBOARD_KEY);
|
||||
if (prev) {
|
||||
const optimistic = {
|
||||
...prev,
|
||||
variableCategories: prev.variableCategories.map((c: any) =>
|
||||
c.id === id ? { ...c, ...body } : c
|
||||
)
|
||||
};
|
||||
qc.setQueryData(DASHBOARD_KEY, optimistic);
|
||||
}
|
||||
return { prev };
|
||||
},
|
||||
onError: (_e, _v, ctx) => ctx?.prev && qc.setQueryData(DASHBOARD_KEY, ctx.prev),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: DASHBOARD_KEY })
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteCategory() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => categoriesApi.delete(id),
|
||||
onMutate: async (id) => {
|
||||
await qc.cancelQueries({ queryKey: DASHBOARD_KEY });
|
||||
const prev = qc.getQueryData<any>(DASHBOARD_KEY);
|
||||
if (prev) {
|
||||
const optimistic = {
|
||||
...prev,
|
||||
variableCategories: prev.variableCategories.filter((c: any) => c.id !== id)
|
||||
};
|
||||
qc.setQueryData(DASHBOARD_KEY, optimistic);
|
||||
}
|
||||
return { prev };
|
||||
},
|
||||
onError: (_e, _v, ctx) => ctx?.prev && qc.setQueryData(DASHBOARD_KEY, ctx.prev),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: DASHBOARD_KEY })
|
||||
});
|
||||
}
|
||||
47
web/src/hooks/useDashboard.ts
Normal file
47
web/src/hooks/useDashboard.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiGet } from "../api/http";
|
||||
|
||||
export type VariableCategory = {
|
||||
id: string;
|
||||
name: string;
|
||||
percent: number;
|
||||
priority: number;
|
||||
isSavings: boolean;
|
||||
balanceCents?: number;
|
||||
};
|
||||
|
||||
export type FixedPlan = {
|
||||
id: string;
|
||||
name: string;
|
||||
priority: number;
|
||||
totalCents?: number;
|
||||
fundedCents?: number;
|
||||
dueOn: string;
|
||||
};
|
||||
|
||||
export type Tx = {
|
||||
id: string;
|
||||
kind: "variable_spend" | "fixed_payment";
|
||||
amountCents: number;
|
||||
occurredAt: string;
|
||||
};
|
||||
|
||||
export type DashboardResponse = {
|
||||
totals: {
|
||||
incomeCents: number;
|
||||
variableBalanceCents: number;
|
||||
fixedRemainingCents: number;
|
||||
};
|
||||
percentTotal: number;
|
||||
variableCategories: VariableCategory[];
|
||||
fixedPlans: FixedPlan[];
|
||||
recentTransactions: Tx[];
|
||||
};
|
||||
|
||||
export function useDashboard() {
|
||||
return useQuery({
|
||||
queryKey: ["dashboard"],
|
||||
queryFn: () => apiGet<DashboardResponse>("/dashboard"),
|
||||
staleTime: 10_000,
|
||||
});
|
||||
}
|
||||
86
web/src/hooks/useFixedPlans.ts
Normal file
86
web/src/hooks/useFixedPlans.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { fixedPlansApi, type NewPlan, type UpdatePlan } from "../api/fixedPlans";
|
||||
|
||||
const DASH = ["dashboard"];
|
||||
|
||||
export function useCreatePlan() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: NewPlan) => fixedPlansApi.create(body),
|
||||
onMutate: async (vars) => {
|
||||
await qc.cancelQueries({ queryKey: DASH });
|
||||
const prev = qc.getQueryData<any>(DASH);
|
||||
if (prev) {
|
||||
const optimistic = {
|
||||
...prev,
|
||||
fixedPlans: [
|
||||
...prev.fixedPlans,
|
||||
{
|
||||
id: -Math.floor(Math.random() * 1e9),
|
||||
fundedCents: Math.min(vars.fundedCents ?? 0, vars.totalCents),
|
||||
...vars,
|
||||
},
|
||||
],
|
||||
};
|
||||
qc.setQueryData(DASH, optimistic);
|
||||
}
|
||||
return { prev };
|
||||
},
|
||||
onError: (_e, _v, ctx) => ctx?.prev && qc.setQueryData(DASH, ctx.prev),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: DASH }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdatePlan() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, body }: { id: number; body: UpdatePlan }) =>
|
||||
fixedPlansApi.update(id, body),
|
||||
onMutate: async ({ id, body }) => {
|
||||
await qc.cancelQueries({ queryKey: DASH });
|
||||
const prev = qc.getQueryData<any>(DASH);
|
||||
if (prev) {
|
||||
const optimistic = {
|
||||
...prev,
|
||||
fixedPlans: prev.fixedPlans.map((p: any) =>
|
||||
p.id === id
|
||||
? {
|
||||
...p,
|
||||
...body,
|
||||
fundedCents: Math.min(
|
||||
"fundedCents" in (body || {}) ? (body as any).fundedCents ?? p.fundedCents : p.fundedCents,
|
||||
"totalCents" in (body || {}) ? (body as any).totalCents ?? p.totalCents : p.totalCents
|
||||
),
|
||||
}
|
||||
: p
|
||||
),
|
||||
};
|
||||
qc.setQueryData(DASH, optimistic);
|
||||
}
|
||||
return { prev };
|
||||
},
|
||||
onError: (_e, _v, ctx) => ctx?.prev && qc.setQueryData(DASH, ctx.prev),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: DASH }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeletePlan() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => fixedPlansApi.delete(id),
|
||||
onMutate: async (id) => {
|
||||
await qc.cancelQueries({ queryKey: DASH });
|
||||
const prev = qc.getQueryData<any>(DASH);
|
||||
if (prev) {
|
||||
const optimistic = {
|
||||
...prev,
|
||||
fixedPlans: prev.fixedPlans.filter((p: any) => p.id !== id),
|
||||
};
|
||||
qc.setQueryData(DASH, optimistic);
|
||||
}
|
||||
return { prev };
|
||||
},
|
||||
onError: (_e, _v, ctx) => ctx?.prev && qc.setQueryData(DASH, ctx.prev),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: DASH }),
|
||||
});
|
||||
}
|
||||
15
web/src/hooks/useIncome.ts
Normal file
15
web/src/hooks/useIncome.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { apiPost } from "../api/http";
|
||||
|
||||
export type IncomeResult = {
|
||||
fixedAllocations: Array<{ fixedPlanId: string; amountCents: number }>;
|
||||
variableAllocations: Array<{ variableCategoryId: string; amountCents: number }>;
|
||||
remainingUnallocatedCents: number;
|
||||
};
|
||||
|
||||
export function useCreateIncome() {
|
||||
return useMutation({
|
||||
mutationFn: (body: { amountCents: number }) =>
|
||||
apiPost<IncomeResult>("/income", body),
|
||||
});
|
||||
}
|
||||
18
web/src/hooks/useIncomePreview.ts
Normal file
18
web/src/hooks/useIncomePreview.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiPost } from "../api/http";
|
||||
|
||||
export type PreviewAlloc = { id: string; name: string; amountCents: number };
|
||||
export type IncomePreview = {
|
||||
fixed: PreviewAlloc[];
|
||||
variable: PreviewAlloc[];
|
||||
unallocatedCents: number;
|
||||
};
|
||||
|
||||
export function useIncomePreview(amountCents: number) {
|
||||
return useQuery({
|
||||
queryKey: ["income-preview", amountCents],
|
||||
enabled: amountCents > 0,
|
||||
queryFn: () =>
|
||||
apiPost<IncomePreview>("/income/preview", { amountCents }),
|
||||
});
|
||||
}
|
||||
28
web/src/hooks/useTransactions.ts
Normal file
28
web/src/hooks/useTransactions.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// web/src/hooks/useTransaction.ts
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { z } from "zod";
|
||||
import { api } from "../api/client";
|
||||
|
||||
const Tx = z.object({
|
||||
kind: z.enum(["variable_spend", "fixed_payment"]),
|
||||
amountCents: z.number().int().positive(),
|
||||
occurredAtISO: z.string().datetime(),
|
||||
variableCategoryId: z.number().int().optional(),
|
||||
fixedPlanId: z.number().int().optional()
|
||||
}).superRefine((v, ctx) => {
|
||||
const isVar = v.kind === "variable_spend";
|
||||
if (isVar && !v.variableCategoryId) ctx.addIssue({ code: "custom", message: "variableCategoryId required" });
|
||||
if (!isVar && !v.fixedPlanId) ctx.addIssue({ code: "custom", message: "fixedPlanId required" });
|
||||
});
|
||||
export type TxInput = z.infer<typeof Tx>;
|
||||
|
||||
export function useCreateTransaction() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (input: TxInput) => api.post("/transactions", Tx.parse(input)),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
qc.invalidateQueries({ queryKey: ["transactions"] }); // ensure list refreshes if open
|
||||
}
|
||||
});
|
||||
}
|
||||
33
web/src/hooks/useTransactionsQuery.tsx
Normal file
33
web/src/hooks/useTransactionsQuery.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiGet } from "../api/http";
|
||||
|
||||
export type TxItem = {
|
||||
id: string;
|
||||
kind: "variable_spend" | "fixed_payment";
|
||||
amountCents: number;
|
||||
occurredAt: string;
|
||||
};
|
||||
|
||||
export type TxListResponse = {
|
||||
items: TxItem[];
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type TxQueryParams = {
|
||||
page: number;
|
||||
limit: number;
|
||||
q?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
kind?: "variable_spend" | "fixed_payment";
|
||||
};
|
||||
|
||||
export function useTransactionsQuery(params: TxQueryParams) {
|
||||
return useQuery({
|
||||
queryKey: ["transactions", params],
|
||||
queryFn: () => apiGet<TxListResponse>("/transactions", params),
|
||||
placeholderData: (previousData) => previousData,
|
||||
});
|
||||
}
|
||||
128
web/src/index.css
Normal file
128
web/src/index.css
Normal file
@@ -0,0 +1,128 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
|
||||
@theme {
|
||||
/* Colors */
|
||||
--color-midnight: #0B1020; /* app bg */
|
||||
--color-ink: #111827; /* surfaces/lines */
|
||||
--color-pine: #165F46; /* primary: discipline/growth */
|
||||
--color-sage: #B7CAB6; /* muted text */
|
||||
--color-sand: #E7E3D7; /* body text */
|
||||
--color-amber: #E0B04E; /* positive */
|
||||
--color-rose: #CC4B4B; /* alerts */
|
||||
|
||||
|
||||
--radius-lg: 0.75rem;
|
||||
--radius-xl: 0.9rem;
|
||||
--radius-2xl: 1.2rem;
|
||||
--shadow-soft: 0 6px 24px rgba(0,0,0,.25);
|
||||
|
||||
|
||||
--container-w: 72rem;
|
||||
|
||||
}
|
||||
|
||||
|
||||
html, body, #root { height: 100%; }
|
||||
body {
|
||||
background: var(--color-midnight);
|
||||
color: var(--color-sand);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
/* Accessible focus ring */
|
||||
:focus-visible { outline: 2px solid color-mix(in oklab, var(--color-pine) 80%, white 20%); outline-offset: 2px; }
|
||||
|
||||
/* Motion sensitivity */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* { animation-duration: 0.001ms !important; animation-iteration-count: 1 !important; transition-duration: 0.001ms !important; scroll-behavior: auto !important; }
|
||||
}
|
||||
|
||||
/* Utility tweaks */
|
||||
.mono { font-family: var(--font-mono); }
|
||||
.muted { color: var(--color-sage); }
|
||||
|
||||
/* ==== App Shell ==== */
|
||||
.app { @apply min-h-full grid grid-rows-[auto,1fr]; }
|
||||
|
||||
/* Top nav */
|
||||
.nav {
|
||||
@apply sticky top-0 z-10 backdrop-blur border-b;
|
||||
background: color-mix(in oklab, var(--color-ink) 85%, transparent);
|
||||
border-color: var(--color-ink);
|
||||
}
|
||||
.container { @apply mx-auto p-4 md:p-6; max-width: var(--container-w); }
|
||||
|
||||
/* Links in the header */
|
||||
.link { @apply px-3 py-2 rounded-[--radius-xl] transition; }
|
||||
.link:hover { background: var(--color-ink); }
|
||||
.link-active { background: var(--color-ink); }
|
||||
|
||||
/* ==== Cards / Sections ==== */
|
||||
.card {
|
||||
@apply p-4 border rounded-[--radius-2xl];
|
||||
background: var(--color-ink);
|
||||
border-color: var(--color-ink);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
.section-title { @apply text-lg font-semibold mb-3; }
|
||||
|
||||
/* KPI tile */
|
||||
.kpi { @apply flex flex-col gap-1; } /* no 'card' here */
|
||||
.kpi h3 { @apply text-sm; color: var(--color-sage); }
|
||||
.kpi .val { @apply text-2xl font-semibold; }
|
||||
/* ==== Buttons ==== */
|
||||
.btn {
|
||||
@apply inline-flex items-center gap-2 px-4 py-2 font-semibold border rounded-[--radius-xl] active:scale-[.99];
|
||||
background: var(--color-pine);
|
||||
color: var(--color-sand);
|
||||
border-color: var(--color-ink);
|
||||
}
|
||||
.btn:disabled { @apply opacity-60 cursor-not-allowed; }
|
||||
|
||||
/* ==== Inputs ==== */
|
||||
.input, select, textarea {
|
||||
@apply w-full px-3 py-2 rounded-[--radius-xl] border outline-none;
|
||||
background: var(--color-midnight);
|
||||
border-color: var(--color-ink);
|
||||
}
|
||||
label.field { @apply grid gap-1 mb-3; }
|
||||
label.field > span { @apply text-sm; color: var(--color-sage); }
|
||||
|
||||
/* ==== Table ==== */
|
||||
.table { @apply w-full border-separate; border-spacing: 0 0.5rem; }
|
||||
.table thead th { @apply text-left text-sm pb-2; color: var(--color-sage); }
|
||||
.table tbody tr { background: var(--color-ink); }
|
||||
.table td, .table th { @apply px-3 py-2; }
|
||||
.table tbody tr > td:first-child { @apply rounded-l-[--radius-xl]; }
|
||||
.table tbody tr > td:last-child { @apply rounded-r-[--radius-xl]; }
|
||||
|
||||
/* ==== Badges / Chips ==== */
|
||||
.badge {
|
||||
@apply inline-flex items-center px-2 py-0.5 text-xs rounded-full border;
|
||||
background: var(--color-ink);
|
||||
border-color: var(--color-ink);
|
||||
}
|
||||
|
||||
/* ==== Simple Toasts ==== */
|
||||
.toast-ok {
|
||||
@apply rounded-[--radius-xl] px-3 py-2 border;
|
||||
background: #0a2917; /* dark green */
|
||||
color: #bbf7d0;
|
||||
border-color: var(--color-pine);
|
||||
}
|
||||
.toast-err {
|
||||
@apply rounded-[--radius-xl] px-3 py-2 border;
|
||||
background: #3f1515;
|
||||
color: #fecaca;
|
||||
border-color: #7f1d1d;
|
||||
}
|
||||
|
||||
/* ==== Layout helpers ==== */
|
||||
.grid-auto { @apply grid gap-4 sm:grid-cols-2 lg:grid-cols-3; }
|
||||
.stack { @apply flex flex-col gap-3; }
|
||||
.row { @apply flex items-center gap-3; }
|
||||
|
||||
/* ==== Chart wrappers (consistent sizing) ==== */
|
||||
.chart-md { @apply h-64; }
|
||||
.chart-lg { @apply h-72; }
|
||||
30
web/src/main.tsx
Normal file
30
web/src/main.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { ToastProvider } from "./components/Toast";
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
const client = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={client}>
|
||||
<BrowserRouter>
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</BrowserRouter>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
148
web/src/pages/DashboardPage.tsx
Normal file
148
web/src/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { apiGet } from "../api/http";
|
||||
import { fmtMoney } from "../utils/money";
|
||||
|
||||
type VariableCategory = {
|
||||
id: string; name: string; percent: number; priority: number;
|
||||
isSavings: boolean; balanceCents?: number;
|
||||
};
|
||||
|
||||
type FixedPlan = {
|
||||
id: string; name: string; priority: number;
|
||||
totalCents?: number; fundedCents?: number; dueOn: string;
|
||||
};
|
||||
|
||||
type Tx = { id: string; kind: "variable_spend"|"fixed_payment"; amountCents: number; occurredAt: string };
|
||||
|
||||
type DashboardResponse = {
|
||||
totals: {
|
||||
incomeCents: number;
|
||||
variableBalanceCents: number;
|
||||
fixedRemainingCents: number;
|
||||
};
|
||||
percentTotal: number;
|
||||
variableCategories: VariableCategory[];
|
||||
fixedPlans: FixedPlan[];
|
||||
recentTransactions: Tx[];
|
||||
};
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [data, setData] = useState<DashboardResponse | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const d = await apiGet<DashboardResponse>("/dashboard");
|
||||
setData(d);
|
||||
} catch (e: any) {
|
||||
setErr(e.message || "Failed to load");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
if (loading) return <div className="p-6">Loading dashboard…</div>;
|
||||
if (err) return (
|
||||
<div className="p-6 text-red-600">
|
||||
{err} <button className="ml-2 underline" onClick={load}>retry</button>
|
||||
</div>
|
||||
);
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-8">
|
||||
<h1 className="text-2xl font-bold">Dashboard</h1>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-3">
|
||||
<Card label="Total Income" value={fmtMoney(data.totals.incomeCents)} />
|
||||
<Card label="Variable Balance" value={fmtMoney(data.totals.variableBalanceCents)} />
|
||||
<Card label="Fixed Remaining" value={fmtMoney(data.totals.fixedRemainingCents)} />
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<h2 className="font-semibold">Variable Categories (sum {data.percentTotal}%)</h2>
|
||||
<div className="border rounded-md divide-y">
|
||||
{data.variableCategories.map(c => (
|
||||
<div key={c.id} className="p-3 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium">{c.name}</div>
|
||||
<div className="text-sm opacity-70">
|
||||
{c.isSavings ? "Savings • " : ""}Priority {c.priority}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-mono">{fmtMoney(c.balanceCents ?? 0)}</div>
|
||||
<div className="text-sm opacity-70">{c.percent}%</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h2 className="font-semibold">Fixed Plans</h2>
|
||||
<div className="border rounded-md divide-y">
|
||||
{data.fixedPlans.map(p => {
|
||||
const total = p.totalCents ?? 0;
|
||||
const funded = p.fundedCents ?? 0;
|
||||
const remaining = Math.max(total - funded, 0);
|
||||
return (
|
||||
<div key={p.id} className="p-3 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium">{p.name}</div>
|
||||
<div className="text-sm opacity-70">
|
||||
Due {new Date(p.dueOn).toLocaleDateString()} • Priority {p.priority}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-mono">{fmtMoney(remaining)}</div>
|
||||
<div className="text-sm opacity-70">remaining</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h2 className="font-semibold">Recent Transactions</h2>
|
||||
<div className="border rounded-md overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="text-left p-2">Date</th>
|
||||
<th className="text-left p-2">Kind</th>
|
||||
<th className="text-right p-2">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.recentTransactions.map(tx => (
|
||||
<tr key={tx.id} className="border-t">
|
||||
<td className="p-2">{new Date(tx.occurredAt).toLocaleString()}</td>
|
||||
<td className="p-2">{tx.kind.replace("_", " ")}</td>
|
||||
<td className="p-2 text-right font-mono">{fmtMoney(tx.amountCents)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-xl border p-4 shadow-sm">
|
||||
<div className="text-sm opacity-70">{label}</div>
|
||||
<div className="text-xl font-bold mt-1">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
web/src/pages/HealthPage.tsx
Normal file
18
web/src/pages/HealthPage.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "../api/client";
|
||||
|
||||
export default function HealthPage() {
|
||||
const app = useQuery({ queryKey: ["health"], queryFn: () => api.get<{ok:true}>("/health") });
|
||||
const db = useQuery({ queryKey: ["health","db"], queryFn: () => api.get<{ok:true; nowISO:string; latencyMs:number}>("/health/db") });
|
||||
|
||||
return (
|
||||
<div className="card max-w-lg">
|
||||
<h2 className="section-title">Health</h2>
|
||||
<ul className="stack">
|
||||
<li>API: {app.isLoading ? "…" : app.data?.ok ? "OK" : "Down"}</li>
|
||||
<li>DB: {db.isLoading ? "…" : db.data?.ok ? `OK (${db.data.latencyMs} ms)` : "Down"}</li>
|
||||
<li>Server Time: {db.data?.nowISO ? new Date(db.data.nowISO).toLocaleString() : "…"}</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
223
web/src/pages/IncomePage.tsx
Normal file
223
web/src/pages/IncomePage.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import { useMemo, useState, type FormEvent } from "react";
|
||||
import { useCreateIncome } from "../hooks/useIncome";
|
||||
import { useDashboard } from "../hooks/useDashboard";
|
||||
import { Money, Field, Button } from "../components/ui";
|
||||
import CurrencyInput from "../components/CurrencyInput";
|
||||
import { previewAllocation } from "../utils/allocatorPreview";
|
||||
import PercentGuard from "../components/PercentGuard";
|
||||
import { useToast } from "../components/Toast";
|
||||
import { useIncomePreview } from "../hooks/useIncomePreview";
|
||||
|
||||
function dollarsToCents(input: string): number {
|
||||
const n = Number.parseFloat(input || "0");
|
||||
if (!Number.isFinite(n)) return 0;
|
||||
return Math.round(n * 100);
|
||||
}
|
||||
|
||||
type Alloc = { id: number | string; amountCents: number; name: string };
|
||||
|
||||
export default function IncomePage() {
|
||||
const [amountStr, setAmountStr] = useState("");
|
||||
const { push } = useToast();
|
||||
const m = useCreateIncome();
|
||||
const dash = useDashboard();
|
||||
|
||||
const cents = dollarsToCents(amountStr);
|
||||
const canSubmit = (dash.data?.percentTotal ?? 0) === 100;
|
||||
|
||||
// Server preview (preferred) with client fallback
|
||||
const srvPreview = useIncomePreview(cents);
|
||||
const preview = useMemo(() => {
|
||||
if (!dash.data || cents <= 0) return null;
|
||||
if (srvPreview.data) return srvPreview.data;
|
||||
// fallback: local simulation
|
||||
return previewAllocation(cents, dash.data.fixedPlans, dash.data.variableCategories);
|
||||
}, [cents, dash.data, srvPreview.data]);
|
||||
|
||||
const submit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (cents <= 0 || !canSubmit) return;
|
||||
m.mutate(
|
||||
{ amountCents: cents },
|
||||
{
|
||||
onSuccess: (res) => {
|
||||
const fixed = (res.fixedAllocations ?? []).reduce(
|
||||
(s: number, a: any) => s + (a.amountCents ?? 0),
|
||||
0
|
||||
);
|
||||
const variable = (res.variableAllocations ?? []).reduce(
|
||||
(s: number, a: any) => s + (a.amountCents ?? 0),
|
||||
0
|
||||
);
|
||||
const unalloc = res.remainingUnallocatedCents ?? 0;
|
||||
push(
|
||||
"ok",
|
||||
`Allocated: Fixed ${(fixed / 100).toFixed(2)} + Variable ${(variable / 100).toFixed(
|
||||
2
|
||||
)}. Unallocated ${(unalloc / 100).toFixed(2)}.`
|
||||
);
|
||||
setAmountStr("");
|
||||
},
|
||||
onError: (err: any) => push("err", err?.message ?? "Income failed"),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const variableAllocations: Alloc[] = useMemo(() => {
|
||||
if (!m.data) return [];
|
||||
const nameById = new Map<string | number, string>(
|
||||
(dash.data?.variableCategories ?? []).map((c) => [c.id as string | number, c.name] as const)
|
||||
);
|
||||
const grouped = new Map<string | number, number>();
|
||||
for (const a of m.data.variableAllocations ?? []) {
|
||||
const id = (a as any).variableCategoryId ?? (a as any).id ?? -1;
|
||||
grouped.set(id, (grouped.get(id) ?? 0) + (a as any).amountCents);
|
||||
}
|
||||
return [...grouped.entries()]
|
||||
.map(([id, amountCents]) => ({ id, amountCents, name: nameById.get(id) ?? `Category #${id}` }))
|
||||
.sort((a, b) => b.amountCents - a.amountCents);
|
||||
}, [m.data, dash.data]);
|
||||
|
||||
const fixedAllocations: Alloc[] = useMemo(() => {
|
||||
if (!m.data) return [];
|
||||
const nameById = new Map<string | number, string>(
|
||||
(dash.data?.fixedPlans ?? []).map((p) => [p.id as string | number, p.name] as const)
|
||||
);
|
||||
const grouped = new Map<string | number, number>();
|
||||
for (const a of m.data.fixedAllocations ?? []) {
|
||||
const id = (a as any).fixedPlanId ?? (a as any).id ?? -1;
|
||||
grouped.set(id, (grouped.get(id) ?? 0) + (a as any).amountCents);
|
||||
}
|
||||
return [...grouped.entries()]
|
||||
.map(([id, amountCents]) => ({ id, amountCents, name: nameById.get(id) ?? `Plan #${id}` }))
|
||||
.sort((a, b) => b.amountCents - a.amountCents);
|
||||
}, [m.data, dash.data]);
|
||||
|
||||
const hasResult = !!m.data;
|
||||
|
||||
return (
|
||||
<div className="stack max-w-lg">
|
||||
<PercentGuard />
|
||||
|
||||
<form onSubmit={submit} className="card">
|
||||
<h2 className="section-title">Record Income</h2>
|
||||
<Field label="Amount (USD)">
|
||||
<CurrencyInput value={amountStr} onValue={setAmountStr} />
|
||||
</Field>
|
||||
|
||||
<Button disabled={m.isPending || cents <= 0 || !canSubmit}>
|
||||
{m.isPending ? "Allocating…" : canSubmit ? "Submit" : "Fix percents to 100%"}
|
||||
</Button>
|
||||
|
||||
{/* Live Preview */}
|
||||
{!hasResult && preview && (
|
||||
<div className="mt-4 stack">
|
||||
<div className="row">
|
||||
<h3 className="text-sm muted">Preview (not yet applied)</h3>
|
||||
<span className="ml-auto text-sm">
|
||||
Unallocated: <Money cents={preview.unallocatedCents} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<section className="border border-[--color-ink] rounded-[--radius-xl] p-3 bg-[--color-ink]">
|
||||
<div className="row mb-2">
|
||||
<h4 className="text-sm muted">Fixed Plans</h4>
|
||||
<span className="ml-auto font-semibold">
|
||||
<Money cents={preview.fixed.reduce((s, x) => s + x.amountCents, 0)} />
|
||||
</span>
|
||||
</div>
|
||||
{preview.fixed.length === 0 ? (
|
||||
<div className="muted text-sm">No fixed allocations.</div>
|
||||
) : (
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
{preview.fixed.map((a) => (
|
||||
<li key={a.id} className="row">
|
||||
<span>{a.name}</span>
|
||||
<span className="ml-auto">
|
||||
<Money cents={a.amountCents} />
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="border border-[--color-ink] rounded-[--radius-xl] p-3 bg-[--color-ink]">
|
||||
<div className="row mb-2">
|
||||
<h4 className="text-sm muted">Variable Categories</h4>
|
||||
<span className="ml-auto font-semibold">
|
||||
<Money cents={preview.variable.reduce((s, x) => s + x.amountCents, 0)} />
|
||||
</span>
|
||||
</div>
|
||||
{preview.variable.length === 0 ? (
|
||||
<div className="muted text-sm">No variable allocations.</div>
|
||||
) : (
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
{preview.variable.map((a) => (
|
||||
<li key={a.id} className="row">
|
||||
<span>{a.name}</span>
|
||||
<span className="ml-auto">
|
||||
<Money cents={a.amountCents} />
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actual Result */}
|
||||
{m.error && <div className="toast-err mt-3">⚠️ {(m.error as any).message}</div>}
|
||||
{hasResult && (
|
||||
<div className="mt-4 stack">
|
||||
<div className="row">
|
||||
<span className="muted text-sm">Unallocated</span>
|
||||
<span className="ml-auto">
|
||||
<Money cents={m.data?.remainingUnallocatedCents ?? 0} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<section className="border border-[--color-ink] rounded-[--radius-xl] p-3 bg-[--color-ink]">
|
||||
<div className="row mb-2">
|
||||
<h3 className="text-sm muted">Fixed Plans (Applied)</h3>
|
||||
<span className="ml-auto font-semibold">
|
||||
<Money cents={fixedAllocations.reduce((s, x) => s + x.amountCents, 0)} />
|
||||
</span>
|
||||
</div>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
{fixedAllocations.map((a) => (
|
||||
<li key={a.id} className="row">
|
||||
<span>{a.name}</span>
|
||||
<span className="ml-auto">
|
||||
<Money cents={a.amountCents} />
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="border border-[--color-ink] rounded-[--radius-xl] p-3 bg-[--color-ink]">
|
||||
<div className="row mb-2">
|
||||
<h3 className="text-sm muted">Variable Categories (Applied)</h3>
|
||||
<span className="ml-auto font-semibold">
|
||||
<Money cents={variableAllocations.reduce((s, x) => s + x.amountCents, 0)} />
|
||||
</span>
|
||||
</div>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
{variableAllocations.map((a) => (
|
||||
<li key={a.id} className="row">
|
||||
<span>{a.name}</span>
|
||||
<span className="ml-auto">
|
||||
<Money cents={a.amountCents} />
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
168
web/src/pages/SpendPage.tsx
Normal file
168
web/src/pages/SpendPage.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { useMemo, useState, type FormEvent, type ChangeEvent } from "react";
|
||||
import { useDashboard } from "../hooks/useDashboard";
|
||||
import { useCreateTransaction } from "../hooks/useTransactions";
|
||||
import { Money, Field, Button } from "../components/ui";
|
||||
import CurrencyInput from "../components/CurrencyInput";
|
||||
import { useToast } from "../components/Toast";
|
||||
import { nowLocalISOStringMinute } from "../utils/format";
|
||||
|
||||
type Kind = "variable_spend" | "fixed_payment";
|
||||
|
||||
function dollarsToCents(input: string): number {
|
||||
const n = Number.parseFloat(input || "0");
|
||||
if (!Number.isFinite(n)) return 0;
|
||||
return Math.round(n * 100);
|
||||
}
|
||||
|
||||
export default function SpendPage() {
|
||||
const dash = useDashboard();
|
||||
const m = useCreateTransaction();
|
||||
const { push } = useToast();
|
||||
|
||||
const [kind, setKind] = useState<Kind>("variable_spend");
|
||||
const [amountStr, setAmountStr] = useState("");
|
||||
const [occurredAt, setOccurredAt] = useState(nowLocalISOStringMinute());
|
||||
const [variableCategoryId, setVariableCategoryId] = useState<string>("");
|
||||
const [fixedPlanId, setFixedPlanId] = useState<string>("");
|
||||
|
||||
const amountCents = dollarsToCents(amountStr);
|
||||
|
||||
// Optional UX lock: block variable spend if chosen category has 0 balance.
|
||||
const selectedCategory = useMemo(() => {
|
||||
if (!dash.data) return null;
|
||||
return dash.data.variableCategories.find(c => String(c.id) === variableCategoryId) ?? null;
|
||||
}, [dash.data, variableCategoryId]);
|
||||
|
||||
const disableIfZeroBalance = true; // flip to false to allow negatives
|
||||
const categoryBlocked =
|
||||
kind === "variable_spend" &&
|
||||
!!selectedCategory &&
|
||||
disableIfZeroBalance &&
|
||||
(selectedCategory.balanceCents ?? 0) <= 0;
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
if (!dash.data) return false;
|
||||
if (amountCents <= 0) return false;
|
||||
if (kind === "variable_spend") return !!variableCategoryId && !categoryBlocked;
|
||||
return !!fixedPlanId; // fixed_payment
|
||||
}, [dash.data, amountCents, kind, variableCategoryId, fixedPlanId, categoryBlocked]);
|
||||
|
||||
const onSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
|
||||
const payload = {
|
||||
kind,
|
||||
amountCents,
|
||||
occurredAtISO: new Date(occurredAt).toISOString(),
|
||||
variableCategoryId: kind === "variable_spend" ? Number(variableCategoryId) : undefined,
|
||||
fixedPlanId: kind === "fixed_payment" ? Number(fixedPlanId) : undefined,
|
||||
} as any;
|
||||
|
||||
m.mutate(payload, {
|
||||
onSuccess: () => {
|
||||
push("ok", kind === "variable_spend" ? "Recorded spend." : "Recorded payment.");
|
||||
setAmountStr("");
|
||||
// Keep date defaulting to “now” for quick entry
|
||||
setOccurredAt(nowLocalISOStringMinute());
|
||||
},
|
||||
onError: (err: any) => push("err", err?.message ?? "Failed to record"),
|
||||
});
|
||||
};
|
||||
|
||||
const cats = dash.data?.variableCategories ?? [];
|
||||
const plans = dash.data?.fixedPlans ?? [];
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 max-w-xl">
|
||||
<form onSubmit={onSubmit} className="card stack">
|
||||
<h2 className="section-title">Spend / Pay</h2>
|
||||
|
||||
{/* Kind toggle */}
|
||||
<div className="row gap-2">
|
||||
<label className="row">
|
||||
<input
|
||||
type="radio"
|
||||
checked={kind === "variable_spend"}
|
||||
onChange={() => setKind("variable_spend")}
|
||||
/>
|
||||
<span className="text-sm">Variable Spend</span>
|
||||
</label>
|
||||
<label className="row">
|
||||
<input
|
||||
type="radio"
|
||||
checked={kind === "fixed_payment"}
|
||||
onChange={() => setKind("fixed_payment")}
|
||||
/>
|
||||
<span className="text-sm">Fixed Payment</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Pick target */}
|
||||
{kind === "variable_spend" ? (
|
||||
<Field label="Category">
|
||||
<select
|
||||
className="input"
|
||||
value={variableCategoryId}
|
||||
onChange={(e: ChangeEvent<HTMLSelectElement>) => setVariableCategoryId(e.target.value)}
|
||||
>
|
||||
<option value="">Select a category…</option>
|
||||
{cats
|
||||
.slice()
|
||||
.sort((a, b) => (a.priority - b.priority) || a.name.localeCompare(b.name))
|
||||
.map(c => (
|
||||
<option key={c.id} value={String(c.id)}>
|
||||
{c.name} — <Money cents={c.balanceCents} />
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
) : (
|
||||
<Field label="Fixed Plan">
|
||||
<select
|
||||
className="input"
|
||||
value={fixedPlanId}
|
||||
onChange={(e: ChangeEvent<HTMLSelectElement>) => setFixedPlanId(e.target.value)}
|
||||
>
|
||||
<option value="">Select a plan…</option>
|
||||
{plans
|
||||
.slice()
|
||||
.sort((a, b) => (a.priority - b.priority) || (new Date(a.dueOn).getTime() - new Date(b.dueOn).getTime()))
|
||||
.map(p => (
|
||||
<option key={p.id} value={String(p.id)}>
|
||||
{p.name} — Funded <Money cents={p.fundedCents} /> / <Money cents={p.totalCents} />
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{/* Amount + Date */}
|
||||
<Field label="Amount (USD)">
|
||||
<CurrencyInput value={amountStr} onValue={setAmountStr} />
|
||||
</Field>
|
||||
<Field label="When">
|
||||
<input
|
||||
className="input"
|
||||
type="datetime-local"
|
||||
value={occurredAt}
|
||||
onChange={(e) => setOccurredAt(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{/* Guard + submit */}
|
||||
{categoryBlocked && (
|
||||
<div className="toast-err">
|
||||
Selected category has no available balance. Add income or pick another category.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button disabled={m.isPending || !canSubmit}>
|
||||
{m.isPending ? "Saving…" : "Submit"}
|
||||
</Button>
|
||||
|
||||
{m.error && <div className="toast-err">⚠️ {(m.error as any).message}</div>}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
183
web/src/pages/TransactionsPage.tsx
Normal file
183
web/src/pages/TransactionsPage.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import { useEffect, useMemo, useState, type ChangeEvent } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useTransactionsQuery } from "../hooks/useTransactionsQuery";
|
||||
import { Money } from "../components/ui";
|
||||
import Pagination from "../components/Pagination";
|
||||
|
||||
type Kind = "all" | "variable_spend" | "fixed_payment";
|
||||
|
||||
function isoDateOnly(d: Date) {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const da = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${da}`;
|
||||
}
|
||||
|
||||
export default function TransactionsPage() {
|
||||
const today = isoDateOnly(new Date());
|
||||
const [sp, setSp] = useSearchParams();
|
||||
|
||||
// init from URL
|
||||
const initKind = (sp.get("kind") as Kind) || "all";
|
||||
const initQ = sp.get("q") || "";
|
||||
const initFrom = sp.get("from") || "";
|
||||
const initTo = sp.get("to") || today;
|
||||
const initPage = Math.max(1, Number(sp.get("page") || 1));
|
||||
|
||||
const [kind, setKind] = useState<Kind>(initKind);
|
||||
const [qRaw, setQRaw] = useState(initQ);
|
||||
const [q, setQ] = useState(initQ.trim());
|
||||
const [from, setFrom] = useState(initFrom);
|
||||
const [to, setTo] = useState(initTo);
|
||||
const [page, setPage] = useState(initPage);
|
||||
const limit = 20;
|
||||
|
||||
// debounce search
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => {
|
||||
setPage(1);
|
||||
setQ(qRaw.trim());
|
||||
}, 250);
|
||||
return () => clearTimeout(id);
|
||||
}, [qRaw]);
|
||||
|
||||
// write to URL on change
|
||||
useEffect(() => {
|
||||
const next = new URLSearchParams();
|
||||
if (kind !== "all") next.set("kind", kind);
|
||||
if (q) next.set("q", q);
|
||||
if (from) next.set("from", from);
|
||||
if (to) next.set("to", to);
|
||||
if (page !== 1) next.set("page", String(page));
|
||||
setSp(next, { replace: true });
|
||||
}, [kind, q, from, to, page, setSp]);
|
||||
|
||||
const params = useMemo(
|
||||
() => ({
|
||||
page,
|
||||
limit,
|
||||
q: q || undefined,
|
||||
from: from || undefined,
|
||||
to: to || undefined,
|
||||
kind: kind === "all" ? undefined : kind,
|
||||
}),
|
||||
[page, limit, q, from, to, kind]
|
||||
);
|
||||
|
||||
const { data, isLoading, error, refetch, isFetching } = useTransactionsQuery(params);
|
||||
|
||||
const clear = () => {
|
||||
setKind("all");
|
||||
setQRaw("");
|
||||
setFrom("");
|
||||
setTo(today);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const rows = data?.items ?? [];
|
||||
const totalAmount = rows.reduce((s, r) => s + (r.amountCents ?? 0), 0);
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<section className="card">
|
||||
<h2 className="section-title">Transactions</h2>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="row gap-2 flex-wrap mb-3">
|
||||
<select
|
||||
className="input w-44"
|
||||
value={kind}
|
||||
onChange={(e: ChangeEvent<HTMLSelectElement>) => {
|
||||
setKind(e.target.value as Kind);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="all">All Types</option>
|
||||
<option value="variable_spend">Variable Spend</option>
|
||||
<option value="fixed_payment">Fixed Payment</option>
|
||||
</select>
|
||||
<input
|
||||
className="input w-56"
|
||||
placeholder="Search…"
|
||||
value={qRaw}
|
||||
onChange={(e) => setQRaw(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input w-40"
|
||||
type="date"
|
||||
value={from}
|
||||
onChange={(e) => {
|
||||
setFrom(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
className="input w-40"
|
||||
type="date"
|
||||
value={to}
|
||||
onChange={(e) => {
|
||||
setTo(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<button type="button" className="btn" onClick={clear}>
|
||||
Clear
|
||||
</button>
|
||||
<div className="badge ml-auto">
|
||||
{isFetching ? "Refreshing…" : `Showing ${rows.length}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* States */}
|
||||
{isLoading && <div className="muted text-sm">Loading…</div>}
|
||||
{error && !isLoading && (
|
||||
<div className="toast-err mb-3">
|
||||
Couldn’t load transactions.{" "}
|
||||
<button className="btn ml-2" onClick={() => refetch()} disabled={isFetching}>
|
||||
{isFetching ? "Retrying…" : "Retry"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
{!isLoading && rows.length === 0 ? (
|
||||
<div className="muted text-sm">No transactions match your filters.</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Amount</th>
|
||||
<th>Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((t) => (
|
||||
<tr key={t.id}>
|
||||
<td className="rounded-l-[--radius-xl] px-3 py-2">{t.kind}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Money cents={t.amountCents} />
|
||||
</td>
|
||||
<td className="rounded-r-[--radius-xl] px-3 py-2">
|
||||
{new Date(t.occurredAt).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="row mt-2">
|
||||
<div className="muted text-sm">Page total</div>
|
||||
<div className="ml-auto font-semibold">
|
||||
<Money cents={totalAmount} />
|
||||
</div>
|
||||
</div>
|
||||
{data && (
|
||||
<Pagination page={data.page} limit={data.limit} total={data.total} onPage={setPage} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
179
web/src/pages/settings/CategoriesPage.tsx
Normal file
179
web/src/pages/settings/CategoriesPage.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { useMemo, useState, type ChangeEvent, type FormEvent } from "react";
|
||||
import { useDashboard } from "../../hooks/useDashboard";
|
||||
import { Money } from "../../components/ui";
|
||||
import SettingsNav from "./_SettingsNav";
|
||||
import { useCategories, useCreateCategory, useUpdateCategory, useDeleteCategory } from "../../hooks/useCategories";
|
||||
import { useToast } from "../../components/Toast";
|
||||
|
||||
type Row = { id: number; name: string; percent: number; priority: number; isSavings: boolean; balanceCents: number };
|
||||
|
||||
function SumBadge({ total }: { total: number }) {
|
||||
const ok = total === 100;
|
||||
return (
|
||||
<div className={`badge ${ok ? "" : ""}`}>
|
||||
Total: {total}%
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsCategoriesPage() {
|
||||
const { data, isLoading, error, refetch, isFetching } = useDashboard();
|
||||
const cats: Row[] = useCategories();
|
||||
const createM = useCreateCategory();
|
||||
const updateM = useUpdateCategory();
|
||||
const deleteM = useDeleteCategory();
|
||||
const { push } = useToast();
|
||||
|
||||
const total = useMemo(() => cats.reduce((s, c) => s + c.percent, 0), [cats]);
|
||||
|
||||
// Add form state
|
||||
const [name, setName] = useState("");
|
||||
const [percent, setPercent] = useState("");
|
||||
const [priority, setPriority] = useState("");
|
||||
const [isSavings, setIsSavings] = useState(false);
|
||||
|
||||
const addDisabled = !name || !percent || !priority || Number(percent) < 0 || Number(percent) > 100;
|
||||
|
||||
const onAdd = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const body = {
|
||||
name: name.trim(),
|
||||
percent: Math.max(0, Math.min(100, Math.floor(Number(percent) || 0))),
|
||||
priority: Math.max(0, Math.floor(Number(priority) || 0)),
|
||||
isSavings
|
||||
};
|
||||
createM.mutate(body, {
|
||||
onSuccess: () => {
|
||||
push("ok", "Category created");
|
||||
setName(""); setPercent(""); setPriority(""); setIsSavings(false);
|
||||
},
|
||||
onError: (err: any) => push("err", err?.message ?? "Create failed")
|
||||
});
|
||||
};
|
||||
|
||||
const onEdit = (id: number, patch: Partial<Row>) => {
|
||||
updateM.mutate({ id, body: patch }, {
|
||||
onError: (err: any) => push("err", err?.message ?? "Update failed")
|
||||
});
|
||||
};
|
||||
|
||||
const onDelete = (id: number) => {
|
||||
deleteM.mutate(id, {
|
||||
onSuccess: () => push("ok", "Category deleted"),
|
||||
onError: (err: any) => push("err", err?.message ?? "Delete failed")
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) return <div className="card max-w-2xl"><SettingsNav/><div className="muted">Loading…</div></div>;
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="card max-w-2xl">
|
||||
<SettingsNav/>
|
||||
<p className="mb-3">Couldn’t load categories.</p>
|
||||
<button className="btn" onClick={() => refetch()} disabled={isFetching}>{isFetching ? "Retrying…" : "Retry"}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 max-w-5xl">
|
||||
<section className="card">
|
||||
<SettingsNav/>
|
||||
|
||||
{/* Add form */}
|
||||
<form onSubmit={onAdd} className="row gap-2 mb-4 flex-wrap">
|
||||
<input className="input w-44" placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<input className="input w-28" placeholder="%"
|
||||
type="number" min={0} max={100} value={percent} onChange={(e) => setPercent(e.target.value)} />
|
||||
<input className="input w-28" placeholder="Priority"
|
||||
type="number" min={0} value={priority} onChange={(e) => setPriority(e.target.value)} />
|
||||
<label className="row">
|
||||
<input type="checkbox" checked={isSavings} onChange={(e) => setIsSavings(e.target.checked)} />
|
||||
<span className="muted text-sm">Savings</span>
|
||||
</label>
|
||||
<button className="btn" disabled={addDisabled || createM.isPending}>Add</button>
|
||||
<div className="ml-auto"><SumBadge total={total} /></div>
|
||||
</form>
|
||||
|
||||
{cats.length === 0 ? (
|
||||
<div className="muted text-sm">No categories yet.</div>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead><tr><th>Name</th><th>%</th><th>Priority</th><th>Savings</th><th>Balance</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{cats
|
||||
.slice()
|
||||
.sort((a, b) => (a.priority - b.priority) || a.name.localeCompare(b.name))
|
||||
.map(c => (
|
||||
<tr key={c.id}>
|
||||
<td className="rounded-l-[--radius-xl] px-3 py-2">
|
||||
<InlineEditText value={c.name} onChange={(v) => onEdit(c.id, { name: v })} />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<InlineEditNumber value={c.percent} min={0} max={100} onChange={(v) => onEdit(c.id, { percent: v })} />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<InlineEditNumber value={c.priority} min={0} onChange={(v) => onEdit(c.id, { priority: v })} />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<InlineEditCheckbox checked={c.isSavings} onChange={(v) => onEdit(c.id, { isSavings: v })} />
|
||||
</td>
|
||||
<td className="px-3 py-2"><Money cents={c.balanceCents} /></td>
|
||||
<td className="rounded-r-[--radius-xl] px-3 py-2">
|
||||
<button className="btn" type="button" onClick={() => onDelete(c.id)} disabled={deleteM.isPending}>Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{/* Guard if total != 100 */}
|
||||
{total !== 100 && (
|
||||
<div className="toast-err mt-3">
|
||||
Percents must sum to <strong>100%</strong> for allocations. Current total: {total}%.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* --- tiny inline editors --- */
|
||||
function InlineEditText({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
const [v, setV] = useState(value);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const commit = () => { if (v !== value) onChange(v.trim()); setEditing(false); };
|
||||
return editing ? (
|
||||
<input className="input" value={v} onChange={(e) => setV(e.target.value)}
|
||||
onBlur={commit} onKeyDown={(e) => e.key === "Enter" && commit()} autoFocus />
|
||||
) : (
|
||||
<button type="button" className="link" onClick={() => setEditing(true)}>{value}</button>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineEditNumber({ value, onChange, min=0, max=Number.MAX_SAFE_INTEGER }:
|
||||
{ value: number; onChange: (v: number) => void; min?: number; max?: number; }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [v, setV] = useState(String(value));
|
||||
const commit = () => {
|
||||
const n = Math.max(min, Math.min(max, Math.floor(Number(v) || 0)));
|
||||
if (n !== value) onChange(n);
|
||||
setEditing(false);
|
||||
};
|
||||
return editing ? (
|
||||
<input className="input w-24" value={v} onChange={(e) => setV(e.target.value)}
|
||||
onBlur={commit} onKeyDown={(e) => e.key === "Enter" && commit()} autoFocus />
|
||||
) : (
|
||||
<button type="button" className="link" onClick={() => setEditing(true)}>{value}</button>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineEditCheckbox({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<label className="row">
|
||||
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
|
||||
<span className="muted text-sm">{checked ? "Yes" : "No"}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
218
web/src/pages/settings/PlansPage.tsx
Normal file
218
web/src/pages/settings/PlansPage.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
// web/src/pages/settings/PlansPage.tsx
|
||||
import { useMemo, useState, type ChangeEvent, type FormEvent } from "react";
|
||||
import { useDashboard } from "../../hooks/useDashboard";
|
||||
import SettingsNav from "./_SettingsNav";
|
||||
import { useCreatePlan, useUpdatePlan, useDeletePlan } from "../../hooks/useFixedPlans";
|
||||
import { Money } from "../../components/ui";
|
||||
import { useToast } from "../../components/Toast";
|
||||
|
||||
function isoDateLocal(d: Date = new Date()) {
|
||||
const z = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
|
||||
return z.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function daysUntil(iso: string) {
|
||||
const today = new Date(isoDateLocal());
|
||||
const due = new Date(isoDateLocal(new Date(iso)));
|
||||
const diffMs = due.getTime() - today.getTime();
|
||||
return Math.round(diffMs / (24 * 60 * 60 * 1000));
|
||||
}
|
||||
|
||||
function DueBadge({ dueISO }: { dueISO: string }) {
|
||||
const d = daysUntil(dueISO);
|
||||
if (d < 0) return <span className="badge" style={{ borderColor: "#7f1d1d" }}>Overdue</span>;
|
||||
if (d <= 7) return <span className="badge">Due in {d}d</span>;
|
||||
return <span className="badge" aria-hidden="true">On track</span>;
|
||||
}
|
||||
|
||||
export default function SettingsPlansPage() {
|
||||
const { data, isLoading, error, refetch, isFetching } = useDashboard();
|
||||
const createM = useCreatePlan();
|
||||
const updateM = useUpdatePlan();
|
||||
const deleteM = useDeletePlan();
|
||||
const { push } = useToast();
|
||||
|
||||
// Add form state
|
||||
const [name, setName] = useState("");
|
||||
const [total, setTotal] = useState("");
|
||||
const [funded, setFunded] = useState("");
|
||||
const [priority, setPriority] = useState("");
|
||||
const [due, setDue] = useState(isoDateLocal());
|
||||
|
||||
const totals = useMemo(() => {
|
||||
if (!data) return { funded: 0, total: 0, remaining: 0 };
|
||||
const funded = data.fixedPlans.reduce((s, p) => s + p.fundedCents, 0);
|
||||
const total = data.fixedPlans.reduce((s, p) => s + p.totalCents, 0);
|
||||
return { funded, total, remaining: Math.max(0, total - funded) };
|
||||
}, [data]);
|
||||
|
||||
if (isLoading) return <div className="card max-w-3xl"><SettingsNav/><div className="muted">Loading…</div></div>;
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="card max-w-3xl">
|
||||
<SettingsNav/>
|
||||
<p className="mb-3">Couldn’t load fixed plans.</p>
|
||||
<button className="btn" onClick={() => refetch()} disabled={isFetching}>{isFetching ? "Retrying…" : "Retry"}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const onAdd = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const totalCents = Math.max(0, Math.round((parseFloat(total || "0")) * 100));
|
||||
const fundedCents = Math.max(0, Math.round((parseFloat(funded || "0")) * 100));
|
||||
const body = {
|
||||
name: name.trim(),
|
||||
totalCents,
|
||||
fundedCents: Math.min(fundedCents, totalCents),
|
||||
priority: Math.max(0, Math.floor(Number(priority) || 0)),
|
||||
dueOn: new Date(due).toISOString(),
|
||||
};
|
||||
if (!body.name || totalCents <= 0) return;
|
||||
createM.mutate(body, {
|
||||
onSuccess: () => {
|
||||
push("ok", "Plan created");
|
||||
setName(""); setTotal(""); setFunded(""); setPriority(""); setDue(isoDateLocal());
|
||||
},
|
||||
onError: (err: any) => push("err", err?.message ?? "Create failed"),
|
||||
});
|
||||
};
|
||||
|
||||
const onEdit = (id: number, patch: Partial<{ name: string; totalCents: number; fundedCents: number; priority: number; dueOn: string }>) => {
|
||||
if ("totalCents" in patch && "fundedCents" in patch && (patch.totalCents ?? 0) < (patch.fundedCents ?? 0)) {
|
||||
patch.fundedCents = patch.totalCents;
|
||||
}
|
||||
updateM.mutate({ id, body: patch }, {
|
||||
onSuccess: () => push("ok", "Plan updated"),
|
||||
onError: (err: any) => push("err", err?.message ?? "Update failed"),
|
||||
});
|
||||
};
|
||||
|
||||
const onDelete = (id: number) => {
|
||||
deleteM.mutate(id, {
|
||||
onSuccess: () => push("ok", "Plan deleted"),
|
||||
onError: (err: any) => push("err", err?.message ?? "Delete failed"),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 max-w-5xl">
|
||||
<section className="card">
|
||||
<SettingsNav/>
|
||||
|
||||
{/* KPI strip */}
|
||||
<div className="grid gap-2 sm:grid-cols-3 mb-4">
|
||||
<div className="card kpi"><h3>Funded</h3><div className="val"><Money cents={totals.funded} /></div></div>
|
||||
<div className="card kpi"><h3>Total</h3><div className="val"><Money cents={totals.total} /></div></div>
|
||||
<div className="card kpi"><h3>Remaining</h3><div className="val"><Money cents={totals.remaining} /></div></div>
|
||||
</div>
|
||||
|
||||
{/* Add form */}
|
||||
<form onSubmit={onAdd} className="row gap-2 mb-4 flex-wrap">
|
||||
<input className="input w-48" placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<input className="input w-28" placeholder="Total $" type="number" min={0} step="0.01" value={total} onChange={(e) => setTotal(e.target.value)} />
|
||||
<input className="input w-28" placeholder="Funded $" type="number" min={0} step="0.01" value={funded} onChange={(e) => setFunded(e.target.value)} />
|
||||
<input className="input w-24" placeholder="Priority" type="number" min={0} value={priority} onChange={(e) => setPriority(e.target.value)} />
|
||||
<input className="input w-40" type="date" value={due} onChange={(e) => setDue(e.target.value)} />
|
||||
<button className="btn" disabled={!name || !total || createM.isPending}>Add</button>
|
||||
</form>
|
||||
|
||||
{/* Table */}
|
||||
{data.fixedPlans.length === 0 ? (
|
||||
<div className="muted text-sm">No fixed plans yet.</div>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th><th>Due</th><th>Priority</th>
|
||||
<th>Funded</th><th>Total</th><th>Remaining</th><th>Status</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.fixedPlans
|
||||
.slice()
|
||||
.sort((a, b) => (a.priority - b.priority) || (new Date(a.dueOn).getTime() - new Date(b.dueOn).getTime()))
|
||||
.map(p => {
|
||||
const remaining = Math.max(0, p.totalCents - p.fundedCents);
|
||||
return (
|
||||
<tr key={p.id}>
|
||||
<td className="rounded-l-[--radius-xl] px-3 py-2">
|
||||
<InlineEditText value={p.name} onChange={(v) => onEdit(p.id, { name: v })} />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<InlineEditDate value={p.dueOn} onChange={(iso) => onEdit(p.id, { dueOn: iso })} />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<InlineEditNumber value={p.priority} min={0} onChange={(n) => onEdit(p.id, { priority: n })} />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<InlineEditMoney
|
||||
valueCents={p.fundedCents}
|
||||
onChange={(cents) => onEdit(p.id, { fundedCents: Math.max(0, Math.min(cents, p.totalCents)) })}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<InlineEditMoney
|
||||
valueCents={p.totalCents}
|
||||
onChange={(cents) => onEdit(p.id, { totalCents: Math.max(cents, 0), fundedCents: Math.min(p.fundedCents, cents) })}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2"><Money cents={remaining} /></td>
|
||||
<td className="px-3 py-2"><DueBadge dueISO={p.dueOn} /></td>
|
||||
<td className="rounded-r-[--radius-xl] px-3 py-2">
|
||||
<button className="btn" type="button" onClick={() => onDelete(p.id)} disabled={deleteM.isPending}>Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* --- Inline editors (minimal) --- */
|
||||
function InlineEditText({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [v, setV] = useState(value);
|
||||
const commit = () => { const t = v.trim(); if (t && t !== value) onChange(t); setEditing(false); };
|
||||
return editing ? (
|
||||
<input className="input" value={v} onChange={(e) => setV(e.target.value)} onBlur={commit} onKeyDown={(e) => e.key === "Enter" && commit()} autoFocus />
|
||||
) : <button type="button" className="link" onClick={() => setEditing(true)}>{value}</button>;
|
||||
}
|
||||
|
||||
function InlineEditNumber({ value, onChange, min=0, max=Number.MAX_SAFE_INTEGER }:
|
||||
{ value: number; onChange: (v: number) => void; min?: number; max?: number }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [v, setV] = useState(String(value));
|
||||
const commit = () => { const n = Math.max(min, Math.min(max, Math.floor(Number(v) || 0))); if (n !== value) onChange(n); setEditing(false); };
|
||||
return editing ? (
|
||||
<input className="input w-24" value={v} onChange={(e) => setV(e.target.value)} onBlur={commit} onKeyDown={(e) => e.key === "Enter" && commit()} autoFocus />
|
||||
) : <button type="button" className="link" onClick={() => setEditing(true)}>{value}</button>;
|
||||
}
|
||||
|
||||
function InlineEditMoney({ valueCents, onChange }: { valueCents: number; onChange: (cents: number) => void }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [v, setV] = useState((valueCents / 100).toFixed(2));
|
||||
const commit = () => {
|
||||
const cents = Math.max(0, Math.round((parseFloat(v || "0")) * 100));
|
||||
if (cents !== valueCents) onChange(cents);
|
||||
setEditing(false);
|
||||
};
|
||||
return editing ? (
|
||||
<input className="input w-28" type="number" step="0.01" min={0} value={v} onChange={(e) => setV(e.target.value)}
|
||||
onBlur={commit} onKeyDown={(e) => e.key === "Enter" && commit()} autoFocus />
|
||||
) : <button type="button" className="link" onClick={() => setEditing(true)}>{(valueCents/100).toFixed(2)}</button>;
|
||||
}
|
||||
|
||||
function InlineEditDate({ value, onChange }: { value: string; onChange: (iso: string) => void }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const local = new Date(value);
|
||||
const [v, setV] = useState(local.toISOString().slice(0, 10));
|
||||
const commit = () => { const iso = new Date(v + "T00:00:00Z").toISOString(); if (iso !== value) onChange(iso); setEditing(false); };
|
||||
return editing ? (
|
||||
<input className="input w-40" type="date" value={v} onChange={(e) => setV(e.target.value)} onBlur={commit} onKeyDown={(e) => e.key === "Enter" && commit()} autoFocus />
|
||||
) : <button type="button" className="link" onClick={() => setEditing(true)}>{new Date(value).toLocaleDateString()}</button>;
|
||||
}
|
||||
14
web/src/pages/settings/_SettingsNav.tsx
Normal file
14
web/src/pages/settings/_SettingsNav.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { NavLink } from "react-router-dom";
|
||||
export default function SettingsNav() {
|
||||
const link = (to: string, label: string) =>
|
||||
<NavLink to={to} className={({isActive}) => `link ${isActive ? "link-active" : ""}`}>{label}</NavLink>;
|
||||
return (
|
||||
<div className="row mb-3">
|
||||
<h2 className="section-title m-0">Settings</h2>
|
||||
<div className="ml-auto flex gap-1">
|
||||
{link("/settings/categories", "Categories")}
|
||||
{link("/settings/plans", "Fixed Plans")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
132
web/src/styles.css
Normal file
132
web/src/styles.css
Normal file
@@ -0,0 +1,132 @@
|
||||
:root {
|
||||
--color-bg: #0b0c10;
|
||||
--color-panel: #111318;
|
||||
--color-fg: #e7e9ee;
|
||||
--color-ink: #2a2e37;
|
||||
--color-accent: #5dd6b2;
|
||||
--radius-xl: 12px;
|
||||
--radius-lg: 10px;
|
||||
--radius-md: 8px;
|
||||
--shadow-1: 0 6px 20px rgba(0,0,0,0.25);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #root { height: 100%; }
|
||||
body { margin: 0; background: var(--color-bg); color: var(--color-fg); font: 14px/1.35 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
|
||||
.container { width: min(1100px, 100%); margin-inline: auto; padding: 0 16px; }
|
||||
.muted { opacity: 0.7; }
|
||||
|
||||
.card {
|
||||
background: var(--color-panel);
|
||||
border: 1px solid var(--color-ink);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 16px;
|
||||
box-shadow: var(--shadow-1);
|
||||
}
|
||||
|
||||
.row { display: flex; align-items: center; }
|
||||
.stack { display: grid; gap: 12px; }
|
||||
|
||||
.section-title { font-weight: 700; font-size: 16px; margin-bottom: 10px; }
|
||||
|
||||
.input {
|
||||
background: #0f1116;
|
||||
color: var(--color-fg);
|
||||
border: 1px solid var(--color-ink);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 8px 10px;
|
||||
outline: none;
|
||||
}
|
||||
.input:focus { border-color: var(--color-accent); }
|
||||
|
||||
.btn {
|
||||
background: var(--color-accent);
|
||||
color: #062016;
|
||||
border: none;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 8px 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn[disabled] { opacity: 0.5; cursor: default; }
|
||||
.badge {
|
||||
background: var(--color-ink);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.table { width: 100%; border-collapse: separate; border-spacing: 0 8px; }
|
||||
.table thead th {
|
||||
text-align: left; font-size: 12px; opacity: 0.7; padding: 0 8px;
|
||||
}
|
||||
.table tbody tr {
|
||||
background: var(--color-panel);
|
||||
border: 1px solid var(--color-ink);
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
.table td { padding: 8px; }
|
||||
|
||||
.toast-err {
|
||||
background: #b21d2a;
|
||||
color: white;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.border { border: 1px solid var(--color-ink); }
|
||||
.rounded-xl { border-radius: var(--radius-xl); }
|
||||
.divide-y > * + * { border-top: 1px solid var(--color-ink); }
|
||||
|
||||
/* utility-ish */
|
||||
.w-44 { width: 11rem; }
|
||||
.w-56 { width: 14rem; }
|
||||
.w-40 { width: 10rem; }
|
||||
.ml-auto { margin-left: auto; }
|
||||
.mt-2 { margin-top: 0.5rem; }
|
||||
.mt-3 { margin-top: 0.75rem; }
|
||||
.mt-4 { margin-top: 1rem; }
|
||||
.mb-3 { margin-bottom: 0.75rem; }
|
||||
.gap-2 { gap: 0.5rem; }
|
||||
.gap-4 { gap: 1rem; }
|
||||
.flex-wrap { flex-wrap: wrap; }
|
||||
.text-sm { font-size: 0.875rem; }
|
||||
.text-xl { font-size: 1.25rem; }
|
||||
.font-semibold { font-weight: 600; }
|
||||
.font-bold { font-weight: 700; }
|
||||
.font-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; }
|
||||
.bg-\[--color-ink\] { background: var(--color-ink); }
|
||||
.bg-\[--color-ink\]\/60 { background: color-mix(in oklab, var(--color-ink), transparent 40%); }
|
||||
.bg-\[--color-panel\] { background: var(--color-panel); }
|
||||
.text-\[--color-fg\] { color: var(--color-fg); }
|
||||
.border-\[--color-ink\] { border-color: var(--color-ink); }
|
||||
.rounded-\[--radius-xl\] { border-radius: var(--radius-xl); }
|
||||
.p-2 { padding: 0.5rem; }
|
||||
.p-3 { padding: 0.75rem; }
|
||||
.p-4 { padding: 1rem; }
|
||||
.p-6 { padding: 1.5rem; }
|
||||
.py-8 { padding-block: 2rem; }
|
||||
.h-14 { height: 3.5rem; }
|
||||
.text-center { text-align: center; }
|
||||
.text-right { text-align: right; }
|
||||
.opacity-70 { opacity: .7; }
|
||||
.grid { display: grid; }
|
||||
.md\:grid-cols-2 { grid-template-columns: 1fr; }
|
||||
.md\:grid-cols-3 { grid-template-columns: 1fr; }
|
||||
@media (min-width: 768px) {
|
||||
.md\:grid-cols-2 { grid-template-columns: 1fr 1fr; }
|
||||
.md\:grid-cols-3 { grid-template-columns: 1fr 1fr 1fr; }
|
||||
}
|
||||
.shadow-sm { box-shadow: 0 2px 12px rgba(0,0,0,0.2); }
|
||||
.underline { text-decoration: underline; }
|
||||
.fixed { position: fixed; }
|
||||
.bottom-4 { bottom: 1rem; }
|
||||
.left-1\/2 { left: 50%; }
|
||||
.-translate-x-1\/2 { transform: translateX(-50%); }
|
||||
.z-50 { z-index: 50; }
|
||||
.space-y-2 > * + * { margin-top: .5rem; }
|
||||
.space-y-8 > * + * { margin-top: 2rem; }
|
||||
.space-y-6 > * + * { margin-top: 1.5rem; }
|
||||
.overflow-x-auto { overflow-x: auto; }
|
||||
77
web/src/utils/allocatorPreview.ts
Normal file
77
web/src/utils/allocatorPreview.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { FixedPlan, VariableCategory } from "../hooks/useDashboard";
|
||||
|
||||
export function previewAllocation(
|
||||
amountCents: number,
|
||||
fixedPlans: FixedPlan[],
|
||||
variableCategories: VariableCategory[]
|
||||
) {
|
||||
let remaining = Math.max(0, amountCents | 0);
|
||||
|
||||
// Fixed pass: fill by priority then due date order (assume arrays are already sorted on the server;
|
||||
// this is just a local fallback).
|
||||
const fixedSorted = [...fixedPlans].sort((a, b) => {
|
||||
if (a.priority !== b.priority) return a.priority - b.priority;
|
||||
return new Date(a.dueOn).getTime() - new Date(b.dueOn).getTime();
|
||||
});
|
||||
|
||||
const fixed: Array<{ id: string; name: string; amountCents: number }> = [];
|
||||
for (const p of fixedSorted) {
|
||||
if (remaining <= 0) break;
|
||||
const total = BigInt(p.totalCents ?? 0);
|
||||
const funded = BigInt(p.fundedCents ?? 0);
|
||||
const needBig = total - funded;
|
||||
const need = Number(needBig > 0n ? needBig : 0n);
|
||||
if (need <= 0) continue;
|
||||
const give = Math.min(need, remaining);
|
||||
fixed.push({ id: p.id, name: p.name, amountCents: give });
|
||||
remaining -= give;
|
||||
}
|
||||
|
||||
// Variable pass: largest remainder; savings-first on ties; then by priority then name.
|
||||
const variable: Array<{ id: string; name: string; amountCents: number }> = [];
|
||||
const cats = [...variableCategories].sort((a, b) => {
|
||||
if (a.priority !== b.priority) return a.priority - b.priority;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
if (remaining > 0 && cats.length > 0) {
|
||||
const totalPercent = cats.reduce((s, c) => s + (c.percent || 0), 0);
|
||||
const normCats =
|
||||
totalPercent === 100
|
||||
? cats
|
||||
: cats.map((c) => ({
|
||||
...c,
|
||||
percent: totalPercent > 0 ? (c.percent * 100) / totalPercent : 0,
|
||||
}));
|
||||
|
||||
const base: number[] = new Array(normCats.length).fill(0);
|
||||
const tie: { idx: number; remainder: number; isSavings: boolean; priority: number; name: string }[] = [];
|
||||
let sumBase = 0;
|
||||
|
||||
normCats.forEach((c, idx) => {
|
||||
const exact = (remaining * c.percent) / 100;
|
||||
const floor = Math.floor(exact);
|
||||
base[idx] = floor;
|
||||
sumBase += floor;
|
||||
tie.push({ idx, remainder: exact - floor, isSavings: !!c.isSavings, priority: c.priority, name: c.name });
|
||||
});
|
||||
|
||||
let leftovers = remaining - sumBase;
|
||||
tie.sort((a, b) => {
|
||||
if (a.isSavings !== b.isSavings) return a.isSavings ? -1 : 1;
|
||||
if (a.remainder !== b.remainder) return b.remainder - a.remainder;
|
||||
if (a.priority !== b.priority) return a.priority - b.priority;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
for (let i = 0; i < tie.length && leftovers > 0; i++, leftovers--) base[tie[i].idx] += 1;
|
||||
|
||||
normCats.forEach((c, idx) => {
|
||||
const give = base[idx] || 0;
|
||||
if (give > 0) variable.push({ id: c.id, name: c.name, amountCents: give });
|
||||
});
|
||||
|
||||
remaining = leftovers;
|
||||
}
|
||||
|
||||
return { fixed, variable, unallocatedCents: Math.max(0, remaining) };
|
||||
}
|
||||
6
web/src/utils/format.ts
Normal file
6
web/src/utils/format.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export function toMoney(cents: number) {
|
||||
return (cents / 100).toLocaleString(undefined, { style: "currency", currency: "USD" });
|
||||
}
|
||||
export function nowLocalISOStringMinute() {
|
||||
return new Date().toISOString().slice(0, 16);
|
||||
}
|
||||
9
web/src/utils/money.ts
Normal file
9
web/src/utils/money.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export function fmtMoney(cents: number) {
|
||||
const dollars = (Number(cents || 0) / 100).toFixed(2);
|
||||
return `$${dollars}`;
|
||||
}
|
||||
export const toCents = (val: string | number): number => {
|
||||
const n = typeof val === "string" ? Number(val) : val;
|
||||
if (!Number.isFinite(n)) return 0;
|
||||
return Math.round(n * 100);
|
||||
};
|
||||
120
web/tests/allocatorPreview.test.ts
Normal file
120
web/tests/allocatorPreview.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { previewAllocation, type FixedPlan, type VariableCategory } from "../src/utils/allocatorPreview";
|
||||
|
||||
const cats = (defs: Array<Partial<VariableCategory> & { name: string }>): VariableCategory[] =>
|
||||
defs.map((d, i) => ({
|
||||
id: i + 1,
|
||||
name: d.name,
|
||||
percent: d.percent ?? 0,
|
||||
isSavings: d.isSavings ?? false,
|
||||
priority: d.priority ?? 100,
|
||||
}));
|
||||
|
||||
const plans = (defs: Array<Partial<FixedPlan> & { name: string; totalCents: number }>): FixedPlan[] =>
|
||||
defs.map((d, i) => ({
|
||||
id: i + 1,
|
||||
name: d.name,
|
||||
totalCents: d.totalCents,
|
||||
fundedCents: d.fundedCents ?? 0,
|
||||
priority: d.priority ?? 100,
|
||||
dueOn: d.dueOn ?? new Date().toISOString(),
|
||||
}));
|
||||
|
||||
describe("previewAllocation — basics", () => {
|
||||
it("single bucket 100% gets all remaining after fixed", () => {
|
||||
const fp = plans([]);
|
||||
const vc = cats([{ name: "Only", percent: 100 }]);
|
||||
const r = previewAllocation(1_000, fp, vc);
|
||||
expect(r.fixed.length).toBe(0);
|
||||
expect(r.variable).toEqual([{ id: 1, name: "Only", amountCents: 1_000 }]);
|
||||
expect(r.unallocatedCents).toBe(0);
|
||||
});
|
||||
|
||||
it("zero allocations when amount is 0", () => {
|
||||
const r = previewAllocation(0, plans([]), cats([{ name: "A", percent: 100 }]));
|
||||
expect(r.fixed.length).toBe(0);
|
||||
expect(r.variable.length).toBe(0);
|
||||
expect(r.unallocatedCents).toBe(0);
|
||||
});
|
||||
|
||||
it("no variable split if no categories", () => {
|
||||
const r = previewAllocation(500, plans([]), []);
|
||||
expect(r.fixed.length).toBe(0);
|
||||
expect(r.variable.length).toBe(0);
|
||||
expect(r.unallocatedCents).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe("previewAllocation — fixed first", () => {
|
||||
it("funds plans in priority then due order up to need", () => {
|
||||
const fp = plans([
|
||||
{ name: "B", totalCents: 8000, fundedCents: 7000, priority: 2, dueOn: "2025-12-31T00:00:00Z" }, // need 1000
|
||||
{ name: "A", totalCents: 5000, fundedCents: 0, priority: 1, dueOn: "2025-12-01T00:00:00Z" }, // need 5000 (but priority=1 => first)
|
||||
]);
|
||||
const vc = cats([{ name: "Var", percent: 100 }]);
|
||||
|
||||
const r = previewAllocation(4000, fp, vc);
|
||||
// Fixed plan A (priority 1) gets as much as possible: 4000 (need 5000)
|
||||
expect(r.fixed).toEqual([{ id: 2, name: "A", amountCents: 4000 }]);
|
||||
expect(r.variable.length).toBe(0);
|
||||
expect(r.unallocatedCents).toBe(0);
|
||||
});
|
||||
|
||||
it("leftover goes to variables after satisfying plan needs", () => {
|
||||
const fp = plans([{ name: "Rent", totalCents: 10000, fundedCents: 9000, priority: 1 }]); // need 1000
|
||||
const vc = cats([{ name: "Groceries", percent: 100 }]);
|
||||
const r = previewAllocation(2500, fp, vc);
|
||||
expect(r.fixed).toEqual([{ id: 1, name: "Rent", amountCents: 1000 }]);
|
||||
expect(r.variable).toEqual([{ id: 1, name: "Groceries", amountCents: 1500 }]);
|
||||
expect(r.unallocatedCents).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("previewAllocation — largest remainder with savings-first tiebreak", () => {
|
||||
it("splits by integer floor and then leftover by remainder", () => {
|
||||
// 3 cats: 50%, 30%, 20%; amount 101 -> floors: 50, 30, 20 => sumBase=100, 1 leftover to largest remainder (all remainders 0, so go to savings-first if any)
|
||||
const vc = cats([
|
||||
{ name: "A", percent: 50, isSavings: false, priority: 10 },
|
||||
{ name: "B", percent: 30, isSavings: true, priority: 10 },
|
||||
{ name: "C", percent: 20, isSavings: false, priority: 10 },
|
||||
]);
|
||||
const r = previewAllocation(101, plans([]), vc);
|
||||
// Base: A50, B30, C20 = 100; leftover=1 -> goes to B (savings-first)
|
||||
expect(r.variable).toEqual([
|
||||
{ id: 1, name: "A", amountCents: 50 },
|
||||
{ id: 2, name: "B", amountCents: 31 },
|
||||
{ id: 3, name: "C", amountCents: 20 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("ties on remainder resolved by savings-first, then priority asc, then name asc", () => {
|
||||
// Force equal remainders with 3 categories @ 33.333…%
|
||||
const vc = cats([
|
||||
{ name: "Zeta", percent: 33, isSavings: false, priority: 2 },
|
||||
{ name: "Alpha", percent: 33, isSavings: false, priority: 1 },
|
||||
{ name: "Saver", percent: 34, isSavings: true, priority: 5 },
|
||||
]);
|
||||
// Amount 4: exacts ~ 1.32, 1.32, 1.36 => floors 1,1,1 sumBase 3 leftover 1 -> goes to Saver (savings-first)
|
||||
const r = previewAllocation(4, plans([]), vc);
|
||||
expect(r.variable).toEqual([
|
||||
{ id: 1, name: "Zeta", amountCents: 1 },
|
||||
{ id: 2, name: "Alpha", amountCents: 1 },
|
||||
{ id: 3, name: "Saver", amountCents: 2 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("previewAllocation — normalization safety", () => {
|
||||
it("normalizes percents when sum != 100 to avoid crashing UI", () => {
|
||||
const vc = cats([
|
||||
{ name: "A", percent: 40 },
|
||||
{ name: "B", percent: 40 },
|
||||
{ name: "C", percent: 40 }, // sum = 120 -> normalize
|
||||
]);
|
||||
const r = previewAllocation(120, plans([]), vc);
|
||||
// Expect all cents allocated (no unallocated) and proportions roughly 1/3 each.
|
||||
const total = r.variable.reduce((s, v) => s + v.amountCents, 0);
|
||||
expect(total).toBe(120);
|
||||
expect(r.unallocatedCents).toBe(0);
|
||||
});
|
||||
});
|
||||
28
web/tsconfig.app.json
Normal file
28
web/tsconfig.app.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
web/tsconfig.json
Normal file
7
web/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
web/tsconfig.node.json
Normal file
26
web/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
14
web/vite.config.ts
Normal file
14
web/vite.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
|
||||
],
|
||||
|
||||
})
|
||||
|
||||
|
||||
9
web/vitest.config.ts
Normal file
9
web/vitest.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["tests/**/*.test.ts"],
|
||||
reporters: "default",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user