diff --git a/app/src/App.tsx b/app/src/App.tsx index f0dc5942..bf917f97 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -16,6 +16,7 @@ import NotFound from "./pages/NotFound"; import { Landing } from "./pages/Landing"; import ProtectedRoute from "./components/auth/ProtectedRoute"; import Account from "./pages/Account"; +import { Savings } from "./pages/Savings"; const queryClient = new QueryClient({ defaultOptions: { @@ -83,6 +84,14 @@ const App = () => ( } /> + + + + } + /> > & { + status?: SavingsGoalStatus; +}; + +export async function listGoals(status?: string): Promise { + const qs = status ? `?status=${status}` : ''; + return api(`/savings${qs}`); +} + +export async function createGoal(payload: SavingsGoalCreate): Promise { + return api('/savings', { method: 'POST', body: payload }); +} + +export async function getGoal(id: number): Promise { + return api(`/savings/${id}`); +} + +export async function updateGoal(id: number, payload: SavingsGoalUpdate): Promise { + return api(`/savings/${id}`, { method: 'PATCH', body: payload }); +} + +export async function deleteGoal(id: number): Promise<{ message: string }> { + return api(`/savings/${id}`, { method: 'DELETE' }); +} + +export async function addDeposit( + goalId: number, + payload: { amount: number; note?: string; deposited_at?: string }, +): Promise { + return api(`/savings/${goalId}/deposits`, { method: 'POST', body: payload }); +} + +export async function listDeposits(goalId: number): Promise { + return api(`/savings/${goalId}/deposits`); +} diff --git a/app/src/components/layout/Navbar.tsx b/app/src/components/layout/Navbar.tsx index c7593b70..8da9f131 100644 --- a/app/src/components/layout/Navbar.tsx +++ b/app/src/components/layout/Navbar.tsx @@ -12,6 +12,7 @@ const navigation = [ { name: 'Bills', href: '/bills' }, { name: 'Reminders', href: '/reminders' }, { name: 'Expenses', href: '/expenses' }, + { name: 'Savings', href: '/savings' }, { name: 'Analytics', href: '/analytics' }, ]; diff --git a/app/src/pages/Savings.tsx b/app/src/pages/Savings.tsx new file mode 100644 index 00000000..956fcf5d --- /dev/null +++ b/app/src/pages/Savings.tsx @@ -0,0 +1,447 @@ +import { useState, useEffect, useCallback } from 'react'; +import { + FinancialCard, + FinancialCardContent, + FinancialCardHeader, + FinancialCardTitle, + FinancialCardDescription, +} from '@/components/ui/financial-card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { useToast } from '@/hooks/use-toast'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dailog'; +import { PiggyBank, Plus, Target, TrendingUp, Trophy, Trash2, PlusCircle } from 'lucide-react'; +import { + listGoals, + createGoal, + updateGoal, + deleteGoal, + addDeposit, + type SavingsGoal, + type SavingsGoalStatus, +} from '@/api/savings'; +import { formatMoney } from '@/lib/currency'; + +const STATUS_COLORS: Record = { + ACTIVE: 'bg-success/10 text-success border-success/20', + COMPLETED: 'bg-primary/10 text-primary border-primary/20', + PAUSED: 'bg-warning/10 text-warning border-warning/20', +}; + +const MILESTONE_LABELS: Record = { + 25: '25%', + 50: 'Halfway', + 75: '75%', + 100: 'Goal reached!', +}; + +function ProgressBar({ pct }: { pct: number }) { + const clamped = Math.min(100, Math.max(0, pct)); + const color = + clamped >= 100 + ? 'bg-success' + : clamped >= 75 + ? 'bg-primary' + : clamped >= 50 + ? 'bg-info' + : 'bg-warning'; + return ( +
+
+
+ ); +} + +function MilestoneBadges({ reached }: { reached: number[] }) { + if (!reached.length) return null; + return ( +
+ {reached.map((m) => ( + + + {MILESTONE_LABELS[m] ?? `${m}%`} + + ))} +
+ ); +} + +export function Savings() { + const [goals, setGoals] = useState([]); + const [loading, setLoading] = useState(true); + const [createOpen, setCreateOpen] = useState(false); + const [depositTarget, setDepositTarget] = useState(null); + const { toast } = useToast(); + + // Create form state + const [form, setForm] = useState({ + name: '', + target_amount: '', + currency: 'INR', + deadline: '', + notes: '', + initial_amount: '', + }); + + // Deposit form state + const [depositForm, setDepositForm] = useState({ amount: '', note: '' }); + + const load = useCallback(async () => { + try { + setLoading(true); + const data = await listGoals(); + setGoals(data); + } catch { + toast({ title: 'Failed to load savings goals', variant: 'destructive' }); + } finally { + setLoading(false); + } + }, [toast]); + + useEffect(() => { + load(); + }, [load]); + + async function handleCreate() { + if (!form.name || !form.target_amount) { + toast({ title: 'Name and target amount are required', variant: 'destructive' }); + return; + } + try { + await createGoal({ + name: form.name, + target_amount: parseFloat(form.target_amount), + currency: form.currency || 'INR', + deadline: form.deadline || undefined, + notes: form.notes || undefined, + initial_amount: form.initial_amount ? parseFloat(form.initial_amount) : undefined, + }); + toast({ title: 'Savings goal created' }); + setCreateOpen(false); + setForm({ name: '', target_amount: '', currency: 'INR', deadline: '', notes: '', initial_amount: '' }); + load(); + } catch (e: unknown) { + toast({ title: (e as Error).message || 'Failed to create goal', variant: 'destructive' }); + } + } + + async function handleDeposit() { + if (!depositTarget || !depositForm.amount) return; + try { + await addDeposit(depositTarget.id, { + amount: parseFloat(depositForm.amount), + note: depositForm.note || undefined, + }); + toast({ title: `Deposit added to "${depositTarget.name}"` }); + setDepositTarget(null); + setDepositForm({ amount: '', note: '' }); + load(); + } catch (e: unknown) { + toast({ title: (e as Error).message || 'Failed to add deposit', variant: 'destructive' }); + } + } + + async function handleStatusToggle(goal: SavingsGoal) { + const next: SavingsGoalStatus = goal.status === 'PAUSED' ? 'ACTIVE' : 'PAUSED'; + try { + await updateGoal(goal.id, { status: next }); + load(); + } catch { + toast({ title: 'Failed to update goal', variant: 'destructive' }); + } + } + + async function handleDelete(id: number) { + try { + await deleteGoal(id); + toast({ title: 'Goal deleted' }); + load(); + } catch { + toast({ title: 'Failed to delete goal', variant: 'destructive' }); + } + } + + const totalTarget = goals.reduce((s, g) => s + g.target_amount, 0); + const totalSaved = goals.reduce((s, g) => s + g.current_amount, 0); + const completedCount = goals.filter((g) => g.status === 'COMPLETED').length; + + return ( +
+ {/* Header */} +
+
+

+ + Savings Goals +

+

+ Track your savings targets and milestones +

+
+ + + + + + + Create Savings Goal + Set a target and start tracking your progress. + +
+ setForm({ ...form, name: e.target.value })} + /> + setForm({ ...form, target_amount: e.target.value })} + /> + setForm({ ...form, currency: e.target.value })} + /> + setForm({ ...form, initial_amount: e.target.value })} + /> + setForm({ ...form, deadline: e.target.value })} + /> +