Skip to content
Merged
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
1 change: 1 addition & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ model Transaction {
type String
category String
date DateTime @default(now())
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
Expand Down
3 changes: 3 additions & 0 deletions backend/src/services/transaction.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ interface CreateTransactionData {
type: string
category: string
date?: string
notes?: string
}

interface TransactionFilters {
Expand All @@ -25,6 +26,7 @@ export const create = async (userId: string, data: CreateTransactionData) => {
type: data.type,
category: data.category,
date: data.date ? new Date(data.date) : new Date(),
notes: data.notes?.trim() ? data.notes.trim() : null,
},
})
return transaction
Expand Down Expand Up @@ -76,6 +78,7 @@ export const update = async (userId: string, id: string, data: Partial<CreateTra
...(data.type && { type: data.type }),
...(data.category && { category: data.category }),
...(data.date && { date: new Date(data.date) }),
...(data.notes !== undefined && { notes: data.notes.trim() ? data.notes.trim() : null }),
},
})
return transaction
Expand Down
1 change: 1 addition & 0 deletions backend/src/validators/transaction.validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const createTransactionSchema = z.object({
type: z.enum(['income', 'expense'], { message: 'Type must be income or expense' }),
category: z.string().min(1, 'Category is required').max(50),
date: z.string().datetime().optional(),
notes: z.string().max(500, 'Notes must be 500 characters or less').optional(),
})

export const updateTransactionSchema = createTransactionSchema.partial()
4 changes: 2 additions & 2 deletions frontend/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,10 @@ export default function DashboardPage() {
>
{/* Greeting */}
<motion.div variants={fadeUp}>
<h2 className="text-2xl font-display font-bold text-foreground">
<h2 className="text-xl md:text-2xl font-display font-bold text-foreground">
Welcome back{user?.name ? `, ${user.name.split(" ")[0]}` : ""}
</h2>
<p className="text-sm text-muted-foreground mt-1">{today}</p>
<p className="text-xs md:text-sm text-muted-foreground mt-1">{today}</p>
</motion.div>

{/* Hero balance card */}
Expand Down
166 changes: 152 additions & 14 deletions frontend/app/goals/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,26 @@ import { toast } from "sonner";
import AppShell from "@/components/layout/AppShell";
import Header from "@/components/layout/Header";
import AddGoalDialog from "@/components/goals/AddGoalDialog";
import EditGoalDialog from "@/components/goals/EditGoalDialog";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useGoalStore, type Goal } from "@/store/goalStore";
import { HugeiconsIcon } from "@hugeicons/react";
import {
Alert02Icon,
Delete01Icon,
Edit01Icon,
Target01Icon,
Add01Icon,
Delete01Icon,
DollarCircleIcon,
} from "@hugeicons/core-free-icons";

Expand All @@ -32,9 +43,20 @@ function daysUntil(dateStr: string) {
return days > 0 ? `${days} days left` : "Overdue";
}

function isGoalOverdue(goal: Goal) {
if (goal.currentAmount >= goal.targetAmount) return false;

const deadline = new Date(goal.deadline);
deadline.setHours(23, 59, 59, 999);

return deadline.getTime() < Date.now();
}

