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
69 changes: 69 additions & 0 deletions app/src/api/savings-goals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { api } from './client';

export type SavingsGoal = {
id: number;
title: string;
target_amount: number;
current_amount: number;
currency: string;
deadline: string | null;
status: 'ON_TRACK' | 'BEHIND' | 'AHEAD' | 'COMPLETED';
monthly_target: number | null;
created_at: string;
};

export type SavingsGoalCreate = {
title: string;
target_amount: number;
current_amount?: number;
currency?: string;
deadline?: string;
};

export type SavingsGoalUpdate = Partial<SavingsGoalCreate>;

export type Milestone = {
id: number;
goal_id: number;
title: string;
amount: number;
reached_at: string | null;
created_at: string;
};

export type MilestoneCreate = {
title: string;
amount: number;
};

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

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

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

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 listMilestones(goalId: number): Promise<Milestone[]> {
return api<Milestone[]>(`/savings-goals/${goalId}/milestones`);
}

export async function createMilestone(goalId: number, payload: MilestoneCreate): Promise<Milestone> {
return api<Milestone>(`/savings-goals/${goalId}/milestones`, { method: 'POST', body: payload });
}

export async function deleteMilestone(goalId: number, milestoneId: number): Promise<{ message: string }> {
return api(`/savings-goals/${goalId}/milestones/${milestoneId}`, { method: 'DELETE' });
}
203 changes: 132 additions & 71 deletions app/src/pages/Budgets.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { useState } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { FinancialCard, FinancialCardContent, FinancialCardDescription, FinancialCardFooter, FinancialCardHeader, FinancialCardTitle } from '@/components/ui/financial-card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Calendar, DollarSign, Plus, PieChart, TrendingDown, TrendingUp, Target, AlertCircle, Settings } from 'lucide-react';
import { Calendar, DollarSign, Plus, PieChart, TrendingDown, TrendingUp, Target, AlertCircle, Settings, Loader2, Trash2 } from 'lucide-react';
import { listSavingsGoals, createSavingsGoal, deleteSavingsGoal, updateSavingsGoal } from '@/api/savings-goals';
import type { SavingsGoal as SavingsGoalType } from '@/api/savings-goals';

