final touches for beta skymoney (at least i think)
This commit is contained in:
@@ -1,63 +1,18 @@
|
||||
import { Link, NavLink, Route, Routes } from "react-router-dom";
|
||||
import DashboardPage from "./pages/DashboardPage";
|
||||
import IncomePage from "./pages/IncomePage";
|
||||
import TransactionsPage from "./pages/TransactionsPage";
|
||||
import { Suspense } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { SessionTimeoutWarning } from "./components/SessionTimeoutWarning";
|
||||
import NavBar from "./components/NavBar";
|
||||
|
||||
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>
|
||||
<>
|
||||
<SessionTimeoutWarning />
|
||||
<NavBar />
|
||||
<main className="container py-6 h-full">
|
||||
<Suspense fallback={<div className="muted text-sm">Loading…</div>}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
77
web/src/api/budget.ts
Normal file
77
web/src/api/budget.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { http } from "./http";
|
||||
|
||||
export interface BudgetAllocationRequest {
|
||||
newIncomeCents: number;
|
||||
fixedExpensePercentage: number;
|
||||
postedAtISO?: string;
|
||||
}
|
||||
|
||||
export interface BudgetAllocationResponse {
|
||||
fixedAllocations: Array<{
|
||||
fixedPlanId: string;
|
||||
amountCents: number;
|
||||
source: string;
|
||||
}>;
|
||||
variableAllocations: Array<{
|
||||
variableCategoryId: string;
|
||||
amountCents: number;
|
||||
}>;
|
||||
totalBudgetCents: number;
|
||||
fundedBudgetCents: number;
|
||||
availableBudgetCents: number;
|
||||
remainingBudgetCents: number;
|
||||
crisis: {
|
||||
active: boolean;
|
||||
plans: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
remainingCents: number;
|
||||
daysUntilDue: number;
|
||||
priority: number;
|
||||
allocatedCents: number;
|
||||
}>;
|
||||
};
|
||||
planStatesAfter: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
totalCents: number;
|
||||
fundedCents: number;
|
||||
remainingCents: number;
|
||||
daysUntilDue: number;
|
||||
isCrisis: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface BudgetReconcileRequest {
|
||||
bankTotalCents: number;
|
||||
}
|
||||
|
||||
export interface BudgetReconcileResponse {
|
||||
ok: boolean;
|
||||
deltaCents: number;
|
||||
currentTotalCents: number;
|
||||
newTotalCents: number;
|
||||
}
|
||||
|
||||
export const budgetApi = {
|
||||
async allocate(data: BudgetAllocationRequest): Promise<BudgetAllocationResponse> {
|
||||
return http<BudgetAllocationResponse>("/budget/allocate", {
|
||||
method: "POST",
|
||||
body: data,
|
||||
});
|
||||
},
|
||||
|
||||
async fund(data: BudgetAllocationRequest): Promise<BudgetAllocationResponse> {
|
||||
return http<BudgetAllocationResponse>("/budget/fund", {
|
||||
method: "POST",
|
||||
body: data,
|
||||
});
|
||||
},
|
||||
|
||||
async reconcile(data: BudgetReconcileRequest): Promise<BudgetReconcileResponse> {
|
||||
return http<BudgetReconcileResponse>("/budget/reconcile", {
|
||||
method: "POST",
|
||||
body: data,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { request } from "./client";
|
||||
import { apiDelete, apiPatch, apiPost } from "./http";
|
||||
|
||||
export type NewCategory = {
|
||||
name: string;
|
||||
@@ -9,22 +9,8 @@ export type NewCategory = {
|
||||
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"
|
||||
})
|
||||
};
|
||||
create: (body: NewCategory) => apiPost<{ id: string }>("/variable-categories", body),
|
||||
update: (id: string, body: UpdateCategory) => apiPatch(`/variable-categories/${id}`, body),
|
||||
delete: (id: string) => apiDelete(`/variable-categories/${id}`),
|
||||
rebalance: () => apiPost<{ ok: boolean; applied?: boolean; totalBalance?: number }>("/variable-categories/rebalance", {}),
|
||||
};
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
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) }),
|
||||
};
|
||||
@@ -1,4 +1,11 @@
|
||||
import { request } from "./client";
|
||||
import { apiDelete, apiGet, apiPatch, apiPost } from "./http";
|
||||
|
||||
export type PaymentScheduleInput = {
|
||||
dayOfMonth?: number;
|
||||
dayOfWeek?: number;
|
||||
everyNDays?: number;
|
||||
minFundingPercent?: number;
|
||||
};
|
||||
|
||||
export type NewPlan = {
|
||||
name: string;
|
||||
@@ -6,22 +13,81 @@ export type NewPlan = {
|
||||
fundedCents?: number; // optional, default 0
|
||||
priority: number; // int
|
||||
dueOn: string; // ISO date
|
||||
frequency?: "one-time" | "weekly" | "biweekly" | "monthly";
|
||||
autoPayEnabled?: boolean;
|
||||
paymentSchedule?: PaymentScheduleInput | null;
|
||||
nextPaymentDate?: string | null;
|
||||
maxRetryAttempts?: number;
|
||||
};
|
||||
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" }),
|
||||
};
|
||||
create: (body: NewPlan) => apiPost<{ id: string }>("/fixed-plans", body),
|
||||
update: (id: string, body: UpdatePlan) =>
|
||||
apiPatch(`/fixed-plans/${id}`, body),
|
||||
delete: (id: string) => apiDelete(`/fixed-plans/${id}`),
|
||||
due: (query?: { asOf?: string; daysAhead?: number }) =>
|
||||
apiGet<{ items: Array<{ id: string; name: string; dueOn: string; remainingCents: number; percentFunded: number; isDue: boolean; isOverdue: boolean }>; asOfISO: string }>(
|
||||
"/fixed-plans/due",
|
||||
query as any
|
||||
),
|
||||
attemptFinalFunding: (id: string) =>
|
||||
apiPost<{
|
||||
ok: boolean;
|
||||
planId: string;
|
||||
status: "fully_funded" | "overdue";
|
||||
fundedCents: number;
|
||||
totalCents: number;
|
||||
isOverdue: boolean;
|
||||
overdueAmount?: number;
|
||||
message: string;
|
||||
}>(`/fixed-plans/${id}/attempt-final-funding`, {}),
|
||||
payNow: (
|
||||
id: string,
|
||||
body: {
|
||||
occurredAtISO?: string;
|
||||
overrideDueOnISO?: string;
|
||||
fundingSource?: "funded" | "savings" | "deficit";
|
||||
savingsCategoryId?: string;
|
||||
note?: string;
|
||||
}
|
||||
) =>
|
||||
apiPost<{
|
||||
ok: boolean;
|
||||
planId: string;
|
||||
transactionId: string;
|
||||
nextDueOn: string | null;
|
||||
savingsUsed: boolean;
|
||||
deficitCovered: boolean;
|
||||
shortageCents: number;
|
||||
}>(`/fixed-plans/${id}/pay-now`, body),
|
||||
markUnpaid: (id: string) =>
|
||||
apiPatch<{
|
||||
ok: boolean;
|
||||
planId: string;
|
||||
isOverdue: boolean;
|
||||
overdueAmount: number;
|
||||
}>(`/fixed-plans/${id}/mark-unpaid`, {}),
|
||||
fundFromAvailable: (id: string) =>
|
||||
apiPost<{
|
||||
ok: boolean;
|
||||
planId: string;
|
||||
funded: boolean;
|
||||
fundedAmountCents: number;
|
||||
fundedCents: number;
|
||||
totalCents: number;
|
||||
availableBudget?: number;
|
||||
message: string;
|
||||
}>(`/fixed-plans/${id}/fund-from-available`, {}),
|
||||
catchUpFunding: (id: string) =>
|
||||
apiPost<{
|
||||
ok: boolean;
|
||||
planId: string;
|
||||
funded: boolean;
|
||||
fundedAmountCents: number;
|
||||
fundedCents: number;
|
||||
totalCents: number;
|
||||
availableBudget?: number;
|
||||
message: string;
|
||||
}>(`/fixed-plans/${id}/catch-up-funding`, {}),
|
||||
};
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
const BASE =
|
||||
(typeof import.meta !== "undefined" && import.meta.env && import.meta.env.VITE_API_URL) ||
|
||||
""; // e.g. "http://localhost:8080" or proxy
|
||||
"/api"; // default to proxy prefix when no explicit API URL
|
||||
|
||||
type FetchOpts = {
|
||||
method?: "GET" | "POST" | "PATCH" | "DELETE";
|
||||
headers?: Record<string, string>;
|
||||
body?: any;
|
||||
query?: Record<string, string | number | boolean | undefined>;
|
||||
skipAuthRedirect?: boolean;
|
||||
};
|
||||
|
||||
function toQS(q?: FetchOpts["query"]) {
|
||||
@@ -22,19 +23,48 @@ function toQS(q?: FetchOpts["query"]) {
|
||||
return s ? `?${s}` : "";
|
||||
}
|
||||
|
||||
async function http<T>(path: string, opts: FetchOpts = {}): Promise<T> {
|
||||
const { method = "GET", headers = {}, body, query } = opts;
|
||||
function getCookie(name: string): string | undefined {
|
||||
if (typeof document === "undefined") return undefined;
|
||||
const cookies = document.cookie.split(";").map((cookie) => cookie.trim());
|
||||
for (const entry of cookies) {
|
||||
if (!entry) continue;
|
||||
const [key, ...rest] = entry.split("=");
|
||||
if (key === name) {
|
||||
return decodeURIComponent(rest.join("="));
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function http<T>(path: string, opts: FetchOpts = {}): Promise<T> {
|
||||
const {
|
||||
method = "GET",
|
||||
headers = {},
|
||||
body,
|
||||
query,
|
||||
skipAuthRedirect = false,
|
||||
} = opts;
|
||||
const url = `${BASE}${path}${toQS(query)}`;
|
||||
|
||||
const hasBody = body !== undefined;
|
||||
const requestHeaders: Record<string, string> = { ...headers };
|
||||
|
||||
// Only set Content-Type header if we have a body to send
|
||||
if (hasBody) {
|
||||
requestHeaders["Content-Type"] = "application/json";
|
||||
}
|
||||
if (!["GET", "HEAD", "OPTIONS"].includes(method)) {
|
||||
const csrfToken = getCookie("csrf");
|
||||
if (csrfToken && !requestHeaders["x-csrf-token"]) {
|
||||
requestHeaders["x-csrf-token"] = csrfToken;
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
credentials: "include",
|
||||
headers: requestHeaders,
|
||||
body: hasBody ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
// Try to parse JSON either way
|
||||
@@ -42,11 +72,44 @@ async function http<T>(path: string, opts: FetchOpts = {}): Promise<T> {
|
||||
const json = text ? JSON.parse(text) : null;
|
||||
|
||||
if (!res.ok) {
|
||||
if (
|
||||
res.status === 401 &&
|
||||
!skipAuthRedirect &&
|
||||
typeof window !== "undefined"
|
||||
) {
|
||||
const next = `${window.location.pathname}${window.location.search}`;
|
||||
const encoded = encodeURIComponent(next);
|
||||
const dest =
|
||||
encoded && encoded !== "%2F" ? `/login?next=${encoded}` : "/login";
|
||||
window.location.assign(dest);
|
||||
}
|
||||
|
||||
const msg =
|
||||
(json && (json.message || json.error)) ||
|
||||
`${res.status} ${res.statusText}` ||
|
||||
"Request failed";
|
||||
throw new Error(msg);
|
||||
const err = new Error(msg) as Error & {
|
||||
status?: number;
|
||||
code?: string;
|
||||
data?: any;
|
||||
overdraftAmount?: number;
|
||||
categoryName?: string;
|
||||
currentBalance?: number;
|
||||
availableBudget?: number;
|
||||
shortage?: number;
|
||||
};
|
||||
err.status = res.status;
|
||||
if (json && typeof json === "object") {
|
||||
const payload = json as any;
|
||||
if (payload.code) err.code = payload.code;
|
||||
err.data = payload;
|
||||
if (payload.overdraftAmount !== undefined) err.overdraftAmount = payload.overdraftAmount;
|
||||
if (payload.categoryName !== undefined) err.categoryName = payload.categoryName;
|
||||
if (payload.currentBalance !== undefined) err.currentBalance = payload.currentBalance;
|
||||
if (payload.availableBudget !== undefined) err.availableBudget = payload.availableBudget;
|
||||
if (payload.shortage !== undefined) err.shortage = payload.shortage;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return json as T;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { request } from "./client";
|
||||
import { apiGet, apiPost } from "./http";
|
||||
import { TransactionsList, type TransactionsListT } from "./schemas";
|
||||
|
||||
export type TxQuery = {
|
||||
@@ -10,11 +10,26 @@ export type TxQuery = {
|
||||
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());
|
||||
export type CreateTransactionPayload = {
|
||||
kind: "variable_spend" | "fixed_payment";
|
||||
amountCents: number;
|
||||
occurredAtISO: string;
|
||||
categoryId?: string;
|
||||
planId?: string;
|
||||
note?: string;
|
||||
receiptUrl?: string;
|
||||
isReconciled?: boolean;
|
||||
};
|
||||
|
||||
export async function listTransactions(
|
||||
params: TxQuery
|
||||
): Promise<TransactionsListT> {
|
||||
const data = await apiGet<unknown>("/transactions", params);
|
||||
return TransactionsList.parse(data);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createTransaction(
|
||||
payload: CreateTransactionPayload
|
||||
): Promise<{ id: string; nextDueOn?: string }> {
|
||||
return await apiPost("/transactions", payload);
|
||||
}
|
||||
|
||||
BIN
web/src/assets/SkyMoneyLogo.png
Normal file
BIN
web/src/assets/SkyMoneyLogo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
@@ -1,31 +1,75 @@
|
||||
import React from "react";
|
||||
|
||||
export default function CurrencyInput({
|
||||
value,
|
||||
onValue,
|
||||
placeholder = "0.00",
|
||||
}: {
|
||||
type BaseProps = Omit<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
"value" | "onChange"
|
||||
> & {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type StringProps = BaseProps & {
|
||||
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);
|
||||
valueCents?: never;
|
||||
onChange?: never;
|
||||
};
|
||||
|
||||
type CentsProps = BaseProps & {
|
||||
valueCents: number;
|
||||
onChange: (cents: number) => void;
|
||||
value?: never;
|
||||
onValue?: never;
|
||||
};
|
||||
|
||||
type Props = StringProps | CentsProps;
|
||||
|
||||
export default function CurrencyInput({
|
||||
className,
|
||||
placeholder = "0.00",
|
||||
...rest
|
||||
}: Props) {
|
||||
const mergedClass = ["input", className].filter(Boolean).join(" ");
|
||||
const formatString = (raw: string) => {
|
||||
const cleanedRaw = raw.replace(/[^0-9.]/g, "");
|
||||
const parts = cleanedRaw.split(".");
|
||||
return parts.length === 1
|
||||
? parts[0]
|
||||
: `${parts[0]}.${parts.slice(1).join("").slice(0, 2)}`;
|
||||
};
|
||||
|
||||
if ("valueCents" in rest) {
|
||||
const { valueCents, onChange, ...inputProps } = rest as CentsProps;
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const formatted = formatString(e.target.value);
|
||||
const parsed = Number.parseFloat(formatted || "0");
|
||||
onChange(Number.isFinite(parsed) ? Math.round(parsed * 100) : 0);
|
||||
};
|
||||
const displayValue = (valueCents ?? 0) / 100;
|
||||
const value = Number.isFinite(displayValue) ? displayValue.toString() : "";
|
||||
return (
|
||||
<input
|
||||
{...inputProps}
|
||||
className={mergedClass}
|
||||
inputMode="decimal"
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const { value, onValue, ...inputProps } = rest as StringProps;
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onValue(formatString(e.target.value));
|
||||
};
|
||||
return (
|
||||
<input
|
||||
className="input"
|
||||
{...inputProps}
|
||||
className={mergedClass}
|
||||
inputMode="decimal"
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
81
web/src/components/EarlyFundingModal.tsx
Normal file
81
web/src/components/EarlyFundingModal.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useState } from "react";
|
||||
import { apiPatch } from "../api/http";
|
||||
import { formatDateInTimezone } from "../utils/timezone";
|
||||
|
||||
interface EarlyFundingModalProps {
|
||||
planId: string;
|
||||
planName: string;
|
||||
nextDueDate?: string;
|
||||
timezone: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function EarlyFundingModal({ planId, planName, nextDueDate, timezone, onClose }: EarlyFundingModalProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleResponse = async (enableEarlyFunding: boolean) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await apiPatch(`/fixed-plans/${planId}/early-funding`, { enableEarlyFunding });
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to update early funding:", error);
|
||||
// Still close the modal even if it fails
|
||||
onClose();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const nextDueLabel = nextDueDate
|
||||
? formatDateInTimezone(nextDueDate, timezone, {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
: "next billing cycle";
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6 space-y-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
Start Funding Early?
|
||||
</h2>
|
||||
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
You've paid <strong>{planName}</strong> which is due on <strong>{nextDueLabel}</strong>.
|
||||
</p>
|
||||
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
Would you like to start funding for the next payment now, or wait until closer to the due date?
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 pt-4">
|
||||
<button
|
||||
onClick={() => handleResponse(true)}
|
||||
disabled={loading}
|
||||
className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400
|
||||
text-white font-medium rounded-md transition-colors"
|
||||
>
|
||||
{loading ? "Starting..." : "Yes, Start Now"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleResponse(false)}
|
||||
disabled={loading}
|
||||
className="flex-1 px-4 py-2 bg-gray-200 hover:bg-gray-300 dark:bg-gray-700
|
||||
dark:hover:bg-gray-600 disabled:bg-gray-100 text-gray-900 dark:text-white
|
||||
font-medium rounded-md transition-colors"
|
||||
>
|
||||
Wait Until Rollover
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 pt-2">
|
||||
<strong>Start Now:</strong> Your next income will begin funding this bill.<br/>
|
||||
<strong>Wait:</strong> Funding will resume automatically on {nextDueLabel}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
web/src/components/EarlyPaymentPromptModal.tsx
Normal file
83
web/src/components/EarlyPaymentPromptModal.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useState } from "react";
|
||||
import { apiPatch } from "../api/http";
|
||||
import { formatDateInTimezone } from "../utils/timezone";
|
||||
|
||||
type EarlyPaymentPromptModalProps = {
|
||||
planId: string;
|
||||
planName: string;
|
||||
dueOn: string;
|
||||
timezone: string;
|
||||
onClose: () => void;
|
||||
onConfirmed: () => void;
|
||||
};
|
||||
|
||||
export default function EarlyPaymentPromptModal({
|
||||
planId,
|
||||
planName,
|
||||
dueOn,
|
||||
timezone,
|
||||
onClose,
|
||||
onConfirmed,
|
||||
}: EarlyPaymentPromptModalProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const dueLabel = formatDateInTimezone(dueOn, timezone, {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
const handleConfirm = async (paidEarly: boolean) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
if (paidEarly) {
|
||||
await apiPatch(`/fixed-plans/${planId}/early-funding`, {
|
||||
enableEarlyFunding: true,
|
||||
});
|
||||
onConfirmed();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to mark paid early:", error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-[color:var(--color-bg)]/80 backdrop-blur-sm flex items-center justify-center z-50">
|
||||
<div className="bg-[--color-bg] border rounded-xl p-6 max-w-md mx-4 space-y-4 shadow-2xl">
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-semibold text-lg">Paid early?</h3>
|
||||
<p className="text-sm muted">
|
||||
{planName} is fully funded for the due date on {dueLabel}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-[color:var(--color-muted)]">
|
||||
Did you actually pay this expense early in real life? If yes, we will
|
||||
move it to the next cycle so your budget stays accurate.
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn bg-gray-600 hover:bg-gray-500"
|
||||
onClick={() => handleConfirm(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
No, keep it
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn ml-auto"
|
||||
onClick={() => handleConfirm(true)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Updating..." : "Yes, paid early"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
143
web/src/components/FundingConfirmationModal.tsx
Normal file
143
web/src/components/FundingConfirmationModal.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { useState } from "react";
|
||||
import { fixedPlansApi } from "../api/fixedPlans";
|
||||
import { Money } from "./ui";
|
||||
|
||||
type FundingConfirmationModalProps = {
|
||||
planId: string;
|
||||
planName: string;
|
||||
totalCents: number;
|
||||
fundedCents: number;
|
||||
availableBudget: number;
|
||||
onClose: () => void;
|
||||
onFundingComplete: (result: {
|
||||
totalCents: number;
|
||||
fundedCents: number;
|
||||
isOverdue?: boolean;
|
||||
overdueAmount?: number;
|
||||
message?: string;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
export default function FundingConfirmationModal({
|
||||
planId,
|
||||
planName,
|
||||
totalCents,
|
||||
fundedCents,
|
||||
availableBudget,
|
||||
onClose,
|
||||
onFundingComplete,
|
||||
}: FundingConfirmationModalProps) {
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const shortfall = totalCents - fundedCents;
|
||||
const canFund = availableBudget >= shortfall;
|
||||
|
||||
const handleRemindLater = () => {
|
||||
// Store dismissal timestamp in localStorage (4 hours from now)
|
||||
const dismissedUntil = Date.now() + (4 * 60 * 60 * 1000); // 4 hours
|
||||
localStorage.setItem(`overdue-dismissed-${planId}`, dismissedUntil.toString());
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handlePullFromBudget = async () => {
|
||||
setError("");
|
||||
setIsProcessing(true);
|
||||
|
||||
try {
|
||||
const result = await fixedPlansApi.attemptFinalFunding(planId);
|
||||
onFundingComplete(result);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Failed to fund from available budget");
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-[color:var(--color-bg)]/80 backdrop-blur-sm flex items-center justify-center z-50">
|
||||
<div className="bg-[--color-bg] border rounded-xl p-6 max-w-md mx-4 space-y-4 shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-semibold text-lg">{planName} is due today</h3>
|
||||
<p className="text-sm muted">
|
||||
This bill is not fully funded yet.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Funding Details */}
|
||||
<div className="space-y-2 p-3 bg-gray-800/30 rounded-lg">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="muted">Total amount:</span>
|
||||
<span className="font-mono"><Money cents={totalCents} /></span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="muted">Currently funded:</span>
|
||||
<span className="font-mono"><Money cents={fundedCents} /></span>
|
||||
</div>
|
||||
<div className="border-t border-gray-700 pt-2 mt-2">
|
||||
<div className="flex justify-between text-sm font-semibold text-amber-700 dark:text-yellow-400">
|
||||
<span>Still needed:</span>
|
||||
<span className="font-mono"><Money cents={shortfall} /></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Available Budget */}
|
||||
<div className="p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="muted">Available budget:</span>
|
||||
<span className="font-mono"><Money cents={availableBudget} /></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
{canFund ? (
|
||||
<div className="text-sm text-center muted">
|
||||
Would you like to pull <Money cents={shortfall} /> from your available budget to fully fund this payment?
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-3 bg-red-500/10 border border-red-500 rounded-lg text-sm text-red-400">
|
||||
Insufficient available budget. You need <Money cents={shortfall - availableBudget} /> more to fully fund this payment.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="p-3 bg-red-500/10 border border-red-500 rounded-lg text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn bg-yellow-600 hover:bg-yellow-500"
|
||||
onClick={handleRemindLater}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Remind Later
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn bg-gray-600 hover:bg-gray-500"
|
||||
onClick={onClose}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{canFund && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn ml-auto"
|
||||
onClick={handlePullFromBudget}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing ? "Processing..." : "Pull from Budget"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
web/src/components/NavBar.tsx
Normal file
102
web/src/components/NavBar.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { NavLink, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useEffect, useState } from "react";
|
||||
import ThemeToggle from "./ThemeToggle";
|
||||
|
||||
export default function NavBar({
|
||||
hideOn = ["/onboarding", "/login", "/register"],
|
||||
}: {
|
||||
hideOn?: string[];
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const linkClass = ({ isActive }: { isActive: boolean }) =>
|
||||
"nav-link " + (isActive ? "nav-link-active" : "");
|
||||
const mobileLinkClass = ({ isActive }: { isActive: boolean }) =>
|
||||
"nav-link text-[--color-text] " +
|
||||
(isActive ? "nav-link-active" : "hover:bg-[--color-ink]/20");
|
||||
|
||||
useEffect(() => {
|
||||
setMenuOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
if (hideOn.some((p) => pathname.startsWith(p))) return null;
|
||||
|
||||
return (
|
||||
<header
|
||||
className="topnav sticky top-0 z-40 border-b"
|
||||
style={{ backdropFilter: "blur(8px)" }}
|
||||
aria-label="Primary"
|
||||
>
|
||||
<div className="container h-14 min-h-14 flex items-center gap-2 flex-nowrap">
|
||||
{/* Brand */}
|
||||
<button
|
||||
onClick={() => navigate("/")}
|
||||
className="brand row items-center shrink-0 px-2 py-1 rounded-lg hover:bg-[--color-panel] transition-all"
|
||||
aria-label="Go to dashboard"
|
||||
>
|
||||
<span className="font-bold text-xl tracking-wide">SkyMoney</span>
|
||||
</button>
|
||||
|
||||
{/* Links */}
|
||||
<nav className="ml-2 topnav-links items-center gap-1 flex-1 overflow-x-auto whitespace-nowrap hide-scrollbar">
|
||||
<NavLink to="/" className={linkClass} end>Dashboard</NavLink>
|
||||
<NavLink to="/spend" className={linkClass}>Transactions</NavLink>
|
||||
<NavLink to="/income" className={linkClass}>Income</NavLink>
|
||||
<NavLink to="/transactions" className={linkClass}>Records</NavLink>
|
||||
<NavLink to="/settings" className={linkClass}>Settings</NavLink>
|
||||
</nav>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="ml-auto row gap-2 items-center shrink-0">
|
||||
<div className="topnav-theme items-center gap-2">
|
||||
<ThemeToggle size="sm" />
|
||||
</div>
|
||||
|
||||
{/* Mobile menu */}
|
||||
<div className="topnav-mobile relative">
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
"rounded-xl border border-[--color-border] bg-[--color-panel] px-3 py-2 text-[--color-text] transition " +
|
||||
(menuOpen ? "shadow-md" : "hover:bg-[--color-ink]/10")
|
||||
}
|
||||
aria-expanded={menuOpen}
|
||||
aria-controls="mobile-menu"
|
||||
onClick={() => setMenuOpen((open) => !open)}
|
||||
>
|
||||
<span className="sr-only">Open menu</span>
|
||||
<span className="grid gap-1">
|
||||
<span className="h-0.5 w-5 bg-current transition-all" />
|
||||
<span className="h-0.5 w-5 bg-current transition-all" />
|
||||
<span className="h-0.5 w-5 bg-current transition-all" />
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
id="mobile-menu"
|
||||
className={
|
||||
"absolute right-0 top-full mt-2 w-64 origin-top-right rounded-2xl border bg-[--color-surface] p-3 shadow-lg transition-all duration-200 ease-out " +
|
||||
(menuOpen
|
||||
? "opacity-100 translate-y-0"
|
||||
: "pointer-events-none opacity-0 -translate-y-2")
|
||||
}
|
||||
role="menu"
|
||||
aria-hidden={!menuOpen}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<NavLink to="/" className={mobileLinkClass} end>Dashboard</NavLink>
|
||||
<NavLink to="/spend" className={mobileLinkClass}>Transactions</NavLink>
|
||||
<NavLink to="/income" className={mobileLinkClass}>Income</NavLink>
|
||||
<NavLink to="/transactions" className={mobileLinkClass}>Records</NavLink>
|
||||
<NavLink to="/settings" className={mobileLinkClass}>Settings</NavLink>
|
||||
</div>
|
||||
<div className="mt-3 border-t border-[--color-border] pt-3 flex items-center justify-between">
|
||||
<ThemeToggle size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
398
web/src/components/OnboardingTracker.tsx
Normal file
398
web/src/components/OnboardingTracker.tsx
Normal file
@@ -0,0 +1,398 @@
|
||||
import { useMemo } from "react";
|
||||
import { previewAllocation } from "../utils/allocatorPreview";
|
||||
import { dateStringToUTCMidnight, getBrowserTimezone, isoToDateString } from "../utils/timezone";
|
||||
|
||||
type VariableCat = {
|
||||
id: string;
|
||||
name: string;
|
||||
percent: number;
|
||||
priority: number;
|
||||
isSavings?: boolean;
|
||||
};
|
||||
|
||||
type FixedItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
amountCents: number;
|
||||
priority: number;
|
||||
dueOn: string;
|
||||
autoPayEnabled?: boolean;
|
||||
};
|
||||
|
||||
type OnboardingTrackerProps = {
|
||||
step: number;
|
||||
budgetCents: number;
|
||||
vars: VariableCat[];
|
||||
fixeds: FixedItem[];
|
||||
incomeType: "regular" | "irregular";
|
||||
budgetPeriod?: "weekly" | "biweekly" | "monthly";
|
||||
conservatismPercent?: number; // For irregular income: percentage to allocate to fixed expenses
|
||||
firstIncomeDate?: Date | string; // For accurate pay period calculation
|
||||
userTimezone?: string;
|
||||
};
|
||||
|
||||
const fmtMoney = (cents: number) =>
|
||||
new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 0,
|
||||
}).format((cents ?? 0) / 100);
|
||||
|
||||
// Calculate expected funding per paycheck for regular income users
|
||||
// Count actual pay periods between two dates, matching API logic
|
||||
function countPayPeriodsBetween(
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
firstIncomeDate: Date,
|
||||
frequency: "weekly" | "biweekly" | "monthly",
|
||||
timezone: string
|
||||
): number {
|
||||
let count = 0;
|
||||
let nextPayDate = new Date(firstIncomeDate);
|
||||
|
||||
const targetDay = Number(isoToDateString(firstIncomeDate.toISOString(), timezone).split("-")[2] || "1");
|
||||
const advanceByPeriod = () => {
|
||||
if (frequency === "monthly") {
|
||||
const year = nextPayDate.getUTCFullYear();
|
||||
const month = nextPayDate.getUTCMonth() + 1;
|
||||
const daysInMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
|
||||
nextPayDate = new Date(Date.UTC(year, month, Math.min(targetDay, daysInMonth)));
|
||||
} else {
|
||||
const days = frequency === "biweekly" ? 14 : 7;
|
||||
nextPayDate = new Date(nextPayDate.getTime() + days * 86_400_000);
|
||||
}
|
||||
};
|
||||
|
||||
// Advance to the first pay date on or after startDate
|
||||
while (nextPayDate < startDate) {
|
||||
advanceByPeriod();
|
||||
}
|
||||
|
||||
// Count all pay dates up to (but not including) the end date
|
||||
while (nextPayDate < endDate) {
|
||||
count++;
|
||||
advanceByPeriod();
|
||||
}
|
||||
|
||||
// Ensure at least 1 period to avoid division by zero
|
||||
return Math.max(1, count);
|
||||
}
|
||||
|
||||
function calculateExpectedFunding(
|
||||
totalCents: number,
|
||||
dueDate: string,
|
||||
incomeFrequency: "weekly" | "biweekly" | "monthly",
|
||||
firstIncomeDate?: Date | string,
|
||||
now = new Date(),
|
||||
timezone = getBrowserTimezone()
|
||||
): number {
|
||||
const daysPerPaycheck = incomeFrequency === "weekly" ? 7 : incomeFrequency === "biweekly" ? 14 : 30;
|
||||
const todayIso = dateStringToUTCMidnight(isoToDateString(now.toISOString(), timezone), timezone);
|
||||
const dueIso = dateStringToUTCMidnight(dueDate, timezone);
|
||||
|
||||
const due = new Date(dueIso);
|
||||
const userNow = new Date(todayIso);
|
||||
|
||||
let totalPaychecks: number;
|
||||
|
||||
if (firstIncomeDate) {
|
||||
// Use the same logic as the API: count actual pay dates
|
||||
const firstIncomeIso =
|
||||
typeof firstIncomeDate === "string"
|
||||
? firstIncomeDate.includes("T")
|
||||
? dateStringToUTCMidnight(isoToDateString(firstIncomeDate, timezone), timezone)
|
||||
: dateStringToUTCMidnight(firstIncomeDate, timezone)
|
||||
: dateStringToUTCMidnight(isoToDateString(firstIncomeDate.toISOString(), timezone), timezone);
|
||||
const firstIncome = new Date(firstIncomeIso);
|
||||
totalPaychecks = countPayPeriodsBetween(userNow, due, firstIncome, incomeFrequency, timezone);
|
||||
} else {
|
||||
// Fallback to simple calculation if firstIncomeDate not provided
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const daysUntilDue = Math.max(0, Math.ceil((due.getTime() - userNow.getTime()) / DAY_MS));
|
||||
totalPaychecks = Math.max(1, Math.ceil(daysUntilDue / daysPerPaycheck));
|
||||
}
|
||||
|
||||
// Amount to fund per paycheck - use ceil to match API
|
||||
const perPaycheck = Math.ceil(totalCents / totalPaychecks);
|
||||
|
||||
return perPaycheck;
|
||||
}
|
||||
|
||||
export default function OnboardingTracker({
|
||||
step,
|
||||
budgetCents,
|
||||
vars,
|
||||
fixeds,
|
||||
incomeType,
|
||||
budgetPeriod = "monthly",
|
||||
conservatismPercent = 40,
|
||||
firstIncomeDate,
|
||||
userTimezone,
|
||||
}: OnboardingTrackerProps) {
|
||||
const timezone = userTimezone || getBrowserTimezone();
|
||||
// Only show tracker on steps 4, 5, and 6 (categories, fixed plans, review)
|
||||
const shouldShow = step >= 4;
|
||||
|
||||
// Calculate totals
|
||||
const eligibleFixeds = useMemo(
|
||||
() => fixeds.filter((f) => f.autoPayEnabled),
|
||||
[fixeds]
|
||||
);
|
||||
|
||||
const fixedTotal = useMemo(
|
||||
() => eligibleFixeds.reduce((sum, f) => sum + (f.amountCents || 0), 0),
|
||||
[eligibleFixeds]
|
||||
);
|
||||
|
||||
const varsTotal = useMemo(
|
||||
() => vars.reduce((sum, v) => sum + (v.percent || 0), 0),
|
||||
[vars]
|
||||
);
|
||||
|
||||
// Preview allocation if we have a budget
|
||||
const preview = useMemo(() => {
|
||||
if (budgetCents <= 0) return null;
|
||||
|
||||
// Convert onboarding data to the format expected by previewAllocation
|
||||
const fixedPlans = eligibleFixeds.map(f => {
|
||||
let totalCents = f.amountCents;
|
||||
let fundedCents = 0;
|
||||
|
||||
if (incomeType === "regular" && f.autoPayEnabled) {
|
||||
// Regular income: calculate per-paycheck amount based on due date
|
||||
totalCents = calculateExpectedFunding(f.amountCents, f.dueOn, budgetPeriod, firstIncomeDate, new Date(), timezone);
|
||||
}
|
||||
// For irregular income, we pass the full amount as the need
|
||||
// The conservatism will be applied by limiting the total budget passed to previewAllocation
|
||||
|
||||
return {
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
totalCents,
|
||||
fundedCents,
|
||||
dueOn: f.dueOn,
|
||||
priority: f.priority,
|
||||
cycleStart: f.dueOn,
|
||||
};
|
||||
});
|
||||
|
||||
const variableCategories = vars.map(v => ({
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
percent: v.percent,
|
||||
balanceCents: 0,
|
||||
isSavings: v.isSavings || false,
|
||||
priority: v.priority,
|
||||
}));
|
||||
|
||||
// For irregular income, apply conservatism to split budget between fixed and variable
|
||||
if (incomeType === "irregular") {
|
||||
const fixedPercentage = conservatismPercent / 100;
|
||||
const fixedBudget = Math.floor(budgetCents * fixedPercentage);
|
||||
const variableBudget = budgetCents - fixedBudget;
|
||||
|
||||
// Allocate fixed budget to fixed plans (by priority)
|
||||
const fixedResult = previewAllocation(fixedBudget, fixedPlans, []);
|
||||
|
||||
// Allocate remaining budget to variables
|
||||
const variableResult = previewAllocation(variableBudget, [], variableCategories);
|
||||
|
||||
return {
|
||||
fixed: fixedResult.fixed,
|
||||
variable: variableResult.variable,
|
||||
unallocatedCents: fixedResult.unallocatedCents + variableResult.unallocatedCents,
|
||||
};
|
||||
}
|
||||
|
||||
// For regular income, use standard allocation (fixed first, then variable)
|
||||
return previewAllocation(budgetCents, fixedPlans, variableCategories);
|
||||
}, [
|
||||
budgetCents,
|
||||
eligibleFixeds,
|
||||
vars,
|
||||
incomeType,
|
||||
budgetPeriod,
|
||||
conservatismPercent,
|
||||
firstIncomeDate,
|
||||
timezone,
|
||||
]);
|
||||
|
||||
// Calculate actual fixed allocation amount from preview
|
||||
const fixedAllocated = preview
|
||||
? preview.fixed.reduce((sum, f) => sum + f.amountCents, 0)
|
||||
: 0;
|
||||
|
||||
const variableAmount = preview
|
||||
? preview.variable.reduce((sum, v) => sum + v.amountCents, 0)
|
||||
: budgetCents - fixedTotal;
|
||||
|
||||
const isValid = varsTotal === 100 && budgetCents > 0;
|
||||
|
||||
if (!shouldShow || budgetCents <= 0) return null;
|
||||
|
||||
return (
|
||||
<div className="onboarding-tracker">
|
||||
<div className="row items-center gap-2 pb-3 border-b border-[--color-ink]/20">
|
||||
<div className="w-3 h-3 rounded-full bg-green-500 animate-pulse" />
|
||||
<h3 className="font-bold text-sm">Live Budget Tracker</h3>
|
||||
</div>
|
||||
|
||||
{/* Total Budget */}
|
||||
<div className="tracker-section tracker-budget">
|
||||
<div className="text-xs muted">Total Budget</div>
|
||||
<div className="font-bold text-xl sm:text-2xl font-mono">{fmtMoney(budgetCents)}</div>
|
||||
<div className="text-xs text-blue-600 dark:text-blue-400">
|
||||
Available to allocate
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fixed Expenses */}
|
||||
<div className="tracker-section tracker-fixed">
|
||||
<div className="row items-center justify-between">
|
||||
<div className="text-xs muted">Fixed Expenses</div>
|
||||
<div className="text-xs font-bold">{eligibleFixeds.length} items</div>
|
||||
</div>
|
||||
<div className="font-bold text-lg sm:text-xl font-mono">{fmtMoney(fixedTotal)}</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className="bg-orange-500 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${Math.min(100, (fixedAllocated / budgetCents) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs muted">
|
||||
{budgetCents > 0
|
||||
? `${Math.round((fixedAllocated / budgetCents) * 100)}% allocated`
|
||||
: "0% of budget"}
|
||||
</div>
|
||||
|
||||
{/* Individual fixed expenses breakdown */}
|
||||
{eligibleFixeds.length > 0 && (
|
||||
<div className="tracker-breakdown">
|
||||
<div className="text-xs font-semibold mb-1">Breakdown:</div>
|
||||
{eligibleFixeds.map((f) => {
|
||||
const previewItem = preview?.fixed.find(item => item.id === f.id);
|
||||
const totalAmount = f.amountCents;
|
||||
const fundedAmount = previewItem?.amountCents || 0;
|
||||
|
||||
const fundingPercentage = totalAmount > 0
|
||||
? Math.round((fundedAmount / totalAmount) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div key={f.id} className="stack gap-1 py-1">
|
||||
<div className="tracker-breakdown-row">
|
||||
<span className="tracker-breakdown-name">
|
||||
{f.name || "Unnamed"}
|
||||
</span>
|
||||
<span className="font-mono text-xs">
|
||||
{fmtMoney(totalAmount)}
|
||||
</span>
|
||||
</div>
|
||||
{f.autoPayEnabled && fundedAmount > 0 && (
|
||||
<div className={`text-xs ${
|
||||
incomeType === "regular"
|
||||
? "text-green-700 dark:text-green-600"
|
||||
: "text-blue-600 dark:text-blue-400"
|
||||
}`}>
|
||||
{incomeType === "regular" ? "Auto" : "Plan"}: {fmtMoney(fundedAmount)} ({fundingPercentage}%)
|
||||
</div>
|
||||
)}
|
||||
{f.autoPayEnabled && fundedAmount === 0 && (
|
||||
<div className="text-xs text-amber-600 dark:text-amber-400">
|
||||
Plan enabled (no funds yet)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Variable Categories */}
|
||||
<div className="tracker-section tracker-variable">
|
||||
<div className="row items-center justify-between">
|
||||
<div className="text-xs muted">Variable Budget</div>
|
||||
<div className="text-xs font-bold">{vars.length} categories</div>
|
||||
</div>
|
||||
<div className="font-bold text-lg sm:text-xl font-mono">{fmtMoney(variableAmount)}</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className="bg-green-500 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${Math.min(100, (variableAmount / budgetCents) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs muted">
|
||||
{budgetCents > 0
|
||||
? `${Math.round((variableAmount / budgetCents) * 100)}% of budget`
|
||||
: "0% of budget"}
|
||||
</div>
|
||||
|
||||
{/* Individual variable categories breakdown */}
|
||||
{vars.length > 0 && preview && (
|
||||
<div className="tracker-breakdown">
|
||||
<div className="text-xs font-semibold mb-1">Breakdown:</div>
|
||||
{preview.variable.map((item) => {
|
||||
const originalVar = vars.find(v => v.id === item.id);
|
||||
const percentage = originalVar?.percent || 0;
|
||||
|
||||
return (
|
||||
<div key={item.id} className="tracker-breakdown-row py-1">
|
||||
<span className="tracker-breakdown-name">
|
||||
{item.name || "Unnamed"}
|
||||
{originalVar?.isSavings && (
|
||||
<span className="text-green-700 dark:text-green-400 font-bold"> (Savings)</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="font-mono text-xs whitespace-nowrap">
|
||||
{fmtMoney(item.amountCents)} ({percentage}%)
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Percentage validation */}
|
||||
<div className="pt-2 border-t border-green-300 dark:border-green-700">
|
||||
<div className="row items-center justify-between">
|
||||
<span className="text-xs muted">Total percentage:</span>
|
||||
<span
|
||||
className={`text-xs font-bold ${
|
||||
varsTotal === 100
|
||||
? "text-green-700 dark:text-green-400"
|
||||
: "text-red-600 dark:text-red-400"
|
||||
}`}
|
||||
>
|
||||
{varsTotal}%
|
||||
</span>
|
||||
</div>
|
||||
{varsTotal !== 100 && (
|
||||
<div className="text-xs text-red-600 dark:text-red-400 mt-1">
|
||||
Must equal 100%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remaining/Unallocated */}
|
||||
{preview && preview.unallocatedCents > 0 && (
|
||||
<div className="tracker-section bg-gray-50 dark:bg-gray-800/50">
|
||||
<div className="text-xs muted">Unallocated</div>
|
||||
<div className="font-bold text-lg font-mono text-gray-600 dark:text-gray-400">
|
||||
{fmtMoney(preview.unallocatedCents)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status indicator */}
|
||||
<div className={`text-center text-xs p-2 rounded-lg ${
|
||||
isValid
|
||||
? "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300"
|
||||
: "bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300"
|
||||
}`}>
|
||||
{isValid ? "Ready to continue" : "Complete setup to continue"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import React from "react";
|
||||
|
||||
export default function Pagination({
|
||||
page,
|
||||
limit,
|
||||
|
||||
214
web/src/components/PaydayOverlay.tsx
Normal file
214
web/src/components/PaydayOverlay.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { http } from "../api/http";
|
||||
import { useAuthSession } from "../hooks/useAuthSession";
|
||||
import { useDashboard } from "../hooks/useDashboard";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { dateStringToUTCMidnight, formatDateInTimezone, getBrowserTimezone } from "../utils/timezone";
|
||||
import CurrencyInput from "./CurrencyInput";
|
||||
|
||||
type PaydayStatus = {
|
||||
shouldShowOverlay: boolean;
|
||||
pendingScheduledIncome: boolean;
|
||||
nextPayday: string | null;
|
||||
};
|
||||
|
||||
export default function PaydayOverlay() {
|
||||
const session = useAuthSession();
|
||||
const { data: dashboard } = useDashboard();
|
||||
const queryClient = useQueryClient();
|
||||
const [paydayStatus, setPaydayStatus] = useState<PaydayStatus | null>(null);
|
||||
const [showPopup, setShowPopup] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [incomeCents, setIncomeCents] = useState(0);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const userTimezone = dashboard?.user?.timezone || getBrowserTimezone();
|
||||
const debugNow = new URLSearchParams(window.location.search).get("debugNow");
|
||||
const debugNowISO = debugNow ? dateStringToUTCMidnight(debugNow, userTimezone) : null;
|
||||
const debugNowDate = debugNowISO ? new Date(debugNowISO) : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!session.data?.userId) return;
|
||||
|
||||
const checkPaydayStatus = async () => {
|
||||
try {
|
||||
const status = await http<PaydayStatus>("/payday/status", {
|
||||
query: debugNow ? { debugNow } : undefined,
|
||||
});
|
||||
setPaydayStatus(status);
|
||||
if (status.shouldShowOverlay) {
|
||||
setShowPopup(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check payday status:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkPaydayStatus();
|
||||
}, [session.data?.userId]);
|
||||
|
||||
const handleDismiss = async () => {
|
||||
try {
|
||||
await http("/payday/dismiss", { method: "POST" });
|
||||
setShowPopup(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to dismiss payday overlay:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddIncome = async () => {
|
||||
if (!incomeCents || incomeCents <= 0) {
|
||||
setError("Please enter a valid amount");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await http("/income", {
|
||||
method: "POST",
|
||||
body: {
|
||||
amountCents: incomeCents,
|
||||
isScheduledIncome: true,
|
||||
occurredAtISO: debugNowISO ?? new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
// Refresh dashboard data
|
||||
await queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
|
||||
// Dismiss overlay
|
||||
await http("/payday/dismiss", { method: "POST" });
|
||||
setShowPopup(false);
|
||||
} catch (err: any) {
|
||||
console.error("Failed to add income:", err);
|
||||
setError(err.message || "Failed to add income. Please try again.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading || !showPopup || !paydayStatus) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40 animate-fade-in"
|
||||
onClick={handleDismiss}
|
||||
/>
|
||||
|
||||
{/* Popup */}
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 pointer-events-none">
|
||||
<div
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-2xl max-w-md w-full p-6 pointer-events-auto animate-scale-in"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
It's Payday!
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{formatDateInTimezone((debugNowDate ?? new Date()).toISOString(), userTimezone, {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div className="mb-4">
|
||||
<p className="text-gray-700 dark:text-gray-300 mb-3">
|
||||
Ready to add your paycheck? Recording your scheduled income helps us:
|
||||
</p>
|
||||
<ul className="space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-green-500 mt-0.5">OK</span>
|
||||
<span>Fund your payment plans automatically</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-green-500 mt-0.5">OK</span>
|
||||
<span>Track your regular income vs bonuses</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-green-500 mt-0.5">OK</span>
|
||||
<span>Keep your budget on schedule</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Income Input */}
|
||||
<div className="mb-6">
|
||||
<label htmlFor="income-amount" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Paycheck Amount
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 pointer-events-none">
|
||||
$
|
||||
</span>
|
||||
<CurrencyInput
|
||||
id="income-amount"
|
||||
valueCents={incomeCents}
|
||||
onChange={(cents) => {
|
||||
setIncomeCents(cents);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder="0.00"
|
||||
className="w-full !pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
disabled={isSubmitting}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="mt-2 text-sm text-red-600 dark:text-red-400">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Maybe Later
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAddIncome}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={isSubmitting || !incomeCents}
|
||||
>
|
||||
{isSubmitting ? "Adding..." : "Add Income"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Next payday info */}
|
||||
{paydayStatus.nextPayday && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 text-center">
|
||||
Next expected payday:{" "}
|
||||
{formatDateInTimezone(paydayStatus.nextPayday, userTimezone, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
51
web/src/components/PaymentConfirmationModal.tsx
Normal file
51
web/src/components/PaymentConfirmationModal.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface PaymentConfirmationModalProps {
|
||||
message: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function PaymentConfirmationModal({
|
||||
message,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: PaymentConfirmationModalProps) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
dialogRef.current?.showModal();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<dialog ref={dialogRef} className="modal">
|
||||
<div className="modal-box">
|
||||
<h3 className="font-bold text-lg mb-3">Confirm Payment</h3>
|
||||
<p className="text-sm mb-4">{message}</p>
|
||||
|
||||
<div className="row gap-2 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => {
|
||||
dialogRef.current?.close();
|
||||
onCancel();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => {
|
||||
dialogRef.current?.close();
|
||||
onConfirm();
|
||||
}}
|
||||
>
|
||||
Confirm Payment
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
239
web/src/components/PaymentReconciliationModal.tsx
Normal file
239
web/src/components/PaymentReconciliationModal.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import { useState } from "react";
|
||||
import { fixedPlansApi } from "../api/fixedPlans";
|
||||
import { createTransaction } from "../api/transactions";
|
||||
|
||||
type PaymentReconciliationModalProps = {
|
||||
planId: string;
|
||||
planName: string;
|
||||
totalCents: number;
|
||||
fundedCents: number;
|
||||
isOverdue: boolean;
|
||||
overdueAmount?: number;
|
||||
message: string;
|
||||
nextDueDate: string;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
};
|
||||
|
||||
export default function PaymentReconciliationModal({
|
||||
planId,
|
||||
planName,
|
||||
totalCents,
|
||||
fundedCents,
|
||||
isOverdue,
|
||||
overdueAmount,
|
||||
message,
|
||||
nextDueDate,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: PaymentReconciliationModalProps) {
|
||||
const [paymentType, setPaymentType] = useState<"full" | "partial" | "none">("full");
|
||||
const [partialAmount, setPartialAmount] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const formatMoney = (cents: number) => {
|
||||
return `$${(cents / 100).toFixed(2)}`;
|
||||
};
|
||||
|
||||
const handleRemindLater = () => {
|
||||
// Store dismissal timestamp in localStorage (4 hours from now)
|
||||
const dismissedUntil = Date.now() + (4 * 60 * 60 * 1000); // 4 hours
|
||||
localStorage.setItem(`overdue-dismissed-${planId}`, dismissedUntil.toString());
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError("");
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
if (paymentType === "full") {
|
||||
// Full payment
|
||||
await createTransaction({
|
||||
kind: "fixed_payment",
|
||||
amountCents: totalCents,
|
||||
planId: planId,
|
||||
occurredAtISO: new Date().toISOString(),
|
||||
note: `Payment for ${planName}`,
|
||||
isReconciled: true,
|
||||
});
|
||||
onSuccess();
|
||||
} else if (paymentType === "partial") {
|
||||
// Partial payment
|
||||
const partialCents = Math.round(parseFloat(partialAmount) * 100);
|
||||
if (isNaN(partialCents) || partialCents <= 0) {
|
||||
setError("Please enter a valid amount");
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
if (partialCents >= totalCents) {
|
||||
setError("Partial amount must be less than total");
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await createTransaction({
|
||||
kind: "fixed_payment",
|
||||
amountCents: partialCents,
|
||||
planId: planId,
|
||||
occurredAtISO: new Date().toISOString(),
|
||||
note: `Partial payment for ${planName}`,
|
||||
isReconciled: true,
|
||||
});
|
||||
onSuccess();
|
||||
} else {
|
||||
// No payment - mark as unpaid
|
||||
await fixedPlansApi.markUnpaid(planId);
|
||||
onSuccess();
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Failed to process payment");
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-[color:var(--color-bg)]/80 backdrop-blur-sm flex items-center justify-center z-50">
|
||||
<div className="bg-[--color-bg] border rounded-xl p-6 max-w-md mx-4 space-y-4 shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-semibold text-lg">{planName} is due</h3>
|
||||
<p className="text-sm muted">
|
||||
{isOverdue
|
||||
? `Warning: ${message}`
|
||||
: "Was the full amount paid?"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Plan Details */}
|
||||
<div className="space-y-2 p-3 bg-gray-800/30 rounded-lg">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="muted">Total amount:</span>
|
||||
<span className="font-mono">{formatMoney(totalCents)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="muted">Currently funded:</span>
|
||||
<span className="font-mono">{formatMoney(fundedCents)}</span>
|
||||
</div>
|
||||
{isOverdue && overdueAmount && overdueAmount > 0 && (
|
||||
<div className="flex justify-between text-sm text-red-400">
|
||||
<span>Overdue amount:</span>
|
||||
<span className="font-mono">{formatMoney(overdueAmount)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="muted">Next due date:</span>
|
||||
<span className="font-mono">{nextDueDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment Options */}
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium">How much was paid?</div>
|
||||
|
||||
{/* Full Payment */}
|
||||
<button
|
||||
type="button"
|
||||
className={`w-full p-3 rounded-lg border-2 text-left transition-colors ${
|
||||
paymentType === "full"
|
||||
? "border-blue-500 bg-blue-500/10"
|
||||
: "border-gray-700 hover:border-gray-600"
|
||||
}`}
|
||||
onClick={() => setPaymentType("full")}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<div className="font-medium">Full amount ({formatMoney(totalCents)})</div>
|
||||
<div className="text-xs muted">I paid the complete bill</div>
|
||||
</button>
|
||||
|
||||
{/* Partial Payment */}
|
||||
<div
|
||||
className={`p-3 rounded-lg border-2 transition-colors ${
|
||||
paymentType === "partial"
|
||||
? "border-yellow-500 bg-yellow-500/10"
|
||||
: "border-gray-700"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-left"
|
||||
onClick={() => setPaymentType("partial")}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<div className="font-medium">Partial payment</div>
|
||||
<div className="text-xs muted">I paid some of the bill</div>
|
||||
</button>
|
||||
{paymentType === "partial" && (
|
||||
<div className="mt-2">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
max={(totalCents / 100).toFixed(2)}
|
||||
placeholder="0.00"
|
||||
className="input w-full"
|
||||
value={partialAmount}
|
||||
onChange={(e) => setPartialAmount(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* No Payment */}
|
||||
<button
|
||||
type="button"
|
||||
className={`w-full p-3 rounded-lg border-2 text-left transition-colors ${
|
||||
paymentType === "none"
|
||||
? "border-red-500 bg-red-500/10"
|
||||
: "border-gray-700 hover:border-gray-600"
|
||||
}`}
|
||||
onClick={() => setPaymentType("none")}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<div className="font-medium">Not paid yet</div>
|
||||
<div className="text-xs muted">Mark as overdue, fund on next income</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="p-3 bg-red-500/10 border border-red-500 rounded-lg text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-2">
|
||||
{isOverdue && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn bg-yellow-600 hover:bg-yellow-500"
|
||||
onClick={handleRemindLater}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Remind Later
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn bg-gray-600 hover:bg-gray-500"
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn ml-auto"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Processing..." : "Confirm"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import { useDashboard } from "../hooks/useDashboard";
|
||||
|
||||
export default function PercentGuard() {
|
||||
@@ -10,7 +9,7 @@ export default function PercentGuard() {
|
||||
|
||||
return (
|
||||
<div className="toast-err">
|
||||
Variable category percents must sum to <strong>100%</strong> (currently {total}%).
|
||||
Expense category percents must sum to <strong>100%</strong> (currently {total}%).
|
||||
Adjust them before recording income.
|
||||
</div>
|
||||
);
|
||||
|
||||
35
web/src/components/RequireAuth.tsx
Normal file
35
web/src/components/RequireAuth.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Navigate, useLocation } from "react-router-dom";
|
||||
import { useAuthSession } from "../hooks/useAuthSession";
|
||||
|
||||
type Props = { children: ReactNode };
|
||||
|
||||
export function RequireAuth({ children }: Props) {
|
||||
const location = useLocation();
|
||||
const session = useAuthSession();
|
||||
|
||||
if (session.isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center text-sm muted">
|
||||
Checking session…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (session.isError) {
|
||||
const status = (session.error as any)?.status ?? 0;
|
||||
if (status === 401) {
|
||||
const next = encodeURIComponent(
|
||||
`${location.pathname}${location.search}`.replace(/^$/, "/")
|
||||
);
|
||||
return <Navigate to={`/login?next=${next}`} replace />;
|
||||
}
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center text-sm text-red-500">
|
||||
Unable to verify session. Try refreshing.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
44
web/src/components/SessionTimeoutWarning.tsx
Normal file
44
web/src/components/SessionTimeoutWarning.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useSessionTimeout } from "../hooks/useSessionTimeout";
|
||||
|
||||
export function SessionTimeoutWarning() {
|
||||
const { state, timeRemaining, extendSession, logout } = useSessionTimeout();
|
||||
|
||||
if (state !== "warning") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-[--color-panel] border border-[--color-border] rounded-xl p-6 max-w-md mx-4 shadow-2xl">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-semibold">Session Expiring Soon</h2>
|
||||
</div>
|
||||
|
||||
<p className="text-sm muted">
|
||||
Your session will expire in <strong className="text-amber-500">{timeRemaining} minute{timeRemaining !== 1 ? "s" : ""}</strong> due to inactivity.
|
||||
</p>
|
||||
|
||||
<p className="text-sm muted">
|
||||
Would you like to stay logged in?
|
||||
</p>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
className="btn flex-1"
|
||||
onClick={extendSession}
|
||||
>
|
||||
Stay Logged In
|
||||
</button>
|
||||
<button
|
||||
className="btn bg-red-500/10 border-red-500/40 text-red-200 hover:bg-red-500/20 flex-1"
|
||||
onClick={logout}
|
||||
>
|
||||
Log Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
web/src/components/ThemeToggle.tsx
Normal file
30
web/src/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useTheme } from "../theme/useTheme";
|
||||
|
||||
export default function ThemeToggle({ size = "md" }: { size?: "sm" | "md" }) {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const opts: Array<"dark" | "light" | "system"> = ["dark", "light", "system"];
|
||||
const base =
|
||||
size === "sm"
|
||||
? "text-[11px] px-2 py-1 rounded-lg"
|
||||
: "text-xs px-2 py-1 rounded-xl";
|
||||
|
||||
return (
|
||||
<div className={"inline-flex items-center gap-1 border bg-[--color-panel] " + base}>
|
||||
{opts.map((opt) => (
|
||||
<button
|
||||
key={opt}
|
||||
type="button"
|
||||
onClick={() => setTheme(opt)}
|
||||
aria-pressed={theme === opt}
|
||||
className={
|
||||
base +
|
||||
" transition " +
|
||||
(theme === opt ? "bg-[--color-ink] text-[--color-bg]" : "hover:bg-[--color-ink]/10")
|
||||
}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { createContext, useContext, useState, useCallback, type PropsWithChildren } from "react";
|
||||
import { 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 };
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,16 @@
|
||||
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend } from "recharts";
|
||||
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Cell } from "recharts";
|
||||
import { fmtMoney } from "../../utils/money";
|
||||
import { useInView } from "../../hooks/useInView";
|
||||
|
||||
export type FixedItem = { name: string; funded: number; remaining: number };
|
||||
export type FixedItem = {
|
||||
name: string;
|
||||
funded: number;
|
||||
remaining: number;
|
||||
fundedCents: number;
|
||||
remainingCents: number;
|
||||
aheadCents?: number;
|
||||
isOverdue?: boolean;
|
||||
};
|
||||
|
||||
export default function FixedFundingBars({ data }: { data: FixedItem[] }) {
|
||||
if (!data.length) {
|
||||
@@ -11,22 +21,89 @@ export default function FixedFundingBars({ data }: { data: FixedItem[] }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { ref, isInView } = useInView();
|
||||
const colorPalette = [
|
||||
"#3B82F6",
|
||||
"#F59E0B",
|
||||
"#EF4444",
|
||||
"#8B5CF6",
|
||||
"#06B6D4",
|
||||
"#F97316",
|
||||
"#EC4899",
|
||||
"#84CC16",
|
||||
];
|
||||
const toRgba = (hex: string, alpha: number) => {
|
||||
const cleaned = hex.replace("#", "");
|
||||
const bigint = parseInt(cleaned, 16);
|
||||
const r = (bigint >> 16) & 255;
|
||||
const g = (bigint >> 8) & 255;
|
||||
const b = bigint & 255;
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
};
|
||||
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 className="chart-lg" ref={ref} aria-busy={!isInView}>
|
||||
{isInView ? (
|
||||
<ResponsiveContainer width="100%" height="100%" minHeight={320}>
|
||||
<BarChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" />
|
||||
<XAxis dataKey="name" stroke="#94a3b8" />
|
||||
<YAxis domain={[0, 100]} tickFormatter={(v) => `${v}%`} stroke="#94a3b8" />
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload || payload.length === 0) return null;
|
||||
const row = payload[0]?.payload as FixedItem | undefined;
|
||||
if (!row) return null;
|
||||
const fundedLabel = `${Math.round(row.funded)}% (${fmtMoney(row.fundedCents)})`;
|
||||
const remainingLabel = `${Math.round(row.remaining)}% (${fmtMoney(row.remainingCents)})`;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: "#1F2937",
|
||||
border: "1px solid #374151",
|
||||
borderRadius: 8,
|
||||
color: "#F9FAFB",
|
||||
fontSize: "14px",
|
||||
fontWeight: "500",
|
||||
padding: "8px 12px",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, marginBottom: 6 }}>{row.name}</div>
|
||||
<div>Funded: {fundedLabel}</div>
|
||||
<div>Remaining: {remainingLabel}</div>
|
||||
{row.isOverdue && (
|
||||
<div style={{ marginTop: 6, color: "#FCA5A5" }}>Overdue</div>
|
||||
)}
|
||||
{!row.isOverdue && row.aheadCents && row.aheadCents > 0 && (
|
||||
<div style={{ marginTop: 6, color: "#86EFAC" }}>
|
||||
Ahead {fmtMoney(row.aheadCents)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="funded" stackId="a" name="Funded">
|
||||
{data.map((entry, index) => (
|
||||
<Cell key={`funded-${entry.name}`} fill={colorPalette[index % colorPalette.length]} />
|
||||
))}
|
||||
</Bar>
|
||||
<Bar dataKey="remaining" stackId="a" name="Remaining">
|
||||
{data.map((entry, index) => {
|
||||
const base = colorPalette[index % colorPalette.length];
|
||||
return (
|
||||
<Cell key={`remaining-${entry.name}`} fill={toRgba(base, 0.35)} />
|
||||
);
|
||||
})}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-full w-full rounded-xl border bg-[--color-panel]" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
58
web/src/components/charts/MonthlyTrendChart.tsx
Normal file
58
web/src/components/charts/MonthlyTrendChart.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend } from "recharts";
|
||||
import { useInView } from "../../hooks/useInView";
|
||||
|
||||
export type TrendPoint = { monthKey: string; label: string; incomeCents: number; spendCents: number };
|
||||
|
||||
const currency = (value: number) =>
|
||||
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 }).format(value);
|
||||
|
||||
export default function MonthlyTrendChart({ data }: { data: TrendPoint[] }) {
|
||||
if (!data.length) {
|
||||
return (
|
||||
<div className="card">
|
||||
<h3 className="section-title mb-2">Monthly Trend</h3>
|
||||
<div className="muted text-sm">No data yet. Add income or spending to see history.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const normalized = data.map((point) => ({
|
||||
label: point.label,
|
||||
income: point.incomeCents / 100,
|
||||
spend: point.spendCents / 100,
|
||||
}));
|
||||
const { ref, isInView } = useInView();
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h3 className="section-title mb-2">Monthly Trend</h3>
|
||||
<div className="chart-lg" ref={ref} aria-busy={!isInView}>
|
||||
{isInView ? (
|
||||
<ResponsiveContainer width="100%" height="100%" minHeight={320}>
|
||||
<LineChart data={normalized} margin={{ top: 16, right: 24, left: 0, bottom: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" />
|
||||
<XAxis dataKey="label" stroke="#94a3b8" />
|
||||
<YAxis stroke="#94a3b8" tickFormatter={(v) => currency(v)} width={90} />
|
||||
<Tooltip
|
||||
formatter={(value: number) => currency(value)}
|
||||
contentStyle={{
|
||||
background: "#1F2937",
|
||||
border: "1px solid #374151",
|
||||
borderRadius: 8,
|
||||
color: "#F9FAFB",
|
||||
fontSize: "14px",
|
||||
fontWeight: "500"
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
<Line type="monotone" dataKey="income" name="Income" stroke="#16a34a" strokeWidth={3} dot={{ r: 4 }} />
|
||||
<Line type="monotone" dataKey="spend" name="Spend" stroke="#f97316" strokeWidth={3} dot={{ r: 4 }} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-full w-full rounded-xl border bg-[--color-panel]" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
// web/src/components/charts/VariableAllocationDonut.tsx
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from "recharts";
|
||||
import { useInView } from "../../hooks/useInView";
|
||||
|
||||
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);
|
||||
const savingsTotal = data.filter((d) => d.isSavings).reduce((s, d) => s + d.value, 0);
|
||||
const savingsPercent = total > 0 ? Math.round((savingsTotal / total) * 100) : 0;
|
||||
const { ref, isInView } = useInView();
|
||||
if (!data.length || total === 0) {
|
||||
return (
|
||||
<div className="card">
|
||||
@@ -14,21 +18,80 @@ export default function VariableAllocationDonut({ data }: { data: VariableSlice[
|
||||
);
|
||||
}
|
||||
|
||||
const fillFor = (s: boolean) => (s ? "#165F46" : "#374151");
|
||||
// Color palette for variable categories with savings highlighting
|
||||
const colorPalette = [
|
||||
"#3B82F6", // bright blue
|
||||
"#F59E0B", // amber/gold
|
||||
"#EF4444", // red
|
||||
"#8B5CF6", // purple
|
||||
"#06B6D4", // cyan
|
||||
"#F97316", // orange
|
||||
"#EC4899", // pink
|
||||
"#84CC16", // lime green
|
||||
];
|
||||
|
||||
const savingsColors = {
|
||||
primary: "#10B981", // emerald-500 (brighter)
|
||||
accent: "#059669", // emerald-600 (darker alternate)
|
||||
};
|
||||
|
||||
const getColor = (index: number, isSavings: boolean) => {
|
||||
if (isSavings) {
|
||||
return index % 2 === 0 ? savingsColors.primary : savingsColors.accent;
|
||||
}
|
||||
return colorPalette[index % colorPalette.length];
|
||||
};
|
||||
|
||||
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 className="chart-md" ref={ref} aria-busy={!isInView}>
|
||||
{isInView ? (
|
||||
<ResponsiveContainer width="100%" height="100%" minHeight={240}>
|
||||
<PieChart>
|
||||
<Pie data={data} dataKey="value" nameKey="name" innerRadius={70} outerRadius={100} stroke="#0B1020" strokeWidth={2}>
|
||||
{data.map((d, i) => <Cell key={i} fill={getColor(i, d.isSavings)} />)}
|
||||
</Pie>
|
||||
<text x="50%" y="46%" textAnchor="middle" dominantBaseline="middle" fill="var(--color-text)" fontSize="16" fontWeight="600">
|
||||
{Math.round(total)}%
|
||||
</text>
|
||||
<text x="50%" y="58%" textAnchor="middle" dominantBaseline="middle" fill="var(--color-muted)" fontSize="12" fontWeight="500">
|
||||
Savings {savingsPercent}%
|
||||
</text>
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0];
|
||||
return (
|
||||
<div style={{
|
||||
background: "#1F2937",
|
||||
border: "1px solid #374151",
|
||||
borderRadius: "8px",
|
||||
padding: "8px 12px",
|
||||
color: "#F9FAFB",
|
||||
fontSize: "14px",
|
||||
fontWeight: "500",
|
||||
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1)"
|
||||
}}>
|
||||
<p style={{ margin: 0, color: "#F9FAFB" }}>
|
||||
{data.name}: {data.value}%
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
<Legend
|
||||
verticalAlign="bottom"
|
||||
height={24}
|
||||
wrapperStyle={{ color: "#F9FAFB" }}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-full w-full rounded-xl border bg-[--color-panel]" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { type PropsWithChildren } from "react";
|
||||
import { type PropsWithChildren } from "react";
|
||||
import { fmtMoney } from "../utils/money";
|
||||
|
||||
export function Money({ cents }: { cents: number }) {
|
||||
|
||||
16
web/src/hooks/useAuthSession.ts
Normal file
16
web/src/hooks/useAuthSession.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useQuery, type UseQueryOptions } from "@tanstack/react-query";
|
||||
import { http } from "../api/http";
|
||||
|
||||
type SessionResponse = { ok: true; userId: string; email: string | null; displayName: string | null };
|
||||
|
||||
type Options = Omit<UseQueryOptions<SessionResponse, Error>, "queryKey" | "queryFn">;
|
||||
|
||||
export function useAuthSession(options?: Options) {
|
||||
return useQuery<SessionResponse, Error>({
|
||||
queryKey: ["auth", "session"],
|
||||
queryFn: async () =>
|
||||
http<SessionResponse>("/auth/session", { skipAuthRedirect: true }),
|
||||
retry: false,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -21,7 +21,11 @@ export function useCreateCategory() {
|
||||
...prev,
|
||||
variableCategories: [
|
||||
...prev.variableCategories,
|
||||
{ id: -Math.floor(Math.random() * 1e9), balanceCents: 0, ...vars }
|
||||
{
|
||||
id: `temp-${Math.random().toString(36).slice(2)}`,
|
||||
balanceCents: 0,
|
||||
...vars,
|
||||
},
|
||||
]
|
||||
};
|
||||
qc.setQueryData(DASHBOARD_KEY, optimistic);
|
||||
@@ -36,7 +40,8 @@ export function useCreateCategory() {
|
||||
export function useUpdateCategory() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, body }: { id: number; body: UpdateCategory }) => categoriesApi.update(id, body),
|
||||
mutationFn: ({ id, body }: { id: string; body: UpdateCategory }) =>
|
||||
categoriesApi.update(id, body),
|
||||
onMutate: async ({ id, body }) => {
|
||||
await qc.cancelQueries({ queryKey: DASHBOARD_KEY });
|
||||
const prev = qc.getQueryData<any>(DASHBOARD_KEY);
|
||||
@@ -59,7 +64,7 @@ export function useUpdateCategory() {
|
||||
export function useDeleteCategory() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => categoriesApi.delete(id),
|
||||
mutationFn: (id: string) => categoriesApi.delete(id),
|
||||
onMutate: async (id) => {
|
||||
await qc.cancelQueries({ queryKey: DASHBOARD_KEY });
|
||||
const prev = qc.getQueryData<any>(DASHBOARD_KEY);
|
||||
@@ -75,4 +80,4 @@ export function useDeleteCategory() {
|
||||
onError: (_e, _v, ctx) => ctx?.prev && qc.setQueryData(DASHBOARD_KEY, ctx.prev),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: DASHBOARD_KEY })
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,15 +8,28 @@ export type VariableCategory = {
|
||||
priority: number;
|
||||
isSavings: boolean;
|
||||
balanceCents?: number;
|
||||
savingsTargetCents?: number | null;
|
||||
};
|
||||
|
||||
export type FixedPlan = {
|
||||
id: string;
|
||||
name: string;
|
||||
priority: number;
|
||||
totalCents?: number;
|
||||
fundedCents?: number;
|
||||
totalCents: number;
|
||||
fundedCents: number;
|
||||
dueOn: string;
|
||||
cycleStart: string;
|
||||
periodDays?: number;
|
||||
autoRollover?: boolean;
|
||||
lastRollover?: string | null;
|
||||
autoPayEnabled?: boolean;
|
||||
paymentSchedule?: {
|
||||
frequency: string;
|
||||
dayOfMonth?: number;
|
||||
customDays?: number;
|
||||
minFundingPercent?: number;
|
||||
} | null;
|
||||
nextPaymentDate?: string | null;
|
||||
};
|
||||
|
||||
export type Tx = {
|
||||
@@ -36,12 +49,41 @@ export type DashboardResponse = {
|
||||
variableCategories: VariableCategory[];
|
||||
fixedPlans: FixedPlan[];
|
||||
recentTransactions: Tx[];
|
||||
monthlyTrend: Array<{
|
||||
monthKey: string;
|
||||
label: string;
|
||||
incomeCents: number;
|
||||
spendCents: number;
|
||||
}>;
|
||||
upcomingPlans: Array<{ id: string; name: string; dueOn: string; remainingCents: number }>;
|
||||
savingsTargets: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
balanceCents: number;
|
||||
targetCents: number;
|
||||
percent: number;
|
||||
}>;
|
||||
hasBudgetSetup: boolean;
|
||||
user: {
|
||||
id: string;
|
||||
email: string | null;
|
||||
displayName: string | null;
|
||||
incomeType?: "regular" | "irregular";
|
||||
incomeFrequency?: "weekly" | "biweekly" | "monthly";
|
||||
timezone?: string; // IANA timezone identifier
|
||||
firstIncomeDate?: string | null;
|
||||
fixedExpensePercentage?: number;
|
||||
};
|
||||
crisis?: {
|
||||
active: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export function useDashboard() {
|
||||
export function useDashboard(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ["dashboard"],
|
||||
queryFn: () => apiGet<DashboardResponse>("/dashboard"),
|
||||
staleTime: 10_000,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export function useCreatePlan() {
|
||||
fixedPlans: [
|
||||
...prev.fixedPlans,
|
||||
{
|
||||
id: -Math.floor(Math.random() * 1e9),
|
||||
id: `temp-${Math.random().toString(36).slice(2)}`,
|
||||
fundedCents: Math.min(vars.fundedCents ?? 0, vars.totalCents),
|
||||
...vars,
|
||||
},
|
||||
@@ -34,7 +34,7 @@ export function useCreatePlan() {
|
||||
export function useUpdatePlan() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, body }: { id: number; body: UpdatePlan }) =>
|
||||
mutationFn: ({ id, body }: { id: string; body: UpdatePlan }) =>
|
||||
fixedPlansApi.update(id, body),
|
||||
onMutate: async ({ id, body }) => {
|
||||
await qc.cancelQueries({ queryKey: DASH });
|
||||
@@ -67,7 +67,7 @@ export function useUpdatePlan() {
|
||||
export function useDeletePlan() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => fixedPlansApi.delete(id),
|
||||
mutationFn: (id: string) => fixedPlansApi.delete(id),
|
||||
onMutate: async (id) => {
|
||||
await qc.cancelQueries({ queryKey: DASH });
|
||||
const prev = qc.getQueryData<any>(DASH);
|
||||
@@ -83,4 +83,4 @@ export function useDeletePlan() {
|
||||
onError: (_e, _v, ctx) => ctx?.prev && qc.setQueryData(DASH, ctx.prev),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: DASH }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
30
web/src/hooks/useInView.ts
Normal file
30
web/src/hooks/useInView.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export function useInView(rootMargin = "200px") {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const [isInView, setIsInView] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const node = ref.current;
|
||||
if (!node || isInView) return;
|
||||
if (typeof IntersectionObserver === "undefined") {
|
||||
setIsInView(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
setIsInView(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ rootMargin }
|
||||
);
|
||||
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, [isInView, rootMargin]);
|
||||
|
||||
return { ref, isInView };
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiPost } from "../api/http";
|
||||
|
||||
export type IncomeResult = {
|
||||
@@ -7,9 +7,27 @@ export type IncomeResult = {
|
||||
remainingUnallocatedCents: number;
|
||||
};
|
||||
|
||||
export type AllocationOverrideInput = {
|
||||
type: "fixed" | "variable";
|
||||
id: string;
|
||||
amountCents: number;
|
||||
};
|
||||
|
||||
export type CreateIncomeInput = {
|
||||
amountCents: number;
|
||||
overrides?: AllocationOverrideInput[];
|
||||
occurredAtISO?: string;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
export function useCreateIncome() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (body: { amountCents: number }) =>
|
||||
apiPost<IncomeResult>("/income", body),
|
||||
mutationFn: (body: CreateIncomeInput) => apiPost<IncomeResult>("/income", body),
|
||||
onSuccess: () => {
|
||||
// Invalidate dashboard to refresh plan states after income posting
|
||||
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
18
web/src/hooks/useIncomeHistory.ts
Normal file
18
web/src/hooks/useIncomeHistory.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiGet } from "../api/http";
|
||||
|
||||
export type IncomeHistoryEntry = {
|
||||
id: string;
|
||||
postedAt: string;
|
||||
amountCents: number;
|
||||
fixedTotal: number;
|
||||
variableTotal: number;
|
||||
};
|
||||
|
||||
export function useIncomeHistory() {
|
||||
return useQuery({
|
||||
queryKey: ["income", "history"],
|
||||
queryFn: () => apiGet<IncomeHistoryEntry[]>("/income/history"),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
104
web/src/hooks/useSessionTimeout.ts
Normal file
104
web/src/hooks/useSessionTimeout.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { apiPost } from "../api/http";
|
||||
|
||||
const TIMEOUT_MINUTES = 30; // Should match backend SESSION_TIMEOUT_MINUTES
|
||||
const WARNING_MINUTES = 5; // Show warning 5 minutes before timeout
|
||||
const ACTIVITY_THROTTLE_MS = 60_000; // Only refresh once per minute max
|
||||
|
||||
type SessionState = "active" | "warning" | "expired";
|
||||
|
||||
export function useSessionTimeout() {
|
||||
const [state, setState] = useState<SessionState>("active");
|
||||
const [timeRemaining, setTimeRemaining] = useState<number>(TIMEOUT_MINUTES);
|
||||
const lastActivityRef = useRef<number>(Date.now());
|
||||
const lastRefreshRef = useRef<number>(Date.now());
|
||||
const timerRef = useRef<number | null>(null);
|
||||
|
||||
const refreshSession = useCallback(async () => {
|
||||
const now = Date.now();
|
||||
|
||||
// Throttle refresh calls
|
||||
if (now - lastRefreshRef.current < ACTIVITY_THROTTLE_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await apiPost("/auth/refresh");
|
||||
lastRefreshRef.current = now;
|
||||
lastActivityRef.current = now;
|
||||
setState("active");
|
||||
setTimeRemaining(TIMEOUT_MINUTES);
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh session:", error);
|
||||
setState("expired");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleActivity = useCallback(() => {
|
||||
const now = Date.now();
|
||||
lastActivityRef.current = now;
|
||||
|
||||
// If in warning state and user is active, refresh the session
|
||||
if (state === "warning") {
|
||||
refreshSession();
|
||||
}
|
||||
}, [state, refreshSession]);
|
||||
|
||||
const extendSession = useCallback(() => {
|
||||
refreshSession();
|
||||
}, [refreshSession]);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await apiPost("/auth/logout");
|
||||
} finally {
|
||||
window.location.href = "/login";
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Track user activity
|
||||
const events = ["mousedown", "keydown", "scroll", "touchstart"];
|
||||
events.forEach((event) => {
|
||||
window.addEventListener(event, handleActivity, { passive: true });
|
||||
});
|
||||
|
||||
// Check session status every 30 seconds
|
||||
const checkSession = () => {
|
||||
const now = Date.now();
|
||||
const elapsed = now - lastActivityRef.current;
|
||||
const elapsedMinutes = Math.floor(elapsed / 60_000);
|
||||
const remaining = TIMEOUT_MINUTES - elapsedMinutes;
|
||||
|
||||
setTimeRemaining(remaining);
|
||||
|
||||
if (remaining <= 0) {
|
||||
setState("expired");
|
||||
logout();
|
||||
} else if (remaining <= WARNING_MINUTES) {
|
||||
setState("warning");
|
||||
} else {
|
||||
setState("active");
|
||||
}
|
||||
};
|
||||
|
||||
timerRef.current = setInterval(checkSession, 30_000);
|
||||
checkSession(); // Run immediately
|
||||
|
||||
return () => {
|
||||
events.forEach((event) => {
|
||||
window.removeEventListener(event, handleActivity);
|
||||
});
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, [handleActivity, logout]);
|
||||
|
||||
return {
|
||||
state,
|
||||
timeRemaining,
|
||||
extendSession,
|
||||
logout,
|
||||
};
|
||||
}
|
||||
@@ -1,28 +1,81 @@
|
||||
// web/src/hooks/useTransaction.ts
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { z } from "zod";
|
||||
import { api } from "../api/client";
|
||||
import { apiPost, apiPatch, apiDelete } from "../api/http";
|
||||
|
||||
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" });
|
||||
});
|
||||
const Tx = z
|
||||
.object({
|
||||
kind: z.enum(["variable_spend", "fixed_payment"]),
|
||||
amountCents: z.number().int().positive(),
|
||||
occurredAtISO: z.string().datetime(),
|
||||
categoryId: z.string().min(1).optional(),
|
||||
planId: z.string().min(1).optional(),
|
||||
note: z.string().trim().max(500).optional(),
|
||||
receiptUrl: z
|
||||
.union([z.string().trim().url().max(2048), z.literal("")])
|
||||
.optional(),
|
||||
isReconciled: z.boolean().optional(),
|
||||
allowOverdraft: z.boolean().optional(),
|
||||
useAvailableBudget: z.boolean().optional(),
|
||||
})
|
||||
.superRefine((v, ctx) => {
|
||||
const isVar = v.kind === "variable_spend";
|
||||
if (isVar && !v.categoryId && !v.useAvailableBudget) {
|
||||
ctx.addIssue({ code: "custom", message: "categoryId required" });
|
||||
}
|
||||
if (!isVar && !v.planId) ctx.addIssue({ code: "custom", message: "planId 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)),
|
||||
mutationFn: async (input: TxInput) =>
|
||||
apiPost("/transactions", Tx.parse(input)),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
qc.invalidateQueries({ queryKey: ["transactions"] }); // ensure list refreshes if open
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const TxPatch = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
note: z.string().trim().max(500).optional(),
|
||||
receiptUrl: z.string().trim().url().max(2048).optional(),
|
||||
isReconciled: z.boolean().optional(),
|
||||
})
|
||||
.refine(
|
||||
(payload) =>
|
||||
payload.note !== undefined ||
|
||||
payload.receiptUrl !== undefined ||
|
||||
payload.isReconciled !== undefined,
|
||||
{ message: "No fields to update" }
|
||||
);
|
||||
export type TxPatchInput = z.infer<typeof TxPatch>;
|
||||
|
||||
export function useUpdateTransaction() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (input: TxPatchInput) => {
|
||||
const { id, ...data } = TxPatch.parse(input);
|
||||
return apiPatch(`/transactions/${id}`, data);
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["transactions"] });
|
||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteTransaction() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => apiDelete(`/transactions/${id}`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["transactions"] });
|
||||
qc.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,14 @@ export type TxItem = {
|
||||
kind: "variable_spend" | "fixed_payment";
|
||||
amountCents: number;
|
||||
occurredAt: string;
|
||||
categoryId?: string | null;
|
||||
categoryName?: string | null;
|
||||
planId?: string | null;
|
||||
planName?: string | null;
|
||||
note?: string | null;
|
||||
receiptUrl?: string | null;
|
||||
isReconciled: boolean;
|
||||
isAutoPayment?: boolean;
|
||||
};
|
||||
|
||||
export type TxListResponse = {
|
||||
@@ -22,6 +30,9 @@ export type TxQueryParams = {
|
||||
from?: string;
|
||||
to?: string;
|
||||
kind?: "variable_spend" | "fixed_payment";
|
||||
bucketId?: string;
|
||||
sort?: "date" | "amount" | "kind" | "bucket";
|
||||
direction?: "asc" | "desc";
|
||||
};
|
||||
|
||||
export function useTransactionsQuery(params: TxQueryParams) {
|
||||
@@ -31,3 +42,7 @@ export function useTransactionsQuery(params: TxQueryParams) {
|
||||
placeholderData: (previousData) => previousData,
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchTransactions(params: TxQueryParams) {
|
||||
return apiGet<TxListResponse>("/transactions", params);
|
||||
}
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
@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; }
|
||||
165
web/src/main.tsx
165
web/src/main.tsx
@@ -1,12 +1,39 @@
|
||||
import React from "react";
|
||||
import React, { lazy } from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import {
|
||||
RouterProvider,
|
||||
createBrowserRouter,
|
||||
createRoutesFromElements,
|
||||
Route,
|
||||
Navigate,
|
||||
} from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { ToastProvider } from "./components/Toast";
|
||||
import { RequireAuth } from "./components/RequireAuth";
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
// Initialize theme before React renders
|
||||
const theme = (localStorage.getItem("theme") as "dark" | "light" | "system") || "system";
|
||||
const actualTheme = theme === "system"
|
||||
? (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")
|
||||
: theme;
|
||||
const colorScheme = (localStorage.getItem("colorScheme") as "blue" | "green" | "purple" | "orange") || "blue";
|
||||
|
||||
if (actualTheme === "dark") {
|
||||
document.documentElement.setAttribute("data-theme", "dark");
|
||||
document.documentElement.classList.add("dark");
|
||||
document.body.classList.add("dark");
|
||||
document.body.setAttribute("data-theme", "dark");
|
||||
} else {
|
||||
document.documentElement.setAttribute("data-theme", "light");
|
||||
document.documentElement.classList.remove("dark");
|
||||
document.body.classList.remove("dark");
|
||||
document.body.setAttribute("data-theme", "light");
|
||||
}
|
||||
document.documentElement.classList.remove("scheme-blue", "scheme-green", "scheme-purple", "scheme-orange");
|
||||
document.documentElement.classList.add(`scheme-${colorScheme}`);
|
||||
|
||||
const client = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
@@ -16,15 +43,135 @@ const client = new QueryClient({
|
||||
},
|
||||
});
|
||||
|
||||
const DashboardPage = lazy(() => import("./pages/DashboardPage"));
|
||||
const SpendPage = lazy(() => import("./pages/SpendPage"));
|
||||
const IncomePage = lazy(() => import("./pages/IncomePage"));
|
||||
const TransactionsPage = lazy(() => import("./pages/TransactionsPage"));
|
||||
const SettingsPage = lazy(() => import("./pages/settings/SettingsPage"));
|
||||
const HealthPage = lazy(() => import("./pages/HealthPage"));
|
||||
const OnboardingPage = lazy(() => import("./pages/OnboardingPage"));
|
||||
const LoginPage = lazy(() => import("./pages/LoginPage"));
|
||||
const RegisterPage = lazy(() => import("./pages/RegisterPage"));
|
||||
|
||||
const router = createBrowserRouter(
|
||||
createRoutesFromElements(
|
||||
<Route element={<App />}>
|
||||
{/* Public */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
|
||||
{/* Protected onboarding */}
|
||||
<Route
|
||||
path="/onboarding"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<OnboardingPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Authenticated app */}
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<DashboardPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/spend"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<SpendPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/income"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<IncomePage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/transactions"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<TransactionsPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<SettingsPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings/categories"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<SettingsPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings/plans"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<SettingsPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings/account"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<SettingsPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings/theme"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<SettingsPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings/reconcile"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<SettingsPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/health"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<HealthPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Fallback */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
)
|
||||
);
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={client}>
|
||||
<BrowserRouter>
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</BrowserRouter>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
<ToastProvider>
|
||||
<RouterProvider router={router} />
|
||||
</ToastProvider>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,218 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "../api/client";
|
||||
import { http } from "../api/http";
|
||||
|
||||
type AppHealth = { ok: true };
|
||||
type DbHealth = { ok: true; nowISO: string; latencyMs: number };
|
||||
|
||||
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") });
|
||||
const app = useQuery({
|
||||
queryKey: ["health"],
|
||||
queryFn: () => http<AppHealth>("/health"),
|
||||
});
|
||||
|
||||
const db = useQuery({
|
||||
queryKey: ["health", "db"],
|
||||
queryFn: () => http<DbHealth>("/health/db"),
|
||||
});
|
||||
|
||||
const appStatus = getStatus(app.isLoading, !!app.data?.ok, !!app.error);
|
||||
const dbStatus = getStatus(db.isLoading, !!db.data?.ok, !!db.error);
|
||||
|
||||
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 className="flex justify-center py-10">
|
||||
<div className="card w-full max-w-xl space-y-6">
|
||||
<header className="space-y-1">
|
||||
<h2 className="section-title">System Health</h2>
|
||||
<p className="muted text-sm">
|
||||
Quick status for the SkyMoney API and database. Use this when debugging issues or latency.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Status overview */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<HealthCard
|
||||
label="API"
|
||||
status={appStatus}
|
||||
description="Core application and auth endpoints."
|
||||
details={
|
||||
<>
|
||||
<StatusLine
|
||||
label="Status"
|
||||
value={labelForStatus(appStatus)}
|
||||
/>
|
||||
{app.error && (
|
||||
<StatusLine
|
||||
label="Error"
|
||||
value={
|
||||
app.error instanceof Error
|
||||
? app.error.message
|
||||
: "Unknown error"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
onRetry={!app.isLoading ? () => app.refetch() : undefined}
|
||||
/>
|
||||
|
||||
<HealthCard
|
||||
label="Database"
|
||||
status={dbStatus}
|
||||
description="Primary PostgreSQL connection and latency."
|
||||
details={
|
||||
<>
|
||||
<StatusLine
|
||||
label="Status"
|
||||
value={labelForStatus(dbStatus)}
|
||||
/>
|
||||
<StatusLine
|
||||
label="Latency"
|
||||
value={
|
||||
db.data?.latencyMs != null
|
||||
? `${db.data.latencyMs} ms`
|
||||
: db.isLoading
|
||||
? "Measuring…"
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<StatusLine
|
||||
label="Server time"
|
||||
value={
|
||||
db.data?.nowISO
|
||||
? new Date(db.data.nowISO).toLocaleString()
|
||||
: db.isLoading
|
||||
? "Loading…"
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
{db.error && (
|
||||
<StatusLine
|
||||
label="Error"
|
||||
value={
|
||||
db.error instanceof Error
|
||||
? db.error.message
|
||||
: "Unknown error"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
onRetry={!db.isLoading ? () => db.refetch() : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Raw data (tiny, for debugging) */}
|
||||
<section className="border rounded-xl p-3 space-y-2 bg-[--color-panel]">
|
||||
<div className="row items-center">
|
||||
<span className="text-xs uppercase tracking-[0.2em] muted">
|
||||
Debug
|
||||
</span>
|
||||
<span className="ml-auto text-xs muted">
|
||||
Useful for logs / screenshots
|
||||
</span>
|
||||
</div>
|
||||
<pre className="text-xs whitespace-pre-wrap break-all muted">
|
||||
API: {app.isLoading ? "Loading…" : JSON.stringify(app.data ?? { ok: false })}{"\n"}
|
||||
DB: {db.isLoading ? "Loading…" : JSON.stringify(db.data ?? { ok: false })}
|
||||
</pre>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type Status = "checking" | "up" | "down";
|
||||
|
||||
function getStatus(
|
||||
isLoading: boolean,
|
||||
ok: boolean,
|
||||
hasError: boolean,
|
||||
): Status {
|
||||
if (isLoading) return "checking";
|
||||
if (ok && !hasError) return "up";
|
||||
return "down";
|
||||
}
|
||||
|
||||
function labelForStatus(status: Status) {
|
||||
if (status === "checking") return "Checking…";
|
||||
if (status === "up") return "Operational";
|
||||
return "Unavailable";
|
||||
}
|
||||
|
||||
function HealthCard({
|
||||
label,
|
||||
status,
|
||||
description,
|
||||
details,
|
||||
onRetry,
|
||||
}: {
|
||||
label: string;
|
||||
status: Status;
|
||||
description: string;
|
||||
details: React.ReactNode;
|
||||
onRetry?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="border rounded-xl p-4 bg-[--color-panel] space-y-3">
|
||||
<div className="row items-center gap-2">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-semibold">{label}</div>
|
||||
<p className="text-xs muted">{description}</p>
|
||||
</div>
|
||||
<StatusPill status={status} className="ml-auto" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-xs">{details}</div>
|
||||
|
||||
{onRetry && (
|
||||
<div className="row mt-2">
|
||||
<button type="button" className="btn text-xs ml-auto" onClick={onRetry}>
|
||||
Recheck
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusPill({
|
||||
status,
|
||||
className = "",
|
||||
}: {
|
||||
status: Status;
|
||||
className?: string;
|
||||
}) {
|
||||
let text = "";
|
||||
let tone = "";
|
||||
|
||||
switch (status) {
|
||||
case "checking":
|
||||
text = "Checking…";
|
||||
tone = "bg-amber-500/10 text-amber-100 border border-amber-500/40";
|
||||
break;
|
||||
case "up":
|
||||
text = "OK";
|
||||
tone = "bg-emerald-500/10 text-emerald-200 border border-emerald-500/40";
|
||||
break;
|
||||
case "down":
|
||||
text = "Down";
|
||||
tone = "bg-red-500/10 text-red-100 border border-red-500/40";
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`badge text-xs ${tone} ${className}`}>
|
||||
<span className="inline-block w-2 h-2 rounded-full mr-1 bg-current" />
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusLine({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="row text-xs">
|
||||
<span className="muted">{label}</span>
|
||||
<span className="ml-auto text-right">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,223 +1,596 @@
|
||||
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 { useDashboard } from "../hooks/useDashboard";
|
||||
import { computeNeedsFixedFunding } from "../utils/funding";
|
||||
import { useCreateIncome, type AllocationOverrideInput, type CreateIncomeInput } from "../hooks/useIncome";
|
||||
import { useToast } from "../components/Toast";
|
||||
import { useIncomePreview } from "../hooks/useIncomePreview";
|
||||
import EarlyFundingModal from "../components/EarlyFundingModal";
|
||||
import { dateStringToUTCMidnight, getBrowserTimezone, isoToDateString } from "../utils/timezone";
|
||||
|
||||
function dollarsToCents(input: string): number {
|
||||
const n = Number.parseFloat(input || "0");
|
||||
if (!Number.isFinite(n)) return 0;
|
||||
return Math.round(n * 100);
|
||||
function fmt(cents: number) {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 2,
|
||||
}).format(cents / 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;
|
||||
function parseCurrencyToCents(value: string) {
|
||||
const cleaned = value.replace(/[^0-9.]/g, "");
|
||||
const [whole, fraction = ""] = cleaned.split(".");
|
||||
const normalized = fraction.length > 0 ? `${whole}.${fraction.slice(0, 2)}` : whole;
|
||||
const parsed = Number.parseFloat(normalized || "0");
|
||||
return Number.isFinite(parsed) ? Math.round(parsed * 100) : 0;
|
||||
}
|
||||
|
||||
function ManualRow({
|
||||
label,
|
||||
amountCents,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
amountCents: number;
|
||||
onChange: (cents: number) => void;
|
||||
}) {
|
||||
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 className="row gap-2 p-2 rounded-xl border bg-[--color-panel] shadow-sm flex-col sm:flex-row sm:items-center">
|
||||
<span className="text-sm font-semibold">{label}</span>
|
||||
<CurrencyInput
|
||||
className="input ml-auto w-40"
|
||||
valueCents={amountCents}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
function allocateIrregularFixed(
|
||||
plans: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
totalCents?: number;
|
||||
fundedCents?: number;
|
||||
dueOn: string;
|
||||
priority: number;
|
||||
}>,
|
||||
fixedPool: number,
|
||||
now: Date,
|
||||
timezone: string
|
||||
) {
|
||||
const userNowIso = dateStringToUTCMidnight(isoToDateString(now.toISOString(), timezone), timezone);
|
||||
const userNow = new Date(userNowIso);
|
||||
|
||||
const planStates = plans.map((plan) => {
|
||||
const total = plan.totalCents ?? 0;
|
||||
const funded = plan.fundedCents ?? 0;
|
||||
const remainingCents = Math.max(0, total - funded);
|
||||
const dueIso = dateStringToUTCMidnight(isoToDateString(plan.dueOn, timezone), timezone);
|
||||
const dueDate = new Date(dueIso);
|
||||
const daysUntilDue = Math.max(0, Math.ceil((dueDate.getTime() - userNow.getTime()) / DAY_MS));
|
||||
const isCrisis = remainingCents > 0 && daysUntilDue <= 14;
|
||||
return { ...plan, remainingCents, daysUntilDue, isCrisis };
|
||||
});
|
||||
|
||||
const fixedAlloc: Record<string, number> = {};
|
||||
const fixedNeed = planStates.reduce((sum, plan) => sum + plan.remainingCents, 0);
|
||||
let remainingPool = fixedPool;
|
||||
|
||||
const crisisPlans = planStates
|
||||
.filter((p) => p.isCrisis && p.remainingCents > 0)
|
||||
.sort((a, b) => {
|
||||
if (a.priority !== b.priority) return a.priority - b.priority;
|
||||
if (a.daysUntilDue !== b.daysUntilDue) return a.daysUntilDue - b.daysUntilDue;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
for (const plan of crisisPlans) {
|
||||
if (remainingPool <= 0) break;
|
||||
const allocation = Math.min(remainingPool, plan.remainingCents);
|
||||
if (allocation > 0) {
|
||||
fixedAlloc[plan.id] = allocation;
|
||||
remainingPool -= allocation;
|
||||
plan.remainingCents -= allocation;
|
||||
}
|
||||
}
|
||||
|
||||
const regularPlans = planStates.filter((p) => !p.isCrisis && p.remainingCents > 0);
|
||||
const totalRegularNeeded = regularPlans.reduce((sum, p) => sum + p.remainingCents, 0);
|
||||
|
||||
if (remainingPool > 0 && totalRegularNeeded > 0) {
|
||||
for (const plan of regularPlans) {
|
||||
const proportion = plan.remainingCents / totalRegularNeeded;
|
||||
const allocation = Math.floor(remainingPool * proportion);
|
||||
if (allocation > 0) {
|
||||
fixedAlloc[plan.id] = (fixedAlloc[plan.id] ?? 0) + allocation;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allocatedTotal = Object.values(fixedAlloc).reduce((sum, val) => sum + val, 0);
|
||||
|
||||
return { fixedAlloc, allocatedTotal, fixedNeed };
|
||||
}
|
||||
|
||||
|
||||
|
||||
export default function IncomePage() {
|
||||
const { data: dashboard } = useDashboard();
|
||||
const toast = useToast();
|
||||
const userTimezone = dashboard?.user?.timezone || getBrowserTimezone();
|
||||
const debugNow = new URLSearchParams(window.location.search).get("debugNow");
|
||||
const debugNowISO = debugNow ? dateStringToUTCMidnight(debugNow, userTimezone) : null;
|
||||
const debugNowDate = debugNowISO ? new Date(debugNowISO) : null;
|
||||
const [amountInput, setAmountInput] = useState("");
|
||||
const amountCents = useMemo(() => parseCurrencyToCents(amountInput), [amountInput]);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [manualMode, setManualMode] = useState(false);
|
||||
const [manualFixed, setManualFixed] = useState<Record<string, number>>({});
|
||||
const [manualVariable, setManualVariable] = useState<Record<string, number>>({});
|
||||
const [earlyFundingModals, setEarlyFundingModals] = useState<Array<{
|
||||
planId: string;
|
||||
planName: string;
|
||||
nextDueDate: string;
|
||||
}>>([]);
|
||||
|
||||
const allocation = useMemo(() => {
|
||||
if (!dashboard || amountCents <= 0) return null;
|
||||
const fixedPlans = dashboard.fixedPlans ?? [];
|
||||
const variableCats = dashboard.variableCategories ?? [];
|
||||
if (fixedPlans.length === 0 && variableCats.length === 0) return null;
|
||||
|
||||
const fixedAlloc: Record<string, number> = {};
|
||||
const varAlloc: Record<string, number> = {};
|
||||
const isIrregular = dashboard?.user?.incomeType === "irregular";
|
||||
const fixedExpensePercentage = dashboard?.user?.fixedExpensePercentage ?? 40;
|
||||
const eligibleFixedPlans = fixedPlans.filter((plan) => plan.autoPayEnabled);
|
||||
|
||||
let remainingAmount = amountCents;
|
||||
let shouldFundFixed = false;
|
||||
let fixedNeed = 0;
|
||||
|
||||
if (isIrregular) {
|
||||
const fixedPool = Math.floor((amountCents * fixedExpensePercentage) / 100);
|
||||
const rawFixedNeed = eligibleFixedPlans.reduce((sum, plan) => {
|
||||
const total = plan.totalCents ?? 0;
|
||||
const funded = plan.fundedCents ?? 0;
|
||||
return sum + Math.max(total - funded, 0);
|
||||
}, 0);
|
||||
const fixedAllocationPool = Math.min(fixedPool, rawFixedNeed);
|
||||
const irregularFunding = allocateIrregularFixed(
|
||||
eligibleFixedPlans,
|
||||
fixedAllocationPool,
|
||||
debugNowDate ?? new Date(),
|
||||
userTimezone
|
||||
);
|
||||
fixedNeed = rawFixedNeed;
|
||||
if (fixedAllocationPool > 0 && rawFixedNeed > 0) {
|
||||
Object.assign(fixedAlloc, irregularFunding.fixedAlloc);
|
||||
remainingAmount = Math.max(0, amountCents - fixedAllocationPool);
|
||||
shouldFundFixed = true;
|
||||
}
|
||||
} else {
|
||||
// Use client-side smart fixed funding detection (preferred)
|
||||
shouldFundFixed = computeNeedsFixedFunding(
|
||||
dashboard?.user?.incomeType ?? "regular",
|
||||
dashboard?.user?.incomeFrequency ?? "biweekly",
|
||||
fixedPlans,
|
||||
debugNowDate ?? new Date(),
|
||||
dashboard?.crisis?.active ?? false,
|
||||
100
|
||||
);
|
||||
fixedNeed = shouldFundFixed
|
||||
? eligibleFixedPlans.reduce((sum, plan) => {
|
||||
const total = plan.totalCents ?? 0;
|
||||
const funded = plan.fundedCents ?? 0;
|
||||
return sum + Math.max(total - funded, 0);
|
||||
}, 0)
|
||||
: 0;
|
||||
}
|
||||
|
||||
// Calculate fixed plan allocation only if needed (regular income path)
|
||||
if (!isIrregular && shouldFundFixed) {
|
||||
// STEP 1: Pay overdue bills first (oldest first)
|
||||
const overduePlans = fixedPlans
|
||||
.filter(p => (p as any).isOverdue && ((p as any).overdueAmount ?? 0) > 0)
|
||||
.sort((a, b) => {
|
||||
const aTime = (a as any).overdueSince ? new Date((a as any).overdueSince).getTime() : 0;
|
||||
const bTime = (b as any).overdueSince ? new Date((b as any).overdueSince).getTime() : 0;
|
||||
return aTime - bTime; // oldest first
|
||||
});
|
||||
|
||||
overduePlans.forEach((plan) => {
|
||||
if (remainingAmount <= 0) return;
|
||||
const overdueAmount = (plan as any).overdueAmount ?? 0;
|
||||
const payment = Math.min(overdueAmount, remainingAmount);
|
||||
if (payment > 0) {
|
||||
fixedAlloc[plan.id] = payment;
|
||||
remainingAmount -= payment;
|
||||
}
|
||||
});
|
||||
|
||||
// STEP 2: Allocate remaining to non-overdue plans (proportional)
|
||||
if (remainingAmount > 0) {
|
||||
const nonOverduePlans = eligibleFixedPlans.filter(
|
||||
(p) => !(p as any).isOverdue || ((p as any).overdueAmount ?? 0) === 0
|
||||
);
|
||||
const fixedNeed = nonOverduePlans.reduce((sum, plan) => {
|
||||
const total = plan.totalCents ?? 0;
|
||||
const funded = plan.fundedCents ?? 0;
|
||||
const alreadyAllocated = fixedAlloc[plan.id] ?? 0;
|
||||
return sum + Math.max(total - funded - alreadyAllocated, 0);
|
||||
}, 0);
|
||||
|
||||
if (fixedNeed > 0) {
|
||||
nonOverduePlans.forEach((plan) => {
|
||||
const total = plan.totalCents ?? 0;
|
||||
const funded = plan.fundedCents ?? 0;
|
||||
const alreadyAllocated = fixedAlloc[plan.id] ?? 0;
|
||||
const need = Math.max(total - funded - alreadyAllocated, 0);
|
||||
const allocation = Math.floor((need / fixedNeed) * remainingAmount);
|
||||
fixedAlloc[plan.id] = (fixedAlloc[plan.id] ?? 0) + Math.min(allocation, need);
|
||||
remainingAmount -= Math.min(allocation, need);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Smart variable allocation with deficit recovery
|
||||
const totalPercent = variableCats.reduce((sum, cat) => sum + (cat.percent || 0), 0);
|
||||
|
||||
if (totalPercent > 0 && remainingAmount > 0) {
|
||||
// Step 1: Handle negative balances first
|
||||
let poolAfterDeficits = remainingAmount;
|
||||
|
||||
variableCats.forEach((cat) => {
|
||||
const currentBalance = cat.balanceCents ?? 0;
|
||||
if (currentBalance < 0 && poolAfterDeficits > 0) {
|
||||
const deficitAmount = Math.min(Math.abs(currentBalance), poolAfterDeficits);
|
||||
varAlloc[cat.id] = (varAlloc[cat.id] || 0) + deficitAmount;
|
||||
poolAfterDeficits -= deficitAmount;
|
||||
}
|
||||
});
|
||||
|
||||
// Step 2: Distribute remaining by percentages
|
||||
if (poolAfterDeficits > 0) {
|
||||
variableCats.forEach((cat) => {
|
||||
const percentageAmount = Math.floor(((cat.percent || 0) / totalPercent) * poolAfterDeficits);
|
||||
varAlloc[cat.id] = (varAlloc[cat.id] || 0) + percentageAmount;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
fixedAlloc,
|
||||
varAlloc,
|
||||
shouldFundFixed,
|
||||
fixedNeed
|
||||
};
|
||||
}, [dashboard, amountCents]);
|
||||
|
||||
const manualTotal = useMemo(() => {
|
||||
const sumFixed = Object.values(manualFixed).reduce((a, b) => a + b, 0);
|
||||
const sumVar = Object.values(manualVariable).reduce((a, b) => a + b, 0);
|
||||
return sumFixed + sumVar;
|
||||
}, [manualFixed, manualVariable]);
|
||||
|
||||
const manualOver = manualTotal > amountCents;
|
||||
|
||||
const createIncome = useCreateIncome();
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (amountCents <= 0) {
|
||||
toast.push("err", "Enter an amount before recording income.");
|
||||
return;
|
||||
}
|
||||
if (manualMode && manualOver) {
|
||||
toast.push("err", "Total exceeds deposit amount.");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: CreateIncomeInput = {
|
||||
amountCents,
|
||||
};
|
||||
if (debugNowISO) {
|
||||
payload.occurredAtISO = debugNowISO;
|
||||
}
|
||||
|
||||
const trimmedNote = notes.trim();
|
||||
if (trimmedNote) {
|
||||
payload.note = trimmedNote;
|
||||
}
|
||||
|
||||
if (manualMode) {
|
||||
const overrides: AllocationOverrideInput[] = [];
|
||||
Object.entries(manualFixed).forEach(([id, cents]) => {
|
||||
if (cents > 0) overrides.push({ type: "fixed", id, amountCents: cents });
|
||||
});
|
||||
Object.entries(manualVariable).forEach(([id, cents]) => {
|
||||
if (cents > 0) overrides.push({ type: "variable", id, amountCents: cents });
|
||||
});
|
||||
if (overrides.length > 0) {
|
||||
payload.overrides = overrides;
|
||||
}
|
||||
}
|
||||
|
||||
createIncome.mutate(payload, {
|
||||
onSuccess: (result: any) => {
|
||||
// Check if overdue bills were paid first
|
||||
if (result?.overduePaid?.totalAmount > 0) {
|
||||
const totalPaid = fmt(result.overduePaid.totalAmount);
|
||||
const plans = result.overduePaid.plans;
|
||||
|
||||
if (plans.length === 1) {
|
||||
// Single overdue bill
|
||||
toast.push("ok", `Paid ${fmt(plans[0].amountPaid)} to overdue bill: ${plans[0].name}`);
|
||||
} else {
|
||||
// Multiple overdue bills - show priority order
|
||||
const plansList = plans
|
||||
.map((p: any) => `${p.name} (${fmt(p.amountPaid)})`)
|
||||
.join(", ");
|
||||
toast.push("ok", `Paid ${totalPaid} to ${plans.length} overdue bills (oldest first): ${plansList}`);
|
||||
}
|
||||
} else {
|
||||
toast.push("ok", "Income recorded.");
|
||||
}
|
||||
|
||||
// Check if any bills were fully funded
|
||||
if (result?.fullyFundedPlans && result.fullyFundedPlans.length > 0) {
|
||||
const modals = result.fullyFundedPlans.map((plan: any) => ({
|
||||
planId: plan.id,
|
||||
planName: plan.name,
|
||||
nextDueDate: plan.dueOn,
|
||||
}));
|
||||
setEarlyFundingModals(modals);
|
||||
}
|
||||
|
||||
setAmountInput("");
|
||||
setManualMode(false);
|
||||
setManualFixed({});
|
||||
setManualVariable({});
|
||||
setNotes("");
|
||||
},
|
||||
onError: (err: any) => toast.push("err", err?.message ?? "Failed."),
|
||||
});
|
||||
};
|
||||
|
||||
if (!dashboard) {
|
||||
return <div className="muted p-4">Loading…</div>;
|
||||
}
|
||||
|
||||
const fixed = dashboard.fixedPlans ?? [];
|
||||
const variable = dashboard.variableCategories ?? [];
|
||||
const isIrregularUser = dashboard?.user?.incomeType === "irregular";
|
||||
const autoFundFixed = isIrregularUser ? fixed.filter((plan) => plan.autoPayEnabled) : fixed;
|
||||
const pageTimezone = dashboard?.user?.timezone || getBrowserTimezone();
|
||||
const monthLabel = new Date().toLocaleString("en-US", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
timeZone: pageTimezone,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-8 fade-in">
|
||||
<header className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<h1 className="text-xl font-bold">Record Income</h1>
|
||||
<span className="badge sm:ml-auto">{monthLabel}</span>
|
||||
</header>
|
||||
|
||||
<form className="card stack pb-28 relative" onSubmit={handleSubmit} noValidate>
|
||||
<section className="stack">
|
||||
<label className="stack">
|
||||
<span className="muted text-sm">Amount</span>
|
||||
<CurrencyInput
|
||||
className="input"
|
||||
value={amountInput}
|
||||
onValue={setAmountInput}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Date field removed: income uses current time automatically */}
|
||||
|
||||
<label className="stack">
|
||||
<span className="muted text-sm">Note (optional)</span>
|
||||
<input
|
||||
className="input"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="e.g., Paycheck"
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
{amountCents > 0 && allocation && !manualMode && (
|
||||
<section className="mt-4 stack">
|
||||
<h2 className="section-title">Automatic Allocation</h2>
|
||||
|
||||
{allocation.shouldFundFixed && allocation.fixedNeed > 0 && (
|
||||
<div className="p-3 rounded-xl bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 mb-3">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center text-sm">
|
||||
<span className="font-semibold text-blue-800 dark:text-blue-200">
|
||||
{fmt(Object.values(allocation.fixedAlloc).reduce((sum, val) => sum + val, 0))} will go to fixed expenses
|
||||
</span>
|
||||
<span className="sm:ml-auto font-semibold text-blue-800 dark:text-blue-200">
|
||||
{fmt(amountCents - Object.values(allocation.fixedAlloc).reduce((sum, val) => sum + val, 0))} remains for categories
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{allocation.shouldFundFixed && allocation.fixedNeed > 0 && (
|
||||
<div className="p-4 rounded-xl bg-amber-50 border border-amber-200 text-amber-800">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-xl font-bold flex-shrink-0">!</span>
|
||||
<div className="stack gap-1 min-w-0">
|
||||
<span className="font-semibold">Fixed Expenses Need Funding</span>
|
||||
<span className="text-sm">
|
||||
{dashboard?.crisis?.active ? "Crisis mode - prioritizing fixed expenses" :
|
||||
dashboard?.user?.incomeType === "irregular" ? "Irregular income - funding available plans" :
|
||||
"Behind schedule - catching up on fixed plans"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{allocation.shouldFundFixed && (
|
||||
<div className="stack">
|
||||
<h3 className="text-sm muted">Fixed Expenses (deducted from income)</h3>
|
||||
{autoFundFixed.map((plan) => {
|
||||
const currentFunded = plan.fundedCents ?? 0;
|
||||
const newAllocation = allocation.fixedAlloc[plan.id] ?? 0;
|
||||
const newFunded = currentFunded + newAllocation;
|
||||
const total = plan.totalCents ?? 0;
|
||||
return (
|
||||
<div key={plan.id} className="stack gap-1 p-3 rounded-xl border bg-[--color-panel]">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center">
|
||||
<span className="font-semibold">{plan.name}</span>
|
||||
<span className="sm:ml-auto font-mono font-bold text-blue-600 dark:text-blue-400">
|
||||
+{fmt(newAllocation)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center text-xs muted">
|
||||
<span>Funded: {fmt(currentFunded)} / {fmt(total)}</span>
|
||||
<span className="sm:ml-auto">New: <span className="font-semibold text-[--color-ink]">{fmt(newFunded)} / {fmt(total)}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="stack mt-3">
|
||||
<h3 className="text-sm muted">Variable Categories</h3>
|
||||
{variable.map((cat) => {
|
||||
const currentBalance = cat.balanceCents ?? 0;
|
||||
const newAllocation = allocation.varAlloc[cat.id] ?? 0;
|
||||
const newTotal = currentBalance + newAllocation;
|
||||
return (
|
||||
<div key={cat.id} className="stack gap-1 p-3 rounded-xl border bg-[--color-panel]">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center">
|
||||
<span className="font-semibold">{cat.name}</span>
|
||||
<span className="sm:ml-auto font-mono font-bold text-green-600 dark:text-green-400">
|
||||
+{fmt(newAllocation)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center text-xs muted">
|
||||
<span>Current: {fmt(currentBalance)}</span>
|
||||
<span className="sm:ml-auto">New Total: <span className="font-semibold text-[--color-ink]">{fmt(newTotal)}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
|
||||
</section>
|
||||
)}
|
||||
|
||||
{manualMode && (
|
||||
<section className="stack mt-4">
|
||||
<h2 className="section-title">Manual Allocation</h2>
|
||||
<div className="stack">
|
||||
<h3 className="text-sm muted">Fixed Expenses</h3>
|
||||
{fixed.map((plan) => (
|
||||
<ManualRow
|
||||
key={plan.id}
|
||||
label={plan.name}
|
||||
amountCents={manualFixed[plan.id] ?? 0}
|
||||
onChange={(cents) =>
|
||||
setManualFixed((prev) => ({
|
||||
...prev,
|
||||
[plan.id]: cents,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="stack mt-3">
|
||||
<h3 className="text-sm muted">Expenses</h3>
|
||||
{variable.map((cat) => (
|
||||
<ManualRow
|
||||
key={cat.id}
|
||||
label={cat.name}
|
||||
amountCents={manualVariable[cat.id] ?? 0}
|
||||
onChange={(cents) =>
|
||||
setManualVariable((prev) => ({
|
||||
...prev,
|
||||
[cat.id]: cents,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center mt-3">
|
||||
<span className="badge">Total Allocated</span>
|
||||
<span
|
||||
className={
|
||||
"sm:ml-auto font-semibold " + (manualOver ? "text-red-400" : "")
|
||||
}
|
||||
>
|
||||
{fmt(manualTotal)} / {fmt(amountCents)}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center p-4 bg-[--color-bg] border-t relative bottom-0 left-0 right-0 rounded-b-xl">
|
||||
{manualMode && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn w-full sm:w-auto"
|
||||
onClick={() => setManualMode(false)}
|
||||
>
|
||||
Back to Auto
|
||||
</button>
|
||||
)}
|
||||
{!manualMode && allocation && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn w-full sm:w-auto"
|
||||
onClick={() => {
|
||||
if (!allocation) return;
|
||||
setManualFixed({ ...allocation.fixedAlloc });
|
||||
setManualVariable({ ...allocation.varAlloc });
|
||||
setManualMode(true);
|
||||
}}
|
||||
>
|
||||
Customize Allocation
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createIncome.isPending}
|
||||
className="btn w-full sm:w-auto sm:ml-auto"
|
||||
>
|
||||
{createIncome.isPending ? "Saving…" : "Record Income"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section className="stack">
|
||||
<h2 className="section-title">History</h2>
|
||||
{/* History content can live here when ready */}
|
||||
</section>
|
||||
|
||||
{earlyFundingModals.map((modal) => (
|
||||
<EarlyFundingModal
|
||||
key={modal.planId}
|
||||
planId={modal.planId}
|
||||
planName={modal.planName}
|
||||
nextDueDate={modal.nextDueDate}
|
||||
timezone={userTimezone}
|
||||
onClose={() => {
|
||||
setEarlyFundingModals((prev) =>
|
||||
prev.filter((m) => m.planId !== modal.planId)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
136
web/src/pages/LoginPage.tsx
Normal file
136
web/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { type FormEvent, useEffect, useState } from "react";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { http } from "../api/http";
|
||||
import { useAuthSession } from "../hooks/useAuthSession";
|
||||
|
||||
function useNextPath() {
|
||||
const location = useLocation();
|
||||
const params = new URLSearchParams(location.search);
|
||||
return params.get("next") || "/";
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const next = useNextPath();
|
||||
const qc = useQueryClient();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [touched, setTouched] = useState({ email: false, password: false });
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const session = useAuthSession({ retry: false });
|
||||
|
||||
const isEmailValid = (value: string) => /\S+@\S+\.\S+/.test(value);
|
||||
const emailError =
|
||||
(touched.email || submitted) && !email.trim()
|
||||
? "Email is required."
|
||||
: (touched.email || submitted) && !isEmailValid(email)
|
||||
? "Enter a valid email address."
|
||||
: "";
|
||||
const passwordError =
|
||||
(touched.password || submitted) && !password
|
||||
? "Password is required."
|
||||
: (touched.password || submitted) && password.length < 8
|
||||
? "Password must be at least 8 characters."
|
||||
: "";
|
||||
|
||||
useEffect(() => {
|
||||
if (session.data?.userId) {
|
||||
navigate(next || "/", { replace: true });
|
||||
}
|
||||
}, [session.data, navigate, next]);
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setPending(true);
|
||||
setSubmitted(true);
|
||||
if (emailError || passwordError) {
|
||||
setPending(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await http<{ ok: true }>("/auth/login", {
|
||||
method: "POST",
|
||||
body: { email, password },
|
||||
skipAuthRedirect: true,
|
||||
});
|
||||
qc.clear();
|
||||
navigate(next || "/", { replace: true });
|
||||
} catch (err) {
|
||||
const status = (err as { status?: number })?.status;
|
||||
const message =
|
||||
status === 401
|
||||
? "Email or password is incorrect."
|
||||
: status === 400
|
||||
? "Enter a valid email and password."
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: "Unable to login. Try again.";
|
||||
setError(message);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center py-16 px-4">
|
||||
<div className="card w-full max-w-md">
|
||||
<h1 className="section-title mb-2">Login</h1>
|
||||
<p className="muted mb-6">Sign in to continue budgeting.</p>
|
||||
{error && <div className="alert alert-error mb-4">{error}</div>}
|
||||
{/* Session errors are expected on login page, so don't show them */}
|
||||
<form className="stack gap-4" onSubmit={handleSubmit}>
|
||||
<label className="stack gap-1">
|
||||
<span className="text-sm font-medium">Email</span>
|
||||
<input
|
||||
className={`input ${emailError ? "border-red-500/60 ring-1 ring-red-500/40" : ""}`}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
onBlur={() => setTouched((prev) => ({ ...prev, email: true }))}
|
||||
autoComplete="email"
|
||||
required
|
||||
/>
|
||||
{emailError && <span className="text-xs text-red-400">{emailError}</span>}
|
||||
</label>
|
||||
<label className="stack gap-1">
|
||||
<span className="text-sm font-medium">Password</span>
|
||||
<input
|
||||
className={`input ${passwordError ? "border-red-500/60 ring-1 ring-red-500/40" : ""}`}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
onBlur={() => setTouched((prev) => ({ ...prev, password: true }))}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
{passwordError && (
|
||||
<span className="text-xs text-red-400">{passwordError}</span>
|
||||
)}
|
||||
</label>
|
||||
<button className="btn primary" type="submit" disabled={pending}>
|
||||
{pending ? "Signing in..." : "Sign in"}
|
||||
</button>
|
||||
</form>
|
||||
<p className="muted text-sm mt-6 text-center">
|
||||
Need an account?{" "}
|
||||
<Link
|
||||
className="link"
|
||||
to={next && next !== "/" ? `/register?next=${encodeURIComponent(next)}` : "/register"}
|
||||
>
|
||||
Register
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1423
web/src/pages/OnboardingPage.tsx
Normal file
1423
web/src/pages/OnboardingPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
170
web/src/pages/RegisterPage.tsx
Normal file
170
web/src/pages/RegisterPage.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { type FormEvent, useEffect, useState } from "react";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { http } from "../api/http";
|
||||
import { useAuthSession } from "../hooks/useAuthSession";
|
||||
|
||||
function useNextPath() {
|
||||
const location = useLocation();
|
||||
const params = new URLSearchParams(location.search);
|
||||
return params.get("next") || "/";
|
||||
}
|
||||
|
||||
export default function RegisterPage() {
|
||||
const navigate = useNavigate();
|
||||
const next = useNextPath();
|
||||
const qc = useQueryClient();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [touched, setTouched] = useState({
|
||||
email: false,
|
||||
password: false,
|
||||
confirmPassword: false,
|
||||
});
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const session = useAuthSession({ retry: false });
|
||||
|
||||
const isEmailValid = (value: string) => /\S+@\S+\.\S+/.test(value);
|
||||
const emailError =
|
||||
(touched.email || submitted) && !email.trim()
|
||||
? "Email is required."
|
||||
: (touched.email || submitted) && !isEmailValid(email)
|
||||
? "Enter a valid email address."
|
||||
: "";
|
||||
const passwordError =
|
||||
(touched.password || submitted) && !password
|
||||
? "Password is required."
|
||||
: (touched.password || submitted) && password.length < 8
|
||||
? "Password must be at least 8 characters."
|
||||
: "";
|
||||
const confirmError =
|
||||
(touched.confirmPassword || submitted) && !confirmPassword
|
||||
? "Please confirm your password."
|
||||
: (touched.confirmPassword || submitted) &&
|
||||
password &&
|
||||
confirmPassword !== password
|
||||
? "Passwords do not match."
|
||||
: "";
|
||||
|
||||
useEffect(() => {
|
||||
if (session.data?.userId) {
|
||||
navigate(next || "/", { replace: true });
|
||||
}
|
||||
}, [session.data, navigate, next]);
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setPending(true);
|
||||
setSubmitted(true);
|
||||
if (emailError || passwordError || confirmError) {
|
||||
setPending(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await http<{ ok: true }>("/auth/register", {
|
||||
method: "POST",
|
||||
body: { email, password },
|
||||
skipAuthRedirect: true,
|
||||
});
|
||||
qc.clear();
|
||||
navigate(next || "/", { replace: true });
|
||||
} catch (err) {
|
||||
const status = (err as { status?: number })?.status;
|
||||
const message =
|
||||
status === 409
|
||||
? "That email is already registered. Try signing in."
|
||||
: status === 400
|
||||
? "Enter a valid email and password."
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: "Unable to register. Try again.";
|
||||
setError(message);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center py-16 px-4">
|
||||
<div className="card w-full max-w-md">
|
||||
<h1 className="section-title mb-2">Register</h1>
|
||||
<p className="muted mb-6">Create an account to track your money buckets.</p>
|
||||
{error && <div className="alert alert-error mb-4">{error}</div>}
|
||||
{/* Session errors are expected on registration page, so don't show them */}
|
||||
<form className="stack gap-4" onSubmit={handleSubmit}>
|
||||
<label className="stack gap-1">
|
||||
<span className="text-sm font-medium">Email</span>
|
||||
<input
|
||||
className={`input ${emailError ? "border-red-500/60 ring-1 ring-red-500/40" : ""}`}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
onBlur={() => setTouched((prev) => ({ ...prev, email: true }))}
|
||||
autoComplete="email"
|
||||
required
|
||||
/>
|
||||
{emailError && <span className="text-xs text-red-400">{emailError}</span>}
|
||||
</label>
|
||||
<label className="stack gap-1">
|
||||
<span className="text-sm font-medium">Password</span>
|
||||
<input
|
||||
className={`input ${passwordError ? "border-red-500/60 ring-1 ring-red-500/40" : ""}`}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
onBlur={() => setTouched((prev) => ({ ...prev, password: true }))}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
{passwordError && (
|
||||
<span className="text-xs text-red-400">{passwordError}</span>
|
||||
)}
|
||||
</label>
|
||||
<label className="stack gap-1">
|
||||
<span className="text-sm font-medium">Confirm password</span>
|
||||
<input
|
||||
className={`input ${confirmError ? "border-red-500/60 ring-1 ring-red-500/40" : ""}`}
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => {
|
||||
setConfirmPassword(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
onBlur={() =>
|
||||
setTouched((prev) => ({ ...prev, confirmPassword: true }))
|
||||
}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
{confirmError && (
|
||||
<span className="text-xs text-red-400">{confirmError}</span>
|
||||
)}
|
||||
</label>
|
||||
<button className="btn primary" type="submit" disabled={pending}>
|
||||
{pending ? "Creating account..." : "Create account"}
|
||||
</button>
|
||||
</form>
|
||||
<p className="muted text-sm mt-6 text-center">
|
||||
Already have an account?{" "}
|
||||
<Link
|
||||
className="link"
|
||||
to={next && next !== "/" ? `/login?next=${encodeURIComponent(next)}` : "/login"}
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,168 +1,581 @@
|
||||
import { useMemo, useState, type FormEvent, type ChangeEvent } from "react";
|
||||
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
||||
import { useDashboard } from "../hooks/useDashboard";
|
||||
import { useCreateTransaction } from "../hooks/useTransactions";
|
||||
import { Money, Field, Button } from "../components/ui";
|
||||
import { useCreateTransaction, useDeleteTransaction } from "../hooks/useTransactions";
|
||||
import { useTransactionsQuery } from "../hooks/useTransactionsQuery";
|
||||
import { Money } from "../components/ui";
|
||||
import CurrencyInput from "../components/CurrencyInput";
|
||||
import { useToast } from "../components/Toast";
|
||||
import { nowLocalISOStringMinute } from "../utils/format";
|
||||
import { getCurrentTimestamp, getBrowserTimezone, formatDateInTimezone } from "../utils/timezone";
|
||||
import EarlyFundingModal from "../components/EarlyFundingModal";
|
||||
import PaymentConfirmationModal from "../components/PaymentConfirmationModal";
|
||||
|
||||
|
||||
type Kind = "variable_spend" | "fixed_payment";
|
||||
type Errors = Partial<Record<"amount" | "kindSpecific", string>>;
|
||||
|
||||
function dollarsToCents(input: string): number {
|
||||
const n = Number.parseFloat(input || "0");
|
||||
if (!Number.isFinite(n)) return 0;
|
||||
return Math.round(n * 100);
|
||||
const LS_KEY = "spend.lastKind";
|
||||
const OTHER_CATEGORY_ID = "__other__";
|
||||
|
||||
function parseCurrencyToCents(value: string) {
|
||||
const cleaned = value.replace(/[^0-9.]/g, "");
|
||||
const [whole, fraction = ""] = cleaned.split(".");
|
||||
const normalized = fraction.length > 0 ? `${whole}.${fraction.slice(0, 2)}` : whole;
|
||||
const parsed = Number.parseFloat(normalized || "0");
|
||||
return Number.isFinite(parsed) ? Math.round(parsed * 100) : 0;
|
||||
}
|
||||
|
||||
export default function SpendPage() {
|
||||
const dash = useDashboard();
|
||||
const m = useCreateTransaction();
|
||||
const createTx = useCreateTransaction();
|
||||
const deleteTx = useDeleteTransaction();
|
||||
const { push } = useToast();
|
||||
|
||||
const [kind, setKind] = useState<Kind>("variable_spend");
|
||||
const [amountStr, setAmountStr] = useState("");
|
||||
const [occurredAt, setOccurredAt] = useState(nowLocalISOStringMinute());
|
||||
// ---- form state
|
||||
const [kind, setKind] = useState<Kind>(() => (localStorage.getItem(LS_KEY) as Kind) || "variable_spend");
|
||||
const [amountInput, setAmountInput] = useState<string>("");
|
||||
const amountCents = useMemo(() => parseCurrencyToCents(amountInput), [amountInput]);
|
||||
const [variableCategoryId, setVariableCategoryId] = useState<string>("");
|
||||
const [fixedPlanId, setFixedPlanId] = useState<string>("");
|
||||
const [note, setNote] = useState<string>("");
|
||||
const [showSavingsWarning, setShowSavingsWarning] = useState<boolean>(false);
|
||||
const [errors, setErrors] = useState<Errors>({});
|
||||
const [earlyFundingModal, setEarlyFundingModal] = useState<{
|
||||
planId: string;
|
||||
planName: string;
|
||||
nextDueDate?: string;
|
||||
} | null>(null);
|
||||
const [confirmationModal, setConfirmationModal] = useState<{
|
||||
message: string;
|
||||
payload: any;
|
||||
} | null>(null);
|
||||
const [overdraftConfirmation, setOverdraftConfirmation] = useState<{
|
||||
message: string;
|
||||
overdraftAmount: number;
|
||||
categoryName: string;
|
||||
payload: any;
|
||||
} | null>(null);
|
||||
const [showMoreRecent, setShowMoreRecent] = useState(false);
|
||||
|
||||
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"),
|
||||
});
|
||||
};
|
||||
// remember chosen kind
|
||||
useEffect(() => {
|
||||
localStorage.setItem(LS_KEY, kind);
|
||||
}, [kind]);
|
||||
|
||||
// data
|
||||
const cats = dash.data?.variableCategories ?? [];
|
||||
const plans = dash.data?.fixedPlans ?? [];
|
||||
const planOptions = useMemo(() => plans.map(p => ({ id: p.id, name: p.name })), [plans]);
|
||||
const catOptions = useMemo(() => cats.map(c => ({ id: c.id, name: c.name, isSavings: c.isSavings })), [cats]);
|
||||
|
||||
// Check if selected category is savings
|
||||
const selectedCategory = useMemo(() =>
|
||||
variableCategoryId === OTHER_CATEGORY_ID ? undefined : cats.find(c => c.id === variableCategoryId),
|
||||
[cats, variableCategoryId]
|
||||
);
|
||||
const isSpendingFromSavings = kind === "variable_spend" && selectedCategory?.isSavings;
|
||||
const isOtherSpend = kind === "variable_spend" && variableCategoryId === OTHER_CATEGORY_ID;
|
||||
|
||||
// quick stats
|
||||
const remainingVariable = useMemo(() => {
|
||||
const categories = dash.data?.variableCategories ?? [];
|
||||
return categories.reduce((acc, c) => acc + (c.balanceCents ?? 0), 0);
|
||||
}, [dash.data?.variableCategories]);
|
||||
|
||||
const budgetDenominator =
|
||||
(dash.data?.totals.variableBalanceCents ?? 0) +
|
||||
(dash.data?.totals.fixedRemainingCents ?? 0) ||
|
||||
1;
|
||||
const lowBalance =
|
||||
remainingVariable > 0 && remainingVariable / budgetDenominator < 0.1;
|
||||
const userTimezone = dash.data?.user?.timezone || getBrowserTimezone();
|
||||
const monthLabel = useMemo(
|
||||
() =>
|
||||
new Date().toLocaleString("en-US", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
timeZone: userTimezone,
|
||||
}),
|
||||
[userTimezone]
|
||||
);
|
||||
|
||||
const recentQuery = useTransactionsQuery({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
sort: "date",
|
||||
direction: "desc",
|
||||
});
|
||||
const recentTransactions = useMemo(() => {
|
||||
const items = recentQuery.data?.items ?? [];
|
||||
const filtered = items.filter((t) => {
|
||||
if (kind === "variable_spend") return t.kind === "variable_spend";
|
||||
return t.kind === "fixed_payment" && !t.isAutoPayment;
|
||||
});
|
||||
const limitRecent = showMoreRecent ? 10 : 3;
|
||||
return filtered.slice(0, limitRecent);
|
||||
}, [recentQuery.data, showMoreRecent, kind]);
|
||||
|
||||
// ---- validation
|
||||
function validate(): boolean {
|
||||
const next: Errors = {};
|
||||
if (!amountCents || amountCents <= 0) next.amount = "Enter an amount greater than $0.";
|
||||
if (kind === "variable_spend" && !variableCategoryId) next.kindSpecific = "Pick a category.";
|
||||
if (kind === "fixed_payment" && !fixedPlanId) next.kindSpecific = "Pick a plan.";
|
||||
|
||||
// Enhanced validation for savings withdrawals
|
||||
if (isSpendingFromSavings) {
|
||||
if (!note || note.trim().length < 10) {
|
||||
next.kindSpecific = "Savings withdrawal requires detailed justification (min 10 characters).";
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(next);
|
||||
return Object.keys(next).length === 0;
|
||||
}
|
||||
|
||||
function onBlurField(key: keyof Errors) {
|
||||
return () => {
|
||||
// re-run partial validation
|
||||
const next = { ...errors };
|
||||
if (key === "amount") {
|
||||
if (!amountCents || amountCents <= 0) next.amount = "Enter an amount greater than $0.";
|
||||
else delete next.amount;
|
||||
}
|
||||
if (key === "kindSpecific") {
|
||||
if (kind === "variable_spend" && !variableCategoryId) next.kindSpecific = "Pick a category.";
|
||||
else if (kind === "fixed_payment" && !fixedPlanId) next.kindSpecific = "Pick a plan.";
|
||||
else delete next.kindSpecific;
|
||||
}
|
||||
setErrors(next);
|
||||
};
|
||||
}
|
||||
|
||||
// ---- submit
|
||||
function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!validate()) return;
|
||||
|
||||
// Show confirmation dialog for savings withdrawals
|
||||
if (isSpendingFromSavings && !showSavingsWarning) {
|
||||
setShowSavingsWarning(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: any = {
|
||||
kind,
|
||||
amountCents,
|
||||
occurredAtISO: getCurrentTimestamp(), // Use current time in UTC
|
||||
note: note.trim() || undefined,
|
||||
categoryId: kind === "variable_spend" && !isOtherSpend ? variableCategoryId : undefined,
|
||||
useAvailableBudget: isOtherSpend ? true : undefined,
|
||||
planId: kind === "fixed_payment" ? fixedPlanId : undefined,
|
||||
};
|
||||
|
||||
createTx.mutate(payload, {
|
||||
onSuccess: (res: any) => {
|
||||
if (kind === "fixed_payment") {
|
||||
const planName = planOptions.find(p => p.id === fixedPlanId)?.name || "Bill";
|
||||
const nextISO: string | undefined = res?.nextDueOn;
|
||||
const nextLabel = nextISO ? formatDateInTimezone(nextISO, userTimezone) : undefined;
|
||||
const msg = nextLabel
|
||||
? `Funded ${planName}. Fully funded. Next due: ${nextLabel}`
|
||||
: `Funded ${planName}.`;
|
||||
push("ok", msg);
|
||||
|
||||
// Show early funding modal if this is a recurring bill
|
||||
if (nextISO) {
|
||||
setEarlyFundingModal({
|
||||
planId: fixedPlanId,
|
||||
planName,
|
||||
nextDueDate: nextISO,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
push("ok", "Recorded spend.");
|
||||
}
|
||||
// soft reset for quick entry
|
||||
setAmountInput("");
|
||||
setNote("");
|
||||
if (kind === "variable_spend") setVariableCategoryId("");
|
||||
if (kind === "fixed_payment") setFixedPlanId("");
|
||||
setErrors({});
|
||||
setShowSavingsWarning(false);
|
||||
setOverdraftConfirmation(null);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
// Check for overdraft confirmation requirement
|
||||
if (err?.code === "OVERDRAFT_CONFIRMATION") {
|
||||
setOverdraftConfirmation({
|
||||
message: err.message,
|
||||
overdraftAmount: err.overdraftAmount,
|
||||
categoryName: err.categoryName,
|
||||
payload: { ...payload, allowOverdraft: true },
|
||||
});
|
||||
} else if (err?.code === "CONFIRMATION_REQUIRED") {
|
||||
setConfirmationModal({
|
||||
message: err.message,
|
||||
payload: { ...payload, confirmVariableImpact: true },
|
||||
});
|
||||
} else {
|
||||
push("err", err?.message ?? "Failed to record.");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function confirmOverdraft() {
|
||||
if (!overdraftConfirmation) return;
|
||||
createTx.mutate(overdraftConfirmation.payload, {
|
||||
onSuccess: () => {
|
||||
push("ok", `Recorded spend. ${overdraftConfirmation.categoryName} is now in overdraft.`);
|
||||
setAmountInput("");
|
||||
setNote("");
|
||||
setVariableCategoryId("");
|
||||
setErrors({});
|
||||
setOverdraftConfirmation(null);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
push("err", err?.message ?? "Failed to record.");
|
||||
setOverdraftConfirmation(null);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function cancelOverdraft() {
|
||||
setOverdraftConfirmation(null);
|
||||
}
|
||||
|
||||
function confirmSavingsWithdrawal() {
|
||||
setShowSavingsWarning(false);
|
||||
handleSubmit({ preventDefault: () => {} } as FormEvent);
|
||||
}
|
||||
|
||||
function cancelSavingsWithdrawal() {
|
||||
setShowSavingsWarning(false);
|
||||
}
|
||||
|
||||
// ---- UI
|
||||
return (
|
||||
<div className="grid gap-4 max-w-xl">
|
||||
<form onSubmit={onSubmit} className="card stack">
|
||||
<h2 className="section-title">Spend / Pay</h2>
|
||||
<div className="space-y-8 fade-in">
|
||||
{/* Header */}
|
||||
<header className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<h1 className="text-xl font-bold">Record Spend</h1>
|
||||
<span className="badge sm:ml-auto">{monthLabel}</span>
|
||||
</header>
|
||||
|
||||
{/* Summary cards */}
|
||||
<section className="grid md:grid-cols-3 gap-4">
|
||||
<InfoCard label="Variable budget remaining" value={<Money cents={remainingVariable} />} helper={lowBalance ? "Heads up: under 10% remaining." : undefined} />
|
||||
<InfoCard label="Fixed expenses" value={`${plans.length}`} helper="Active expenses you can pay into." />
|
||||
<InfoCard label="Expenses" value={`${cats.length}`} helper="Available expense categories." />
|
||||
</section>
|
||||
|
||||
{/* Recent activity */}
|
||||
<section className="rounded-xl border bg-[--color-panel] p-4 shadow-sm">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between mb-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">Recent activity</h2>
|
||||
<div className="text-xs muted">Quick undo for your latest transactions.</div>
|
||||
</div>
|
||||
{(recentQuery.data?.items?.length ?? 0) > 3 && (
|
||||
<button
|
||||
className="btn w-full sm:w-auto"
|
||||
type="button"
|
||||
onClick={() => setShowMoreRecent((prev) => !prev)}
|
||||
>
|
||||
{showMoreRecent ? "Show 3" : "Show 10"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{recentQuery.isLoading ? (
|
||||
<div className="muted text-sm">Loading recent transactions…</div>
|
||||
) : recentTransactions.length === 0 ? (
|
||||
<div className="muted text-sm">No recent transactions yet.</div>
|
||||
) : (
|
||||
<div className="stack gap-2">
|
||||
{recentTransactions.map((t) => (
|
||||
<div key={t.id} className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between rounded-xl border px-3 py-2">
|
||||
<div className="stack">
|
||||
<span className="font-semibold">
|
||||
{t.categoryName ?? t.planName ?? t.note ?? "–"}
|
||||
</span>
|
||||
<span className="text-xs muted">
|
||||
{t.kind === "variable_spend" ? "Variable spend" : "Fixed payment"} ·{" "}
|
||||
{formatDateInTimezone(t.occurredAt, userTimezone)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="row items-center gap-3 sm:ml-auto">
|
||||
<Money cents={t.amountCents} />
|
||||
<button
|
||||
className="btn"
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await deleteTx.mutateAsync(t.id);
|
||||
push("ok", "Transaction undone.");
|
||||
} catch (err: any) {
|
||||
push("err", err?.message ?? "Failed to undo transaction.");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Undo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="card stack" noValidate>
|
||||
{/* 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 className="flex flex-col gap-2 sm:flex-row" role="tablist" aria-label="Transaction type">
|
||||
<Toggle
|
||||
pressed={kind === "variable_spend"}
|
||||
onClick={() => { setKind("variable_spend"); setFixedPlanId(""); setErrors(e => ({ ...e, kindSpecific: undefined })); }}
|
||||
>
|
||||
Variable Spend
|
||||
</Toggle>
|
||||
<Toggle
|
||||
pressed={kind === "fixed_payment"}
|
||||
onClick={() => { setKind("fixed_payment"); setVariableCategoryId(""); setErrors(e => ({ ...e, kindSpecific: undefined })); }}
|
||||
>
|
||||
Fixed Payment
|
||||
</Toggle>
|
||||
</div>
|
||||
|
||||
{/* Pick target */}
|
||||
{/* Amount */}
|
||||
<label className="stack">
|
||||
<span className="muted text-sm">Amount</span>
|
||||
<CurrencyInput
|
||||
className={"input" + (errors.amount ? " border-[--color-ink]" : "")}
|
||||
value={amountInput}
|
||||
onValue={setAmountInput}
|
||||
onBlur={onBlurField("amount")}
|
||||
autoFocus
|
||||
/>
|
||||
{errors.amount && <div className="toast-err mt-2">{errors.amount}</div>}
|
||||
</label>
|
||||
|
||||
|
||||
|
||||
{/* Kind-specific inputs */}
|
||||
{kind === "variable_spend" ? (
|
||||
<Field label="Category">
|
||||
<label className="stack">
|
||||
<span className="muted text-sm">Category</span>
|
||||
<select
|
||||
className="input"
|
||||
value={variableCategoryId}
|
||||
onChange={(e: ChangeEvent<HTMLSelectElement>) => setVariableCategoryId(e.target.value)}
|
||||
onChange={(e) => setVariableCategoryId(e.target.value)}
|
||||
onBlur={onBlurField("kindSpecific")}
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
<option value={OTHER_CATEGORY_ID}>Other (use available budget)</option>
|
||||
{catOptions.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
{errors.kindSpecific && <div className="toast-err mt-2">{errors.kindSpecific}</div>}
|
||||
</label>
|
||||
) : (
|
||||
<Field label="Fixed Plan">
|
||||
<label className="stack">
|
||||
<span className="muted text-sm">Fixed expense</span>
|
||||
<select
|
||||
className="input"
|
||||
value={fixedPlanId}
|
||||
onChange={(e: ChangeEvent<HTMLSelectElement>) => setFixedPlanId(e.target.value)}
|
||||
onChange={(e) => setFixedPlanId(e.target.value)}
|
||||
onBlur={onBlurField("kindSpecific")}
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
{planOptions.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
{errors.kindSpecific && <div className="toast-err mt-2">{errors.kindSpecific}</div>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Amount + Date */}
|
||||
<Field label="Amount (USD)">
|
||||
<CurrencyInput value={amountStr} onValue={setAmountStr} />
|
||||
</Field>
|
||||
<Field label="When">
|
||||
{/* Optional note */}
|
||||
<label className="stack">
|
||||
<span className="muted text-sm">
|
||||
Note {isSpendingFromSavings ? "(required for savings withdrawal)" : "(optional)"}
|
||||
</span>
|
||||
<input
|
||||
className="input"
|
||||
type="datetime-local"
|
||||
value={occurredAt}
|
||||
onChange={(e) => setOccurredAt(e.target.value)}
|
||||
className={`input ${isSpendingFromSavings ? 'border-warning' : ''}`}
|
||||
placeholder={isSpendingFromSavings ? "Detailed reason for savings withdrawal..." : "e.g., Grocery run at Market St."}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
{isSpendingFromSavings && (
|
||||
<div className="text-xs text-warning mt-1">
|
||||
Withdrawing from savings - please explain why this is necessary
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{/* 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>}
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center mt-3">
|
||||
<span />
|
||||
<button type="submit" className="btn sm:ml-auto" disabled={createTx.isPending}>
|
||||
{createTx.isPending ? "Saving…" : kind === "variable_spend" ? "Add Spend" : "Add Payment"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Savings Withdrawal Warning Dialog */}
|
||||
{showSavingsWarning && (
|
||||
<div className="fixed inset-0 bg-color-bg/80 backdrop-blur-sm flex items-center justify-center z-50">
|
||||
<div className="bg-[--color-bg] border-2 border-[var(--color-warning-border)] rounded-xl p-6 max-w-md mx-4 space-y-4 shadow-2xl">
|
||||
<div className="flex items-center gap-3 text-warning">
|
||||
<span className="text-2xl">!</span>
|
||||
<h3 className="font-semibold text-lg">Savings Withdrawal Warning</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<p className="text-color-text">You're about to withdraw <strong><Money cents={amountCents} /></strong> from your <strong>{selectedCategory?.name}</strong> savings.</p>
|
||||
<p className="text-warning-light">Reason: "{note}"</p>
|
||||
<p className="text-muted">This will reduce your savings progress. Are you sure this is necessary?</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelSavingsWithdrawal}
|
||||
className="btn flex-1 bg-gray-600 hover:bg-gray-500"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={confirmSavingsWithdrawal}
|
||||
className="btn flex-1 bg-warning-solid hover:bg-warning-hover text-white"
|
||||
>
|
||||
Confirm Withdrawal
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overdraft Confirmation Modal */}
|
||||
{overdraftConfirmation && (
|
||||
<div className="fixed inset-0 bg-[color:var(--color-bg)]/80 backdrop-blur-sm flex items-center justify-center z-50">
|
||||
<div className="bg-[--color-bg] border-2 border-yellow-500 rounded-xl p-6 max-w-md mx-4 space-y-4 shadow-2xl">
|
||||
<div className="flex items-center gap-3 text-yellow-400">
|
||||
<span className="text-2xl">!</span>
|
||||
<h3 className="font-semibold text-lg">Overdraft Warning</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<p>{overdraftConfirmation.message}</p>
|
||||
<div className="p-3 bg-yellow-500/10 rounded-lg border border-yellow-500/30">
|
||||
<div className="text-yellow-300 font-medium">
|
||||
{overdraftConfirmation.categoryName} will go negative by <Money cents={overdraftConfirmation.overdraftAmount} />
|
||||
</div>
|
||||
<div className="text-xs muted mt-1">
|
||||
This deficit will be automatically recovered from your next income.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelOverdraft}
|
||||
className="btn flex-1 bg-gray-600 hover:bg-gray-500"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={confirmOverdraft}
|
||||
className="btn flex-1 bg-yellow-600 hover:bg-yellow-500"
|
||||
>
|
||||
Allow Overdraft
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Early Funding Modal */}
|
||||
{earlyFundingModal && (
|
||||
<EarlyFundingModal
|
||||
planId={earlyFundingModal.planId}
|
||||
planName={earlyFundingModal.planName}
|
||||
nextDueDate={earlyFundingModal.nextDueDate}
|
||||
timezone={userTimezone}
|
||||
onClose={() => setEarlyFundingModal(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{confirmationModal && (
|
||||
<PaymentConfirmationModal
|
||||
message={confirmationModal.message}
|
||||
onConfirm={() => {
|
||||
createTx.mutate(confirmationModal.payload, {
|
||||
onSuccess: (res: any) => {
|
||||
if (kind === "fixed_payment") {
|
||||
const planName = planOptions.find(p => p.id === fixedPlanId)?.name || "Bill";
|
||||
const nextISO: string | undefined = res?.nextDueOn;
|
||||
const nextLabel = nextISO ? formatDateInTimezone(nextISO, userTimezone) : undefined;
|
||||
const msg = nextLabel
|
||||
? `Paid ${planName}. Next due: ${nextLabel}`
|
||||
: `Paid ${planName}.`;
|
||||
push("ok", msg);
|
||||
|
||||
if (nextISO) {
|
||||
setEarlyFundingModal({
|
||||
planId: fixedPlanId,
|
||||
planName,
|
||||
nextDueDate: nextISO,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
push("ok", "Recorded spend.");
|
||||
}
|
||||
setAmountInput("");
|
||||
setNote("");
|
||||
if (kind === "variable_spend") setVariableCategoryId("");
|
||||
if (kind === "fixed_payment") setFixedPlanId("");
|
||||
setErrors({});
|
||||
setShowSavingsWarning(false);
|
||||
setConfirmationModal(null);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
push("err", err?.message ?? "Failed to record.");
|
||||
setConfirmationModal(null);
|
||||
},
|
||||
});
|
||||
}}
|
||||
onCancel={() => setConfirmationModal(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function Toggle({ pressed, onClick, children }: { pressed: boolean; onClick: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={pressed}
|
||||
onClick={onClick}
|
||||
className={"nav-link " + (pressed ? "nav-link-active" : "")}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoCard({ label, value, helper }: { label: string; value: React.ReactNode; helper?: string }) {
|
||||
return (
|
||||
<div className="rounded-xl border bg-[--color-panel] p-4 shadow-sm">
|
||||
<div className="text-xs uppercase tracking-wide muted">{label}</div>
|
||||
<div className="text-2xl font-semibold mt-1">{value}</div>
|
||||
{helper && <div className="text-xs muted mt-1">{helper}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,183 +1,263 @@
|
||||
import { useEffect, useMemo, useState, type ChangeEvent } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useTransactionsQuery } from "../hooks/useTransactionsQuery";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Money } from "../components/ui";
|
||||
import { Skeleton } from "../components/Skeleton";
|
||||
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}`;
|
||||
}
|
||||
import { useTransactionsQuery, type TxQueryParams } from "../hooks/useTransactionsQuery";
|
||||
import { useDashboard } from "../hooks/useDashboard";
|
||||
import { getTodayInTimezone, getBrowserTimezone, addDaysToDate } from "../utils/timezone";
|
||||
|
||||
export default function TransactionsPage() {
|
||||
const today = isoDateOnly(new Date());
|
||||
const [sp, setSp] = useSearchParams();
|
||||
const { data: dashboard } = useDashboard();
|
||||
const userTimezone = dashboard?.user?.timezone || getBrowserTimezone();
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState<"all" | "variable" | "fixed">("all");
|
||||
const [catFilter, setCatFilter] = useState<string>("all");
|
||||
const [dateFilter, setDateFilter] = useState<"month" | "30" | "all">("month");
|
||||
const [page, setPage] = useState(1);
|
||||
const limit = 100;
|
||||
|
||||
// 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));
|
||||
// Get current date in user's timezone
|
||||
const todayStr = getTodayInTimezone(userTimezone);
|
||||
const [year, month] = todayStr.split('-').map(Number);
|
||||
const firstOfMonthStr = `${year}-${String(month).padStart(2, '0')}-01`;
|
||||
const thirtyAgoStr = addDaysToDate(todayStr, -30, userTimezone);
|
||||
|
||||
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(
|
||||
() => ({
|
||||
const params = useMemo<TxQueryParams>(() => {
|
||||
const qp: TxQueryParams = {
|
||||
page,
|
||||
limit,
|
||||
q: q || undefined,
|
||||
from: from || undefined,
|
||||
to: to || undefined,
|
||||
kind: kind === "all" ? undefined : kind,
|
||||
}),
|
||||
[page, limit, q, from, to, kind]
|
||||
);
|
||||
sort: "date",
|
||||
direction: "desc",
|
||||
};
|
||||
const term = search.trim();
|
||||
if (term) qp.q = term;
|
||||
if (typeFilter !== "all") {
|
||||
qp.kind = typeFilter === "variable" ? "variable_spend" : "fixed_payment";
|
||||
}
|
||||
if (catFilter !== "all") {
|
||||
qp.bucketId = catFilter;
|
||||
}
|
||||
if (dateFilter === "month") {
|
||||
qp.from = firstOfMonthStr;
|
||||
} else if (dateFilter === "30") {
|
||||
qp.from = thirtyAgoStr;
|
||||
} else {
|
||||
qp.from = undefined;
|
||||
}
|
||||
return qp;
|
||||
}, [page, limit, search, typeFilter, catFilter, dateFilter, firstOfMonthStr, thirtyAgoStr]);
|
||||
|
||||
const { data, isLoading, error, refetch, isFetching } = useTransactionsQuery(params);
|
||||
const txQuery = useTransactionsQuery(params);
|
||||
const transactions = txQuery.data?.items ?? [];
|
||||
const total = txQuery.data?.total ?? 0;
|
||||
|
||||
const catOptions = useMemo(() => {
|
||||
if (transactions.length === 0) return [];
|
||||
const buckets = new Map<string, string>();
|
||||
transactions.forEach((t) => {
|
||||
if (t.categoryId && t.categoryName) buckets.set(t.categoryId, t.categoryName);
|
||||
if (t.planId && t.planName) buckets.set(t.planId, t.planName);
|
||||
});
|
||||
return Array.from(buckets.entries()).map(([id, name]) => ({ id, name }));
|
||||
}, [transactions]);
|
||||
|
||||
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>
|
||||
<div className="fade-in space-y-8">
|
||||
<header className="row">
|
||||
<h1 className="text-xl font-bold">Records</h1>
|
||||
</header>
|
||||
|
||||
<div className="topnav p-3 rounded-xl sticky top-14 z-10" style={{ backdropFilter: "blur(8px)" }}>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<input
|
||||
className="input w-full sm:w-56"
|
||||
placeholder="Search..."
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-2 border rounded-xl p-1 bg-[--color-panel] w-full sm:w-auto">
|
||||
<FilterToggle
|
||||
label="All"
|
||||
active={typeFilter === "all"}
|
||||
onClick={() => {
|
||||
setTypeFilter("all");
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<FilterToggle
|
||||
label="Variable"
|
||||
active={typeFilter === "variable"}
|
||||
onClick={() => {
|
||||
setTypeFilter("variable");
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<FilterToggle
|
||||
label="Fixed"
|
||||
active={typeFilter === "fixed"}
|
||||
onClick={() => {
|
||||
setTypeFilter("fixed");
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 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);
|
||||
className="input w-full sm:w-auto"
|
||||
value={catFilter}
|
||||
onChange={(e) => {
|
||||
setCatFilter(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="all">All Types</option>
|
||||
<option value="variable_spend">Variable Spend</option>
|
||||
<option value="fixed_payment">Fixed Payment</option>
|
||||
<option value="all">All categories & plans…</option>
|
||||
{catOptions.map((option) => (
|
||||
<option key={option.id} value={option.id}>
|
||||
{option.name}
|
||||
</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}
|
||||
|
||||
<select
|
||||
className="input w-full sm:w-auto"
|
||||
value={dateFilter}
|
||||
onChange={(e) => {
|
||||
setFrom(e.target.value);
|
||||
setDateFilter(e.target.value as typeof dateFilter);
|
||||
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>
|
||||
>
|
||||
<option value="month">This month</option>
|
||||
<option value="30">Last 30 days</option>
|
||||
<option value="all">All time</option>
|
||||
</select>
|
||||
</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">
|
||||
{txQuery.isLoading ? (
|
||||
<Skeleton className="h-48" />
|
||||
) : (
|
||||
<>
|
||||
<div className="hidden md:block card overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Amount</th>
|
||||
<th>Date</th>
|
||||
<tr className="border-b">
|
||||
<th className="text-left py-3 px-4 muted text-xs font-semibold uppercase tracking-wide">Date</th>
|
||||
<th className="text-left py-3 px-4 muted text-xs font-semibold uppercase tracking-wide">Type</th>
|
||||
<th className="text-left py-3 px-4 muted text-xs font-semibold uppercase tracking-wide">Category</th>
|
||||
<th className="text-right py-3 px-4 muted text-xs font-semibold uppercase tracking-wide">Amount</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} />
|
||||
{transactions.map((t) => (
|
||||
<tr key={t.id} className="border-t transition-colors hover:bg-[--color-panel-hover]">
|
||||
<td className="py-3 px-4">{formatDate(t.occurredAt, userTimezone)}</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="inline-flex items-center gap-2 text-sm muted">
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-full"
|
||||
style={{
|
||||
background:
|
||||
t.kind === "variable_spend"
|
||||
? "var(--color-accent)"
|
||||
: "var(--color-ink)",
|
||||
}}
|
||||
></span>
|
||||
{prettyKind(t.kind)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="rounded-r-[--radius-xl] px-3 py-2">
|
||||
{new Date(t.occurredAt).toLocaleString()}
|
||||
<td className="py-3 px-4 font-medium">{t.categoryName ?? t.planName ?? t.note ?? "–"}</td>
|
||||
<td className="py-3 px-4 text-right font-semibold">
|
||||
<Money cents={t.amountCents} />
|
||||
</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} />
|
||||
{total > limit && (
|
||||
<Pagination page={page} limit={limit} total={total} onPage={setPage} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="md:hidden space-y-3 fade-in">
|
||||
{transactions.map((t) => (
|
||||
<div key={t.id} className="rounded-xl border bg-[--color-panel] p-4 shadow-sm stack">
|
||||
<div className="row">
|
||||
<div className="stack">
|
||||
<span className="font-semibold">{t.categoryName ?? t.planName ?? t.note ?? "–"}</span>
|
||||
<span className="row gap-2 text-sm muted">
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-full"
|
||||
style={{
|
||||
background:
|
||||
t.kind === "variable_spend"
|
||||
? "var(--color-accent)"
|
||||
: "var(--color-ink)",
|
||||
}}
|
||||
></span>
|
||||
{prettyKind(t.kind)} · {formatDate(t.occurredAt, userTimezone)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ml-auto text-right">
|
||||
<Money cents={t.amountCents} />
|
||||
</div>
|
||||
</div>
|
||||
{t.note && (
|
||||
<div className="row mt-2">
|
||||
<span className="muted text-sm">{t.note}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{total > limit && (
|
||||
<Pagination page={page} limit={limit} total={total} onPage={setPage} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterToggle({
|
||||
label,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={"nav-link text-sm " + (active ? "nav-link-active font-semibold" : "")}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
function prettyKind(kind: string) {
|
||||
if (kind === "variable_spend") return "Variable Spend";
|
||||
if (kind === "fixed_payment") return "Fixed Payment";
|
||||
return kind;
|
||||
}
|
||||
|
||||
function formatDate(iso: string, userTimezone: string) {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
timeZone: userTimezone,
|
||||
}).format(new Date(iso));
|
||||
}
|
||||
|
||||
515
web/src/pages/settings/AccountSettings.tsx
Normal file
515
web/src/pages/settings/AccountSettings.tsx
Normal file
@@ -0,0 +1,515 @@
|
||||
// web/src/pages/settings/AccountSettings.tsx
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useToast } from "../../components/Toast";
|
||||
import { useAuthSession } from "../../hooks/useAuthSession";
|
||||
import { useDashboard } from "../../hooks/useDashboard";
|
||||
import { http } from "../../api/http";
|
||||
import {
|
||||
addDaysToDate,
|
||||
dateStringToUTCMidnight,
|
||||
getBrowserTimezone,
|
||||
getTodayInTimezone,
|
||||
isoToDateString,
|
||||
} from "../../utils/timezone";
|
||||
|
||||
export default function AccountSettings() {
|
||||
const qc = useQueryClient();
|
||||
const { data: session, refetch: refetchSession } = useAuthSession();
|
||||
const { data: dashboard } = useDashboard();
|
||||
const { push } = useToast();
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [timezone, setTimezone] = useState(getBrowserTimezone());
|
||||
const [isUpdatingTimezone, setIsUpdatingTimezone] = useState(false);
|
||||
const [incomeFrequency, setIncomeFrequency] = useState<
|
||||
"weekly" | "biweekly" | "monthly"
|
||||
>("weekly");
|
||||
const [nextPayDate, setNextPayDate] = useState("");
|
||||
const [isUpdatingSchedule, setIsUpdatingSchedule] = useState(false);
|
||||
const [fixedExpensePercentage, setFixedExpensePercentage] = useState(40);
|
||||
const [isUpdatingConservatism, setIsUpdatingConservatism] = useState(false);
|
||||
|
||||
const browserTimezone = useMemo(() => getBrowserTimezone(), []);
|
||||
const timezoneOptions = useMemo(() => {
|
||||
const supported =
|
||||
typeof Intl !== "undefined" && "supportedValuesOf" in Intl
|
||||
? (Intl as unknown as { supportedValuesOf: (k: string) => string[] }).supportedValuesOf("timeZone")
|
||||
: [];
|
||||
const list = supported.length ? supported : [browserTimezone];
|
||||
const withBrowser = list.includes(browserTimezone) ? list : [browserTimezone, ...list];
|
||||
return Array.from(new Set(withBrowser)).sort();
|
||||
}, [browserTimezone]);
|
||||
|
||||
// Load session data when available
|
||||
useEffect(() => {
|
||||
if (session) {
|
||||
setDisplayName(session.displayName || "");
|
||||
setEmail(session.email || "");
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
useEffect(() => {
|
||||
if (dashboard?.user?.timezone) {
|
||||
setTimezone(dashboard.user.timezone);
|
||||
}
|
||||
}, [dashboard?.user?.timezone]);
|
||||
|
||||
useEffect(() => {
|
||||
if (dashboard?.user?.incomeType !== "regular") return;
|
||||
if (dashboard.user.incomeFrequency) {
|
||||
setIncomeFrequency(dashboard.user.incomeFrequency);
|
||||
}
|
||||
if (dashboard.user.firstIncomeDate && dashboard.user.timezone) {
|
||||
setNextPayDate(
|
||||
isoToDateString(dashboard.user.firstIncomeDate, dashboard.user.timezone)
|
||||
);
|
||||
} else if (dashboard?.user?.timezone) {
|
||||
setNextPayDate(getTodayInTimezone(dashboard.user.timezone));
|
||||
}
|
||||
}, [
|
||||
dashboard?.user?.incomeType,
|
||||
dashboard?.user?.incomeFrequency,
|
||||
dashboard?.user?.firstIncomeDate,
|
||||
dashboard?.user?.timezone,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (dashboard?.user?.incomeType !== "irregular") return;
|
||||
setFixedExpensePercentage(dashboard.user.fixedExpensePercentage ?? 40);
|
||||
}, [dashboard?.user?.incomeType, dashboard?.user?.fixedExpensePercentage]);
|
||||
|
||||
const scheduleError = useMemo(() => {
|
||||
if (dashboard?.user?.incomeType !== "regular") return null;
|
||||
if (!nextPayDate) return "Next payday is required.";
|
||||
const todayStr = getTodayInTimezone(timezone);
|
||||
if (nextPayDate < todayStr) {
|
||||
return "Next payday must be today or in the future.";
|
||||
}
|
||||
const maxDays =
|
||||
incomeFrequency === "weekly"
|
||||
? 7
|
||||
: incomeFrequency === "biweekly"
|
||||
? 14
|
||||
: 31;
|
||||
const maxDateStr = addDaysToDate(todayStr, maxDays, timezone);
|
||||
if (nextPayDate > maxDateStr) {
|
||||
return `For ${incomeFrequency} income, next payday should be within ${maxDays} days.`;
|
||||
}
|
||||
return null;
|
||||
}, [dashboard?.user?.incomeType, incomeFrequency, nextPayDate, timezone]);
|
||||
|
||||
const handleUpdateProfile = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsUpdating(true);
|
||||
|
||||
try {
|
||||
await http("/me", {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
displayName: displayName.trim(),
|
||||
},
|
||||
});
|
||||
|
||||
// Refetch session to update displayed data
|
||||
await refetchSession();
|
||||
push("ok", "Profile updated successfully");
|
||||
} catch (error: any) {
|
||||
push("err", error?.message ?? "Failed to update profile");
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangePassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
push("err", "New passwords don't match");
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
push("err", "New password must be at least 8 characters");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUpdating(true);
|
||||
|
||||
try {
|
||||
await http("/me/password", {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
currentPassword,
|
||||
newPassword,
|
||||
},
|
||||
});
|
||||
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
push("ok", "Password changed successfully");
|
||||
} catch (error: any) {
|
||||
push("err", error?.message ?? "Failed to change password");
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateTimezone = async () => {
|
||||
if (!timezone) return;
|
||||
setIsUpdatingTimezone(true);
|
||||
try {
|
||||
await http("/user/config", {
|
||||
method: "PATCH",
|
||||
body: { timezone },
|
||||
});
|
||||
await qc.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
push("ok", "Timezone updated");
|
||||
} catch (error: any) {
|
||||
push("err", error?.message ?? "Failed to update timezone");
|
||||
} finally {
|
||||
setIsUpdatingTimezone(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateSchedule = async () => {
|
||||
if (dashboard?.user?.incomeType !== "regular") return;
|
||||
if (scheduleError) {
|
||||
push("err", scheduleError);
|
||||
return;
|
||||
}
|
||||
if (!nextPayDate) {
|
||||
push("err", "Next payday is required.");
|
||||
return;
|
||||
}
|
||||
setIsUpdatingSchedule(true);
|
||||
try {
|
||||
await http("/user/config", {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
incomeFrequency,
|
||||
budgetPeriod: incomeFrequency,
|
||||
firstIncomeDate: dateStringToUTCMidnight(nextPayDate, timezone),
|
||||
},
|
||||
});
|
||||
await qc.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
push("ok", "Income schedule updated");
|
||||
} catch (error: any) {
|
||||
push("err", error?.message ?? "Failed to update schedule");
|
||||
} finally {
|
||||
setIsUpdatingSchedule(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await http("/auth/logout", {
|
||||
method: "POST",
|
||||
body: {},
|
||||
skipAuthRedirect: true,
|
||||
});
|
||||
} finally {
|
||||
qc.clear();
|
||||
window.location.replace("/login");
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateConservatism = async () => {
|
||||
if (dashboard?.user?.incomeType !== "irregular") return;
|
||||
const clamped = Math.max(0, Math.min(100, Math.round(fixedExpensePercentage)));
|
||||
setIsUpdatingConservatism(true);
|
||||
try {
|
||||
await http("/user/config", {
|
||||
method: "PATCH",
|
||||
body: { fixedExpensePercentage: clamped },
|
||||
});
|
||||
await qc.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
push("ok", "Auto-fund percentage updated");
|
||||
} catch (error: any) {
|
||||
push("err", error?.message ?? "Failed to update auto-fund percentage");
|
||||
} finally {
|
||||
setIsUpdatingConservatism(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Profile Information */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<h3 className="settings-section-title">Profile Information</h3>
|
||||
<p className="settings-section-desc">Update your display name and view your account details.</p>
|
||||
</div>
|
||||
<form onSubmit={handleUpdateProfile} className="space-y-4">
|
||||
<div className="settings-field">
|
||||
<label className="settings-label">Display Name</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input settings-input"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="Your display name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="settings-field">
|
||||
<label className="settings-label">Email Address</label>
|
||||
<div className="font-medium">{email || "Not set"}</div>
|
||||
<p className="settings-help">
|
||||
Email address cannot be changed at this time.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn"
|
||||
disabled={isUpdating || !displayName.trim()}
|
||||
>
|
||||
{isUpdating ? "Updating..." : "Update Profile"}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* Timezone */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<h3 className="settings-section-title">Timezone</h3>
|
||||
<p className="settings-section-desc">Used for income dates and due dates. Update this if you travel or want a different reference timezone.</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="settings-field">
|
||||
<label className="settings-label">Timezone</label>
|
||||
<select
|
||||
className="input settings-input"
|
||||
value={timezone}
|
||||
onChange={(e) => setTimezone(e.target.value)}
|
||||
>
|
||||
{timezoneOptions.map((tz) => (
|
||||
<option key={tz} value={tz}>
|
||||
{tz}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="settings-help">Current: {dashboard?.user?.timezone ?? "Not set"}</p>
|
||||
</div>
|
||||
<div className="settings-actions" style={{ borderTop: "none", paddingTop: 0, marginTop: 0 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={() => setTimezone(browserTimezone)}
|
||||
disabled={timezone === browserTimezone}
|
||||
>
|
||||
Use browser timezone
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={handleUpdateTimezone}
|
||||
disabled={isUpdatingTimezone || !timezone}
|
||||
>
|
||||
{isUpdatingTimezone ? "Saving..." : "Save Timezone"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Income Schedule */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<h3 className="settings-section-title">Income Schedule</h3>
|
||||
<p className="settings-section-desc">
|
||||
{dashboard?.user?.incomeType !== "regular"
|
||||
? "Income schedule changes are available for regular income users only."
|
||||
: "Update your pay frequency and next payday. This recalculates payment plan timelines without changing current funding."}
|
||||
</p>
|
||||
</div>
|
||||
{dashboard?.user?.incomeType === "regular" && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="settings-field">
|
||||
<label className="settings-label">Pay frequency</label>
|
||||
<select
|
||||
className="input settings-input"
|
||||
value={incomeFrequency}
|
||||
onChange={(e) =>
|
||||
setIncomeFrequency(e.target.value as typeof incomeFrequency)
|
||||
}
|
||||
>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="biweekly">Biweekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="settings-field">
|
||||
<label className="settings-label">Next payday</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input settings-input"
|
||||
value={nextPayDate}
|
||||
onChange={(e) => setNextPayDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{scheduleError && (
|
||||
<div className="text-sm text-red-400">{scheduleError}</div>
|
||||
)}
|
||||
<div className="settings-actions" style={{ borderTop: "none", paddingTop: 0, marginTop: 0 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={handleUpdateSchedule}
|
||||
disabled={isUpdatingSchedule || !!scheduleError}
|
||||
>
|
||||
{isUpdatingSchedule ? "Saving..." : "Save Schedule"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Irregular Income Auto-Fund */}
|
||||
{dashboard?.user?.incomeType === "irregular" && (
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<h3 className="settings-section-title">Irregular Income Auto-Fund</h3>
|
||||
<p className="settings-section-desc">Choose how much of each income deposit is reserved to auto-fund fixed expenses.</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="settings-field">
|
||||
<label className="settings-label">Auto-fund percentage: {fixedExpensePercentage}%</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
className="w-full max-w-md"
|
||||
value={fixedExpensePercentage}
|
||||
onChange={(e) => {
|
||||
const nextValue = Number(e.target.value);
|
||||
setFixedExpensePercentage(Number.isFinite(nextValue) ? nextValue : 0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-actions" style={{ borderTop: "none", paddingTop: 0, marginTop: 0 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={handleUpdateConservatism}
|
||||
disabled={isUpdatingConservatism}
|
||||
>
|
||||
{isUpdatingConservatism ? "Saving..." : "Save Auto-Fund"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Change Password */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<h3 className="settings-section-title">Change Password</h3>
|
||||
<p className="settings-section-desc">Update your account password for security.</p>
|
||||
</div>
|
||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||
<div className="settings-field">
|
||||
<label className="settings-label">Current Password</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input settings-input"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
placeholder="Enter current password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="settings-field">
|
||||
<label className="settings-label">New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input settings-input"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="Enter new password"
|
||||
/>
|
||||
<p className="settings-help">Must be at least 8 characters long.</p>
|
||||
</div>
|
||||
|
||||
<div className="settings-field">
|
||||
<label className="settings-label">Confirm New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input settings-input"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="Confirm new password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn"
|
||||
disabled={
|
||||
isUpdating ||
|
||||
!currentPassword ||
|
||||
!newPassword ||
|
||||
!confirmPassword ||
|
||||
newPassword !== confirmPassword
|
||||
}
|
||||
>
|
||||
{isUpdating ? "Changing..." : "Change Password"}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* Account Actions */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<h3 className="settings-section-title">Account Actions</h3>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="settings-option-card" style={{ cursor: "default" }}>
|
||||
<div className="option-label">Sign out</div>
|
||||
<p className="option-desc mb-3">Log out of SkyMoney on this device.</p>
|
||||
<button type="button" className="btn" onClick={handleLogout}>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
<div className="settings-option-card" style={{ cursor: "default", borderLeftColor: "var(--color-warning, #f59e0b)", borderLeftWidth: "3px" }}>
|
||||
<div className="option-label">Export Data</div>
|
||||
<p className="option-desc mb-3">Download a copy of all your financial data including transactions, expenses, and fixed expenses.</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={() => {
|
||||
push("ok", "Data export feature coming soon");
|
||||
}}
|
||||
>
|
||||
Export My Data
|
||||
</button>
|
||||
</div>
|
||||
<div className="settings-section settings-danger-section" style={{ marginBottom: 0 }}>
|
||||
<div className="settings-section-header" style={{ borderBottom: "none", paddingBottom: 0, marginBottom: "0.5rem" }}>
|
||||
<h3 className="settings-section-title">Delete Account</h3>
|
||||
<p className="settings-section-desc">Permanently delete your account and all associated data. This action cannot be undone.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
style={{ borderColor: "var(--color-danger, #ef4444)", color: "var(--color-danger, #ef4444)" }}
|
||||
onClick={() => {
|
||||
push("ok", "Account deletion requires email confirmation");
|
||||
}}
|
||||
>
|
||||
Delete Account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +1,91 @@
|
||||
import { useMemo, useState, type ChangeEvent, type FormEvent } from "react";
|
||||
// web/src/pages/settings/CategoriesPage.tsx
|
||||
import { useMemo, useState, useEffect, type FormEvent } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
arrayMove,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { useDashboard } from "../../hooks/useDashboard";
|
||||
import { Money } from "../../components/ui";
|
||||
import SettingsNav from "./_SettingsNav";
|
||||
import { useCategories, useCreateCategory, useUpdateCategory, useDeleteCategory } from "../../hooks/useCategories";
|
||||
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 };
|
||||
type Row = {
|
||||
id: string;
|
||||
name: string;
|
||||
percent: number;
|
||||
priority: number;
|
||||
isSavings: boolean;
|
||||
balanceCents: number;
|
||||
};
|
||||
|
||||
function SumBadge({ total }: { total: number }) {
|
||||
const ok = total === 100;
|
||||
const tone =
|
||||
total === 100
|
||||
? "bg-emerald-500/10 text-emerald-200 border border-emerald-500/40"
|
||||
: total < 100
|
||||
? "bg-amber-500/10 text-amber-100 border border-amber-500/30"
|
||||
: "bg-red-500/10 text-red-100 border border-red-500/40";
|
||||
const label = total === 100 ? "Perfect" : total < 100 ? "Under" : "Over";
|
||||
return (
|
||||
<div className={`badge ${ok ? "" : ""}`}>
|
||||
Total: {total}%
|
||||
<div className={`badge ${tone}`}>
|
||||
{label}: {total}%
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsCategoriesPage() {
|
||||
const { data, isLoading, error, refetch, isFetching } = useDashboard();
|
||||
const cats: Row[] = useCategories();
|
||||
const cats = useCategories() as Row[];
|
||||
const createM = useCreateCategory();
|
||||
const updateM = useUpdateCategory();
|
||||
const deleteM = useDeleteCategory();
|
||||
const { push } = useToast();
|
||||
const normalizeName = (value: string) => value.trim().toLowerCase();
|
||||
const MIN_SAVINGS_PERCENT = 20;
|
||||
|
||||
const total = useMemo(() => cats.reduce((s, c) => s + c.percent, 0), [cats]);
|
||||
const total = useMemo(
|
||||
() => cats.reduce((s, c) => s + c.percent, 0),
|
||||
[cats],
|
||||
);
|
||||
const savingsTotal = useMemo(
|
||||
() => cats.reduce((sum, c) => sum + (c.isSavings ? c.percent : 0), 0),
|
||||
[cats]
|
||||
);
|
||||
const remainingPercent = Math.max(0, 100 - total);
|
||||
|
||||
// Drag ordering state (initially from priority)
|
||||
const [order, setOrder] = useState<string[]>([]);
|
||||
useEffect(() => {
|
||||
const sorted = cats
|
||||
.slice()
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.priority - b.priority || a.name.localeCompare(b.name),
|
||||
);
|
||||
const next = sorted.map((c) => c.id);
|
||||
// Reset order when cats change in length or ids
|
||||
if (
|
||||
order.length !== next.length ||
|
||||
next.some((id, i) => order[i] !== id)
|
||||
) {
|
||||
setOrder(next);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cats.map((c) => c.id).join("|")]);
|
||||
|
||||
// Add form state
|
||||
const [name, setName] = useState("");
|
||||
@@ -32,45 +93,157 @@ export default function SettingsCategoriesPage() {
|
||||
const [priority, setPriority] = useState("");
|
||||
const [isSavings, setIsSavings] = useState(false);
|
||||
|
||||
const addDisabled = !name || !percent || !priority || Number(percent) < 0 || Number(percent) > 100;
|
||||
const parsedPercent = Math.max(0, Math.floor(Number(percent) || 0));
|
||||
const parsedPriority = Math.max(0, Math.floor(Number(priority) || 0));
|
||||
const addDisabled =
|
||||
!name.trim() ||
|
||||
parsedPercent <= 0 ||
|
||||
parsedPercent > 100 ||
|
||||
parsedPercent > remainingPercent ||
|
||||
createM.isPending;
|
||||
|
||||
const onAdd = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const normalizedName = normalizeName(name);
|
||||
if (cats.some((c) => normalizeName(c.name) === normalizedName)) {
|
||||
push("err", `Expense name '${normalizedName}' already exists`);
|
||||
return;
|
||||
}
|
||||
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
|
||||
name: normalizedName,
|
||||
percent: parsedPercent,
|
||||
priority: parsedPriority,
|
||||
isSavings,
|
||||
};
|
||||
if (!body.name) return;
|
||||
if (body.percent > remainingPercent) {
|
||||
push("err", `Only ${remainingPercent}% is available right now.`);
|
||||
return;
|
||||
}
|
||||
const nextTotal = total + body.percent;
|
||||
const nextSavingsTotal = savingsTotal + (body.isSavings ? body.percent : 0);
|
||||
if (nextTotal === 100 && nextSavingsTotal < MIN_SAVINGS_PERCENT) {
|
||||
push(
|
||||
"err",
|
||||
`Savings must total at least ${MIN_SAVINGS_PERCENT}% (currently ${nextSavingsTotal}%)`
|
||||
);
|
||||
return;
|
||||
}
|
||||
createM.mutate(body, {
|
||||
onSuccess: () => {
|
||||
push("ok", "Category created");
|
||||
setName(""); setPercent(""); setPriority(""); setIsSavings(false);
|
||||
push("ok", "Expense created");
|
||||
setName("");
|
||||
setPercent("");
|
||||
setPriority("");
|
||||
setIsSavings(false);
|
||||
},
|
||||
onError: (err: any) => push("err", err?.message ?? "Create failed")
|
||||
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 onEdit = (id: string, patch: Partial<Row>) => {
|
||||
if (patch.name !== undefined) {
|
||||
const normalizedName = normalizeName(patch.name);
|
||||
if (
|
||||
cats.some((c) => c.id !== id && normalizeName(c.name) === normalizedName)
|
||||
) {
|
||||
push("err", `Expense name '${normalizedName}' already exists`);
|
||||
return;
|
||||
}
|
||||
patch.name = normalizedName;
|
||||
}
|
||||
if (patch.percent !== undefined) {
|
||||
const current = cats.find((c) => c.id === id);
|
||||
if (!current) return;
|
||||
const sanitized = Math.max(
|
||||
0,
|
||||
Math.min(100, Math.floor(patch.percent)),
|
||||
);
|
||||
const nextTotal = total - current.percent + sanitized;
|
||||
if (nextTotal > 100) {
|
||||
push("err", `Updating this would push totals to ${nextTotal}%.`);
|
||||
return;
|
||||
}
|
||||
patch.percent = sanitized;
|
||||
}
|
||||
if (patch.priority !== undefined) {
|
||||
patch.priority = Math.max(0, Math.floor(patch.priority));
|
||||
}
|
||||
if (patch.isSavings !== undefined || patch.percent !== undefined) {
|
||||
const current = cats.find((c) => c.id === id);
|
||||
if (!current) return;
|
||||
const nextPercent = patch.percent ?? current.percent;
|
||||
const wasSavings = current.isSavings ? current.percent : 0;
|
||||
const nextIsSavings = patch.isSavings ?? current.isSavings;
|
||||
const nextSavings = nextIsSavings ? nextPercent : 0;
|
||||
const nextTotal =
|
||||
total - current.percent + (patch.percent ?? current.percent);
|
||||
const nextSavingsTotal =
|
||||
savingsTotal - wasSavings + nextSavings;
|
||||
if (nextTotal === 100 && nextSavingsTotal < MIN_SAVINGS_PERCENT) {
|
||||
push(
|
||||
"err",
|
||||
`Savings must total at least ${MIN_SAVINGS_PERCENT}% (currently ${nextSavingsTotal}%)`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
updateM.mutate(
|
||||
{ id, body: patch },
|
||||
{
|
||||
onError: (err: any) =>
|
||||
push("err", err?.message ?? "Update failed"),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const onDelete = (id: number) => {
|
||||
const onDelete = (id: string) => {
|
||||
deleteM.mutate(id, {
|
||||
onSuccess: () => push("ok", "Category deleted"),
|
||||
onError: (err: any) => push("err", err?.message ?? "Delete failed")
|
||||
onSuccess: () => push("ok", "Expense 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>;
|
||||
const onDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
setOrder((prev) => {
|
||||
const oldIndex = prev.indexOf(String(active.id));
|
||||
const newIndex = prev.indexOf(String(over.id));
|
||||
const next = arrayMove(prev, oldIndex, newIndex);
|
||||
// Apply new priorities to server (only changed ones)
|
||||
const updates = onDragOrderApply(next);
|
||||
updates.forEach(({ id, priority }) => {
|
||||
const existing = cats.find((c) => c.id === id);
|
||||
if (existing && existing.priority !== priority) {
|
||||
updateM.mutate({ id, body: { priority } });
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
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>
|
||||
<SettingsNav />
|
||||
<p className="mb-3">Couldn't load expenses.</p>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
>
|
||||
{isFetching ? "Retrying…" : "Retry"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -78,60 +251,165 @@ export default function SettingsCategoriesPage() {
|
||||
return (
|
||||
<div className="grid gap-4 max-w-5xl">
|
||||
<section className="card">
|
||||
<SettingsNav/>
|
||||
<SettingsNav />
|
||||
|
||||
<header className="mb-4 space-y-1">
|
||||
<h1 className="text-lg font-semibold">Expenses</h1>
|
||||
<p className="text-sm muted">
|
||||
Decide how every dollar is divided. Percentages must always
|
||||
add up to 100%.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* 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)} />
|
||||
<form
|
||||
onSubmit={onAdd}
|
||||
className="row gap-2 mb-4 flex-wrap items-end"
|
||||
>
|
||||
<input
|
||||
className="input w-full sm:w-44"
|
||||
placeholder="Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input w-full sm:w-28"
|
||||
placeholder="%"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={percent}
|
||||
onChange={(e) => setPercent(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input w-full sm:w-28"
|
||||
placeholder="Priority"
|
||||
type="number"
|
||||
min={0}
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value)}
|
||||
/>
|
||||
<label className="row w-full sm:w-auto">
|
||||
<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>
|
||||
<button
|
||||
className="btn w-full sm:w-auto"
|
||||
disabled={addDisabled || createM.isPending}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
<div className="ml-auto text-right">
|
||||
<SumBadge total={total} />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{cats.length === 0 ? (
|
||||
<div className="muted text-sm">No categories yet.</div>
|
||||
<div className="muted text-sm mt-4">
|
||||
No expenses 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>
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<table className="table mt-4">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Name</th>
|
||||
<th>%</th>
|
||||
<th>Priority</th>
|
||||
<th>Savings</th>
|
||||
<th>Balance</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<SortableContext
|
||||
items={order}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<tbody>
|
||||
{order
|
||||
.map((id) => cats.find((c) => c.id === id))
|
||||
.filter(Boolean)
|
||||
.map((c) => (
|
||||
<SortableTr key={c!.id} id={c!.id}>
|
||||
<td className="px-3 py-2">
|
||||
<span className="drag-handle inline-flex items-center justify-center w-6 h-6 rounded-full bg-[--color-panel] text-lg cursor-grab active:cursor-grabbing">
|
||||
⋮⋮
|
||||
</span>
|
||||
</td>
|
||||
<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 })
|
||||
}
|
||||
/>
|
||||
{c!.isSavings && (
|
||||
<span className="badge ml-2 text-[10px] bg-emerald-500/10 text-emerald-200 border border-emerald-500/40">
|
||||
Savings
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Money cents={c!.balanceCents ?? 0} />
|
||||
</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>
|
||||
</SortableTr>
|
||||
))}
|
||||
</tbody>
|
||||
</SortableContext>
|
||||
</table>
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
{/* Guard if total != 100 */}
|
||||
{total !== 100 && (
|
||||
<div className="toast-err mt-3">
|
||||
Percents must sum to <strong>100%</strong> for allocations. Current total: {total}%.
|
||||
Percents must sum to <strong>100%</strong> for allocations.
|
||||
Current total: {total}%.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
@@ -140,40 +418,135 @@ export default function SettingsCategoriesPage() {
|
||||
}
|
||||
|
||||
/* --- tiny inline editors --- */
|
||||
function InlineEditText({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
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); };
|
||||
useEffect(() => setV(value), [value]);
|
||||
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 />
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
className="link inline-flex items-center gap-1"
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
<span>{value}</span>
|
||||
<span className="text-[10px] opacity-60">Edit</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineEditNumber({ value, onChange, min=0, max=Number.MAX_SAFE_INTEGER }:
|
||||
{ value: number; onChange: (v: number) => void; min?: number; max?: number; }) {
|
||||
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));
|
||||
useEffect(() => setV(String(value)), [value]);
|
||||
const commit = () => {
|
||||
const n = Math.max(min, Math.min(max, Math.floor(Number(v) || 0)));
|
||||
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 />
|
||||
<input
|
||||
className="input w-24 text-right"
|
||||
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>
|
||||
<button
|
||||
type="button"
|
||||
className="link"
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
{value}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineEditCheckbox({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||||
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>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
<span className="muted text-sm">
|
||||
{checked ? "Yes" : "No"}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function SortableTr({
|
||||
id,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
} = useSortable({ id });
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
} as React.CSSProperties;
|
||||
return (
|
||||
<tr
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="sortable-row"
|
||||
>
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function onDragOrderApply(ids: string[]) {
|
||||
return ids.map((id, idx) => ({ id, priority: idx + 1 }));
|
||||
}
|
||||
|
||||
650
web/src/pages/settings/CategoriesSettings.tsx
Normal file
650
web/src/pages/settings/CategoriesSettings.tsx
Normal file
@@ -0,0 +1,650 @@
|
||||
// web/src/pages/settings/CategoriesSettings.tsx
|
||||
import {
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
type FormEvent,
|
||||
} from "react";
|
||||
import type React from "react";
|
||||
import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
arrayMove,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { useDashboard } from "../../hooks/useDashboard";
|
||||
import { Money } from "../../components/ui";
|
||||
import { useToast } from "../../components/Toast";
|
||||
import { categoriesApi } from "../../api/categories";
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
name: string;
|
||||
percent: number;
|
||||
priority: number;
|
||||
isSavings: boolean;
|
||||
balanceCents: number;
|
||||
};
|
||||
|
||||
type LocalRow = Row & { _isNew?: boolean; _isDeleted?: boolean };
|
||||
|
||||
const MIN_SAVINGS_PERCENT = 20;
|
||||
|
||||
function SumBadge({ total }: { total: number }) {
|
||||
const tone =
|
||||
total === 100
|
||||
? "bg-emerald-500/10 text-emerald-200 border border-emerald-500/40"
|
||||
: total < 100
|
||||
? "bg-amber-500/10 text-amber-100 border border-amber-500/30"
|
||||
: "bg-red-500/10 text-red-100 border border-red-500/40";
|
||||
const label = total === 100 ? "Perfect" : total < 100 ? "Under" : "Over";
|
||||
return (
|
||||
<div className={`badge ${tone}`}>
|
||||
{label}: {total}%
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CategoriesSettingsProps {
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}
|
||||
|
||||
export type CategoriesSettingsHandle = {
|
||||
save: () => Promise<boolean>;
|
||||
};
|
||||
|
||||
function CategoriesSettingsInner(
|
||||
{ onDirtyChange }: CategoriesSettingsProps,
|
||||
ref: React.ForwardedRef<CategoriesSettingsHandle>
|
||||
) {
|
||||
const { data, isLoading, error, refetch, isFetching } = useDashboard();
|
||||
const serverCats = (data?.variableCategories ?? []) as Row[];
|
||||
|
||||
const { push } = useToast();
|
||||
const normalizeName = useCallback((value: string) => value.trim().toLowerCase(), []);
|
||||
const recalcBalances = useCallback((rows: LocalRow[]) => {
|
||||
const active = rows.filter((c) => !c._isDeleted);
|
||||
if (active.length === 0) return rows;
|
||||
|
||||
const totalBalance = active.reduce((sum, c) => sum + (c.balanceCents ?? 0), 0);
|
||||
const percentTotal = active.reduce((sum, c) => sum + (c.percent || 0), 0);
|
||||
if (totalBalance <= 0 || percentTotal <= 0) return rows;
|
||||
|
||||
const targets = active.map((cat) => {
|
||||
const raw = (totalBalance * cat.percent) / percentTotal;
|
||||
const floored = Math.floor(raw);
|
||||
return { id: cat.id, target: floored, frac: raw - floored };
|
||||
});
|
||||
|
||||
let remainder = totalBalance - targets.reduce((sum, t) => sum + t.target, 0);
|
||||
targets
|
||||
.slice()
|
||||
.sort((a, b) => b.frac - a.frac)
|
||||
.forEach((t) => {
|
||||
if (remainder > 0) {
|
||||
t.target += 1;
|
||||
remainder -= 1;
|
||||
}
|
||||
});
|
||||
|
||||
const targetById = new Map(targets.map((t) => [t.id, t.target]));
|
||||
return rows.map((cat) =>
|
||||
cat._isDeleted ? cat : { ...cat, balanceCents: targetById.get(cat.id) ?? cat.balanceCents }
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Local editable state
|
||||
const [localCats, setLocalCats] = useState<LocalRow[]>([]);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Initialize local state from server once
|
||||
useEffect(() => {
|
||||
if (!initialized && serverCats && serverCats.length > 0) {
|
||||
setLocalCats(serverCats.map((c) => ({ ...c })));
|
||||
setInitialized(true);
|
||||
}
|
||||
}, [serverCats, initialized]);
|
||||
|
||||
const resetToServer = useCallback(() => {
|
||||
setLocalCats((serverCats ?? []).map((c) => ({ ...c })));
|
||||
setInitialized(true);
|
||||
}, [serverCats]);
|
||||
|
||||
const activeCats = useMemo(
|
||||
() => localCats.filter((c) => !c._isDeleted),
|
||||
[localCats]
|
||||
);
|
||||
|
||||
const total = useMemo(
|
||||
() => activeCats.reduce((s, c) => s + c.percent, 0),
|
||||
[activeCats]
|
||||
);
|
||||
|
||||
const savingsCount = useMemo(
|
||||
() => activeCats.filter((c) => c.isSavings).length,
|
||||
[activeCats]
|
||||
);
|
||||
const savingsTotal = useMemo(
|
||||
() => activeCats.reduce((sum, c) => sum + (c.isSavings ? c.percent : 0), 0),
|
||||
[activeCats]
|
||||
);
|
||||
|
||||
const duplicateNames = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
activeCats.forEach((c) => {
|
||||
const key = normalizeName(c.name);
|
||||
if (!key) return;
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
});
|
||||
return Array.from(counts.entries())
|
||||
.filter(([, count]) => count > 1)
|
||||
.map(([name]) => name);
|
||||
}, [activeCats, normalizeName]);
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
if (localCats.length === 0) return false;
|
||||
|
||||
if (localCats.some((c) => c._isNew || c._isDeleted)) return true;
|
||||
|
||||
for (const local of localCats) {
|
||||
if (local._isNew || local._isDeleted) continue;
|
||||
const server = serverCats.find((s) => s.id === local.id);
|
||||
if (!server) return true;
|
||||
|
||||
if (
|
||||
local.name !== server.name ||
|
||||
local.percent !== server.percent ||
|
||||
local.priority !== server.priority ||
|
||||
local.isSavings !== server.isSavings
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const server of serverCats) {
|
||||
if (!localCats.find((l) => l.id === server.id)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [localCats, serverCats]);
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(hasChanges);
|
||||
}, [hasChanges, onDirtyChange]);
|
||||
|
||||
// Drag ordering
|
||||
const [order, setOrder] = useState<string[]>([]);
|
||||
useEffect(() => {
|
||||
const sorted = activeCats
|
||||
.slice()
|
||||
.sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name));
|
||||
const next = sorted.map((c) => c.id);
|
||||
|
||||
if (order.length !== next.length || next.some((id, i) => order[i] !== id)) {
|
||||
setOrder(next);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeCats.map((c) => c.id).join("|")]);
|
||||
|
||||
// Add form state
|
||||
const [name, setName] = useState("");
|
||||
const [percent, setPercent] = useState("");
|
||||
const [priority, setPriority] = useState("");
|
||||
const [isSavings, setIsSavings] = useState(false);
|
||||
|
||||
const parsedPercent = Math.max(0, Math.floor(Number(percent) || 0));
|
||||
const parsedPriority = Math.max(0, Math.floor(Number(priority) || 0));
|
||||
const addDisabled = !name.trim() || parsedPercent <= 0 || parsedPercent > 100;
|
||||
|
||||
const onAdd = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
const normalized = normalizeName(name);
|
||||
if (activeCats.some((c) => normalizeName(c.name) === normalized)) {
|
||||
push("err", `Expense name '${normalized}' already exists`);
|
||||
return;
|
||||
}
|
||||
|
||||
const tempId = `temp_${Date.now()}`;
|
||||
const newCat: LocalRow = {
|
||||
id: tempId,
|
||||
name: normalized,
|
||||
percent: parsedPercent,
|
||||
priority: parsedPriority || activeCats.length + 1,
|
||||
isSavings,
|
||||
balanceCents: 0,
|
||||
_isNew: true,
|
||||
};
|
||||
|
||||
setLocalCats((prev) => recalcBalances([...prev, newCat]));
|
||||
setName("");
|
||||
setPercent("");
|
||||
setPriority("");
|
||||
setIsSavings(false);
|
||||
};
|
||||
|
||||
const onEdit = (id: string, patch: Partial<LocalRow>) => {
|
||||
setLocalCats((prev) =>
|
||||
prev.map((c) => {
|
||||
if (c.id !== id) return c;
|
||||
const updated = {
|
||||
...c,
|
||||
...patch,
|
||||
...(patch.name !== undefined ? { name: normalizeName(patch.name) } : {}),
|
||||
};
|
||||
|
||||
if (patch.percent !== undefined) {
|
||||
updated.percent = Math.max(
|
||||
0,
|
||||
Math.min(100, Math.floor(patch.percent))
|
||||
);
|
||||
}
|
||||
if (patch.priority !== undefined) {
|
||||
updated.priority = Math.max(0, Math.floor(patch.priority));
|
||||
}
|
||||
return updated;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const onDelete = (id: string) => {
|
||||
setLocalCats((prev) =>
|
||||
prev
|
||||
.map((c) => (c.id === id ? { ...c, _isDeleted: true } : c))
|
||||
.filter((c) => !(c._isNew && c._isDeleted))
|
||||
);
|
||||
};
|
||||
|
||||
const onDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
setOrder((prev) => {
|
||||
const oldIndex = prev.indexOf(String(active.id));
|
||||
const newIndex = prev.indexOf(String(over.id));
|
||||
const next = arrayMove(prev, oldIndex, newIndex);
|
||||
|
||||
const updates = onDragOrderApply(next);
|
||||
setLocalCats((prevCats) =>
|
||||
prevCats.map((c) => {
|
||||
const update = updates.find((u) => u.id === c.id);
|
||||
if (update && c.priority !== update.priority) {
|
||||
return { ...c, priority: update.priority };
|
||||
}
|
||||
return c;
|
||||
})
|
||||
);
|
||||
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const onCancel = () => {
|
||||
resetToServer();
|
||||
push("ok", "Changes discarded");
|
||||
};
|
||||
|
||||
const onSave = useCallback(async (): Promise<boolean> => {
|
||||
const normalizedPriorityOrder = activeCats
|
||||
.slice()
|
||||
.sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name))
|
||||
.map((cat, index) => ({ id: cat.id, priority: index + 1 }));
|
||||
const priorityById = new Map(
|
||||
normalizedPriorityOrder.map((item) => [item.id, item.priority])
|
||||
);
|
||||
const normalizedCats = localCats.map((cat) =>
|
||||
cat._isDeleted ? cat : { ...cat, priority: priorityById.get(cat.id) ?? cat.priority }
|
||||
);
|
||||
|
||||
if (duplicateNames.length > 0) {
|
||||
push("err", `Duplicate expense names: ${duplicateNames.join(", ")}`);
|
||||
return false;
|
||||
}
|
||||
if (total !== 100) {
|
||||
push("err", `Percentages must sum to 100% (currently ${total}%)`);
|
||||
return false;
|
||||
}
|
||||
if (savingsCount === 0) {
|
||||
push("err", "You must have at least one Savings expense");
|
||||
return false;
|
||||
}
|
||||
if (savingsTotal < MIN_SAVINGS_PERCENT) {
|
||||
push(
|
||||
"err",
|
||||
`Savings must total at least ${MIN_SAVINGS_PERCENT}% (currently ${savingsTotal}%)`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const hasNew = normalizedCats.some((c) => c._isNew && !c._isDeleted);
|
||||
|
||||
// Deletes
|
||||
const toDelete = normalizedCats.filter((c) => c._isDeleted && !c._isNew);
|
||||
for (const cat of toDelete) {
|
||||
await categoriesApi.delete(cat.id);
|
||||
}
|
||||
|
||||
// Creates
|
||||
const toCreate = normalizedCats.filter((c) => c._isNew && !c._isDeleted);
|
||||
for (const cat of toCreate) {
|
||||
await categoriesApi.create({
|
||||
name: normalizeName(cat.name),
|
||||
percent: cat.percent,
|
||||
priority: cat.priority,
|
||||
isSavings: cat.isSavings,
|
||||
});
|
||||
}
|
||||
|
||||
// Updates
|
||||
const toUpdate = normalizedCats.filter((c) => !c._isNew && !c._isDeleted);
|
||||
for (const local of toUpdate) {
|
||||
const server = serverCats.find((s) => s.id === local.id);
|
||||
if (!server) continue;
|
||||
|
||||
const patch: Partial<Row> = {};
|
||||
if (local.name !== server.name) patch.name = normalizeName(local.name);
|
||||
if (local.percent !== server.percent) patch.percent = local.percent;
|
||||
if (local.priority !== server.priority) patch.priority = local.priority;
|
||||
if (local.isSavings !== server.isSavings)
|
||||
patch.isSavings = local.isSavings;
|
||||
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await categoriesApi.update(local.id, patch);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasNew) {
|
||||
try {
|
||||
await categoriesApi.rebalance();
|
||||
} catch (err: any) {
|
||||
push("err", err?.message ?? "Failed to rebalance expenses");
|
||||
}
|
||||
}
|
||||
|
||||
push("ok", "Expenses saved successfully");
|
||||
const refreshed = await refetch();
|
||||
const nextCats =
|
||||
(refreshed.data?.variableCategories ?? serverCats) as Row[];
|
||||
setLocalCats(nextCats.map((c) => ({ ...c })));
|
||||
setInitialized(true);
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
push("err", err?.message ?? "Save failed");
|
||||
return false;
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [
|
||||
total,
|
||||
savingsCount,
|
||||
localCats,
|
||||
serverCats,
|
||||
refetch,
|
||||
resetToServer,
|
||||
push,
|
||||
]);
|
||||
|
||||
useImperativeHandle(ref, () => ({ save: onSave }), [onSave]);
|
||||
|
||||
if (isLoading) return <div className="muted">Loading expenses...</div>;
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-3">Couldn't load expenses.</p>
|
||||
<button className="btn" onClick={() => refetch()} disabled={isFetching}>
|
||||
{isFetching ? "Retrying…" : "Retry"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="space-y-1">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<h2 className="text-lg font-semibold">Expense Categories</h2>
|
||||
{hasChanges && (
|
||||
<span className="text-xs text-amber-400">Unsaved changes</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm muted">
|
||||
Decide how every dollar is divided. Percentages must always add up to
|
||||
100%.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form onSubmit={onAdd} className="settings-add-form">
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="%"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={percent}
|
||||
onChange={(e) => setPercent(e.target.value)}
|
||||
/>
|
||||
<label className="settings-checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSavings}
|
||||
onChange={(e) => setIsSavings(e.target.checked)}
|
||||
/>
|
||||
<span>Savings</span>
|
||||
</label>
|
||||
<button className="btn" type="submit" disabled={addDisabled}>
|
||||
Add
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<SumBadge total={total} />
|
||||
</div>
|
||||
|
||||
{activeCats.length === 0 ? (
|
||||
<div className="muted text-sm">No expenses yet.</div>
|
||||
) : (
|
||||
<DndContext collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
||||
<SortableContext items={order} strategy={verticalListSortingStrategy}>
|
||||
<div className="settings-category-list">
|
||||
{order
|
||||
.map((id) => activeCats.find((c) => c.id === id))
|
||||
.filter(Boolean)
|
||||
.map((c) => (
|
||||
<SortableRow key={c!.id} id={c!.id}>
|
||||
{(dragListeners: any) => (
|
||||
<div className={`settings-category-row ${c!.isSavings ? 'savings' : ''}`}>
|
||||
<div className="settings-category-main">
|
||||
<span
|
||||
{...dragListeners}
|
||||
className="settings-drag-handle"
|
||||
>
|
||||
⋮⋮
|
||||
</span>
|
||||
<div className="settings-category-info">
|
||||
<div className="settings-category-name">
|
||||
<InlineEditText
|
||||
value={c!.name}
|
||||
onChange={(v) => onEdit(c!.id, { name: v })}
|
||||
/>
|
||||
{c!._isNew && (
|
||||
<span className="badge text-[10px] bg-blue-500/10 text-blue-200 border border-blue-500/40">
|
||||
New
|
||||
</span>
|
||||
)}
|
||||
{c!.isSavings && (
|
||||
<span className="badge text-[10px] bg-emerald-500/10 text-emerald-200 border border-emerald-500/40">
|
||||
Savings
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="settings-category-balance">
|
||||
<Money cents={c!.balanceCents ?? 0} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-category-percent">
|
||||
<InlineEditNumber
|
||||
value={c!.percent}
|
||||
min={0}
|
||||
max={100}
|
||||
onChange={(v) => onEdit(c!.id, { percent: v })}
|
||||
/>
|
||||
<span className="text-xs muted">%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-category-actions">
|
||||
<label className="settings-checkbox-label small">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={c!.isSavings}
|
||||
onChange={(e) => onEdit(c!.id, { isSavings: e.target.checked })}
|
||||
/>
|
||||
<span>Savings</span>
|
||||
</label>
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
type="button"
|
||||
onClick={() => onDelete(c!.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SortableRow>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
{hasChanges && (
|
||||
<div className="flex gap-3 pt-4 border-t border-[--color-border]">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => void onSave()}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save Changes"}
|
||||
</button>
|
||||
<button className="btn" onClick={onCancel} disabled={isSaving}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CategoriesSettings = forwardRef(CategoriesSettingsInner);
|
||||
export default CategoriesSettings;
|
||||
|
||||
/* --- tiny inline editors --- */
|
||||
function InlineEditText({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
const [v, setV] = useState(value);
|
||||
const [editing, setEditing] = useState(false);
|
||||
useEffect(() => setV(value), [value]);
|
||||
const commit = () => {
|
||||
const next = v.trim();
|
||||
if (next !== value) onChange(next);
|
||||
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 inline-flex items-center gap-1"
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
<span>{value}</span>
|
||||
<span className="text-[10px] opacity-60">Edit</span>
|
||||
</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));
|
||||
useEffect(() => setV(String(value)), [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 text-right"
|
||||
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 SortableRow({
|
||||
id,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
children: (dragListeners: any) => React.ReactNode;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
useSortable({ id });
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
} as React.CSSProperties;
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes}>
|
||||
{children(listeners)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function onDragOrderApply(ids: string[]) {
|
||||
return ids.map((id, idx) => ({ id, priority: idx + 1 }));
|
||||
}
|
||||
@@ -1,28 +1,48 @@
|
||||
// web/src/pages/settings/PlansPage.tsx
|
||||
import { useMemo, useState, type ChangeEvent, type FormEvent } from "react";
|
||||
import {
|
||||
useMemo,
|
||||
useState,
|
||||
useEffect,
|
||||
type FormEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useDashboard } from "../../hooks/useDashboard";
|
||||
import SettingsNav from "./_SettingsNav";
|
||||
import { useCreatePlan, useUpdatePlan, useDeletePlan } from "../../hooks/useFixedPlans";
|
||||
import {
|
||||
useCreatePlan,
|
||||
useUpdatePlan,
|
||||
useDeletePlan,
|
||||
} from "../../hooks/useFixedPlans";
|
||||
import { Money } from "../../components/ui";
|
||||
import { useToast } from "../../components/Toast";
|
||||
import { getTodayInTimezone, dateStringToUTCMidnight, isoToDateString, getBrowserTimezone, formatDateInTimezone } from "../../utils/timezone";
|
||||
|
||||
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();
|
||||
function daysUntil(iso: string, userTimezone: string) {
|
||||
const today = getTodayInTimezone(userTimezone);
|
||||
const due = isoToDateString(iso, userTimezone);
|
||||
const todayDate = new Date(today);
|
||||
const dueDate = new Date(due);
|
||||
const diffMs = dueDate.getTime() - todayDate.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>;
|
||||
function DueBadge({ dueISO, userTimezone }: { dueISO: string; userTimezone: string }) {
|
||||
const d = daysUntil(dueISO, userTimezone);
|
||||
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>;
|
||||
return (
|
||||
<span className="badge" aria-hidden="true">
|
||||
On track
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsPlansPage() {
|
||||
@@ -31,136 +51,490 @@ export default function SettingsPlansPage() {
|
||||
const updateM = useUpdatePlan();
|
||||
const deleteM = useDeletePlan();
|
||||
const { push } = useToast();
|
||||
|
||||
// Get user timezone from dashboard data
|
||||
const userTimezone = data?.user?.timezone || getBrowserTimezone();
|
||||
|
||||
// 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 [due, setDue] = useState(getTodayInTimezone(userTimezone));
|
||||
|
||||
// Auto-payment form state
|
||||
const [autoPayEnabled, setAutoPayEnabled] = useState(false);
|
||||
const [frequency, setFrequency] = useState<"weekly" | "biweekly" | "monthly" | "daily" | "custom">("monthly");
|
||||
const [dayOfMonth, setDayOfMonth] = useState(1);
|
||||
const [dayOfWeek, setDayOfWeek] = useState(0);
|
||||
const [everyNDays, setEveryNDays] = useState(30);
|
||||
const [minFundingPercent, setMinFundingPercent] = useState(100);
|
||||
|
||||
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) };
|
||||
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>;
|
||||
const overallPctFunded = useMemo(() => {
|
||||
if (!totals.total) return 0;
|
||||
return Math.round((totals.funded / totals.total) * 100);
|
||||
}, [totals.funded, totals.total]);
|
||||
|
||||
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>
|
||||
<SettingsNav />
|
||||
<p className="mb-3">Couldn't load fixed expenses.</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 totalCents = Math.max(
|
||||
0,
|
||||
Math.round((parseFloat(total || "0")) * 100),
|
||||
);
|
||||
const fundedCents = Math.max(
|
||||
0,
|
||||
Math.round((parseFloat(funded || "0")) * 100),
|
||||
);
|
||||
const paymentSchedule = autoPayEnabled ? {
|
||||
frequency,
|
||||
...(frequency === "monthly" ? { dayOfMonth } : {}),
|
||||
...(frequency === "weekly" || frequency === "biweekly" ? { dayOfWeek } : {}),
|
||||
...(frequency === "custom" ? { everyNDays } : {}),
|
||||
minFundingPercent,
|
||||
} : undefined;
|
||||
|
||||
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(),
|
||||
priority: Math.max(
|
||||
0,
|
||||
Math.floor(Number(priority) || 0),
|
||||
),
|
||||
dueOn: dateStringToUTCMidnight(due, userTimezone),
|
||||
autoPayEnabled,
|
||||
paymentSchedule,
|
||||
};
|
||||
if (!body.name || totalCents <= 0) return;
|
||||
createM.mutate(body, {
|
||||
onSuccess: () => {
|
||||
push("ok", "Plan created");
|
||||
setName(""); setTotal(""); setFunded(""); setPriority(""); setDue(isoDateLocal());
|
||||
setName("");
|
||||
setTotal("");
|
||||
setFunded("");
|
||||
setPriority("");
|
||||
setDue(getTodayInTimezone(userTimezone));
|
||||
setAutoPayEnabled(false);
|
||||
setFrequency("monthly");
|
||||
setDayOfMonth(1);
|
||||
setDayOfWeek(0);
|
||||
setEveryNDays(30);
|
||||
setMinFundingPercent(100);
|
||||
},
|
||||
onError: (err: any) => push("err", err?.message ?? "Create failed"),
|
||||
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)) {
|
||||
const onEdit = (
|
||||
id: string,
|
||||
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"),
|
||||
updateM.mutate(
|
||||
{ id, body: patch },
|
||||
{
|
||||
onSuccess: () => push("ok", "Plan updated"),
|
||||
onError: (err: any) =>
|
||||
push("err", err?.message ?? "Update failed"),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const onDelete = (id: string) => {
|
||||
deleteM.mutate(id, {
|
||||
onSuccess: () => push("ok", "Plan deleted"),
|
||||
onError: (err: any) =>
|
||||
push("err", err?.message ?? "Delete failed"),
|
||||
});
|
||||
};
|
||||
|
||||
const onDelete = (id: number) => {
|
||||
deleteM.mutate(id, {
|
||||
onSuccess: () => push("ok", "Plan deleted"),
|
||||
onError: (err: any) => push("err", err?.message ?? "Delete failed"),
|
||||
});
|
||||
};
|
||||
const addDisabled =
|
||||
!name || !total || createM.isPending;
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 max-w-5xl">
|
||||
<section className="card">
|
||||
<SettingsNav/>
|
||||
<SettingsNav />
|
||||
|
||||
{/* Header */}
|
||||
<header className="mb-4 space-y-1">
|
||||
<h1 className="text-lg font-semibold">
|
||||
Fixed expenses
|
||||
</h1>
|
||||
<p className="text-sm muted">
|
||||
Long-term goals and obligations you’re funding over
|
||||
time.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* 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 className="grid gap-2 sm:grid-cols-4 mb-4">
|
||||
<KpiCard label="Funded">
|
||||
<Money cents={totals.funded} />
|
||||
</KpiCard>
|
||||
<KpiCard label="Total">
|
||||
<Money cents={totals.total} />
|
||||
</KpiCard>
|
||||
<KpiCard label="Remaining">
|
||||
<Money cents={totals.remaining} />
|
||||
</KpiCard>
|
||||
<KpiCard label="Overall progress">
|
||||
<span className="text-xl font-semibold">
|
||||
{overallPctFunded}%
|
||||
</span>
|
||||
</KpiCard>
|
||||
</div>
|
||||
|
||||
{/* Overall progress bar */}
|
||||
<div className="mb-4 space-y-1">
|
||||
<div className="row text-xs muted">
|
||||
<span>All fixed expenses funded</span>
|
||||
<span className="ml-auto">
|
||||
{overallPctFunded}% of target
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-full bg-[--color-panel] overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[--color-accent]"
|
||||
style={{
|
||||
width: `${Math.min(100, overallPctFunded)}%`,
|
||||
}}
|
||||
/>
|
||||
</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
|
||||
onSubmit={onAdd}
|
||||
className="row gap-2 mb-4 flex-wrap items-end"
|
||||
>
|
||||
<input
|
||||
className="input w-full sm:w-48"
|
||||
placeholder="Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input w-full sm:w-28"
|
||||
placeholder="Total $"
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
value={total}
|
||||
onChange={(e) => setTotal(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input w-full sm:w-28"
|
||||
placeholder="Funded $"
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
value={funded}
|
||||
onChange={(e) => setFunded(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input w-full sm:w-24"
|
||||
placeholder="Priority"
|
||||
type="number"
|
||||
min={0}
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input w-full sm:w-40"
|
||||
type="date"
|
||||
value={due}
|
||||
onChange={(e) => setDue(e.target.value)}
|
||||
/>
|
||||
<label className="row gap-2 items-center text-sm cursor-pointer px-3 py-2 rounded-lg bg-[--color-panel] w-full sm:w-auto">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoPayEnabled}
|
||||
onChange={(e) => setAutoPayEnabled(e.target.checked)}
|
||||
/>
|
||||
<span>Auto-fund</span>
|
||||
</label>
|
||||
<button className="btn w-full sm:w-auto" disabled={addDisabled}>
|
||||
Add
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Auto-payment configuration */}
|
||||
{autoPayEnabled && (
|
||||
<div className="card bg-[--color-panel] p-4 mb-4">
|
||||
<h4 className="section-title text-sm mb-3">Auto-Fund Schedule</h4>
|
||||
<div className="row gap-4 flex-wrap items-end">
|
||||
<label className="stack text-sm">
|
||||
<span className="muted text-xs">Frequency</span>
|
||||
<select
|
||||
className="input w-32"
|
||||
value={frequency}
|
||||
onChange={(e) => setFrequency(e.target.value as any)}
|
||||
>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="biweekly">Biweekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{(frequency === "weekly" || frequency === "biweekly") && (
|
||||
<label className="stack text-sm">
|
||||
<span className="muted text-xs">Day of Week</span>
|
||||
<select
|
||||
className="input w-32"
|
||||
value={dayOfWeek}
|
||||
onChange={(e) => setDayOfWeek(Number(e.target.value))}
|
||||
>
|
||||
<option value={0}>Sunday</option>
|
||||
<option value={1}>Monday</option>
|
||||
<option value={2}>Tuesday</option>
|
||||
<option value={3}>Wednesday</option>
|
||||
<option value={4}>Thursday</option>
|
||||
<option value={5}>Friday</option>
|
||||
<option value={6}>Saturday</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{frequency === "monthly" && (
|
||||
<label className="stack text-sm">
|
||||
<span className="muted text-xs">Day of Month</span>
|
||||
<input
|
||||
className="input w-24"
|
||||
type="number"
|
||||
min="1"
|
||||
max="31"
|
||||
value={dayOfMonth}
|
||||
onChange={(e) => setDayOfMonth(Number(e.target.value) || 1)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{frequency === "custom" && (
|
||||
<label className="stack text-sm">
|
||||
<span className="muted text-xs">Every N Days</span>
|
||||
<input
|
||||
className="input w-24"
|
||||
type="number"
|
||||
min="1"
|
||||
value={everyNDays}
|
||||
onChange={(e) => setEveryNDays(Number(e.target.value) || 30)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label className="stack text-sm">
|
||||
<span className="muted text-xs">Min. Funding %</span>
|
||||
<input
|
||||
className="input w-24"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
value={minFundingPercent}
|
||||
onChange={(e) => setMinFundingPercent(Math.max(0, Math.min(100, Number(e.target.value) || 0)))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs muted mt-2">
|
||||
Automatic payments will only occur if the expense is funded to at least the minimum percentage.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
{data.fixedPlans.length === 0 ? (
|
||||
<div className="muted text-sm">No fixed plans yet.</div>
|
||||
<div className="muted text-sm">
|
||||
No fixed expenses 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>
|
||||
<th>Name</th>
|
||||
<th>Due</th>
|
||||
<th>Priority</th>
|
||||
<th>Funded</th>
|
||||
<th>Total</th>
|
||||
<th>Remaining</th>
|
||||
<th>Status</th>
|
||||
<th>Auto-Pay</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);
|
||||
.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,
|
||||
);
|
||||
const pctFunded = p.totalCents
|
||||
? Math.round(
|
||||
(p.fundedCents / p.totalCents) * 100,
|
||||
)
|
||||
: 0;
|
||||
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)) })}
|
||||
<InlineEditText
|
||||
value={p.name}
|
||||
onChange={(v) =>
|
||||
onEdit(p.id, { name: v })
|
||||
}
|
||||
/>
|
||||
</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) })}
|
||||
<InlineEditDate
|
||||
value={p.dueOn}
|
||||
timezone={userTimezone}
|
||||
onChange={(iso) =>
|
||||
onEdit(p.id, { dueOn: iso })
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2"><Money cents={remaining} /></td>
|
||||
<td className="px-3 py-2"><DueBadge dueISO={p.dueOn} /></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">
|
||||
<div className="space-y-1">
|
||||
<InlineEditMoney
|
||||
valueCents={p.fundedCents}
|
||||
onChange={(cents) =>
|
||||
onEdit(p.id, {
|
||||
fundedCents: Math.max(
|
||||
0,
|
||||
Math.min(cents, p.totalCents),
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div className="row text-xs muted">
|
||||
<span>{pctFunded}% funded</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="space-y-1">
|
||||
<InlineEditMoney
|
||||
valueCents={p.totalCents}
|
||||
onChange={(cents) =>
|
||||
onEdit(p.id, {
|
||||
totalCents: Math.max(cents, 0),
|
||||
fundedCents: Math.min(
|
||||
p.fundedCents,
|
||||
cents,
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<FundingBar
|
||||
pct={pctFunded}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Money cents={remaining} />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<DueBadge dueISO={p.dueOn} userTimezone={userTimezone} />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className={`text-xs px-2 py-1 rounded ${
|
||||
p.autoPayEnabled
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{p.autoPayEnabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
{p.autoPayEnabled && p.paymentSchedule && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{p.paymentSchedule.frequency === 'custom'
|
||||
? `Every ${p.paymentSchedule.customDays} days`
|
||||
: p.paymentSchedule.frequency.charAt(0).toUpperCase() + p.paymentSchedule.frequency.slice(1)
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
<button
|
||||
className="btn"
|
||||
type="button"
|
||||
onClick={() => onDelete(p.id)}
|
||||
disabled={deleteM.isPending}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
@@ -173,46 +547,201 @@ export default function SettingsPlansPage() {
|
||||
);
|
||||
}
|
||||
|
||||
/* --- Inline editors (minimal) --- */
|
||||
function InlineEditText({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
/* --- Small presentational helpers --- */
|
||||
|
||||
function KpiCard({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="card kpi">
|
||||
<h3>{label}</h3>
|
||||
<div className="val">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FundingBar({ pct }: { pct: number }) {
|
||||
const clamped = Math.min(100, Math.max(0, pct));
|
||||
return (
|
||||
<div className="h-1.5 w-full rounded-full bg-[--color-panel] overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[--color-accent]"
|
||||
style={{ width: `${clamped}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* --- Inline editors (same behavior, slightly nicer UX) --- */
|
||||
|
||||
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); };
|
||||
useEffect(() => setV(value), [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>;
|
||||
<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 inline-flex items-center gap-1"
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
<span>{value}</span>
|
||||
<span className="text-[10px] opacity-60">Edit</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineEditNumber({ value, onChange, min=0, max=Number.MAX_SAFE_INTEGER }:
|
||||
{ value: number; onChange: (v: number) => void; min?: number; max?: number }) {
|
||||
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); };
|
||||
useEffect(() => setV(String(value)), [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>;
|
||||
<input
|
||||
className="input w-24 text-right"
|
||||
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 }) {
|
||||
function InlineEditMoney({
|
||||
valueCents,
|
||||
onChange,
|
||||
}: {
|
||||
valueCents: number;
|
||||
onChange: (cents: number) => void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [v, setV] = useState((valueCents / 100).toFixed(2));
|
||||
useEffect(
|
||||
() => setV((valueCents / 100).toFixed(2)),
|
||||
[valueCents],
|
||||
);
|
||||
const commit = () => {
|
||||
const cents = Math.max(0, Math.round((parseFloat(v || "0")) * 100));
|
||||
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>;
|
||||
<input
|
||||
className="input w-28 text-right font-mono"
|
||||
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 font-mono"
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
{(valueCents / 100).toFixed(2)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineEditDate({ value, onChange }: { value: string; onChange: (iso: string) => void }) {
|
||||
function InlineEditDate({
|
||||
value,
|
||||
onChange,
|
||||
timezone,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (iso: string) => void;
|
||||
timezone: string;
|
||||
}) {
|
||||
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); };
|
||||
const [v, setV] = useState(
|
||||
isoToDateString(value, timezone),
|
||||
);
|
||||
useEffect(
|
||||
() =>
|
||||
setV(
|
||||
isoToDateString(value, timezone),
|
||||
),
|
||||
[value, timezone],
|
||||
);
|
||||
const commit = () => {
|
||||
const iso = dateStringToUTCMidnight(v, timezone);
|
||||
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>;
|
||||
<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)}
|
||||
>
|
||||
{formatDateInTimezone(value, timezone)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
894
web/src/pages/settings/PlansSettings.tsx
Normal file
894
web/src/pages/settings/PlansSettings.tsx
Normal file
@@ -0,0 +1,894 @@
|
||||
// web/src/pages/settings/PlansSettings.tsx
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useState,
|
||||
type FormEvent,
|
||||
} from "react";
|
||||
import { Money } from "../../components/ui";
|
||||
import { useDashboard } from "../../hooks/useDashboard";
|
||||
import { useToast } from "../../components/Toast";
|
||||
import {
|
||||
dateStringToUTCMidnight,
|
||||
formatDateInTimezone,
|
||||
getBrowserTimezone,
|
||||
getTodayInTimezone,
|
||||
isoToDateString,
|
||||
} from "../../utils/timezone";
|
||||
import { fixedPlansApi } from "../../api/fixedPlans";
|
||||
|
||||
type FixedPlan = {
|
||||
id: string;
|
||||
name: string;
|
||||
totalCents: number;
|
||||
fundedCents: number;
|
||||
priority: number;
|
||||
dueOn: string;
|
||||
cycleStart: string;
|
||||
frequency?: "one-time" | "weekly" | "biweekly" | "monthly";
|
||||
autoPayEnabled?: boolean;
|
||||
paymentSchedule?: any;
|
||||
nextPaymentDate?: string | null;
|
||||
};
|
||||
|
||||
type LocalPlan = FixedPlan & { _isNew?: boolean; _isDeleted?: boolean };
|
||||
|
||||
export type PlansSettingsHandle = {
|
||||
save: () => Promise<boolean>;
|
||||
};
|
||||
|
||||
interface PlansSettingsProps {
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}
|
||||
|
||||
const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
|
||||
function PlansSettings({ onDirtyChange }, ref) {
|
||||
const { data, isLoading, error, refetch, isFetching } = useDashboard();
|
||||
const plans = (data?.fixedPlans ?? []) as FixedPlan[];
|
||||
const { push } = useToast();
|
||||
|
||||
// Get user timezone from dashboard data
|
||||
const userTimezone = data?.user?.timezone || getBrowserTimezone();
|
||||
const incomeType = data?.user?.incomeType ?? "regular";
|
||||
const incomeFrequency = data?.user?.incomeFrequency;
|
||||
const firstIncomeDate = data?.user?.firstIncomeDate ?? null;
|
||||
|
||||
// Local editable state (preview mode)
|
||||
const [localPlans, setLocalPlans] = useState<LocalPlan[]>([]);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [deletePrompt, setDeletePrompt] = useState<LocalPlan | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialized && plans.length > 0) {
|
||||
setLocalPlans(plans.map((p) => ({ ...p })));
|
||||
setInitialized(true);
|
||||
}
|
||||
}, [plans, initialized]);
|
||||
|
||||
const resetToServer = useCallback(
|
||||
(nextPlans: FixedPlan[] = plans) => {
|
||||
setLocalPlans(nextPlans.map((p) => ({ ...p })));
|
||||
setInitialized(true);
|
||||
},
|
||||
[plans]
|
||||
);
|
||||
|
||||
const activePlans = useMemo(
|
||||
() => localPlans.filter((p) => !p._isDeleted),
|
||||
[localPlans]
|
||||
);
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
if (localPlans.length === 0) return false;
|
||||
if (localPlans.some((p) => p._isNew || p._isDeleted)) return true;
|
||||
|
||||
for (const local of localPlans) {
|
||||
if (local._isNew || local._isDeleted) continue;
|
||||
const server = plans.find((p) => p.id === local.id);
|
||||
if (!server) return true;
|
||||
|
||||
const scheduleEqual =
|
||||
JSON.stringify(local.paymentSchedule ?? null) ===
|
||||
JSON.stringify(server.paymentSchedule ?? null);
|
||||
|
||||
if (
|
||||
local.name !== server.name ||
|
||||
local.totalCents !== server.totalCents ||
|
||||
local.priority !== server.priority ||
|
||||
local.dueOn !== server.dueOn ||
|
||||
(local.autoPayEnabled ?? false) !== (server.autoPayEnabled ?? false) ||
|
||||
(local.frequency ?? null) !== (server.frequency ?? null) ||
|
||||
!scheduleEqual ||
|
||||
(local.nextPaymentDate ?? null) !== (server.nextPaymentDate ?? null)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const server of plans) {
|
||||
if (!localPlans.find((p) => p.id === server.id)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [localPlans, plans]);
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(hasChanges);
|
||||
}, [hasChanges, onDirtyChange]);
|
||||
|
||||
// Form state for adding new plan
|
||||
const [name, setName] = useState("");
|
||||
const [total, setTotal] = useState("");
|
||||
const [priority, setPriority] = useState("");
|
||||
const [due, setDue] = useState(getTodayInTimezone(userTimezone));
|
||||
const [frequency, setFrequency] = useState<"" | FixedPlan["frequency"]>("monthly");
|
||||
const [autoPayEnabled, setAutoPayEnabled] = useState(false);
|
||||
|
||||
const totalCents = Math.max(0, Math.round((Number(total) || 0) * 100));
|
||||
const parsedPriority = Math.max(0, Math.floor(Number(priority) || 0));
|
||||
const addDisabled = !name.trim() || totalCents <= 0 || !due || isSaving;
|
||||
|
||||
function mapScheduleFrequency(
|
||||
nextFrequency?: FixedPlan["frequency"]
|
||||
): "daily" | "weekly" | "biweekly" | "monthly" | "custom" {
|
||||
if (nextFrequency === "weekly") return "weekly";
|
||||
if (nextFrequency === "biweekly") return "biweekly";
|
||||
return "monthly";
|
||||
}
|
||||
|
||||
function buildDefaultSchedule(nextFrequency?: FixedPlan["frequency"]) {
|
||||
return {
|
||||
frequency: mapScheduleFrequency(nextFrequency),
|
||||
minFundingPercent: 100,
|
||||
};
|
||||
}
|
||||
|
||||
const onAdd = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
const dueOnISO = dateStringToUTCMidnight(due, userTimezone);
|
||||
const schedule = autoPayEnabled
|
||||
? buildDefaultSchedule(frequency || undefined)
|
||||
: null;
|
||||
const nextPaymentDate =
|
||||
autoPayEnabled && schedule
|
||||
? calculateNextPaymentDate(dueOnISO, schedule, userTimezone)
|
||||
: null;
|
||||
const tempId = `temp_${Date.now()}`;
|
||||
const newPlan: LocalPlan = {
|
||||
id: tempId,
|
||||
name: name.trim(),
|
||||
totalCents,
|
||||
fundedCents: 0,
|
||||
priority: parsedPriority || localPlans.length + 1,
|
||||
dueOn: dueOnISO,
|
||||
cycleStart: dueOnISO,
|
||||
frequency: frequency || undefined,
|
||||
autoPayEnabled,
|
||||
paymentSchedule: schedule,
|
||||
nextPaymentDate,
|
||||
_isNew: true,
|
||||
};
|
||||
setLocalPlans((prev) => [...prev, newPlan]);
|
||||
setName("");
|
||||
setTotal("");
|
||||
setPriority("");
|
||||
setDue(getTodayInTimezone(userTimezone));
|
||||
setFrequency("monthly");
|
||||
setAutoPayEnabled(false);
|
||||
};
|
||||
|
||||
function toUserMidnight(iso: string, timezone: string) {
|
||||
const dateStr = isoToDateString(iso, timezone);
|
||||
return new Date(dateStringToUTCMidnight(dateStr, timezone));
|
||||
}
|
||||
|
||||
function countPayPeriodsBetween(
|
||||
startIso: string,
|
||||
endIso: string,
|
||||
firstIncomeIso: string,
|
||||
frequency: NonNullable<typeof incomeFrequency>,
|
||||
timezone: string
|
||||
) {
|
||||
let count = 0;
|
||||
let nextPayDate = toUserMidnight(firstIncomeIso, timezone);
|
||||
const normalizedStart = toUserMidnight(startIso, timezone);
|
||||
const normalizedEnd = toUserMidnight(endIso, timezone);
|
||||
|
||||
const targetDay = Number(isoToDateString(firstIncomeIso, timezone).split("-")[2] || "1");
|
||||
const advanceByPeriod = () => {
|
||||
if (frequency === "monthly") {
|
||||
const year = nextPayDate.getUTCFullYear();
|
||||
const month = nextPayDate.getUTCMonth() + 1;
|
||||
const daysInMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
|
||||
nextPayDate = new Date(Date.UTC(year, month, Math.min(targetDay, daysInMonth)));
|
||||
} else {
|
||||
const days = frequency === "biweekly" ? 14 : 7;
|
||||
nextPayDate = new Date(nextPayDate.getTime() + days * 86_400_000);
|
||||
}
|
||||
};
|
||||
|
||||
while (nextPayDate < normalizedStart) {
|
||||
advanceByPeriod();
|
||||
}
|
||||
while (nextPayDate < normalizedEnd) {
|
||||
count++;
|
||||
advanceByPeriod();
|
||||
}
|
||||
return Math.max(1, count);
|
||||
}
|
||||
|
||||
function getFundingAhead(plan: FixedPlan) {
|
||||
if (
|
||||
incomeType !== "regular" ||
|
||||
!incomeFrequency ||
|
||||
!firstIncomeDate ||
|
||||
!plan.cycleStart ||
|
||||
!plan.dueOn
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
let cycleStart = plan.cycleStart;
|
||||
const dueOn = plan.dueOn;
|
||||
|
||||
let cycleStartDate: Date;
|
||||
let dueDate: Date;
|
||||
let nowDate: Date;
|
||||
try {
|
||||
cycleStartDate = toUserMidnight(cycleStart, userTimezone);
|
||||
dueDate = toUserMidnight(dueOn, userTimezone);
|
||||
nowDate = toUserMidnight(now, userTimezone);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (cycleStartDate >= dueDate || cycleStartDate > nowDate) {
|
||||
cycleStart = now;
|
||||
}
|
||||
|
||||
const totalPeriods = countPayPeriodsBetween(
|
||||
cycleStart,
|
||||
dueOn,
|
||||
firstIncomeDate,
|
||||
incomeFrequency,
|
||||
userTimezone
|
||||
);
|
||||
const elapsedPeriods = countPayPeriodsBetween(
|
||||
cycleStart,
|
||||
now,
|
||||
firstIncomeDate,
|
||||
incomeFrequency,
|
||||
userTimezone
|
||||
);
|
||||
const targetFunded = Math.min(
|
||||
plan.totalCents,
|
||||
Math.ceil((plan.totalCents * elapsedPeriods) / totalPeriods)
|
||||
);
|
||||
const aheadBy = Math.max(0, plan.fundedCents - targetFunded);
|
||||
return aheadBy > 0 ? aheadBy : null;
|
||||
}
|
||||
|
||||
function calculateNextPaymentDate(
|
||||
dueOnISO: string,
|
||||
schedule: any,
|
||||
timezone: string
|
||||
): string | null {
|
||||
if (!schedule || !schedule.frequency) return null;
|
||||
const dateStr = isoToDateString(dueOnISO, timezone);
|
||||
const [year, month, day] = dateStr.split("-").map(Number);
|
||||
const base = new Date(Date.UTC(year, month - 1, day));
|
||||
const toDateString = (d: Date) =>
|
||||
`${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(
|
||||
d.getUTCDate()
|
||||
).padStart(2, "0")}`;
|
||||
|
||||
switch (schedule.frequency) {
|
||||
case "daily":
|
||||
base.setUTCDate(base.getUTCDate() + 1);
|
||||
break;
|
||||
case "weekly": {
|
||||
const targetDay = schedule.dayOfWeek ?? 0;
|
||||
const currentDay = base.getUTCDay();
|
||||
const daysUntilTarget = (targetDay - currentDay + 7) % 7;
|
||||
base.setUTCDate(base.getUTCDate() + (daysUntilTarget || 7));
|
||||
break;
|
||||
}
|
||||
case "biweekly":
|
||||
base.setUTCDate(base.getUTCDate() + 14);
|
||||
break;
|
||||
case "monthly": {
|
||||
const targetDay = schedule.dayOfMonth;
|
||||
const nextMonth = base.getUTCMonth() + 1;
|
||||
const nextYear = base.getUTCFullYear() + Math.floor(nextMonth / 12);
|
||||
const normalizedMonth = nextMonth % 12;
|
||||
const daysInMonth = new Date(Date.UTC(nextYear, normalizedMonth + 1, 0)).getUTCDate();
|
||||
base.setUTCFullYear(nextYear, normalizedMonth, targetDay ? Math.min(targetDay, daysInMonth) : base.getUTCDate());
|
||||
break;
|
||||
}
|
||||
case "custom":
|
||||
base.setUTCDate(base.getUTCDate() + Math.max(1, Number(schedule.everyNDays || 0)));
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
return dateStringToUTCMidnight(toDateString(base), timezone);
|
||||
}
|
||||
|
||||
const onEdit = (id: string, patch: Partial<FixedPlan>) => {
|
||||
setLocalPlans((prev) =>
|
||||
prev.map((p) => {
|
||||
if (p.id !== id) return p;
|
||||
const next: LocalPlan = { ...p, ...patch };
|
||||
if (patch.frequency !== undefined && next.autoPayEnabled) {
|
||||
const schedule = next.paymentSchedule ?? buildDefaultSchedule(patch.frequency);
|
||||
next.paymentSchedule = {
|
||||
...schedule,
|
||||
frequency: mapScheduleFrequency(patch.frequency),
|
||||
};
|
||||
}
|
||||
if (patch.totalCents !== undefined) {
|
||||
next.totalCents = Math.max(0, Math.round(patch.totalCents));
|
||||
}
|
||||
if (patch.priority !== undefined) {
|
||||
next.priority = Math.max(0, Math.floor(patch.priority));
|
||||
}
|
||||
if (patch.autoPayEnabled !== undefined && !patch.autoPayEnabled) {
|
||||
next.nextPaymentDate = null;
|
||||
}
|
||||
if (next.autoPayEnabled && next.paymentSchedule) {
|
||||
const dueOnISO = patch.dueOn ?? next.dueOn;
|
||||
next.nextPaymentDate = calculateNextPaymentDate(
|
||||
dueOnISO,
|
||||
next.paymentSchedule,
|
||||
userTimezone
|
||||
);
|
||||
}
|
||||
return next;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const onDelete = (id: string) => {
|
||||
setLocalPlans((prev) =>
|
||||
prev
|
||||
.map((p) => {
|
||||
if (p.id !== id) return p;
|
||||
if (p._isNew) return { ...p, _isDeleted: true };
|
||||
return { ...p, _isDeleted: true };
|
||||
})
|
||||
.filter((p) => !(p._isNew && p._isDeleted))
|
||||
);
|
||||
};
|
||||
|
||||
const onCancel = () => {
|
||||
resetToServer();
|
||||
push("ok", "Changes discarded");
|
||||
};
|
||||
|
||||
const onSave = useCallback(async (): Promise<boolean> => {
|
||||
const normalizedPriorityOrder = activePlans
|
||||
.slice()
|
||||
.sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name))
|
||||
.map((plan, index) => ({ id: plan.id, priority: index + 1 }));
|
||||
const priorityById = new Map(
|
||||
normalizedPriorityOrder.map((item) => [item.id, item.priority])
|
||||
);
|
||||
const normalizedPlans = localPlans.map((plan) =>
|
||||
plan._isDeleted ? plan : { ...plan, priority: priorityById.get(plan.id) ?? plan.priority }
|
||||
);
|
||||
|
||||
for (const plan of localPlans) {
|
||||
if (plan._isDeleted) continue;
|
||||
if (plan.totalCents < (plan.fundedCents ?? 0)) {
|
||||
push(
|
||||
"err",
|
||||
`Total for ${plan.name} cannot be less than funded amount.`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const toDelete = normalizedPlans.filter((p) => p._isDeleted && !p._isNew);
|
||||
for (const plan of toDelete) {
|
||||
await fixedPlansApi.delete(plan.id);
|
||||
}
|
||||
|
||||
const toCreate = normalizedPlans.filter((p) => p._isNew && !p._isDeleted);
|
||||
for (const plan of toCreate) {
|
||||
const created = await fixedPlansApi.create({
|
||||
name: plan.name,
|
||||
totalCents: plan.totalCents,
|
||||
fundedCents: plan.fundedCents ?? 0,
|
||||
priority: plan.priority,
|
||||
dueOn: plan.dueOn,
|
||||
frequency: plan.frequency,
|
||||
autoPayEnabled: plan.autoPayEnabled ?? false,
|
||||
paymentSchedule: plan.paymentSchedule ?? undefined,
|
||||
nextPaymentDate: plan.nextPaymentDate ?? undefined,
|
||||
});
|
||||
if (plan.autoPayEnabled) {
|
||||
try {
|
||||
const res = await fixedPlansApi.fundFromAvailable(created.id);
|
||||
if (res.funded) {
|
||||
const dollars = (res.fundedAmountCents / 100).toFixed(2);
|
||||
push("ok", `Funded $${dollars} toward ${plan.name}.`);
|
||||
} else {
|
||||
push("err", `Not enough budget to fund ${plan.name}.`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
push(
|
||||
"err",
|
||||
err?.message ?? `Funding ${plan.name} failed`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toUpdate = normalizedPlans.filter((p) => !p._isNew && !p._isDeleted);
|
||||
for (const local of toUpdate) {
|
||||
const server = plans.find((p) => p.id === local.id);
|
||||
if (!server) continue;
|
||||
|
||||
const patch: Partial<FixedPlan> = {};
|
||||
if (local.name !== server.name) patch.name = local.name;
|
||||
if (local.totalCents !== server.totalCents)
|
||||
patch.totalCents = local.totalCents;
|
||||
if (local.priority !== server.priority)
|
||||
patch.priority = local.priority;
|
||||
if (local.dueOn !== server.dueOn) patch.dueOn = local.dueOn;
|
||||
if ((local.autoPayEnabled ?? false) !== (server.autoPayEnabled ?? false))
|
||||
patch.autoPayEnabled = local.autoPayEnabled;
|
||||
if (
|
||||
JSON.stringify(local.paymentSchedule ?? null) !==
|
||||
JSON.stringify(server.paymentSchedule ?? null)
|
||||
)
|
||||
patch.paymentSchedule = local.paymentSchedule;
|
||||
if ((local.frequency ?? null) !== (server.frequency ?? null))
|
||||
patch.frequency = local.frequency;
|
||||
if ((local.nextPaymentDate ?? null) !== (server.nextPaymentDate ?? null))
|
||||
patch.nextPaymentDate = local.nextPaymentDate ?? null;
|
||||
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await fixedPlansApi.update(local.id, patch);
|
||||
}
|
||||
|
||||
const paymentPlanEnabled =
|
||||
!!local.autoPayEnabled && local.paymentSchedule !== null && local.paymentSchedule !== undefined;
|
||||
const amountChanged = local.totalCents !== server.totalCents;
|
||||
const dueChanged = local.dueOn !== server.dueOn;
|
||||
|
||||
if (paymentPlanEnabled && (amountChanged || dueChanged)) {
|
||||
try {
|
||||
const res = await fixedPlansApi.catchUpFunding(local.id);
|
||||
if (res.funded) {
|
||||
const dollars = (res.fundedAmountCents / 100).toFixed(2);
|
||||
push("ok", `Funded $${dollars} toward ${local.name}.`);
|
||||
} else if (res.message === "Insufficient available budget") {
|
||||
push("err", `Not enough budget to fund ${local.name}.`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
push("err", err?.message ?? `Funding ${local.name} failed`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
push("ok", "Fixed expenses saved successfully");
|
||||
const refreshed = await refetch();
|
||||
const nextPlans = (refreshed.data?.fixedPlans ?? plans) as FixedPlan[];
|
||||
resetToServer(nextPlans);
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
push("err", err?.message ?? "Save failed");
|
||||
return false;
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [localPlans, plans, refetch, resetToServer, push]);
|
||||
|
||||
useImperativeHandle(ref, () => ({ save: onSave }), [onSave]);
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<div className="muted">Loading fixed expenses...</div>
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-3">Couldn't load fixed expenses.</p>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
>
|
||||
{isFetching ? "Retrying…" : "Retry"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="space-y-1">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<h2 className="text-lg font-semibold">Fixed Expenses</h2>
|
||||
{hasChanges && (
|
||||
<span className="text-xs text-amber-400">Unsaved changes</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm muted">
|
||||
Bills and recurring expenses that get funded over time until due.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Add form */}
|
||||
<form onSubmit={onAdd} className="settings-add-form">
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Total $"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={total}
|
||||
onChange={(e) => setTotal(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Due date"
|
||||
type="date"
|
||||
value={due}
|
||||
onChange={(e) => setDue(e.target.value)}
|
||||
/>
|
||||
<select
|
||||
className="input"
|
||||
value={frequency || ""}
|
||||
onChange={(e) =>
|
||||
setFrequency((e.target.value || "") as "" | FixedPlan["frequency"])
|
||||
}
|
||||
>
|
||||
<option value="">Frequency</option>
|
||||
<option value="one-time">One-time</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="biweekly">Biweekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
</select>
|
||||
<label className="settings-checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoPayEnabled}
|
||||
onChange={(e) => setAutoPayEnabled(e.target.checked)}
|
||||
/>
|
||||
<span>Auto-fund</span>
|
||||
</label>
|
||||
<button className="btn" disabled={addDisabled}>
|
||||
Add
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{activePlans.length === 0 ? (
|
||||
<div className="muted text-sm">No fixed expenses yet.</div>
|
||||
) : (
|
||||
<div className="settings-plans-list">
|
||||
{activePlans.map((plan) => {
|
||||
const aheadCents = getFundingAhead(plan);
|
||||
const fundedCents = plan.fundedCents ?? 0;
|
||||
const totalCents = plan.totalCents || 1; // Avoid division by zero
|
||||
const progressPercent = Math.min(100, (fundedCents / totalCents) * 100);
|
||||
return (
|
||||
<div key={plan.id} className={`settings-plan-card ${plan.autoPayEnabled ? 'auto-fund' : ''}`}>
|
||||
{/* Header row */}
|
||||
<div className="settings-plan-header">
|
||||
<div className="settings-plan-title">
|
||||
<InlineEditText
|
||||
value={plan.name}
|
||||
onChange={(v) => onEdit(plan.id, { name: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-plan-amount">
|
||||
<InlineEditMoney
|
||||
cents={plan.totalCents}
|
||||
onChange={(cents) => onEdit(plan.id, { totalCents: cents })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details row */}
|
||||
<div className="settings-plan-details">
|
||||
<div className="settings-plan-detail">
|
||||
<span className="label">Due</span>
|
||||
<InlineEditDate
|
||||
value={plan.dueOn}
|
||||
timezone={userTimezone}
|
||||
onChange={(iso) => onEdit(plan.id, { dueOn: iso })}
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-plan-detail">
|
||||
<span className="label">Freq</span>
|
||||
<InlineEditSelect
|
||||
value={plan.frequency ?? ""}
|
||||
onChange={(v) =>
|
||||
onEdit(plan.id, {
|
||||
frequency: (v || undefined) as FixedPlan["frequency"],
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{aheadCents !== null && (
|
||||
<div className="settings-plan-badge ahead">
|
||||
+{new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 0,
|
||||
}).format((aheadCents ?? 0) / 100)} ahead
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="settings-plan-progress">
|
||||
<div className="settings-plan-progress-bar">
|
||||
<div
|
||||
className="settings-plan-progress-fill"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-plan-progress-text">
|
||||
<Money cents={plan.fundedCents} /> / <Money cents={plan.totalCents} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions row */}
|
||||
<div className="settings-plan-actions">
|
||||
<label className="settings-checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!plan.autoPayEnabled}
|
||||
onChange={(e) =>
|
||||
onEdit(plan.id, {
|
||||
autoPayEnabled: e.target.checked,
|
||||
paymentSchedule: e.target.checked
|
||||
? plan.paymentSchedule ?? buildDefaultSchedule(plan.frequency)
|
||||
: plan.paymentSchedule,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
{incomeType === "regular" ? "Auto-fund" : "Payment plan"}
|
||||
</span>
|
||||
</label>
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => setDeletePrompt(plan)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{plan.autoPayEnabled && (
|
||||
<div className={`settings-plan-status ${incomeType === "regular" ? "funded" : "planned"}`}>
|
||||
{incomeType === "regular"
|
||||
? "Auto-funded each paycheck until fully funded"
|
||||
: "Prioritized in budget allocation"
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)})}
|
||||
</div>
|
||||
)}
|
||||
{deletePrompt && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="card p-6 max-w-md space-y-4">
|
||||
<h3 className="text-lg font-semibold">Delete fixed expense?</h3>
|
||||
<p className="text-sm muted">
|
||||
Are you sure you want to delete {deletePrompt.name}? This action cannot be undone.
|
||||
</p>
|
||||
{!deletePrompt._isNew && (deletePrompt.fundedCents ?? 0) > 0 && (
|
||||
<div className="text-sm">
|
||||
Funded amount{" "}
|
||||
<span className="font-semibold">
|
||||
<Money cents={deletePrompt.fundedCents ?? 0} />
|
||||
</span>{" "}
|
||||
will be refunded to your available budget.
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button className="btn" onClick={() => setDeletePrompt(null)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => {
|
||||
onDelete(deletePrompt.id);
|
||||
setDeletePrompt(null);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{hasChanges && (
|
||||
<div className="flex gap-3 pt-4 border-t border-[--color-border]">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => void onSave()}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save Changes"}
|
||||
</button>
|
||||
<button className="btn" onClick={onCancel} disabled={isSaving}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default PlansSettings;
|
||||
|
||||
// Inline editor components
|
||||
function InlineEditText({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const [v, setV] = useState(value);
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
useEffect(() => setV(value), [value]);
|
||||
|
||||
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()}
|
||||
placeholder={placeholder}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="link inline-flex items-center gap-1"
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
<span>{value || placeholder}</span>
|
||||
<span className="text-[10px] opacity-60">Edit</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineEditMoney({
|
||||
cents,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
cents: number;
|
||||
onChange: (cents: number) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [v, setV] = useState((cents / 100).toFixed(2));
|
||||
|
||||
useEffect(() => setV((cents / 100).toFixed(2)), [cents]);
|
||||
|
||||
const commit = () => {
|
||||
const newCents = Math.max(0, Math.round((Number(v) || 0) * 100));
|
||||
if (newCents !== cents) onChange(newCents);
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
return editing ? (
|
||||
<input
|
||||
className="input w-24 text-right"
|
||||
value={v}
|
||||
onChange={(e) => setV(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => e.key === "Enter" && commit()}
|
||||
placeholder={placeholder}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="link"
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
<Money cents={cents} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineEditSelect({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<select
|
||||
className="input w-32 text-sm"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
>
|
||||
<option value="">Select</option>
|
||||
<option value="one-time">One-time</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="biweekly">Biweekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineEditDate({
|
||||
value,
|
||||
timezone,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
timezone: string;
|
||||
onChange: (iso: string) => void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [v, setV] = useState(isoToDateString(value, timezone));
|
||||
|
||||
useEffect(() => setV(isoToDateString(value, timezone)), [value, timezone]);
|
||||
|
||||
const commit = () => {
|
||||
if (v) {
|
||||
const nextISO = dateStringToUTCMidnight(v, timezone);
|
||||
if (nextISO !== value) onChange(nextISO);
|
||||
}
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
return editing ? (
|
||||
<input
|
||||
className="input w-32"
|
||||
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)}
|
||||
>
|
||||
{formatDateInTimezone(value, timezone)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
137
web/src/pages/settings/ReconcileSettings.tsx
Normal file
137
web/src/pages/settings/ReconcileSettings.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
// web/src/pages/settings/ReconcileSettings.tsx
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import CurrencyInput from "../../components/CurrencyInput";
|
||||
import { Money } from "../../components/ui";
|
||||
import { useToast } from "../../components/Toast";
|
||||
import { useDashboard } from "../../hooks/useDashboard";
|
||||
import { budgetApi } from "../../api/budget";
|
||||
|
||||
export default function ReconcileSettings() {
|
||||
const { data, isLoading, isError } = useDashboard();
|
||||
const { push } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const [bankTotalCents, setBankTotalCents] = useState<number | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (bankTotalCents === null && data) {
|
||||
setBankTotalCents(data.totals.incomeCents);
|
||||
}
|
||||
}, [bankTotalCents, data]);
|
||||
|
||||
const currentTotalCents = data?.totals.incomeCents ?? 0;
|
||||
const fixedFundedCents = useMemo(
|
||||
() => data?.fixedPlans.reduce((sum, plan) => sum + (plan.fundedCents || 0), 0) ?? 0,
|
||||
[data]
|
||||
);
|
||||
const deltaCents =
|
||||
bankTotalCents === null ? 0 : bankTotalCents - currentTotalCents;
|
||||
const belowFixed = bankTotalCents !== null && bankTotalCents < fixedFundedCents;
|
||||
const isNoChange = bankTotalCents !== null && deltaCents === 0;
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (bankTotalCents === null || belowFixed || isNoChange) return;
|
||||
setPending(true);
|
||||
try {
|
||||
const result = await budgetApi.reconcile({
|
||||
bankTotalCents,
|
||||
});
|
||||
if (result.deltaCents === 0) {
|
||||
push("ok", "No changes needed.");
|
||||
} else {
|
||||
const direction = result.deltaCents > 0 ? "Added" : "Removed";
|
||||
push(
|
||||
"ok",
|
||||
`${direction} $${Math.abs(result.deltaCents / 100).toFixed(
|
||||
2
|
||||
)} to match your bank balance.`
|
||||
);
|
||||
}
|
||||
await qc.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
} catch (err: any) {
|
||||
push("err", err?.message ?? "Reconciliation failed");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="muted">Loading…</div>;
|
||||
}
|
||||
if (isError || !data) {
|
||||
return <div className="muted">Unable to load reconciliation data.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Balance Reconciliation</h2>
|
||||
<p className="text-sm muted">
|
||||
Enter the combined total of all your bank accounts (checking + savings).
|
||||
We’ll adjust your available budget to match without affecting fixed plans.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="card">
|
||||
<div className="text-xs muted">SkyMoney Total</div>
|
||||
<div className="mt-1 text-lg font-semibold">
|
||||
<Money cents={currentTotalCents} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="text-xs muted">Available Budget</div>
|
||||
<div className="mt-1 text-lg font-semibold">
|
||||
<Money cents={data.totals.variableBalanceCents} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="text-xs muted">Funded Fixed Total</div>
|
||||
<div className="mt-1 text-lg font-semibold">
|
||||
<Money cents={fixedFundedCents} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card space-y-4">
|
||||
<label className="stack gap-1">
|
||||
<span className="text-sm font-medium">Your bank total</span>
|
||||
<CurrencyInput
|
||||
valueCents={bankTotalCents ?? 0}
|
||||
onChange={setBankTotalCents}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{belowFixed && (
|
||||
<div className="alert alert-error">
|
||||
Bank total can’t be lower than funded fixed expenses.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!belowFixed && bankTotalCents !== null && (
|
||||
<div className="text-sm muted">
|
||||
{deltaCents > 0 && (
|
||||
<>This will add <strong>${(deltaCents / 100).toFixed(2)}</strong> to your available budget.</>
|
||||
)}
|
||||
{deltaCents < 0 && (
|
||||
<>This will remove <strong>${Math.abs(deltaCents / 100).toFixed(2)}</strong> from your available budget.</>
|
||||
)}
|
||||
{deltaCents === 0 && <>No adjustment needed.</>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
className="btn primary"
|
||||
onClick={onSubmit}
|
||||
disabled={pending || belowFixed || isNoChange}
|
||||
>
|
||||
{pending ? "Applying..." : "Apply Adjustment"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
213
web/src/pages/settings/SettingsPage.tsx
Normal file
213
web/src/pages/settings/SettingsPage.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
// web/src/pages/settings/SettingsPage.tsx
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useBlocker } from "react-router";
|
||||
|
||||
import CategoriesSettings, {
|
||||
type CategoriesSettingsHandle,
|
||||
} from "./CategoriesSettings";
|
||||
import PlansSettings, { type PlansSettingsHandle } from "./PlansSettings";
|
||||
import AccountSettings from "./AccountSettings";
|
||||
import ThemeSettings from "./ThemeSettings";
|
||||
import ReconcileSettings from "./ReconcileSettings";
|
||||
|
||||
type Tab = "categories" | "plans" | "account" | "theme" | "reconcile";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const location = useLocation();
|
||||
|
||||
const getActiveTab = (): Tab => {
|
||||
if (location.pathname.includes("/settings/plans")) return "plans";
|
||||
if (location.pathname.includes("/settings/account")) return "account";
|
||||
if (location.pathname.includes("/settings/theme")) return "theme";
|
||||
if (location.pathname.includes("/settings/reconcile")) return "reconcile";
|
||||
return "categories";
|
||||
};
|
||||
|
||||
const [activeTab, setActiveTab] = useState<Tab>(getActiveTab());
|
||||
|
||||
// Dirty / confirm state
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [pendingTab, setPendingTab] = useState<Tab | null>(null);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
|
||||
// ref so the modal can "Save" for the current tab
|
||||
const categoriesRef = useRef<CategoriesSettingsHandle>(null);
|
||||
const plansRef = useRef<PlansSettingsHandle>(null);
|
||||
|
||||
// Block leaving /settings when dirty (Navbar links, back button, etc.)
|
||||
const blocker = useBlocker(({ currentLocation, nextLocation }) => {
|
||||
const leavingSettings =
|
||||
currentLocation.pathname.startsWith("/settings") &&
|
||||
nextLocation.pathname !== currentLocation.pathname;
|
||||
return isDirty && leavingSettings;
|
||||
});
|
||||
|
||||
// If router blocks, show modal
|
||||
useEffect(() => {
|
||||
if (blocker.state === "blocked") setShowConfirm(true);
|
||||
}, [blocker.state]);
|
||||
|
||||
// Sync tab with URL changes (e.g., direct navigation)
|
||||
useEffect(() => {
|
||||
setActiveTab(getActiveTab());
|
||||
}, [location.pathname]);
|
||||
|
||||
// Blocks refresh/close tab when dirty
|
||||
useEffect(() => {
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
if (!isDirty) return;
|
||||
e.preventDefault();
|
||||
e.returnValue = "";
|
||||
};
|
||||
window.addEventListener("beforeunload", handler);
|
||||
return () => window.removeEventListener("beforeunload", handler);
|
||||
}, [isDirty]);
|
||||
|
||||
const tabs = [
|
||||
{ id: "categories" as const, label: "Expenses" },
|
||||
{ id: "plans" as const, label: "Fixed Expenses" },
|
||||
{ id: "account" as const, label: "Account" },
|
||||
{ id: "theme" as const, label: "Theme" },
|
||||
{ id: "reconcile" as const, label: "Reconcile" },
|
||||
];
|
||||
|
||||
function requestTabChange(next: Tab) {
|
||||
if (next === activeTab) return;
|
||||
if (isDirty) {
|
||||
setPendingTab(next);
|
||||
setShowConfirm(true);
|
||||
return;
|
||||
}
|
||||
setActiveTab(next);
|
||||
}
|
||||
|
||||
function stayHere() {
|
||||
setPendingTab(null);
|
||||
setShowConfirm(false);
|
||||
if (blocker.state === "blocked") blocker.reset();
|
||||
}
|
||||
|
||||
function discardAndLeave() {
|
||||
setIsDirty(false);
|
||||
setShowConfirm(false);
|
||||
|
||||
if (pendingTab) {
|
||||
setActiveTab(pendingTab);
|
||||
setPendingTab(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (blocker.state === "blocked") blocker.proceed();
|
||||
}
|
||||
|
||||
async function saveAndLeave() {
|
||||
let ok = true;
|
||||
|
||||
if (activeTab === "categories") {
|
||||
ok = (await categoriesRef.current?.save()) ?? false;
|
||||
} else if (activeTab === "plans") {
|
||||
ok = (await plansRef.current?.save()) ?? false;
|
||||
} else {
|
||||
ok = false;
|
||||
}
|
||||
|
||||
if (!ok) return;
|
||||
|
||||
setIsDirty(false);
|
||||
setShowConfirm(false);
|
||||
|
||||
if (pendingTab) {
|
||||
setActiveTab(pendingTab);
|
||||
setPendingTab(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (blocker.state === "blocked") blocker.proceed();
|
||||
}
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case "categories":
|
||||
return (
|
||||
<CategoriesSettings
|
||||
ref={categoriesRef}
|
||||
onDirtyChange={setIsDirty}
|
||||
/>
|
||||
);
|
||||
case "plans":
|
||||
return (
|
||||
<PlansSettings ref={plansRef} onDirtyChange={setIsDirty} />
|
||||
);
|
||||
case "account":
|
||||
return <AccountSettings />;
|
||||
case "theme":
|
||||
return <ThemeSettings />;
|
||||
case "reconcile":
|
||||
return <ReconcileSettings />;
|
||||
default:
|
||||
return (
|
||||
<CategoriesSettings
|
||||
ref={categoriesRef}
|
||||
onDirtyChange={setIsDirty}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fade-in container max-w-4xl mx-auto py-6">
|
||||
<header className="settings-header">
|
||||
<h1>Settings</h1>
|
||||
</header>
|
||||
|
||||
<div className="settings-tabs">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => requestTabChange(tab.id)}
|
||||
className={`settings-tab ${activeTab === tab.id ? "active" : ""}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="min-h-[400px]">{renderTabContent()}</div>
|
||||
|
||||
{showConfirm && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="card p-6 max-w-md space-y-4">
|
||||
<h3 className="text-lg font-semibold">Unsaved Changes</h3>
|
||||
<p className="text-sm muted">
|
||||
You have unsaved changes. Do you want to save them before leaving, discard them, or stay here?
|
||||
</p>
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button className="btn" onClick={stayHere}>
|
||||
Stay
|
||||
</button>
|
||||
|
||||
<button className="btn" onClick={discardAndLeave}>
|
||||
Discard
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={saveAndLeave}
|
||||
disabled={activeTab !== "categories" && activeTab !== "plans"}
|
||||
title={
|
||||
activeTab !== "categories" && activeTab !== "plans"
|
||||
? "Save-from-modal is wired for Expenses and Fixed Expenses right now."
|
||||
: ""
|
||||
}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
145
web/src/pages/settings/ThemeSettings.tsx
Normal file
145
web/src/pages/settings/ThemeSettings.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
// web/src/pages/settings/ThemeSettings.tsx
|
||||
import { useState, useEffect } from "react";
|
||||
import { useToast } from "../../components/Toast";
|
||||
import { useTheme } from "../../theme/useTheme";
|
||||
|
||||
type Theme = "light" | "dark" | "system";
|
||||
type ColorScheme = "blue" | "green" | "purple" | "orange";
|
||||
|
||||
export default function ThemeSettings() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [colorScheme, setColorScheme] = useState<ColorScheme>("blue");
|
||||
const { push } = useToast();
|
||||
|
||||
// Load settings from localStorage on mount
|
||||
useEffect(() => {
|
||||
const savedColorScheme = localStorage.getItem("colorScheme") as ColorScheme;
|
||||
|
||||
if (savedColorScheme) setColorScheme(savedColorScheme);
|
||||
}, []);
|
||||
|
||||
// Apply color scheme changes
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
|
||||
// Remove existing color scheme classes
|
||||
root.classList.remove("scheme-blue", "scheme-green", "scheme-purple", "scheme-orange");
|
||||
|
||||
// Add new color scheme class
|
||||
root.classList.add(`scheme-${colorScheme}`);
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem("colorScheme", colorScheme);
|
||||
}, [colorScheme]);
|
||||
|
||||
const handleResetSettings = () => {
|
||||
setTheme("system");
|
||||
setColorScheme("blue");
|
||||
localStorage.removeItem("theme");
|
||||
localStorage.removeItem("colorScheme");
|
||||
push("ok", "Theme settings reset to defaults");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Theme Selection */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<h3 className="settings-section-title">Theme</h3>
|
||||
<p className="settings-section-desc">Choose your preferred color mode.</p>
|
||||
</div>
|
||||
<div className="settings-theme-grid">
|
||||
<label className={`settings-theme-card ${theme === "light" ? "selected" : ""}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="theme"
|
||||
value="light"
|
||||
checked={theme === "light"}
|
||||
onChange={(e) => setTheme(e.target.value as Theme)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div className="settings-theme-icon light" />
|
||||
<div className="settings-theme-info">
|
||||
<div className="settings-theme-label">Light</div>
|
||||
<div className="settings-theme-desc">Clean and bright</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className={`settings-theme-card ${theme === "dark" ? "selected" : ""}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="theme"
|
||||
value="dark"
|
||||
checked={theme === "dark"}
|
||||
onChange={(e) => setTheme(e.target.value as Theme)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div className="settings-theme-icon dark" />
|
||||
<div className="settings-theme-info">
|
||||
<div className="settings-theme-label">Dark</div>
|
||||
<div className="settings-theme-desc">Easy on the eyes</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className={`settings-theme-card ${theme === "system" ? "selected" : ""}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="theme"
|
||||
value="system"
|
||||
checked={theme === "system"}
|
||||
onChange={(e) => setTheme(e.target.value as Theme)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div className="settings-theme-icon system" />
|
||||
<div className="settings-theme-info">
|
||||
<div className="settings-theme-label">System</div>
|
||||
<div className="settings-theme-desc">Match your OS</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Color Scheme */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<h3 className="settings-section-title">Color Scheme</h3>
|
||||
<p className="settings-section-desc">Pick an accent color for the app.</p>
|
||||
</div>
|
||||
<div className="settings-color-grid">
|
||||
{(["blue", "green", "purple", "orange"] as const).map((scheme) => (
|
||||
<label
|
||||
key={scheme}
|
||||
className={`settings-color-card ${colorScheme === scheme ? "selected" : ""}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="colorScheme"
|
||||
value={scheme}
|
||||
checked={colorScheme === scheme}
|
||||
onChange={(e) => setColorScheme(e.target.value as ColorScheme)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div className={`settings-color-swatch ${scheme}`} />
|
||||
<div className="settings-color-label">{scheme}</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Reset */}
|
||||
<section className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<h3 className="settings-section-title">Reset</h3>
|
||||
<p className="settings-section-desc">Theme settings are saved automatically as you make changes.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={handleResetSettings}
|
||||
>
|
||||
Reset to Defaults
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,12 +3,12 @@ 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">
|
||||
<div className="flex flex-col gap-2 mb-3 sm:flex-row sm:items-center">
|
||||
<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 className="flex flex-wrap gap-1 sm:ml-auto">
|
||||
{link("/settings/categories", "Expenses")}
|
||||
{link("/settings/plans", "Fixed Expenses")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
2239
web/src/styles.css
2239
web/src/styles.css
File diff suppressed because it is too large
Load Diff
132
web/src/styles.css.bak
Normal file
132
web/src/styles.css.bak
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, 100vww); 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; }
|
||||
59
web/src/theme/useTheme.ts
Normal file
59
web/src/theme/useTheme.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export type Theme = "dark" | "light" | "system";
|
||||
const KEY = "theme";
|
||||
|
||||
function readTheme(): Theme {
|
||||
return (localStorage.getItem(KEY) as Theme) || "system";
|
||||
}
|
||||
|
||||
function resolve(t: Theme) {
|
||||
if (t !== "system") return t;
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setThemeState] = useState<Theme>(() => readTheme());
|
||||
|
||||
useEffect(() => {
|
||||
const actual = resolve(theme);
|
||||
const root = document.documentElement;
|
||||
const body = document.body;
|
||||
// your styles.css uses :root (dark) + :root[data-theme="light"] for light
|
||||
if (actual === "dark") {
|
||||
root.setAttribute("data-theme", "dark");
|
||||
root.classList.add("dark");
|
||||
body.classList.add("dark");
|
||||
body.setAttribute("data-theme", "dark");
|
||||
} else {
|
||||
root.setAttribute("data-theme", "light");
|
||||
root.classList.remove("dark");
|
||||
body.classList.remove("dark");
|
||||
body.setAttribute("data-theme", "light");
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
const sync = () => {
|
||||
const next = readTheme();
|
||||
setThemeState((prev) => (prev === next ? prev : next));
|
||||
};
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === KEY) sync();
|
||||
};
|
||||
window.addEventListener("storage", onStorage);
|
||||
window.addEventListener("theme-change", sync);
|
||||
return () => {
|
||||
window.removeEventListener("storage", onStorage);
|
||||
window.removeEventListener("theme-change", sync);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const setTheme = (next: Theme) => {
|
||||
localStorage.setItem(KEY, next);
|
||||
setThemeState(next);
|
||||
window.dispatchEvent(new Event("theme-change"));
|
||||
};
|
||||
|
||||
return { theme, setTheme };
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { FixedPlan, VariableCategory } from "../hooks/useDashboard";
|
||||
|
||||
export type { FixedPlan, VariableCategory };
|
||||
|
||||
export function previewAllocation(
|
||||
amountCents: number,
|
||||
fixedPlans: FixedPlan[],
|
||||
|
||||
101
web/src/utils/funding.ts
Normal file
101
web/src/utils/funding.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
export type IncomeFrequency = "weekly" | "biweekly" | "monthly";
|
||||
export type UserType = "regular" | "irregular";
|
||||
|
||||
export interface FixedPlanClient {
|
||||
id: string;
|
||||
name?: string;
|
||||
totalCents: number;
|
||||
fundedCents?: number;
|
||||
currentFundedCents?: number;
|
||||
dueOn: string | Date;
|
||||
cycleStart?: string | Date | null;
|
||||
autoPayEnabled?: boolean;
|
||||
}
|
||||
|
||||
export function computeFixedFundingStatus(
|
||||
userType: UserType | string | undefined,
|
||||
incomeFrequency: IncomeFrequency | string | undefined,
|
||||
fixedPlans: FixedPlanClient[] = [],
|
||||
now = new Date(),
|
||||
crisisActive = false,
|
||||
bufferCents = 100
|
||||
) {
|
||||
// Crisis: always flag
|
||||
if (crisisActive) return { needsFunding: true, plans: fixedPlans.map((p) => ({ id: p.id, needsFunding: true })) };
|
||||
|
||||
// Irregular users: prompt if any remaining > 0
|
||||
if (userType === "irregular") {
|
||||
const eligiblePlans = fixedPlans.filter((p) => p.autoPayEnabled);
|
||||
const any = eligiblePlans.some((p) => {
|
||||
const funded = Number(p.currentFundedCents ?? p.fundedCents ?? 0);
|
||||
return Math.max(0, Number(p.totalCents) - funded) > 0;
|
||||
});
|
||||
return {
|
||||
needsFunding: any,
|
||||
plans: fixedPlans.map((p) => ({
|
||||
id: p.id,
|
||||
needsFunding: p.autoPayEnabled
|
||||
? Math.max(0, Number(p.totalCents) - Number(p.currentFundedCents ?? p.fundedCents ?? 0)) > 0
|
||||
: false,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const daysPerPaycheck = incomeFrequency === "weekly" ? 7 : incomeFrequency === "biweekly" ? 14 : 30;
|
||||
|
||||
const eligiblePlans = fixedPlans.filter((p) => p.autoPayEnabled);
|
||||
const planResults = fixedPlans.map((plan) => {
|
||||
if (!plan.autoPayEnabled) {
|
||||
return { id: plan.id, needsFunding: false, reason: "auto_fund_disabled" };
|
||||
}
|
||||
const funded = Math.max(0, Math.floor(Number(plan.currentFundedCents ?? plan.fundedCents ?? 0)));
|
||||
const total = Math.max(0, Math.floor(Number(plan.totalCents ?? 0)));
|
||||
const due = new Date(plan.dueOn);
|
||||
const cycleStart = plan.cycleStart ? new Date(plan.cycleStart as any) : null;
|
||||
|
||||
const daysUntilDue = Math.max(0, Math.ceil((due.getTime() - now.getTime()) / DAY_MS));
|
||||
const remainingCents = Math.max(0, total - funded);
|
||||
|
||||
if (remainingCents <= 0) {
|
||||
return { id: plan.id, needsFunding: false, reason: "already_funded" };
|
||||
}
|
||||
|
||||
// Determine funding window using cycleStart when available
|
||||
const start = cycleStart ?? new Date(due.getTime() - Math.max(1, daysUntilDue) * DAY_MS);
|
||||
const totalWindowDays = Math.max(1, Math.ceil((due.getTime() - start.getTime()) / DAY_MS));
|
||||
|
||||
const totalPaychecks = Math.max(1, Math.ceil(totalWindowDays / daysPerPaycheck));
|
||||
const daysSinceStart = Math.max(0, Math.ceil((now.getTime() - start.getTime()) / DAY_MS));
|
||||
const elapsedPaychecks = Math.min(totalPaychecks, Math.floor(daysSinceStart / daysPerPaycheck));
|
||||
|
||||
const expectedCumulative = Math.round((total * elapsedPaychecks) / totalPaychecks);
|
||||
const needsFunding = funded < Math.max(0, expectedCumulative - bufferCents);
|
||||
|
||||
return {
|
||||
id: plan.id,
|
||||
needsFunding,
|
||||
reason: needsFunding ? "behind_schedule" : "on_schedule",
|
||||
funded,
|
||||
expectedCumulative,
|
||||
totalPaychecks,
|
||||
elapsedPaychecks,
|
||||
remainingCents,
|
||||
daysUntilDue,
|
||||
};
|
||||
});
|
||||
|
||||
const overallNeeds = eligiblePlans.length > 0 && planResults.some((r) => r.needsFunding);
|
||||
return { needsFunding: overallNeeds, plans: planResults };
|
||||
}
|
||||
|
||||
export function computeNeedsFixedFunding(
|
||||
userType: UserType | string | undefined,
|
||||
incomeFrequency: IncomeFrequency | string | undefined,
|
||||
fixedPlans: FixedPlanClient[] = [],
|
||||
now = new Date(),
|
||||
crisisActive = false,
|
||||
bufferCents = 100
|
||||
) {
|
||||
return computeFixedFundingStatus(userType, incomeFrequency, fixedPlans, now, crisisActive, bufferCents).needsFunding;
|
||||
}
|
||||
156
web/src/utils/timezone.ts
Normal file
156
web/src/utils/timezone.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Timezone utility functions for consistent date handling across the application.
|
||||
*
|
||||
* All dates should be:
|
||||
* 1. Stored in the backend as UTC ISO strings
|
||||
* 2. Displayed to users in their saved timezone
|
||||
* 3. Input from users interpreted in their saved timezone
|
||||
*
|
||||
* The user's timezone is stored in the database and should be fetched from the dashboard.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get today's date in the user's timezone as YYYY-MM-DD format.
|
||||
* This should be used for date inputs to ensure consistency with user's timezone.
|
||||
*
|
||||
* @param userTimezone - IANA timezone string (e.g., "America/New_York")
|
||||
* @returns Date string in YYYY-MM-DD format
|
||||
*/
|
||||
export function getTodayInTimezone(userTimezone: string): string {
|
||||
const now = new Date();
|
||||
// Use Intl.DateTimeFormat to get the date in the user's timezone
|
||||
const formatter = new Intl.DateTimeFormat('en-CA', { // en-CA gives YYYY-MM-DD format
|
||||
timeZone: userTimezone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
return formatter.format(now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a date input string (YYYY-MM-DD) to an ISO string that represents
|
||||
* midnight in the user's timezone.
|
||||
*
|
||||
* This should be used when sending date-only data to the backend.
|
||||
*
|
||||
* @param dateString - Date in YYYY-MM-DD format
|
||||
* @param userTimezone - IANA timezone string
|
||||
* @returns ISO string representing midnight in the user's timezone
|
||||
*/
|
||||
export function dateStringToUTCMidnight(dateString: string, userTimezone: string): string {
|
||||
// Parse the date string as-is (YYYY-MM-DD)
|
||||
const [year, month, day] = dateString.split('-').map(Number);
|
||||
|
||||
// Create a date object representing midnight in the user's timezone
|
||||
// We format a string that includes timezone info
|
||||
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}T00:00:00`;
|
||||
|
||||
// Get the date/time string in the user's timezone to calculate offset
|
||||
const tzDate = new Date(new Date(dateStr).toLocaleString('en-US', { timeZone: userTimezone }));
|
||||
const utcDate = new Date(new Date(dateStr).toLocaleString('en-US', { timeZone: 'UTC' }));
|
||||
const offset = tzDate.getTime() - utcDate.getTime();
|
||||
|
||||
// Create final date adjusted for timezone
|
||||
const adjustedDate = new Date(Date.UTC(year, month - 1, day, 0, 0, 0) - offset);
|
||||
|
||||
return adjustedDate.toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an ISO date string for display in the user's timezone.
|
||||
*
|
||||
* @param isoString - ISO date string from backend
|
||||
* @param userTimezone - IANA timezone string
|
||||
* @param options - Intl.DateTimeFormatOptions for formatting
|
||||
* @returns Formatted date string
|
||||
*/
|
||||
export function formatDateInTimezone(
|
||||
isoString: string,
|
||||
userTimezone: string,
|
||||
options: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'short', day: 'numeric' }
|
||||
): string {
|
||||
const date = new Date(isoString);
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
...options,
|
||||
timeZone: userTimezone,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an ISO string to YYYY-MM-DD format in the user's timezone.
|
||||
* This is useful for populating date inputs.
|
||||
*
|
||||
* @param isoString - ISO date string from backend
|
||||
* @param userTimezone - IANA timezone string
|
||||
* @returns Date string in YYYY-MM-DD format
|
||||
*/
|
||||
export function isoToDateString(isoString: string, userTimezone: string): string {
|
||||
const date = new Date(isoString);
|
||||
const formatter = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: userTimezone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
return formatter.format(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current date and time as an ISO string in UTC.
|
||||
* This should be used for timestamps (not date-only fields).
|
||||
*
|
||||
* @returns ISO string in UTC
|
||||
*/
|
||||
export function getCurrentTimestamp(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two date strings (YYYY-MM-DD) to determine if date1 is before date2.
|
||||
* This is timezone-safe because it compares date strings directly.
|
||||
*
|
||||
* @param date1 - First date string
|
||||
* @param date2 - Second date string
|
||||
* @returns true if date1 is before date2
|
||||
*/
|
||||
export function isDateBefore(date1: string, date2: string): boolean {
|
||||
return date1 < date2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two date strings (YYYY-MM-DD) to determine if date1 is after date2.
|
||||
* This is timezone-safe because it compares date strings directly.
|
||||
*
|
||||
* @param date1 - First date string
|
||||
* @param date2 - Second date string
|
||||
* @returns true if date1 is after date2
|
||||
*/
|
||||
export function isDateAfter(date1: string, date2: string): boolean {
|
||||
return date1 > date2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add days to a date string, accounting for the user's timezone.
|
||||
*
|
||||
* @param dateString - Date in YYYY-MM-DD format
|
||||
* @param days - Number of days to add
|
||||
* @param userTimezone - IANA timezone string
|
||||
* @returns New date string in YYYY-MM-DD format
|
||||
*/
|
||||
export function addDaysToDate(dateString: string, days: number, userTimezone: string): string {
|
||||
const baseISO = dateStringToUTCMidnight(dateString, userTimezone);
|
||||
const base = new Date(baseISO);
|
||||
base.setUTCDate(base.getUTCDate() + days);
|
||||
return isoToDateString(base.toISOString(), userTimezone);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's browser timezone as a fallback.
|
||||
* This should only be used when the backend timezone is not available.
|
||||
*
|
||||
* @returns IANA timezone string
|
||||
*/
|
||||
export function getBrowserTimezone(): string {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
}
|
||||
Reference in New Issue
Block a user