Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 SavingsGoals from "./pages/SavingsGoals";

const queryClient = new QueryClient({
defaultOptions: {
Expand Down Expand Up @@ -83,6 +84,14 @@ const App = () => (
</ProtectedRoute>
}
/>
<Route
path="savings"
element={
<ProtectedRoute>
<SavingsGoals />
</ProtectedRoute>
}
/>
<Route
path="account"
element={
Expand Down
57 changes: 57 additions & 0 deletions app/src/api/savings_goals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { api } from './client';

export type SavingsMilestone = {
id: number;
percent: number;
reached: boolean;
reached_at: string | null;
};

export type SavingsGoal = {
id: number;
name: string;
target_amount: number;
current_amount: number;
currency: string;
deadline: string | null;
progress: number;
active: boolean;
created_at: string;
milestones: SavingsMilestone[];
};

export type SavingsGoalCreate = {
name: string;
target_amount: number;
currency?: string;
deadline?: string | null;
};

export type SavingsGoalUpdate = Partial<SavingsGoalCreate>;

export async function listSavingsGoals(): Promise<SavingsGoal[]> {
return api<SavingsGoal[]>('/savings/goals');
}

export async function getSavingsGoal(id: number): Promise<SavingsGoal> {
return api<SavingsGoal>(`/savings/goals/${id}`);
}

export async function createSavingsGoal(payload: SavingsGoalCreate): Promise<SavingsGoal> {
return api<SavingsGoal>('/savings/goals', { method: 'POST', body: payload });
}

export async function updateSavingsGoal(id: number, payload: SavingsGoalUpdate): Promise<SavingsGoal> {
return api<SavingsGoal>(`/savings/goals/${id}`, { method: 'PATCH', body: payload });
}

export async function deleteSavingsGoal(id: number): Promise<{ message: string }> {
return api(`/savings/goals/${id}`, { method: 'DELETE' });
}

export async function contributeSavingsGoal(id: number, amount: number): Promise<SavingsGoal> {
return api<SavingsGoal>(`/savings/goals/${id}/contribute`, {
method: 'POST',
body: { amount },
});
}
1 change: 1 addition & 0 deletions app/src/components/layout/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { logout as logoutApi } from '@/api/auth';
const navigation = [
{ name: 'Dashboard', href: '/dashboard' },
{ name: 'Budgets', href: '/budgets' },
{ name: 'Savings', href: '/savings' },
{ name: 'Bills', href: '/bills' },
{ name: 'Reminders', href: '/reminders' },
{ name: 'Expenses', href: '/expenses' },
Expand Down
220 changes: 220 additions & 0 deletions app/src/pages/SavingsGoals.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useToast } from '@/components/ui/use-toast';
import { Target, Plus, TrendingUp, Trophy, Trash2 } from 'lucide-react';
import {
listSavingsGoals,
createSavingsGoal,
contributeSavingsGoal,
deleteSavingsGoal,
type SavingsGoal,
} from '@/api/savings_goals';

export default function SavingsGoals() {
const [goals, setGoals] = useState<SavingsGoal[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [contributeId, setContributeId] = useState<number | null>(null);
const [contributeAmount, setContributeAmount] = useState('');
const [form, setForm] = useState({ name: '', target_amount: '', currency: '', deadline: '' });
const { toast } = useToast();

const fetchGoals = async () => {
try {
const data = await listSavingsGoals();
setGoals(data);
} catch {
toast({ title: 'Error', description: 'Failed to load savings goals', variant: 'destructive' });
} finally {
setLoading(false);
}
};

useEffect(() => { fetchGoals(); }, []);

const handleCreate = async () => {
if (!form.name || !form.target_amount) {
toast({ title: 'Error', description: 'Name and target amount are required', variant: 'destructive' });
return;
}
try {
await createSavingsGoal({
name: form.name,
target_amount: parseFloat(form.target_amount),
currency: form.currency || undefined,
deadline: form.deadline || null,
});
setForm({ name: '', target_amount: '', currency: '', deadline: '' });
setShowCreate(false);
toast({ title: 'Success', description: 'Savings goal created!' });
fetchGoals();
} catch {
toast({ title: 'Error', description: 'Failed to create goal', variant: 'destructive' });
}
};

const handleContribute = async (goalId: number) => {
const amount = parseFloat(contributeAmount);
if (!amount || amount <= 0) {
toast({ title: 'Error', description: 'Enter a valid amount', variant: 'destructive' });
return;
}
try {
await contributeSavingsGoal(goalId, amount);
setContributeId(null);
setContributeAmount('');
toast({ title: 'Success', description: 'Contribution added!' });
fetchGoals();
} catch {
toast({ title: 'Error', description: 'Failed to contribute', variant: 'destructive' });
}
};

const handleDelete = async (goalId: number) => {
try {
await deleteSavingsGoal(goalId);
toast({ title: 'Deleted', description: 'Goal removed' });
fetchGoals();
} catch {
toast({ title: 'Error', description: 'Failed to delete', variant: 'destructive' });
}
};

if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}

return (
<div className="max-w-4xl mx-auto p-6 space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Target className="h-8 w-8 text-primary" />
<h1 className="text-2xl font-bold">Savings Goals</h1>
</div>
<Button onClick={() => setShowCreate(!showCreate)}>
<Plus className="h-4 w-4 mr-2" />
New Goal
</Button>
</div>

{showCreate && (
<div className="border rounded-lg p-4 space-y-3 bg-card">
<h3 className="font-semibold">Create New Goal</h3>
<div className="grid grid-cols-2 gap-3">
<Input
placeholder="Goal name"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
<Input
type="number"
placeholder="Target amount"
value={form.target_amount}
onChange={(e) => setForm({ ...form, target_amount: e.target.value })}
/>
<Input
placeholder="Currency (e.g. USD)"
value={form.currency}
onChange={(e) => setForm({ ...form, currency: e.target.value })}
/>
<Input
type="date"
placeholder="Deadline"
value={form.deadline}
onChange={(e) => setForm({ ...form, deadline: e.target.value })}
/>
</div>
<div className="flex gap-2">
<Button onClick={handleCreate}>Create</Button>
<Button variant="outline" onClick={() => setShowCreate(false)}>Cancel</Button>
</div>
</div>
)}

{goals.length === 0 && !showCreate ? (
<div className="text-center py-12 text-muted-foreground">
<Target className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p className="text-lg">No savings goals yet</p>
<p className="text-sm">Create your first goal to start tracking your savings.</p>
</div>
) : (
<div className="space-y-4">
{goals.map((goal) => (
<div key={goal.id} className="border rounded-lg p-4 space-y-3 bg-card">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-lg">{goal.name}</h3>
<p className="text-sm text-muted-foreground">
{goal.currency} {goal.current_amount.toLocaleString()} / {goal.target_amount.toLocaleString()}
{goal.deadline && ` · Due ${goal.deadline}`}
</p>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => setContributeId(contributeId === goal.id ? null : goal.id)}
>
<TrendingUp className="h-4 w-4 mr-1" />
Contribute
</Button>
<Button size="sm" variant="ghost" onClick={() => handleDelete(goal.id)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>

{/* Progress bar */}
<div className="w-full bg-secondary rounded-full h-3">
<div
className="bg-primary h-3 rounded-full transition-all duration-500"
style={{ width: `${Math.min(goal.progress, 100)}%` }}
/>
</div>
<p className="text-sm font-medium">{goal.progress}% complete</p>

{/* Milestones */}
<div className="flex gap-2">
{goal.milestones.map((m) => (
<div
key={m.id}
className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${
m.reached
? 'bg-primary/20 text-primary'
: 'bg-secondary text-muted-foreground'
}`}
>
<Trophy className="h-3 w-3" />
{m.percent}%
</div>
))}
</div>

{/* Contribute form */}
{contributeId === goal.id && (
<div className="flex gap-2 pt-2 border-t">
<Input
type="number"
placeholder="Amount"
value={contributeAmount}
onChange={(e) => setContributeAmount(e.target.value)}
className="max-w-[200px]"
/>
<Button size="sm" onClick={() => handleContribute(goal.id)}>Add</Button>
<Button size="sm" variant="outline" onClick={() => { setContributeId(null); setContributeAmount(''); }}>
Cancel
</Button>
</div>
)}
</div>
))}
</div>
)}
</div>
);
}
28 changes: 28 additions & 0 deletions packages/backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,34 @@ class UserSubscription(db.Model):
started_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)


class SavingsGoal(db.Model):
__tablename__ = "savings_goals"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
name = db.Column(db.String(200), nullable=False)
target_amount = db.Column(db.Numeric(12, 2), nullable=False)
current_amount = db.Column(db.Numeric(12, 2), default=0, nullable=False)
currency = db.Column(db.String(10), default="INR", nullable=False)
deadline = db.Column(db.Date, nullable=True)
active = db.Column(db.Boolean, default=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)

milestones = db.relationship(
"SavingsMilestone", backref="goal", lazy=True, cascade="all, delete-orphan"
)


class SavingsMilestone(db.Model):
__tablename__ = "savings_milestones"
id = db.Column(db.Integer, primary_key=True)
goal_id = db.Column(
db.Integer, db.ForeignKey("savings_goals.id"), nullable=False
)
percent = db.Column(db.Integer, nullable=False)
reached = db.Column(db.Boolean, default=False, nullable=False)
reached_at = db.Column(db.DateTime, nullable=True)


class AuditLog(db.Model):
__tablename__ = "audit_logs"
id = db.Column(db.Integer, primary_key=True)
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/app/routes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from .categories import bp as categories_bp
from .docs import bp as docs_bp
from .dashboard import bp as dashboard_bp
from .savings_goals import bp as savings_goals_bp


def register_routes(app: Flask):
Expand All @@ -18,3 +19,4 @@ def register_routes(app: Flask):
app.register_blueprint(categories_bp, url_prefix="/categories")
app.register_blueprint(docs_bp, url_prefix="/docs")
app.register_blueprint(dashboard_bp, url_prefix="/dashboard")
app.register_blueprint(savings_goals_bp, url_prefix="/savings/goals")
Loading