const budgetCategories = [
{
Expand Down Expand Up @@ -67,38 +69,50 @@ const budgetCategories = [
}
];

const budgetGoals = [
{
id: 1,
title: 'Emergency Fund',
target: 10000,
current: 7250,
deadline: 'Dec 2025',
monthlyTarget: 458,
status: 'on-track'
},
{
id: 2,
title: 'Vacation Fund',
target: 3000,
current: 1850,
deadline: 'Jun 2025',
monthlyTarget: 383,
status: 'behind'
},
{
id: 3,
title: 'New Car',
target: 25000,
current: 15600,
deadline: 'Mar 2026',
monthlyTarget: 625,
status: 'ahead'
}
];

export function Budgets() {
const [selectedPeriod] = useState('monthly');
const [savingsGoals, setSavingsGoals] = useState<SavingsGoalType[]>([]);
const [goalsLoading, setGoalsLoading] = useState(true);
const [showGoalForm, setShowGoalForm] = useState(false);
const [newGoal, setNewGoal] = useState({ title: '', target_amount: '', deadline: '' });

const fetchGoals = useCallback(async () => {
try {
const goals = await listSavingsGoals();
setSavingsGoals(goals);
} catch {
// silently handle - user may not be authenticated
} finally {
setGoalsLoading(false);
}
}, []);

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

const handleCreateGoal = async () => {
if (!newGoal.title || !newGoal.target_amount) return;
try {
await createSavingsGoal({
title: newGoal.title,
target_amount: parseFloat(newGoal.target_amount),
deadline: newGoal.deadline || undefined,
});
setNewGoal({ title: '', target_amount: '', deadline: '' });
setShowGoalForm(false);
fetchGoals();
} catch {
// handle error silently
}
};

const handleDeleteGoal = async (id: number) => {
try {
await deleteSavingsGoal(id);
fetchGoals();
} catch {
// handle error silently
}
};

const totalAllocated = budgetCategories.reduce((sum, cat) => sum + cat.allocated, 0);
const totalSpent = budgetCategories.reduce((sum, cat) => sum + cat.spent, 0);
Expand Down Expand Up @@ -269,7 +283,7 @@ export function Budgets() {
<FinancialCardHeader>
<div className="flex items-center justify-between">
<FinancialCardTitle className="section-title">Savings Goals</FinancialCardTitle>
<Button variant="ghost" size="sm">
<Button variant="ghost" size="sm" onClick={() => setShowGoalForm(!showGoalForm)}>
<Plus className="w-4 h-4" />
</Button>
</div>
Expand All @@ -278,51 +292,98 @@ export function Budgets() {
</FinancialCardDescription>
</FinancialCardHeader>
<FinancialCardContent>
{showGoalForm && (
<div className="mb-4 p-3 rounded-lg border border-border space-y-2">
<input
className="w-full px-2 py-1 text-sm border border-border rounded bg-background text-foreground"
placeholder="Goal title"
value={newGoal.title}
onChange={(e) => setNewGoal({ ...newGoal, title: e.target.value })}
/>
<input
className="w-full px-2 py-1 text-sm border border-border rounded bg-background text-foreground"
placeholder="Target amount"
type="number"
value={newGoal.target_amount}
onChange={(e) => setNewGoal({ ...newGoal, target_amount: e.target.value })}
/>
<input
className="w-full px-2 py-1 text-sm border border-border rounded bg-background text-foreground"
placeholder="Deadline (YYYY-MM-DD)"
type="date"
value={newGoal.deadline}
onChange={(e) => setNewGoal({ ...newGoal, deadline: e.target.value })}
/>
<div className="flex gap-2">
<Button variant="financial" size="sm" onClick={handleCreateGoal}>Save</Button>
<Button variant="outline" size="sm" onClick={() => setShowGoalForm(false)}>Cancel</Button>
</div>
</div>
)}
<div className="space-y-4">
{budgetGoals.map((goal) => {
const percentage = (goal.current / goal.target) * 100;

return (
<div key={goal.id} className="interactive-row p-3 rounded-lg border border-border">
<div className="flex items-center justify-between mb-2">
<div className="font-medium text-foreground text-sm">
{goal.title}
</div>
<Badge
variant={
goal.status === 'on-track' ? 'default' :
goal.status === 'ahead' ? 'secondary' : 'destructive'
}
className="text-xs"
>
{goal.status === 'on-track' ? 'On Track' :
goal.status === 'ahead' ? 'Ahead' : 'Behind'}
</Badge>
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
${goal.current.toLocaleString()} / ${goal.target.toLocaleString()}
</span>
<span className="text-foreground font-medium">
{percentage.toFixed(0)}%
</span>
</div>
<div className="chart-track">
<div className="chart-fill-success" style={{ width: `${Math.min(percentage, 100)}%` }} />
{goalsLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
) : savingsGoals.length === 0 ? (
<div className="text-center text-sm text-muted-foreground py-4">
No savings goals yet. Create one to get started!
</div>
) : (
savingsGoals.map((goal) => {
const percentage = (goal.current_amount / goal.target_amount) * 100;
const statusLabel = goal.status === 'ON_TRACK' ? 'On Track' :
goal.status === 'AHEAD' ? 'Ahead' :
goal.status === 'COMPLETED' ? 'Completed' : 'Behind';
const statusVariant = goal.status === 'ON_TRACK' ? 'default' as const :
goal.status === 'AHEAD' ? 'secondary' as const :
goal.status === 'COMPLETED' ? 'secondary' as const : 'destructive' as const;

return (
<div key={goal.id} className="interactive-row p-3 rounded-lg border border-border">
<div className="flex items-center justify-between mb-2">
<div className="font-medium text-foreground text-sm">
{goal.title}
</div>
<div className="flex items-center gap-1">
<Badge variant={statusVariant} className="text-xs">
{statusLabel}
</Badge>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => handleDeleteGoal(goal.id)}
>
<Trash2 className="w-3 h-3 text-muted-foreground" />
</Button>
</div>
</div>
<div className="flex justify-between text-xs text-muted-foreground">
<span>Target: {goal.deadline}</span>
<span>${goal.monthlyTarget}/mo</span>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
${goal.current_amount.toLocaleString()} / ${goal.target_amount.toLocaleString()}
</span>
<span className="text-foreground font-medium">
{percentage.toFixed(0)}%
</span>
</div>
<div className="chart-track">
<div className="chart-fill-success" style={{ width: `${Math.min(percentage, 100)}%` }} />
</div>
<div className="flex justify-between text-xs text-muted-foreground">
{goal.deadline && <span>Target: {goal.deadline}</span>}
{goal.monthly_target !== null && <span>${goal.monthly_target}/mo</span>}
</div>
</div>
</div>
</div>
);
})}
);
})
)}
</div>
</FinancialCardContent>
<FinancialCardFooter>
<Button variant="financial" size="sm" className="w-full">
<Button variant="financial" size="sm" className="w-full" onClick={() => setShowGoalForm(true)}>
<Plus className="w-4 h-4" />
Add New Goal
</Button>
Expand Down
22 changes: 22 additions & 0 deletions packages/backend/app/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,28 @@ CREATE TABLE IF NOT EXISTS user_subscriptions (
started_at TIMESTAMP NOT NULL DEFAULT NOW()
);

CREATE TABLE IF NOT EXISTS savings_goals (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(200) NOT NULL,
target_amount NUMERIC(12,2) NOT NULL,
current_amount NUMERIC(12,2) NOT NULL DEFAULT 0,
currency VARCHAR(10) NOT NULL DEFAULT 'INR',
deadline DATE,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_savings_goals_user ON savings_goals(user_id);

CREATE TABLE IF NOT EXISTS savings_goal_milestones (
id SERIAL PRIMARY KEY,
goal_id INT NOT NULL REFERENCES savings_goals(id) ON DELETE CASCADE,
title VARCHAR(200) NOT NULL,
amount NUMERIC(12,2) NOT NULL,
reached_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_savings_goal_milestones_goal ON savings_goal_milestones(goal_id);

CREATE TABLE IF NOT EXISTS audit_logs (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id) ON DELETE SET NULL,
Expand Down
31 changes: 31 additions & 0 deletions packages/backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,37 @@ class UserSubscription(db.Model):
started_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)


class GoalStatus(str, Enum):
ON_TRACK = "ON_TRACK"
BEHIND = "BEHIND"
AHEAD = "AHEAD"
COMPLETED = "COMPLETED"


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)
title = 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)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)


class SavingsGoalMilestone(db.Model):
__tablename__ = "savings_goal_milestones"
id = db.Column(db.Integer, primary_key=True)
goal_id = db.Column(
db.Integer, db.ForeignKey("savings_goals.id", ondelete="CASCADE"), nullable=False
)
title = db.Column(db.String(200), nullable=False)
amount = db.Column(db.Numeric(12, 2), nullable=False)
reached_at = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)


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