export default function GoalsPage() {
const { goals, addFunds, deleteGoal, fetchGoals, isLoading } = useGoalStore();
const [addOpen, setAddOpen] = useState(false);
const [editGoal, setEditGoal] = useState<Goal | null>(null);
const [deleteGoalTarget, setDeleteGoalTarget] = useState<Goal | null>(null);

useEffect(() => {
fetchGoals();
Expand All @@ -50,10 +72,11 @@ export default function GoalsPage() {
}
};

const handleDelete = async (goal: Goal) => {
const handleDeleteGoal = async (goal: Goal) => {
try {
await deleteGoal(goal.id);
toast.success("Goal deleted");
setDeleteGoalTarget(null);
} catch {
toast.error("Failed to delete goal");
}
Expand All @@ -66,8 +89,8 @@ export default function GoalsPage() {
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-display font-bold">Your Goals</h2>
<p className="text-sm text-muted-foreground">{goals.length} active goal{goals.length !== 1 ? "s" : ""}</p>
<h2 className="text-sm md:text-lg font-display font-bold">Your Goals</h2>
<p className="text-xs md:text-sm text-muted-foreground">{goals.length} active goal{goals.length !== 1 ? "s" : ""}</p>
</div>
<Button size="sm" onClick={() => setAddOpen(true)}>
<HugeiconsIcon icon={Add01Icon} className="size-3.5" />
Expand Down Expand Up @@ -116,8 +139,8 @@ export default function GoalsPage() {
</motion.div>
</motion.div>
</div>
<h3 className="text-xl font-display font-bold mb-2">Set your first goal</h3>
<p className="text-sm text-muted-foreground max-w-xs mb-6 leading-relaxed">
<h3 className="text-[1rem] md:text-xl font-display font-bold mb-2">Set your first goal</h3>
<p className="text-xs md:text-sm text-muted-foreground max-w-xs mb-6 leading-relaxed">
Track progress toward the things that matter most to you.
</p>
<Button size="lg" className="rounded-xl" onClick={() => setAddOpen(true)}>
Expand All @@ -131,6 +154,7 @@ export default function GoalsPage() {
{goals.map((goal, i) => {
const percentage = Math.round((goal.currentAmount / goal.targetAmount) * 100);
const isComplete = percentage >= 100;
const isOverdue = isGoalOverdue(goal);

return (
<motion.div
Expand All @@ -139,22 +163,51 @@ export default function GoalsPage() {
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: i * 0.05 }}
>
<Card>
<Card className={isOverdue ? "border border-destructive/30 bg-destructive/[0.03]" : undefined}>
<CardContent className="space-y-4">
<div className="flex items-start gap-3">
<div className="size-10 rounded-xl bg-primary/10 flex items-center justify-center shrink-0">
<HugeiconsIcon icon={Target01Icon} className="size-5 text-primary" />
<div
className={`size-10 rounded-xl flex items-center justify-center shrink-0 ${
isOverdue ? "bg-destructive/10" : "bg-primary/10"
}`}
>
<HugeiconsIcon icon={Target01Icon} className={`size-5 ${isOverdue ? "text-destructive" : "text-primary"}`} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<h3 className="font-display font-bold text-sm truncate">{goal.name}</h3>
<span className="text-xs font-display font-bold text-primary ml-2">{percentage}%</span>
<div className="flex min-w-0 items-center gap-2">
<h3 className="truncate font-display text-sm font-bold">{goal.name}</h3>
<button
type="button"
onClick={() => setEditGoal(goal)}
className="flex size-7 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label={`Edit ${goal.name}`}
>
<HugeiconsIcon icon={Edit01Icon} className="size-3.5" />
</button>
</div>
<div className="ml-2 flex items-center gap-2">
{isOverdue && (
<span className="inline-flex items-center gap-1 rounded-full bg-destructive/10 px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-destructive">
<HugeiconsIcon icon={Alert02Icon} className="size-3" />
Overdue
</span>
)}
<span className={`text-xs font-display font-bold ${isOverdue ? "text-destructive" : "text-primary"}`}>
{percentage}%
</span>
</div>
</div>
<p className="text-xs text-muted-foreground">{daysUntil(goal.deadline)}</p>
<p className={`text-xs ${isOverdue ? "font-medium text-destructive" : "text-muted-foreground"}`}>
{daysUntil(goal.deadline)}
</p>
</div>
</div>

<Progress value={percentage} className="h-3 rounded-full" />
<Progress
value={percentage}
className={isOverdue ? "h-3 rounded-full [&_[data-slot=progress-indicator]]:bg-destructive" : "h-3 rounded-full"}
/>

<div className="flex items-center justify-between">
<div className="text-xs text-muted-foreground">
Expand All @@ -176,7 +229,7 @@ export default function GoalsPage() {
size="xs"
variant="ghost"
className="text-muted-foreground hover:text-destructive"
onClick={() => handleDelete(goal)}
onClick={() => setDeleteGoalTarget(goal)}
>
<HugeiconsIcon icon={Delete01Icon} className="size-3" />
</Button>
Expand All @@ -201,6 +254,91 @@ export default function GoalsPage() {
</div>

<AddGoalDialog open={addOpen} onOpenChange={setAddOpen} />
<EditGoalDialog
goal={editGoal}
open={editGoal !== null}
onOpenChange={(open) => {
if (!open) setEditGoal(null);
}}
/>
<DeleteGoalDialog
goal={deleteGoalTarget}
open={deleteGoalTarget !== null}
onOpenChange={(open) => {
if (!open) setDeleteGoalTarget(null);
}}
onConfirm={handleDeleteGoal}
/>
</AppShell>
);
}

function DeleteGoalDialog({
goal,
open,
onOpenChange,
onConfirm,
}: {
goal: Goal | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: (goal: Goal) => Promise<void>;
}) {
const [deleting, setDeleting] = useState(false);

if (!goal) return null;

return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (deleting) return;
onOpenChange(nextOpen);
}}
>
<DialogContent
className="rounded-2xl p-6 sm:max-w-md"
showCloseButton={!deleting}
onEscapeKeyDown={(event) => {
if (deleting) event.preventDefault();
}}
onPointerDownOutside={(event) => {
if (deleting) event.preventDefault();
}}
>
<DialogHeader>
<div className="flex items-center gap-3">
<div className="flex size-11 items-center justify-center rounded-2xl bg-destructive/10 text-destructive">
<HugeiconsIcon icon={Delete01Icon} className="size-5" />
</div>
<DialogTitle className="font-display text-lg font-bold text-destructive">Delete goal?</DialogTitle>
</div>
<DialogDescription className="text-sm leading-relaxed">
This action will permanently remove <span className="font-semibold text-foreground">{goal.name}</span>.
</DialogDescription>
</DialogHeader>

<DialogFooter className="pt-2">
<Button type="button" variant="outline" disabled={deleting} onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
type="button"
variant="destructive"
disabled={deleting}
onClick={async () => {
setDeleting(true);
try {
await onConfirm(goal);
} finally {
setDeleting(false);
}
}}
>
{deleting ? "Deleting..." : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
Loading
Loading