Initial commit: MyMidas personal finance tracker
Full-stack self-hosted finance app with FastAPI backend and React frontend. Features: - Accounts, transactions, budgets, investments with GBP base currency - CSV import with auto-detection for 10 UK bank formats - ML predictions: spending forecast, net worth projection, Monte Carlo - 7 selectable themes (Obsidian, Arctic, Midnight, Vault, Terminal, Synthwave, Ledger) - Receipt/document attachments on transactions (JPEG, PNG, WebP, PDF) - AES-256-GCM field encryption, RS256 JWT, TOTP 2FA, RLS, audit log - Encrypted nightly backups + key rotation script - Mobile-responsive layout with bottom nav Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
61a7884ee5
127 changed files with 13323 additions and 0 deletions
164
frontend/src/pages/budgets/BudgetFormModal.tsx
Normal file
164
frontend/src/pages/budgets/BudgetFormModal.tsx
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import { useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { BudgetCreate } from "@/api/budgets";
|
||||
import { format } from "date-fns";
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
categories: Category[];
|
||||
onClose: () => void;
|
||||
onSubmit: (data: BudgetCreate) => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export default function BudgetFormModal({ categories, onClose, onSubmit, isLoading }: Props) {
|
||||
const today = format(new Date(), "yyyy-MM-dd");
|
||||
const [form, setForm] = useState<BudgetCreate>({
|
||||
category_id: "",
|
||||
name: "",
|
||||
amount: 0,
|
||||
currency: "GBP",
|
||||
period: "monthly",
|
||||
start_date: today,
|
||||
end_date: null,
|
||||
rollover: false,
|
||||
alert_threshold: 80,
|
||||
});
|
||||
|
||||
const expenseCategories = categories.filter((c) => c.type === "expense" || c.type === "system");
|
||||
|
||||
function set<K extends keyof BudgetCreate>(key: K, value: BudgetCreate[K]) {
|
||||
setForm((f) => ({ ...f, [key]: value }));
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!form.category_id || !form.name || !form.amount) return;
|
||||
onSubmit(form);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||
<div className="bg-card border border-border rounded-2xl w-full max-w-md shadow-xl">
|
||||
<div className="flex items-center justify-between p-5 border-b border-border">
|
||||
<h2 className="font-semibold text-lg">New Budget</h2>
|
||||
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-secondary text-muted-foreground">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-5 space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-1.5">Budget name *</label>
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(e) => set("name", e.target.value)}
|
||||
placeholder="e.g. Monthly Groceries"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-1.5">Category *</label>
|
||||
<select
|
||||
value={form.category_id}
|
||||
onChange={(e) => set("category_id", e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
required
|
||||
>
|
||||
<option value="">Select category...</option>
|
||||
{expenseCategories.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-1.5">Amount *</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
value={form.amount || ""}
|
||||
onChange={(e) => set("amount", parseFloat(e.target.value) || 0)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-1.5">Period</label>
|
||||
<select
|
||||
value={form.period}
|
||||
onChange={(e) => set("period", e.target.value as BudgetCreate["period"])}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="quarterly">Quarterly</option>
|
||||
<option value="yearly">Yearly</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-1.5">Start date *</label>
|
||||
<input
|
||||
type="date"
|
||||
value={form.start_date}
|
||||
onChange={(e) => set("start_date", e.target.value)}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-1.5">Alert at (%)</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
value={form.alert_threshold}
|
||||
onChange={(e) => set("alert_threshold", parseFloat(e.target.value))}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.rollover}
|
||||
onChange={(e) => set("rollover", e.target.checked)}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
Roll over unused budget to next period
|
||||
</label>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 py-2.5 rounded-lg border border-border text-sm hover:bg-secondary transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="flex-1 py-2.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isLoading ? "Creating..." : "Create Budget"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
197
frontend/src/pages/budgets/BudgetPage.tsx
Normal file
197
frontend/src/pages/budgets/BudgetPage.tsx
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { getBudgetSummary, createBudget, deleteBudget } from "@/api/budgets";
|
||||
import { getCategories } from "@/api/transactions";
|
||||
import { formatCurrency } from "@/utils/currency";
|
||||
import { cn } from "@/utils/cn";
|
||||
import { Plus, Trash2, AlertTriangle, CheckCircle } from "lucide-react";
|
||||
import BudgetFormModal from "./BudgetFormModal";
|
||||
|
||||
function RadialGauge({ percent, size = 80 }: { percent: number; size?: number }) {
|
||||
const r = size / 2 - 8;
|
||||
const circumference = 2 * Math.PI * r;
|
||||
const clamped = Math.min(percent, 100);
|
||||
const offset = circumference - (clamped / 100) * circumference;
|
||||
const color = percent >= 100 ? "#ef4444" : percent >= 80 ? "#f97316" : "#22c55e";
|
||||
|
||||
return (
|
||||
<svg width={size} height={size} className="shrink-0">
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={6}
|
||||
className="text-secondary"
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={6}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
||||
style={{ transition: "stroke-dashoffset 0.4s ease" }}
|
||||
/>
|
||||
<text
|
||||
x={size / 2}
|
||||
y={size / 2 + 4}
|
||||
textAnchor="middle"
|
||||
fontSize={12}
|
||||
fontWeight="600"
|
||||
fill={color}
|
||||
>
|
||||
{Math.round(percent)}%
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BudgetPage() {
|
||||
const qc = useQueryClient();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
const { data: summary = [], isLoading } = useQuery({
|
||||
queryKey: ["budget-summary"],
|
||||
queryFn: getBudgetSummary,
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const { data: categories = [] } = useQuery({ queryKey: ["categories"], queryFn: getCategories });
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createBudget,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["budget-summary"] });
|
||||
qc.invalidateQueries({ queryKey: ["budgets"] });
|
||||
setShowForm(false);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteBudget,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["budget-summary"] });
|
||||
qc.invalidateQueries({ queryKey: ["budgets"] });
|
||||
},
|
||||
});
|
||||
|
||||
const overBudget = summary.filter((s) => s.is_over_budget).length;
|
||||
const alerted = summary.filter((s) => s.alert_triggered && !s.is_over_budget).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Budgets</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{summary.length} active budget{summary.length !== 1 ? "s" : ""}
|
||||
{overBudget > 0 && (
|
||||
<span className="ml-2 text-destructive font-medium">· {overBudget} over budget</span>
|
||||
)}
|
||||
{alerted > 0 && (
|
||||
<span className="ml-2 text-orange-500 font-medium">· {alerted} near limit</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="flex items-center gap-2 bg-primary text-primary-foreground px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Budget
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="h-32 bg-card border border-border rounded-xl animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : summary.length === 0 ? (
|
||||
<div className="text-center py-16 text-muted-foreground">
|
||||
<p className="font-medium">No budgets yet</p>
|
||||
<p className="text-sm mt-1">Create a budget to start tracking your spending</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{summary.map((item) => (
|
||||
<div
|
||||
key={item.budget_id}
|
||||
className={cn(
|
||||
"bg-card border rounded-xl p-5 relative group",
|
||||
item.is_over_budget
|
||||
? "border-destructive/50"
|
||||
: item.alert_triggered
|
||||
? "border-orange-500/50"
|
||||
: "border-border"
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(item.budget_id)}
|
||||
className="absolute top-3 right-3 p-1 rounded text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-all"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-start gap-4">
|
||||
<RadialGauge percent={Number(item.percent_used)} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
{item.is_over_budget ? (
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-destructive shrink-0" />
|
||||
) : item.alert_triggered ? (
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-orange-500 shrink-0" />
|
||||
) : (
|
||||
<CheckCircle className="w-3.5 h-3.5 text-success shrink-0" />
|
||||
)}
|
||||
<p className="font-semibold text-sm truncate">{item.budget_name}</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-2">{item.category_name} · {item.period}</p>
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-muted-foreground">Spent</span>
|
||||
<span className={cn("font-medium", item.is_over_budget ? "text-destructive" : "")}>
|
||||
{formatCurrency(item.spent_amount, item.currency)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-muted-foreground">Budget</span>
|
||||
<span className="font-medium">{formatCurrency(item.budget_amount, item.currency)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-muted-foreground">Remaining</span>
|
||||
<span className={cn("font-medium", item.remaining_amount < 0 ? "text-destructive" : "text-success")}>
|
||||
{formatCurrency(Math.abs(item.remaining_amount), item.currency)}
|
||||
{item.remaining_amount < 0 ? " over" : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-xs text-muted-foreground">
|
||||
{item.period_start} → {item.period_end}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<BudgetFormModal
|
||||
categories={categories}
|
||||
onClose={() => setShowForm(false)}
|
||||
onSubmit={(data) => createMutation.mutate(data)}
|
||||
isLoading={createMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue