diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 0c4d971..923b521 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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) diff --git a/backend/src/services/transaction.service.ts b/backend/src/services/transaction.service.ts index 3689a87..4c0bbfe 100644 --- a/backend/src/services/transaction.service.ts +++ b/backend/src/services/transaction.service.ts @@ -6,6 +6,7 @@ interface CreateTransactionData { type: string category: string date?: string + notes?: string } interface TransactionFilters { @@ -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 @@ -76,6 +78,7 @@ export const update = async (userId: string, id: string, data: Partial {/* Greeting */} -

+

Welcome back{user?.name ? `, ${user.name.split(" ")[0]}` : ""}

-

{today}

+

{today}

{/* Hero balance card */} diff --git a/frontend/app/goals/page.tsx b/frontend/app/goals/page.tsx index d11f304..2b6a234 100644 --- a/frontend/app/goals/page.tsx +++ b/frontend/app/goals/page.tsx @@ -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"; @@ -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(null); + const [deleteGoalTarget, setDeleteGoalTarget] = useState(null); useEffect(() => { fetchGoals(); @@ -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"); } @@ -66,8 +89,8 @@ export default function GoalsPage() { {/* Header */}
-

Your Goals

-

{goals.length} active goal{goals.length !== 1 ? "s" : ""}

+

Your Goals

+

{goals.length} active goal{goals.length !== 1 ? "s" : ""}

-

Set your first goal

-

+

Set your first goal

+

Track progress toward the things that matter most to you.

+ +
+ {isOverdue && ( + + + Overdue + + )} + + {percentage}% + +
-

{daysUntil(goal.deadline)}

+

+ {daysUntil(goal.deadline)} +

- +
@@ -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)} > @@ -201,6 +254,91 @@ export default function GoalsPage() {
+ { + if (!open) setEditGoal(null); + }} + /> + { + if (!open) setDeleteGoalTarget(null); + }} + onConfirm={handleDeleteGoal} + /> ); } + +function DeleteGoalDialog({ + goal, + open, + onOpenChange, + onConfirm, +}: { + goal: Goal | null; + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: (goal: Goal) => Promise; +}) { + const [deleting, setDeleting] = useState(false); + + if (!goal) return null; + + return ( + { + if (deleting) return; + onOpenChange(nextOpen); + }} + > + { + if (deleting) event.preventDefault(); + }} + onPointerDownOutside={(event) => { + if (deleting) event.preventDefault(); + }} + > + +
+
+ +
+ Delete goal? +
+ + This action will permanently remove {goal.name}. + +
+ + + + + +
+
+ ); +} diff --git a/frontend/app/transactions/page.tsx b/frontend/app/transactions/page.tsx index e3b993e..61d65e9 100644 --- a/frontend/app/transactions/page.tsx +++ b/frontend/app/transactions/page.tsx @@ -1,27 +1,34 @@ "use client"; -import { useState, useEffect } from "react"; +import { useEffect, useState } from "react"; import { motion } from "motion/react"; -import AppShell from "@/components/layout/AppShell"; -import Header from "@/components/layout/Header"; -import AddTransactionDialog from "@/components/transactions/AddTransactionDialog"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -import { useTransactionStore, type Transaction } from "@/store/transactionStore"; +import { toast } from "sonner"; import { HugeiconsIcon } from "@hugeicons/react"; import { - CreditCardIcon, + Activity01Icon, Add01Icon, - Restaurant01Icon, - MoneyReceive01Icon, - Bus01Icon, Briefcase01Icon, + Bus01Icon, + CreditCardIcon, + Delete01Icon, Invoice01Icon, + MoneyReceive01Icon, + Restaurant01Icon, ShoppingBag01Icon, - Activity01Icon, - Delete01Icon, } from "@hugeicons/core-free-icons"; -import { toast } from "sonner"; +import AppShell from "@/components/layout/AppShell"; +import Header from "@/components/layout/Header"; +import AddTransactionDialog from "@/components/transactions/AddTransactionDialog"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useTransactionStore, type Transaction } from "@/store/transactionStore"; const categoryConfig: Record = { Food: { icon: Restaurant01Icon, color: "text-orange-400", bg: "bg-orange-400/10" }, @@ -58,6 +65,7 @@ export default function TransactionsPage() { const { filter, setFilter, getFiltered, deleteTransaction, fetchTransactions, isLoading } = useTransactionStore(); const transactions = getFiltered(); const [addOpen, setAddOpen] = useState(false); + const [selectedTransaction, setSelectedTransaction] = useState(null); useEffect(() => { fetchTransactions(); @@ -67,6 +75,10 @@ export default function TransactionsPage() { try { await deleteTransaction(tx.id); toast.success("Transaction deleted"); + + if (selectedTransaction?.id === tx.id) { + setSelectedTransaction(null); + } } catch { toast.error("Failed to delete transaction"); } @@ -77,20 +89,19 @@ export default function TransactionsPage() { return (
-
- {/* Filter tabs + add button */} +
- {filters.map((f) => ( + {filters.map((value) => ( ))}
@@ -101,11 +112,10 @@ export default function TransactionsPage() {
- {/* Loading / Transaction list / Empty state */} {isLoading ? (
- {[...Array(5)].map((_, i) => ( -
+ {[...Array(5)].map((_, index) => ( +
@@ -123,20 +133,20 @@ export default function TransactionsPage() { className="flex flex-col items-center justify-center py-20 text-center" >
-
+
-

No transactions yet

-

+

No transactions yet

+

Start logging your income and expenses to track your financial progress.

- @@ -144,37 +154,51 @@ export default function TransactionsPage() { ) : ( - {transactions.map((tx, i) => { + {transactions.map((tx, index) => { const config = categoryConfig[tx.category] || defaultConfig; + return ( setSelectedTransaction(tx)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + setSelectedTransaction(tx); + } + }} > -
+
-
+ +

{tx.category}

-

- {formatDate(tx.date)} - {tx.note && ` ยท ${tx.note}`} -

+

{formatDate(tx.date)}

+ {tx.type === "income" ? "+" : "-"} {formatCurrency(tx.amount)} + @@ -187,6 +211,77 @@ export default function TransactionsPage() {
+ { + if (!open) setSelectedTransaction(null); + }} + /> ); } + +function TransactionDetailsDialog({ + transaction, + open, + onOpenChange, +}: { + transaction: Transaction | null; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + if (!transaction) return null; + + return ( + + + + Transaction details + View the full details for this transaction. + + +
+ + + + {transaction.notes && } +
+
+
+ ); +} + +function DetailRow({ + label, + value, + valueClassName, + multiline = false, + separated = false, +}: { + label: string; + value: string; + valueClassName?: string; + multiline?: boolean; + separated?: boolean; +}) { + return ( +
+

{label}

+

{value}

+
+ ); +} diff --git a/frontend/components/dashboard/BalanceCard.tsx b/frontend/components/dashboard/BalanceCard.tsx index dbd64ab..17c8f99 100644 --- a/frontend/components/dashboard/BalanceCard.tsx +++ b/frontend/components/dashboard/BalanceCard.tsx @@ -5,7 +5,6 @@ import { HugeiconsIcon } from "@hugeicons/react"; import { ViewIcon, ViewOffIcon, - ArrowUpRight01Icon, Add01Icon, Remove01Icon, } from "@hugeicons/core-free-icons"; @@ -20,6 +19,10 @@ function formatCurrency(amount: number) { }).format(amount); } +function formatHiddenAmount() { + return "****"; +} + function useCountUp(target: number, duration = 800) { const [value, setValue] = useState(0); const ref = useRef(null); @@ -68,21 +71,16 @@ export default function BalanceCard() { {/* Header row */}

Total Balance

-
- - -
+
{/* Balance */} -

+

{isLoading ? ( ) : visible ? formatCurrency(animatedBalance) : "****"} @@ -92,14 +90,14 @@ export default function BalanceCard() {

Income

-

- +{formatCurrency(income)} +

+ {visible ? `+${formatCurrency(income)}` : formatHiddenAmount()}

Expenses

-

- -{formatCurrency(expense)} +

+ {visible ? `-${formatCurrency(expense)}` : formatHiddenAmount()}

@@ -110,14 +108,14 @@ export default function BalanceCard() { onClick={() => openAdd("income")} className="flex items-center gap-1.5 bg-white/[0.08] hover:bg-white/[0.12] text-white rounded-full px-4 py-2 text-xs font-medium transition-colors" > - + {visible && } Add Income

diff --git a/frontend/components/goals/EditGoalDialog.tsx b/frontend/components/goals/EditGoalDialog.tsx new file mode 100644 index 0000000..680c7c2 --- /dev/null +++ b/frontend/components/goals/EditGoalDialog.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { useGoalStore, type Goal } from "@/store/goalStore"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +interface EditGoalDialogProps { + goal: Goal | null; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export default function EditGoalDialog({ goal, open, onOpenChange }: EditGoalDialogProps) { + const updateGoal = useGoalStore((state) => state.updateGoal); + const [name, setName] = useState(""); + const [target, setTarget] = useState(""); + const [deadline, setDeadline] = useState(""); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (!goal) return; + + setName(goal.name); + setTarget(String(goal.targetAmount)); + setDeadline(goal.deadline); + }, [goal]); + + const resetState = () => { + setName(""); + setTarget(""); + setDeadline(""); + setSaving(false); + }; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + if (!goal) return; + + if (!name.trim()) { + toast.error("Please enter a goal name"); + return; + } + + const numTarget = parseFloat(target); + if (!numTarget || numTarget <= 0) { + toast.error("Please enter a valid target amount"); + return; + } + + if (!deadline) { + toast.error("Please select a deadline"); + return; + } + + setSaving(true); + try { + await updateGoal(goal.id, { + name: name.trim(), + targetAmount: numTarget, + deadline, + }); + toast.success("Goal updated"); + onOpenChange(false); + resetState(); + } catch { + toast.error("Failed to update goal"); + } finally { + setSaving(false); + } + }; + + return ( + { + if (saving) return; + if (!nextOpen) resetState(); + onOpenChange(nextOpen); + }} + > + + + Edit Goal + Update your goal name, target amount, and deadline. + + +
+
+ + setName(event.target.value)} + placeholder="e.g. Emergency Fund" + required + /> +
+ +
+ + setTarget(event.target.value)} + className="text-lg font-display font-bold" + required + /> +
+ +
+ + setDeadline(event.target.value)} + required + /> +
+ + + + + +
+
+
+ ); +} diff --git a/frontend/components/layout/Header.tsx b/frontend/components/layout/Header.tsx index 2dc7b65..a86cb0c 100644 --- a/frontend/components/layout/Header.tsx +++ b/frontend/components/layout/Header.tsx @@ -22,8 +22,6 @@ export default function Header({ title }: HeaderProps) {

{title}

-

{title}

-