87 | >(({ className, ...props }, ref) => (
88 | [role=checkbox]]:translate-y-[2px]",
92 | className
93 | )}
94 | {...props}
95 | />
96 | ))
97 | TableCell.displayName = "TableCell"
98 |
99 | const TableCaption = React.forwardRef<
100 | HTMLTableCaptionElement,
101 | React.HTMLAttributes
102 | >(({ className, ...props }, ref) => (
103 |
108 | ))
109 | TableCaption.displayName = "TableCaption"
110 |
111 | export {
112 | Table,
113 | TableHeader,
114 | TableBody,
115 | TableFooter,
116 | TableHead,
117 | TableRow,
118 | TableCell,
119 | TableCaption,
120 | }
121 |
--------------------------------------------------------------------------------
/frontend/src/pages/Statistics.tsx:
--------------------------------------------------------------------------------
1 | import { useContext } from "react";
2 | import { AppContext } from "@/App";
3 | import HiddenStats from "../components/stats/HiddenStats";
4 | import CheckoutButton from "../components/CheckoutButton";
5 | import useTitle from "../hooks/useTitle";
6 | import FixedIncomeExpenseGraph from "../components/stats/FixedIncomeExpenseGraph";
7 | import TimeRangeIncomeAndExpense from "../components/stats/TimeRangeIncomeAndExpense";
8 | import axios from "axios";
9 | import { useQuery } from "@tanstack/react-query";
10 | import { ScrollArea } from "@/components/ui/scroll-area";
11 | import {
12 | HoverCard,
13 | HoverCardContent,
14 | HoverCardTrigger,
15 | } from "@/components/ui/hover-card";
16 | import { FaInfoCircle as InfoIcon } from "react-icons/fa";
17 |
18 | function Statistics() {
19 | useTitle("Statistics");
20 | const { userData } = useContext(AppContext);
21 | const { data, isLoading } = useQuery({
22 | queryKey: ["time-range-income-expense"],
23 | queryFn: () => {
24 | return axios
25 | .get("/stats/get-income-expense-by-time-range")
26 | .then((res) => res.data.data);
27 | },
28 | });
29 |
30 | return (
31 |
32 |
Statistics
33 |
34 |
35 | {userData.user.isPaidUser ? (
36 |
37 |
38 |
39 |
40 | Income and expense by week / month / year
41 |
42 |
43 |
44 |
45 |
46 | This is calculated based on the transactions that you have
47 | made.
48 |
49 |
50 |
51 | {isLoading ? (
52 | // TODO: Add loading skeletons here
53 |
Data loading...
54 | ) : (
55 |
56 |
57 |
61 |
62 |
63 | )}
64 |
65 | ) : (
66 |
67 |
68 |
69 |
70 |
71 | This is a premium feature
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 | )}
80 |
81 |
82 | );
83 | }
84 |
85 | export default Statistics;
86 |
--------------------------------------------------------------------------------
/frontend/src/components/DatePicker.tsx:
--------------------------------------------------------------------------------
1 | import { CalendarIcon } from "@radix-ui/react-icons";
2 | import { format } from "date-fns";
3 |
4 | import { cn } from "@/lib/utils";
5 | import { Button } from "@/components/ui/button";
6 | import { Calendar } from "@/components/ui/calendar";
7 | import {
8 | Popover,
9 | PopoverContent,
10 | PopoverTrigger,
11 | } from "@/components/ui/popover";
12 | import { FormEvent, useContext, useState } from "react";
13 | import { Label } from "@radix-ui/react-dropdown-menu";
14 | import { toast } from "react-hot-toast";
15 | import { AppContext } from "@/App";
16 | import axios from "axios";
17 |
18 | function DatePicker() {
19 | const { userData, setUserData } = useContext(AppContext);
20 | const [date, setDate] = useState(userData.user.dateOfBirth);
21 |
22 | // const { mutate, mutateAsync } = useMutation({
23 | // mutationKey: ["userData"],
24 | // mutationFn: (date) => axios.post("/user/update-date", { date }),
25 | // });
26 |
27 | async function updateDate(e: FormEvent) {
28 | e.preventDefault();
29 | if (!date) {
30 | toast.error("Date is required", { id: "date-required" });
31 | return;
32 | }
33 |
34 | if (date > new Date()) {
35 | toast.error("Date of birth can't be in the future", {
36 | id: "date-in-future",
37 | });
38 | return;
39 | }
40 |
41 | if (date === userData.user.dateOfBirth) {
42 | toast.error("This date of birth has already been set by you", {
43 | id: "date-already-set",
44 | icon: "📌",
45 | });
46 | return;
47 | }
48 |
49 | const toastPromise = axios
50 | .post("/users/update-date", { date })
51 | .then((res) => {
52 | localStorage.setItem("userData", JSON.stringify(res.data.data));
53 | setUserData(res.data.data);
54 | });
55 |
56 | toast.promise(toastPromise, {
57 | loading: "Saving",
58 | success: "Saved",
59 | error: "Error when fetching",
60 | });
61 | }
62 |
63 | return (
64 |
100 | );
101 | }
102 |
103 | export default DatePicker;
104 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/drawer.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Drawer as DrawerPrimitive } from "vaul"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const Drawer = ({
7 | shouldScaleBackground = true,
8 | ...props
9 | }: React.ComponentProps) => (
10 |
14 | )
15 | Drawer.displayName = "Drawer"
16 |
17 | const DrawerTrigger = DrawerPrimitive.Trigger
18 |
19 | const DrawerPortal = DrawerPrimitive.Portal
20 |
21 | const DrawerClose = DrawerPrimitive.Close
22 |
23 | const DrawerOverlay = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, ...props }, ref) => (
27 |
32 | ))
33 | DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
34 |
35 | const DrawerContent = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, children, ...props }, ref) => (
39 |
40 |
41 |
49 |
50 | {children}
51 |
52 |
53 | ))
54 | DrawerContent.displayName = "DrawerContent"
55 |
56 | const DrawerHeader = ({
57 | className,
58 | ...props
59 | }: React.HTMLAttributes) => (
60 |
64 | )
65 | DrawerHeader.displayName = "DrawerHeader"
66 |
67 | const DrawerFooter = ({
68 | className,
69 | ...props
70 | }: React.HTMLAttributes) => (
71 |
75 | )
76 | DrawerFooter.displayName = "DrawerFooter"
77 |
78 | const DrawerTitle = React.forwardRef<
79 | React.ElementRef,
80 | React.ComponentPropsWithoutRef
81 | >(({ className, ...props }, ref) => (
82 |
90 | ))
91 | DrawerTitle.displayName = DrawerPrimitive.Title.displayName
92 |
93 | const DrawerDescription = React.forwardRef<
94 | React.ElementRef,
95 | React.ComponentPropsWithoutRef
96 | >(({ className, ...props }, ref) => (
97 |
102 | ))
103 | DrawerDescription.displayName = DrawerPrimitive.Description.displayName
104 |
105 | export {
106 | Drawer,
107 | DrawerPortal,
108 | DrawerOverlay,
109 | DrawerTrigger,
110 | DrawerClose,
111 | DrawerContent,
112 | DrawerHeader,
113 | DrawerFooter,
114 | DrawerTitle,
115 | DrawerDescription,
116 | }
117 |
--------------------------------------------------------------------------------
/frontend/src/components/Command.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useContext } from "react";
2 | import { LuLayoutDashboard as OverviewIcon } from "react-icons/lu";
3 | import { GrTransaction as TransactionsIcon } from "react-icons/gr";
4 | // import { FaMoneyBillTransfer as HomeIcon } from "react-icons/fa6";
5 | import { IoSettingsOutline as SettingsIcon } from "react-icons/io5";
6 | import { TbTargetArrow as GoalsIcon } from "react-icons/tb";
7 | import { IoPerson as AccountIcon } from "react-icons/io5";
8 | import {
9 | CommandDialog,
10 | CommandEmpty,
11 | CommandGroup,
12 | CommandInput,
13 | CommandItem,
14 | CommandList,
15 | // CommandSeparator,
16 | // CommandShortcut,
17 | } from "@/components/ui/command";
18 | import { HiOutlineLogout as LogoutIcon } from "react-icons/hi";
19 | import React from "react";
20 | import { useNavigate } from "react-router-dom";
21 | import axios from "axios";
22 | import { AppContext } from "@/App";
23 | import toast from "react-hot-toast";
24 |
25 | export default function Command({ children }: { children: React.ReactNode }) {
26 | const [open, setOpen] = useState(false);
27 | const { setIsLoggedIn } = useContext(AppContext);
28 | const navigate = useNavigate();
29 |
30 | useEffect(() => {
31 | const down = (e: KeyboardEvent) => {
32 | if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
33 | e.preventDefault();
34 | setOpen((open) => !open);
35 | }
36 | };
37 |
38 | document.addEventListener("keydown", down);
39 | return () => document.removeEventListener("keydown", down);
40 | }, []);
41 |
42 | function handleSelect(page: string) {
43 | navigate(page);
44 | setOpen(false);
45 | }
46 |
47 | function handleLogout() {
48 | const toastPromise = axios.post("/users/logout").then(() => {
49 | localStorage.removeItem("userStatus");
50 | setIsLoggedIn(false);
51 | });
52 |
53 | toast.promise(toastPromise, {
54 | success: "Logged out",
55 | loading: "Logging out",
56 | error: "Unable to log out",
57 | });
58 | }
59 |
60 | return (
61 | <>
62 | {children}
63 |
64 |
65 |
66 | No results found.
67 |
68 | handleSelect("/")}>
69 |
70 | Overview
71 |
72 | handleSelect("/transactions")}>
73 |
74 | Transactions
75 |
76 | handleSelect("/goals")}>
77 |
78 | Goals
79 |
80 | handleSelect("/account")}>
81 |
82 | Account
83 |
84 | handleSelect("/settings")}>
85 |
86 | Settings
87 |
88 |
89 |
90 | Log out
91 |
92 |
93 |
94 |
95 | >
96 | );
97 | }
98 |
--------------------------------------------------------------------------------
/frontend/src/components/Feedback.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Drawer,
3 | DrawerContent,
4 | DrawerFooter,
5 | DrawerHeader,
6 | DrawerTitle,
7 | } from "@/components/ui/drawer";
8 | import { Button } from "@/components/ui/button";
9 | import { FormEvent, useState } from "react";
10 | import {
11 | Select,
12 | SelectContent,
13 | SelectItem,
14 | SelectTrigger,
15 | SelectValue,
16 | } from "@/components/ui/select";
17 | import { Label } from "@/components/ui/label";
18 | import { Textarea } from "@/components/ui/textarea";
19 | import axios from "axios";
20 | import { toast } from "react-hot-toast";
21 |
22 | function Feedback() {
23 | const [drawerOpen, setDrawerOpen] = useState(false);
24 | const [rating, setRating] = useState();
25 | const [description, setDescription] = useState();
26 |
27 | async function submitFeedback(e: FormEvent) {
28 | e.preventDefault();
29 | if (!rating) return;
30 |
31 | await axios
32 | .post("/feedback/create-feedback", { rating, description })
33 | .then(() => {
34 | setDrawerOpen(false);
35 | toast.success("Thank you for your feedback!");
36 | })
37 | .catch(() => {
38 | setDrawerOpen(false);
39 | toast.error("Something went wrong");
40 | });
41 | }
42 |
43 | return (
44 |
45 | setDrawerOpen(true)}>
46 | Give Feedback
47 |
48 |
49 |
99 |
100 |
101 | );
102 | }
103 |
104 | export default Feedback;
105 |
--------------------------------------------------------------------------------
/frontend/src/components/AddIncomeAndExpense.tsx:
--------------------------------------------------------------------------------
1 | import { FormEvent, useContext, useState } from "react";
2 | import { AppContext } from "@/App";
3 | import { Button } from "@/components/ui/button";
4 | import {
5 | Dialog,
6 | DialogContent,
7 | DialogDescription,
8 | DialogFooter,
9 | DialogHeader,
10 | DialogTitle,
11 | DialogTrigger,
12 | } from "@/components/ui/dialog";
13 | import { Input } from "@/components/ui/input";
14 | import { Label } from "@/components/ui/label";
15 | import { toast } from "./ui/use-toast";
16 | import axios from "axios";
17 |
18 | function AddIncomeAndExpense() {
19 | const { userData, setUserData } = useContext(AppContext);
20 | const [modalOpen, setModalOpen] = useState(false);
21 | const [income, setIncome] = useState();
22 | const [expense, setExpense] = useState();
23 |
24 | async function handleSubmit(e: FormEvent) {
25 | e.preventDefault();
26 | if (expense === undefined || income === undefined) return;
27 | if (expense > income) {
28 | toast({
29 | description: "Expenses can't be greater than income.",
30 | });
31 | return;
32 | }
33 | await axios
34 | .post("/users/add-income-and-expense", {
35 | email: userData.user.email,
36 | income,
37 | expense,
38 | })
39 | .then((res) => {
40 | localStorage.setItem("userData", JSON.stringify(res.data.data));
41 | setUserData(res.data.data);
42 | setModalOpen(false);
43 | });
44 | }
45 |
46 | return (
47 |
48 |
49 | You haven't set your income and expenses yet.
50 |
51 |
52 |
53 | Add Income and Expenses
54 |
55 |
56 |
57 |
58 | Add Income and Expenses
59 |
60 | Add your income and expenses here. Click save when you're done.
61 |
62 |
63 |
95 |
96 | Save changes
97 |
98 |
99 |
100 |
101 |
102 | );
103 | }
104 |
105 | export default AddIncomeAndExpense;
106 |
--------------------------------------------------------------------------------
/frontend/src/components/UpdateIncomeAndExpense.tsx:
--------------------------------------------------------------------------------
1 | import { FormEvent, useContext, useState } from "react";
2 | import { AppContext } from "@/App";
3 | import { Button } from "@/components/ui/button";
4 | import {
5 | Dialog,
6 | DialogContent,
7 | DialogDescription,
8 | DialogFooter,
9 | DialogHeader,
10 | DialogTitle,
11 | DialogTrigger,
12 | } from "@/components/ui/dialog";
13 | import { Input } from "@/components/ui/input";
14 | import { Label } from "@/components/ui/label";
15 | import { toast } from "react-hot-toast";
16 | import axios from "axios";
17 |
18 | const formatter = new Intl.NumberFormat("en-US");
19 |
20 | function UpdateIncomeAndExpense() {
21 | const { userData, setUserData } = useContext(AppContext);
22 | const [modalOpen, setModalOpen] = useState(false);
23 | const [income, setIncome] = useState();
24 | const [expense, setExpense] = useState();
25 |
26 | async function handleSubmit(e: FormEvent) {
27 | e.preventDefault();
28 | if (expense === undefined || income === undefined) {
29 | toast.error("Income and expense is required");
30 | return;
31 | }
32 | if (expense > income) {
33 | toast.error("Expenses can't be greater than income.");
34 | return;
35 | }
36 | await axios
37 | .post("/users/add-income-and-expense", {
38 | income,
39 | expense,
40 | })
41 | .then((res) => {
42 | localStorage.setItem("userData", JSON.stringify(res.data.data));
43 | setUserData(res.data.data);
44 | setModalOpen(false);
45 | toast.success(
46 | `Income and expenses updated to ${
47 | userData.user.currency
48 | }${formatter.format(income)} and ${
49 | userData.user.currency
50 | }${formatter.format(expense)}`
51 | );
52 | });
53 | }
54 |
55 | return (
56 |
57 |
58 | Update Income and Expense
59 |
60 |
61 |
62 |
63 | Update Income and Expense
64 |
65 | Add your income and expenses here. Click save when you're done.
66 |
67 |
68 |
100 |
101 | Save changes
102 |
103 |
104 |
105 |
106 | );
107 | }
108 |
109 | export default UpdateIncomeAndExpense;
110 |
--------------------------------------------------------------------------------
/frontend/src/components/GoalsDisplay.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState, useContext } from "react";
2 | import { AppContext } from "@/App";
3 | import axios from "axios";
4 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
5 | import { ScrollArea } from "@/components/ui/scroll-area";
6 | import { Badge } from "@/components/ui/badge";
7 | import { Progress } from "@/components/ui/progress";
8 | import AddNewGoal from "./AddNewGoal";
9 | import SingularGoalView from "./SingularGoalView";
10 | import GoalsSkeleton from "./GoalsSkeleton";
11 | import { useParams, useNavigate } from "react-router-dom";
12 | import { useQuery } from "@tanstack/react-query";
13 | import { formatter } from "@/utils/formatter";
14 |
15 | type GoalType = {
16 | _id: string;
17 | title: string;
18 | description: string;
19 | category: string;
20 | currentAmount: number;
21 | finalAmount: number;
22 | };
23 |
24 | function GoalsDisplay() {
25 | const { userData, selectedGoal, setSelectedGoal } = useContext(AppContext);
26 | const [isSelected, setIsSelected] = useState(false);
27 | const { goalId } = useParams();
28 | const navigate = useNavigate();
29 |
30 | const { data, isPending, error, refetch } = useQuery({
31 | queryKey: ["get-goals"],
32 | queryFn: async () => {
33 | return axios
34 | .get("/goals/get-goals")
35 | .then((res) => res.data.data.goals.reverse());
36 | },
37 | });
38 |
39 | useEffect(() => {
40 | refetch();
41 | }, [selectedGoal]); // eslint-disable-line react-hooks/exhaustive-deps
42 |
43 | useEffect(() => {
44 | if (!goalId) setIsSelected(false);
45 | }, [goalId]);
46 |
47 | if (error) return "Error";
48 |
49 | return (
50 |
51 | {isPending ? (
52 |
53 | ) : (
54 |
55 | {data.map((goal: GoalType) => (
56 | {
60 | setSelectedGoal(goal);
61 | setIsSelected(true);
62 | navigate(`/goals/${goal._id}`);
63 | }}
64 | >
65 |
66 | {goal.title}
67 |
68 | {goal.category}
69 |
70 |
71 |
72 |
73 | {userData.user.currency}
74 | {formatter.format(goal.currentAmount)}
75 |
78 | {userData.user.currency}
79 | {formatter.format(goal.finalAmount)}
80 |
81 |
82 |
83 | ))}
84 |
85 | )}
86 |
87 | {goalId === selectedGoal._id && goalId !== undefined ? (
88 |
89 | ) : (
90 |
91 |
92 | Select a goal to view
93 |
94 |
or
95 |
96 |
97 | )}
98 |
99 | {isSelected ? (
100 |
103 | ) : (
104 |
105 | )}
106 |
107 |
108 | );
109 | }
110 |
111 | export default GoalsDisplay;
112 |
--------------------------------------------------------------------------------
/frontend/src/components/TransactionDisplay.tsx:
--------------------------------------------------------------------------------
1 | import { useContext } from "react";
2 | import axios from "axios";
3 | import {
4 | Table,
5 | TableBody,
6 | TableCell,
7 | TableHead,
8 | TableHeader,
9 | TableRow,
10 | } from "@/components/ui/table";
11 | import { AppContext } from "@/App";
12 | import { ScrollArea } from "./ui/scroll-area";
13 | import { Button } from "./ui/button";
14 | import { FaPlus as PlusIcon } from "react-icons/fa";
15 | import { useNavigate } from "react-router-dom";
16 | import TransactionSkeleton from "./TransactionSkeleton";
17 | import { useQuery } from "@tanstack/react-query";
18 |
19 | const formatter = new Intl.NumberFormat("en-US");
20 |
21 | export type TransactionType = {
22 | _id: string;
23 | title: string;
24 | wallet: string;
25 | category: string;
26 | amount: number;
27 | type: string;
28 | };
29 |
30 | function TransactionDisplay() {
31 | const { userData } = useContext(AppContext);
32 | const navigate = useNavigate();
33 | const { data, isPending, error } = useQuery({
34 | queryKey: ["get-transactions"],
35 | queryFn: async () => {
36 | return axios
37 | .get("/transaction/get-transactions")
38 | .then((res) => res.data.data.transactions.reverse());
39 | },
40 | });
41 |
42 | // Make the table-row-element to behave like a real button, can't use a real Link element or button to wrap the table-row as that messes up default shadcn table styling
43 | const onKeyDown = (
44 | event: React.KeyboardEvent,
45 | id: string
46 | ) => {
47 | if (event.key === "Enter" || event.key === " ") {
48 | navigate(`/transactions/${id}`);
49 | }
50 | return;
51 | };
52 |
53 | if (error) return "Error";
54 |
55 | return (
56 |
57 |
58 |
59 |
60 |
61 | Type
62 | Method
63 | Title
64 | Amount
65 |
66 |
67 |
68 | {isPending ? (
69 |
70 | ) : (
71 |
72 | {data.map((transaction: TransactionType) => (
73 | navigate(`/transactions/${transaction._id}`)}
76 | className="cursor-pointer"
77 | role="button"
78 | onKeyDown={(e) => onKeyDown(e, transaction._id)}
79 | tabIndex={0}
80 | >
81 |
82 | {transaction.type}
83 |
84 | {transaction.wallet}
85 | {transaction.title}
86 |
87 | {transaction.type === "Expense" ? (
88 | −
89 | ) : (
90 | +
91 | )}
92 | {userData.user.currency}
93 | {formatter.format(transaction.amount)}
94 |
95 |
96 | ))}
97 |
98 | )}
99 |
100 |
101 |
102 |
103 |
navigate("/create-transaction")}
107 | >
108 |
109 | New Transaction
110 |
111 |
112 |
113 | );
114 | }
115 |
116 | export default TransactionDisplay;
117 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as DialogPrimitive from "@radix-ui/react-dialog"
3 | import { Cross2Icon } from "@radix-ui/react-icons"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const Dialog = DialogPrimitive.Root
8 |
9 | const DialogTrigger = DialogPrimitive.Trigger
10 |
11 | const DialogPortal = DialogPrimitive.Portal
12 |
13 | const DialogClose = DialogPrimitive.Close
14 |
15 | const DialogOverlay = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, ...props }, ref) => (
19 |
27 | ))
28 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
29 |
30 | const DialogContent = React.forwardRef<
31 | React.ElementRef,
32 | React.ComponentPropsWithoutRef
33 | >(({ className, children, ...props }, ref) => (
34 |
35 |
36 |
44 | {children}
45 |
46 |
47 | Close
48 |
49 |
50 |
51 | ))
52 | DialogContent.displayName = DialogPrimitive.Content.displayName
53 |
54 | const DialogHeader = ({
55 | className,
56 | ...props
57 | }: React.HTMLAttributes) => (
58 |
65 | )
66 | DialogHeader.displayName = "DialogHeader"
67 |
68 | const DialogFooter = ({
69 | className,
70 | ...props
71 | }: React.HTMLAttributes) => (
72 |
79 | )
80 | DialogFooter.displayName = "DialogFooter"
81 |
82 | const DialogTitle = React.forwardRef<
83 | React.ElementRef,
84 | React.ComponentPropsWithoutRef
85 | >(({ className, ...props }, ref) => (
86 |
94 | ))
95 | DialogTitle.displayName = DialogPrimitive.Title.displayName
96 |
97 | const DialogDescription = React.forwardRef<
98 | React.ElementRef,
99 | React.ComponentPropsWithoutRef
100 | >(({ className, ...props }, ref) => (
101 |
106 | ))
107 | DialogDescription.displayName = DialogPrimitive.Description.displayName
108 |
109 | export {
110 | Dialog,
111 | DialogPortal,
112 | DialogOverlay,
113 | DialogTrigger,
114 | DialogClose,
115 | DialogContent,
116 | DialogHeader,
117 | DialogFooter,
118 | DialogTitle,
119 | DialogDescription,
120 | }
121 |
--------------------------------------------------------------------------------
/frontend/src/pages/Dashboard.tsx:
--------------------------------------------------------------------------------
1 | import LogoutButton from "@/components/LogoutButton";
2 | import { Outlet } from "react-router-dom";
3 | import { LuLayoutDashboard as OverviewIcon } from "react-icons/lu";
4 | import { GrTransaction as TransactionsIcon } from "react-icons/gr";
5 | import { FaMoneyBillTransfer as HomeIcon } from "react-icons/fa6";
6 | import { IoSettingsOutline as SettingsIcon } from "react-icons/io5";
7 | import { VscGraph as StatsIcon } from "react-icons/vsc";
8 | import { PiCrown as ProIcon } from "react-icons/pi";
9 | import { TbTargetArrow as GoalsIcon } from "react-icons/tb";
10 | import { IoPerson as AccountIcon } from "react-icons/io5";
11 | import { FaCodeBranch as CodeIcon } from "react-icons/fa6";
12 | import { Link } from "react-router-dom";
13 | import { useContext } from "react";
14 | import { AppContext } from "@/App";
15 | import HamburgerMenu from "@/components/HamburgerMenu";
16 | import Command from "@/components/Command";
17 |
18 | function Dashboard() {
19 | const { showGoals, showTransactions, userData } = useContext(AppContext);
20 |
21 | return (
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 | SpendSync
31 | {userData.user.isPaidUser && (
32 |
36 | )}
37 |
38 |
39 |
40 |
44 |
45 |
Overview
46 |
47 |
48 | {showTransactions ? (
49 |
53 |
54 |
Transactions
55 |
56 | ) : (
57 | ""
58 | )}
59 |
60 | {showGoals ? (
61 |
65 |
66 |
Goals
67 |
68 | ) : (
69 | ""
70 | )}
71 |
72 |
76 |
77 |
Statistics
78 |
79 |
80 |
84 |
85 |
Account
86 |
87 |
88 |
92 |
93 |
Settings
94 |
95 |
100 |
101 | Source Code
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 | );
110 | }
111 |
112 | export default Dashboard;
113 |
--------------------------------------------------------------------------------
/todo.md:
--------------------------------------------------------------------------------
1 | - [x] logout controller
2 | - [x] logout frontend
3 | - [x] add initial deposit frontend
4 | - [x] add monthly recurring income
5 | - [x] add hasSetIncome in user.model
6 | - [x] add hasSetExpenses in user.model
7 | - [x] add monthlyIncome in user.model
8 | - [x] add monthlyExpenses in user.model
9 | - [x] get Transactions controller + route
10 | - [x] create Transaction controller + route
11 | - [x] Merge income and expenses in a single route w/ controller as you can't have expense more than income
12 |
13 | - [ ] filter Transactions feature
14 | - [x] Demo user in register/login
15 | - [x] Pro version of the thing with stripe integration
16 |
17 | - [x] DashBoard
18 |
19 | - [x] SideBar
20 | - [x] Logout at the bottom of the sidebar
21 | - [x] Overview
22 | - [x] Transactions
23 | - [x] Settings / Account
24 |
25 | - [x] Goals
26 |
27 | - [x] Goals not found frontend
28 | - [x] Goals model, finalAmount, currentAmount, title, desc
29 | - [x] Each goal seperate page
30 |
31 | - [x] when do you wan't this goal to finish? shadcn datetime in the frontend
32 |
33 | - [ ] paginate goals display
34 | - [x] GoalsDisplay doesn't immideatly refresh to add the new goal after creating a new goal
35 |
36 | - [x] update goal controller w/ endpoint
37 | - [x] show successfull toast messages whenever a req user makes is 200ok, for AddMoneyToGoal
38 | - [x] user avatar
39 | - [ ] Setings page: theme switch
40 |
41 | - [x] change password
42 | - [x] add date of birth
43 | - [x] update income and expense
44 | - [x] export all transaction data as a csv
45 | - [x] export all goals data as a csv
46 | - [x] in overview add option to control + k andd add "Tip: try ctrl + k"
47 | - [x] update account balance
48 | - [x] overview: you can update this in /account ; incomeAndExpense / accountBalance
49 | - [x] give feedback controller
50 | - [x] update date-of-birth controller
51 | - [x] delete goal option w/ alert dialog shadcn
52 | - [x] delete goal controller
53 | - [x] register component immideatly logges user in
54 | - [x] stripe integration w/ upgrade to pro
55 |
56 |
57 | - [x] submit feedback button will open a shadcn drawer
58 | - [ ] created-date in each goalDisplay + singularGoalView
59 | - [x] deleting a goal add money back to the user's balance
60 | - [ ] danger zone in settings made with shadcn accordian:
61 |
62 | - [ ] delete all goals; this doesn't work for demo account
63 | - [ ] delete all transactions; this doesn't work for demo account
64 | - [ ] delete account permanantly; this doesn't work for demo account
65 |
66 | - [x] add react-hot-toast
67 | - [x] remove password from all findByIdAndUpdateQueries
68 | - [ ] Demo user - as a paid user
69 | - [x] insights page: only for paid users, has the ui blurred, says "Upgrade to premium or login as a paid user"
70 | - [ ] goals tabluar view : check mantine ui table
71 | - [ ] a little "i" in the Wallet label in add expense that tells user that selecting cash would deduct this ammount from their account balance
72 | - [x] Issue: Stripe Integration
73 | - [ ] Issue: Doesn't work in brave browser
74 | - [x] Issue: repo link in project
75 | - [x] Issue: Logout toast notifications
76 | - [x] Issue: loading icons in GoalsDisplay & transactionDisplay
77 | - [x] filetype check in frontend when uploading avatar
78 | - [x] Issue: refreshing causes app to crash - vercel.json
79 | - [ ] change html title and metadata to spendsync!
80 |
81 | - [x] get current user controller in backend
82 | - [x] get the latest user data in the App component
83 | - [x] single transaction loading skeleton
84 | - [ ] searching in transactions
85 | - [ ] sorting in transactions
86 | - [x] fix issue w/ going to transaction page when there are no transactions, screen flickering
87 | - [x] fix feedback styling
88 |
89 |
90 | - [ ] Make the sidebar of current page a different background that merges with page on right
91 |
92 |
93 | #### Statistics
94 |
95 | - [x] Fixed income and expense
96 | - [x] Income this week
97 | - [x] Income this month
98 | - [x] Income this year
99 | - [x] Expense this week
100 | - [x] Expense this month
101 | - [x] Expense this year
102 |
103 | - [ ] Build a way to import test data for new user accounts, or anyone else
104 |
105 |
--------------------------------------------------------------------------------
/frontend/src/pages/Login.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Card,
3 | CardContent,
4 | CardDescription,
5 | CardFooter,
6 | CardHeader,
7 | CardTitle,
8 | } from "@/components/ui/card";
9 |
10 | import { Button } from "@/components/ui/button";
11 | import { Input } from "@/components/ui/input";
12 | import { Label } from "@radix-ui/react-dropdown-menu";
13 | import { FormEvent, useContext, useEffect, useState } from "react";
14 | import axios from "axios";
15 | import { Link, useNavigate } from "react-router-dom";
16 | import { AppContext } from "@/App";
17 | import { ToastAction } from "@radix-ui/react-toast";
18 | import { useToast } from "@/components/ui/use-toast";
19 | import DemoLoginButton from "@/components/DemoLoginButton";
20 |
21 | function Login() {
22 | const [email, setEmail] = useState("");
23 | const [password, setPassword] = useState("");
24 | const { isLoggedIn, setIsLoggedIn, setUserData } = useContext(AppContext);
25 | const { toast } = useToast();
26 | const navigate = useNavigate();
27 |
28 | useEffect(() => {
29 | if (isLoggedIn) navigate("/");
30 | });
31 |
32 | async function handleSubmit(e: FormEvent) {
33 | e.preventDefault();
34 | if (email.trim() === "") return;
35 | if (password.trim() === "") return;
36 |
37 | axios
38 | .post("/users/login", {
39 | email,
40 | password,
41 | })
42 | .then((response) => {
43 | localStorage.setItem("userStatus", "loggedIn");
44 | setIsLoggedIn(true);
45 | localStorage.setItem("userData", JSON.stringify(response.data.data));
46 | setUserData(response.data.data);
47 | navigate("/");
48 | })
49 | .catch((error) => {
50 | if (error.response?.status === 404) {
51 | toast({
52 | title: "User does not exist",
53 | description: "Did you mean to sign up?",
54 | action: (
55 | {
58 | navigate("/register");
59 | }}
60 | >
61 | Sign up
62 |
63 | ),
64 | });
65 | }
66 |
67 | if (error.response?.status === 401) {
68 | toast({ description: "Incorrect password" });
69 | }
70 | });
71 | }
72 |
73 | return (
74 | {
77 | handleSubmit(e);
78 | }}
79 | >
80 |
81 |
82 | Login to your account
83 |
84 | Enter your email and password to login to your account
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 | Or continue with
96 |
97 |
98 |
99 |
100 | Email
101 | setEmail(e.target.value)}
107 | />
108 |
109 |
110 | Password
111 | setPassword(e.target.value)}
116 | />
117 |
118 |
119 |
120 |
121 | Login
122 |
123 |
124 | Don't have an account?{" "}
125 |
126 | Sign up
127 |
128 |
129 |
130 |
131 |
132 | );
133 | }
134 |
135 | export default Login;
136 |
--------------------------------------------------------------------------------
/frontend/src/components/FirstGoal.tsx:
--------------------------------------------------------------------------------
1 | import { FormEvent, useContext, useState } from "react";
2 | import { AppContext } from "@/App";
3 | import {
4 | Card,
5 | CardContent,
6 | CardHeader,
7 | CardTitle,
8 | CardDescription,
9 | CardFooter,
10 | } from "@/components/ui/card";
11 | import { Button } from "@/components/ui/button";
12 | import { Label } from "@/components/ui/label";
13 | import { Input } from "@/components/ui/input";
14 | import { Textarea } from "@/components/ui/textarea";
15 | import {
16 | Select,
17 | SelectContent,
18 | SelectItem,
19 | SelectTrigger,
20 | SelectValue,
21 | } from "@/components/ui/select";
22 | import axios from "axios";
23 |
24 | function FirstGoal() {
25 | const { setUserData } = useContext(AppContext);
26 | const [title, setTitle] = useState();
27 | const [amount, setAmount] = useState();
28 | const [category, setCategory] = useState();
29 | const [description, setDescription] = useState();
30 |
31 | async function handleSubmit(e: FormEvent) {
32 | e.preventDefault();
33 | if (title === undefined || amount === undefined) return;
34 | await axios
35 | .post("/goals/create-goal", {
36 | title,
37 | finalAmount: amount,
38 | category,
39 | description,
40 | })
41 | .then((res) => {
42 | localStorage.setItem("userData", JSON.stringify(res.data.data));
43 | setUserData(res.data.data);
44 | });
45 | }
46 |
47 | return (
48 |
49 | {
51 | handleSubmit(e);
52 | }}
53 | >
54 |
55 | Add a New Goal
56 |
57 | Create a goal for yourself to save money!
58 |
59 |
60 |
61 |
62 |
63 |
64 | Title
65 |
66 | {
74 | setTitle(e.target.value);
75 | }}
76 | />
77 |
78 |
79 |
80 | Amount
81 |
82 | {
90 | setAmount(+e.target.value);
91 | }}
92 | />
93 |
94 |
95 | Category
96 | setCategory(value)}>
97 |
98 |
99 |
100 |
101 | Savings
102 | Investment
103 | Emergency
104 | Travel
105 | Retirement
106 |
107 |
108 |
109 |
110 |
111 | Description
112 |
113 | {
117 | setDescription(e.target.value);
118 | }}
119 | />
120 |
121 |
122 |
123 |
124 | Save changes
125 |
126 |
127 |
128 | );
129 | }
130 |
131 | export default FirstGoal;
132 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/use-toast.ts:
--------------------------------------------------------------------------------
1 | // Inspired by react-hot-toast library
2 | import * as React from "react"
3 |
4 | import type {
5 | ToastActionElement,
6 | ToastProps,
7 | } from "@/components/ui/toast"
8 |
9 | const TOAST_LIMIT = 1
10 | const TOAST_REMOVE_DELAY = 1000000
11 |
12 | type ToasterToast = ToastProps & {
13 | id: string
14 | title?: React.ReactNode
15 | description?: React.ReactNode
16 | action?: ToastActionElement
17 | }
18 |
19 | const actionTypes = {
20 | ADD_TOAST: "ADD_TOAST",
21 | UPDATE_TOAST: "UPDATE_TOAST",
22 | DISMISS_TOAST: "DISMISS_TOAST",
23 | REMOVE_TOAST: "REMOVE_TOAST",
24 | } as const
25 |
26 | let count = 0
27 |
28 | function genId() {
29 | count = (count + 1) % Number.MAX_SAFE_INTEGER
30 | return count.toString()
31 | }
32 |
33 | type ActionType = typeof actionTypes
34 |
35 | type Action =
36 | | {
37 | type: ActionType["ADD_TOAST"]
38 | toast: ToasterToast
39 | }
40 | | {
41 | type: ActionType["UPDATE_TOAST"]
42 | toast: Partial
43 | }
44 | | {
45 | type: ActionType["DISMISS_TOAST"]
46 | toastId?: ToasterToast["id"]
47 | }
48 | | {
49 | type: ActionType["REMOVE_TOAST"]
50 | toastId?: ToasterToast["id"]
51 | }
52 |
53 | interface State {
54 | toasts: ToasterToast[]
55 | }
56 |
57 | const toastTimeouts = new Map>()
58 |
59 | const addToRemoveQueue = (toastId: string) => {
60 | if (toastTimeouts.has(toastId)) {
61 | return
62 | }
63 |
64 | const timeout = setTimeout(() => {
65 | toastTimeouts.delete(toastId)
66 | dispatch({
67 | type: "REMOVE_TOAST",
68 | toastId: toastId,
69 | })
70 | }, TOAST_REMOVE_DELAY)
71 |
72 | toastTimeouts.set(toastId, timeout)
73 | }
74 |
75 | export const reducer = (state: State, action: Action): State => {
76 | switch (action.type) {
77 | case "ADD_TOAST":
78 | return {
79 | ...state,
80 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
81 | }
82 |
83 | case "UPDATE_TOAST":
84 | return {
85 | ...state,
86 | toasts: state.toasts.map((t) =>
87 | t.id === action.toast.id ? { ...t, ...action.toast } : t
88 | ),
89 | }
90 |
91 | case "DISMISS_TOAST": {
92 | const { toastId } = action
93 |
94 | // ! Side effects ! - This could be extracted into a dismissToast() action,
95 | // but I'll keep it here for simplicity
96 | if (toastId) {
97 | addToRemoveQueue(toastId)
98 | } else {
99 | state.toasts.forEach((toast) => {
100 | addToRemoveQueue(toast.id)
101 | })
102 | }
103 |
104 | return {
105 | ...state,
106 | toasts: state.toasts.map((t) =>
107 | t.id === toastId || toastId === undefined
108 | ? {
109 | ...t,
110 | open: false,
111 | }
112 | : t
113 | ),
114 | }
115 | }
116 | case "REMOVE_TOAST":
117 | if (action.toastId === undefined) {
118 | return {
119 | ...state,
120 | toasts: [],
121 | }
122 | }
123 | return {
124 | ...state,
125 | toasts: state.toasts.filter((t) => t.id !== action.toastId),
126 | }
127 | }
128 | }
129 |
130 | const listeners: Array<(state: State) => void> = []
131 |
132 | let memoryState: State = { toasts: [] }
133 |
134 | function dispatch(action: Action) {
135 | memoryState = reducer(memoryState, action)
136 | listeners.forEach((listener) => {
137 | listener(memoryState)
138 | })
139 | }
140 |
141 | type Toast = Omit
142 |
143 | function toast({ ...props }: Toast) {
144 | const id = genId()
145 |
146 | const update = (props: ToasterToast) =>
147 | dispatch({
148 | type: "UPDATE_TOAST",
149 | toast: { ...props, id },
150 | })
151 | const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
152 |
153 | dispatch({
154 | type: "ADD_TOAST",
155 | toast: {
156 | ...props,
157 | id,
158 | open: true,
159 | onOpenChange: (open) => {
160 | if (!open) dismiss()
161 | },
162 | },
163 | })
164 |
165 | return {
166 | id: id,
167 | dismiss,
168 | update,
169 | }
170 | }
171 |
172 | function useToast() {
173 | const [state, setState] = React.useState(memoryState)
174 |
175 | React.useEffect(() => {
176 | listeners.push(setState)
177 | return () => {
178 | const index = listeners.indexOf(setState)
179 | if (index > -1) {
180 | listeners.splice(index, 1)
181 | }
182 | }
183 | }, [state])
184 |
185 | return {
186 | ...state,
187 | toast,
188 | dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
189 | }
190 | }
191 |
192 | export { useToast, toast }
193 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/sheet.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as SheetPrimitive from "@radix-ui/react-dialog"
3 | import { Cross2Icon } from "@radix-ui/react-icons"
4 | import { cva, type VariantProps } from "class-variance-authority"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Sheet = SheetPrimitive.Root
9 |
10 | const SheetTrigger = SheetPrimitive.Trigger
11 |
12 | const SheetClose = SheetPrimitive.Close
13 |
14 | const SheetPortal = SheetPrimitive.Portal
15 |
16 | const SheetOverlay = React.forwardRef<
17 | React.ElementRef,
18 | React.ComponentPropsWithoutRef
19 | >(({ className, ...props }, ref) => (
20 |
28 | ))
29 | SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
30 |
31 | const sheetVariants = cva(
32 | "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
33 | {
34 | variants: {
35 | side: {
36 | top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
37 | bottom:
38 | "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
39 | left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
40 | right:
41 | "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
42 | },
43 | },
44 | defaultVariants: {
45 | side: "right",
46 | },
47 | }
48 | )
49 |
50 | interface SheetContentProps
51 | extends React.ComponentPropsWithoutRef,
52 | VariantProps {}
53 |
54 | const SheetContent = React.forwardRef<
55 | React.ElementRef,
56 | SheetContentProps
57 | >(({ side = "right", className, children, ...props }, ref) => (
58 |
59 |
60 |
65 | {children}
66 |
67 |
68 | Close
69 |
70 |
71 |
72 | ))
73 | SheetContent.displayName = SheetPrimitive.Content.displayName
74 |
75 | const SheetHeader = ({
76 | className,
77 | ...props
78 | }: React.HTMLAttributes) => (
79 |
86 | )
87 | SheetHeader.displayName = "SheetHeader"
88 |
89 | const SheetFooter = ({
90 | className,
91 | ...props
92 | }: React.HTMLAttributes) => (
93 |
100 | )
101 | SheetFooter.displayName = "SheetFooter"
102 |
103 | const SheetTitle = React.forwardRef<
104 | React.ElementRef,
105 | React.ComponentPropsWithoutRef
106 | >(({ className, ...props }, ref) => (
107 |
112 | ))
113 | SheetTitle.displayName = SheetPrimitive.Title.displayName
114 |
115 | const SheetDescription = React.forwardRef<
116 | React.ElementRef,
117 | React.ComponentPropsWithoutRef
118 | >(({ className, ...props }, ref) => (
119 |
124 | ))
125 | SheetDescription.displayName = SheetPrimitive.Description.displayName
126 |
127 | export {
128 | Sheet,
129 | SheetPortal,
130 | SheetOverlay,
131 | SheetTrigger,
132 | SheetClose,
133 | SheetContent,
134 | SheetHeader,
135 | SheetFooter,
136 | SheetTitle,
137 | SheetDescription,
138 | }
139 |
--------------------------------------------------------------------------------
/frontend/src/components/AddNewGoal.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Sheet,
3 | SheetContent,
4 | SheetDescription,
5 | SheetHeader,
6 | SheetFooter,
7 | SheetTitle,
8 | } from "@/components/ui/sheet";
9 | import { Button } from "./ui/button";
10 | import { Label } from "@radix-ui/react-dropdown-menu";
11 | import { Input } from "./ui/input";
12 | import { Textarea } from "@/components/ui/textarea";
13 | import {
14 | Select,
15 | SelectContent,
16 | SelectItem,
17 | SelectTrigger,
18 | SelectValue,
19 | } from "@/components/ui/select";
20 | import axios from "axios";
21 | import { FormEvent, useContext, useState } from "react";
22 | import { AppContext } from "@/App";
23 | import { FaPlus as PlusIcon } from "react-icons/fa";
24 |
25 | function AddNewGoal() {
26 | const { setUserData, setSelectedGoal } = useContext(AppContext);
27 | const [title, setTitle] = useState();
28 | const [amount, setAmount] = useState();
29 | const [category, setCategory] = useState();
30 | const [description, setDescription] = useState();
31 | const [sheetOpen, setSheetOpen] = useState(false);
32 |
33 | async function handleSubmit(e: FormEvent) {
34 | e.preventDefault();
35 | if (title === undefined || amount === undefined) return;
36 | await axios
37 | .post("/goals/create-goal", {
38 | title,
39 | finalAmount: amount,
40 | category,
41 | description,
42 | })
43 | .then((res) => {
44 | localStorage.setItem("userData", JSON.stringify(res.data.data));
45 | setUserData(res.data.data);
46 | setSheetOpen(false);
47 | setSelectedGoal(res.data.data.goal);
48 | });
49 | }
50 |
51 | return (
52 |
53 | {/* */}
54 | {
58 | setSheetOpen(true);
59 | }}
60 | >
61 |
62 | Create a New Goal
63 |
64 | {/* */}
65 |
66 |
67 |
68 | Create a New Goal
69 |
70 | Create a goal for yourself to save money!
71 |
72 |
73 |
74 |
75 | Title
76 | {
84 | setTitle(e.target.value);
85 | }}
86 | />
87 |
88 |
89 | Amount
90 | {
98 | setAmount(+e.target.value);
99 | }}
100 | />
101 |
102 |
103 | Category
104 | setCategory(value)}>
105 |
106 |
107 |
108 |
109 | Savings
110 | Investment
111 | Emergency
112 | Travel
113 | Retirement
114 |
115 |
116 |
117 |
118 | Description
119 | {
123 | setDescription(e.target.value);
124 | }}
125 | />
126 |
127 |
128 |
129 | {/* */}
130 | Save changes
131 | {/* */}
132 |
133 |
134 |
135 |
136 | );
137 | }
138 |
139 | export default AddNewGoal;
140 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/alert-dialog.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
3 |
4 | import { cn } from "@/lib/utils"
5 | import { buttonVariants } from "@/components/ui/button"
6 |
7 | const AlertDialog = AlertDialogPrimitive.Root
8 |
9 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger
10 |
11 | const AlertDialogPortal = AlertDialogPrimitive.Portal
12 |
13 | const AlertDialogOverlay = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef
16 | >(({ className, ...props }, ref) => (
17 |
25 | ))
26 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
27 |
28 | const AlertDialogContent = React.forwardRef<
29 | React.ElementRef,
30 | React.ComponentPropsWithoutRef
31 | >(({ className, ...props }, ref) => (
32 |
33 |
34 |
42 |
43 | ))
44 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
45 |
46 | const AlertDialogHeader = ({
47 | className,
48 | ...props
49 | }: React.HTMLAttributes) => (
50 |
57 | )
58 | AlertDialogHeader.displayName = "AlertDialogHeader"
59 |
60 | const AlertDialogFooter = ({
61 | className,
62 | ...props
63 | }: React.HTMLAttributes) => (
64 |
71 | )
72 | AlertDialogFooter.displayName = "AlertDialogFooter"
73 |
74 | const AlertDialogTitle = React.forwardRef<
75 | React.ElementRef,
76 | React.ComponentPropsWithoutRef
77 | >(({ className, ...props }, ref) => (
78 |
83 | ))
84 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
85 |
86 | const AlertDialogDescription = React.forwardRef<
87 | React.ElementRef,
88 | React.ComponentPropsWithoutRef
89 | >(({ className, ...props }, ref) => (
90 |
95 | ))
96 | AlertDialogDescription.displayName =
97 | AlertDialogPrimitive.Description.displayName
98 |
99 | const AlertDialogAction = React.forwardRef<
100 | React.ElementRef,
101 | React.ComponentPropsWithoutRef
102 | >(({ className, ...props }, ref) => (
103 |
108 | ))
109 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
110 |
111 | const AlertDialogCancel = React.forwardRef<
112 | React.ElementRef,
113 | React.ComponentPropsWithoutRef
114 | >(({ className, ...props }, ref) => (
115 |
124 | ))
125 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
126 |
127 | export {
128 | AlertDialog,
129 | AlertDialogPortal,
130 | AlertDialogOverlay,
131 | AlertDialogTrigger,
132 | AlertDialogContent,
133 | AlertDialogHeader,
134 | AlertDialogFooter,
135 | AlertDialogTitle,
136 | AlertDialogDescription,
137 | AlertDialogAction,
138 | AlertDialogCancel,
139 | }
140 |
--------------------------------------------------------------------------------
/frontend/src/components/SingularGoalView.tsx:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 | import {
3 | Card,
4 | CardContent,
5 | CardHeader,
6 | CardTitle,
7 | CardFooter,
8 | } from "@/components/ui/card";
9 | import { Badge } from "@/components/ui/badge";
10 | import { Progress } from "@/components/ui/progress";
11 | import UpdateGoal from "./UpdateGoal";
12 | import { useContext } from "react";
13 | import { AppContext } from "@/App";
14 | import AddMoneyToGoal from "./AddMoneyToGoal";
15 | import { MdDeleteForever as DeleteIcon } from "react-icons/md";
16 | import {
17 | AlertDialog,
18 | AlertDialogAction,
19 | AlertDialogCancel,
20 | AlertDialogContent,
21 | AlertDialogDescription,
22 | AlertDialogFooter,
23 | AlertDialogHeader,
24 | AlertDialogTitle,
25 | AlertDialogTrigger,
26 | } from "@/components/ui/alert-dialog";
27 |
28 | const formatter = new Intl.NumberFormat("en-US");
29 |
30 | type PropTypes = {
31 | setIsSelected?: React.Dispatch>;
32 | };
33 |
34 | function SingularGoalView({ setIsSelected }: PropTypes) {
35 | const { userData, selectedGoal, setUserData, setSelectedGoal } =
36 | useContext(AppContext);
37 |
38 | async function deleteGoal() {
39 | if (!selectedGoal) return;
40 |
41 | await axios
42 | .post("/goals/delete-goal", {
43 | goalId: selectedGoal._id,
44 | goalCurrentAmount: selectedGoal.currentAmount,
45 | })
46 | .then((res) => {
47 | setUserData(res.data.data);
48 | localStorage.setItem("userData", JSON.stringify(res.data.data));
49 | setSelectedGoal({});
50 | if (!setIsSelected) return;
51 | setIsSelected(false);
52 | });
53 | }
54 |
55 | return (
56 |
57 |
58 |
59 | {selectedGoal.title}
60 |
61 |
62 |
63 |
64 |
65 |
66 | Are you absolutely sure?
67 |
68 | This action cannot be undone. This will permanently delete
69 | your goal, and all it's money will be added to your account
70 | balance.
71 |
72 |
73 |
74 | Cancel
75 |
76 | Continue
77 |
78 |
79 |
80 |
81 |
82 |
83 | {selectedGoal.category}
84 |
85 |
86 |
87 |
88 | You have saved{" "}
89 |
90 | {(
91 | (selectedGoal.currentAmount * 100) /
92 | selectedGoal.finalAmount
93 | ).toFixed(1)}
94 | %
95 | {" "}
96 | of your goal, keep going!
97 |
98 |
99 | {userData.user.currency}
100 | {formatter.format(selectedGoal.currentAmount)}
101 |
106 | {userData.user.currency}
107 | {formatter.format(selectedGoal.finalAmount)}
108 |
109 |
110 |
111 | Final Target:
112 | {userData.user.currency}
113 | {formatter.format(selectedGoal.finalAmount)}
114 |
115 |
116 |
117 | Current Saved Amount:{" "}
118 |
119 | {userData.user.currency}
120 | {formatter.format(selectedGoal.currentAmount)}
121 |
122 |
123 | Date of Completion:
124 | {!selectedGoal.date ? Not Set : selectedGoal.date}
125 |
126 |
127 | Description:
128 | {!selectedGoal.description ? (
129 | Not Set
130 | ) : (
131 | selectedGoal.description
132 | )}
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 | );
142 | }
143 |
144 | export default SingularGoalView;
145 |
--------------------------------------------------------------------------------
/frontend/src/components/RecentTransactions.tsx:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import axios from "axios";
3 | import { TransactionType } from "./TransactionDisplay";
4 | import {
5 | Table,
6 | TableBody,
7 | TableCell,
8 | TableHead,
9 | TableHeader,
10 | TableRow,
11 | } from "@/components/ui/table";
12 | import {
13 | Card,
14 | CardHeader,
15 | CardContent,
16 | CardFooter,
17 | CardTitle,
18 | } from "./ui/card";
19 | import { useNavigate, Link } from "react-router-dom";
20 | import { formatter } from "@/utils/formatter";
21 | import { useContext } from "react";
22 | import { AppContext } from "@/App";
23 | import { Button } from "./ui/button";
24 | import { Skeleton } from "./ui/skeleton";
25 |
26 | function RecentTransactions() {
27 | const { userData } = useContext(AppContext);
28 | const navigate = useNavigate();
29 | const { data, isLoading, error } = useQuery({
30 | queryKey: ["recent-transactions"],
31 | queryFn: async () => {
32 | if (
33 | !userData.user?.transactionHistory ||
34 | userData.user.transactionHistory?.length === 0
35 | )
36 | return null;
37 | return axios
38 | .get("/transaction/recent-transactions")
39 | .then((res) => res.data.data.transactions);
40 | },
41 | });
42 |
43 | // Make the table-row-element to behave like a real button, can't use a real Link element or button to wrap the table-row as that messes up default shadcn table styling
44 | const onKeyDown = (
45 | event: React.KeyboardEvent,
46 | id: string
47 | ) => {
48 | if (event.key === "Enter" || event.key === " ") {
49 | navigate(`/transactions/${id}`);
50 | }
51 | return;
52 | };
53 |
54 | if (
55 | !userData.user?.transactionHistory ||
56 | userData.user.transactionHistory?.length === 0
57 | ) {
58 | return (
59 |
60 |
61 | Recent Transactions
62 |
63 |
64 |
65 | You don't have any transactions
66 |
67 |
68 | Make a transaction
69 |
70 |
71 |
72 | );
73 | }
74 |
75 | if (error) return "error";
76 | return (
77 |
78 |
79 | Recent Transactions
80 |
81 |
82 |
83 |
84 |
85 | Type
86 | Title
87 | Amount
88 |
89 |
90 |
91 | {isLoading ? (
92 |
93 | {[1, 2, 3, 4, 5].map((index) => (
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 | ))}
106 |
107 | ) : (
108 |
109 | {data.map((transaction: TransactionType) => (
110 | navigate(`/transactions/${transaction._id}`)}
113 | className="cursor-pointer"
114 | role="button"
115 | onKeyDown={(e) => onKeyDown(e, transaction._id)}
116 | tabIndex={0}
117 | >
118 |
119 | {transaction.type}
120 |
121 | {transaction.title}
122 |
123 | {transaction.type === "Expense" ? (
124 | −
125 | ) : (
126 | +
127 | )}
128 | {userData.user.currency}
129 | {formatter.format(transaction.amount)}
130 |
131 |
132 | ))}
133 |
134 | )}
135 |
136 |
137 |
138 |
139 |
140 | View all transactions
141 |
142 |
143 |
144 | );
145 | }
146 |
147 | export default RecentTransactions;
148 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/toast.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Cross2Icon } from "@radix-ui/react-icons"
3 | import * as ToastPrimitives from "@radix-ui/react-toast"
4 | import { cva, type VariantProps } from "class-variance-authority"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const ToastProvider = ToastPrimitives.Provider
9 |
10 | const ToastViewport = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, ...props }, ref) => (
14 |
22 | ))
23 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName
24 |
25 | const toastVariants = cva(
26 | "group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
27 | {
28 | variants: {
29 | variant: {
30 | default: "border bg-background text-foreground",
31 | destructive:
32 | "destructive group border-destructive bg-destructive text-destructive-foreground",
33 | },
34 | },
35 | defaultVariants: {
36 | variant: "default",
37 | },
38 | }
39 | )
40 |
41 | const Toast = React.forwardRef<
42 | React.ElementRef,
43 | React.ComponentPropsWithoutRef &
44 | VariantProps
45 | >(({ className, variant, ...props }, ref) => {
46 | return (
47 |
52 | )
53 | })
54 | Toast.displayName = ToastPrimitives.Root.displayName
55 |
56 | const ToastAction = React.forwardRef<
57 | React.ElementRef,
58 | React.ComponentPropsWithoutRef
59 | >(({ className, ...props }, ref) => (
60 |
68 | ))
69 | ToastAction.displayName = ToastPrimitives.Action.displayName
70 |
71 | const ToastClose = React.forwardRef<
72 | React.ElementRef,
73 | React.ComponentPropsWithoutRef
74 | >(({ className, ...props }, ref) => (
75 |
84 |
85 |
86 | ))
87 | ToastClose.displayName = ToastPrimitives.Close.displayName
88 |
89 | const ToastTitle = React.forwardRef<
90 | React.ElementRef,
91 | React.ComponentPropsWithoutRef
92 | >(({ className, ...props }, ref) => (
93 |
98 | ))
99 | ToastTitle.displayName = ToastPrimitives.Title.displayName
100 |
101 | const ToastDescription = React.forwardRef<
102 | React.ElementRef,
103 | React.ComponentPropsWithoutRef
104 | >(({ className, ...props }, ref) => (
105 |
110 | ))
111 | ToastDescription.displayName = ToastPrimitives.Description.displayName
112 |
113 | type ToastProps = React.ComponentPropsWithoutRef
114 |
115 | type ToastActionElement = React.ReactElement
116 |
117 | export {
118 | type ToastProps,
119 | type ToastActionElement,
120 | ToastProvider,
121 | ToastViewport,
122 | Toast,
123 | ToastTitle,
124 | ToastDescription,
125 | ToastClose,
126 | ToastAction,
127 | }
128 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/command.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { type DialogProps } from "@radix-ui/react-dialog"
3 | import { MagnifyingGlassIcon } from "@radix-ui/react-icons"
4 | import { Command as CommandPrimitive } from "cmdk"
5 |
6 | import { cn } from "@/lib/utils"
7 | import { Dialog, DialogContent } from "@/components/ui/dialog"
8 |
9 | const Command = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, ...props }, ref) => (
13 |
21 | ))
22 | Command.displayName = CommandPrimitive.displayName
23 |
24 | interface CommandDialogProps extends DialogProps {}
25 |
26 | const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
27 | return (
28 |
29 |
30 |
31 | {children}
32 |
33 |
34 |
35 | )
36 | }
37 |
38 | const CommandInput = React.forwardRef<
39 | React.ElementRef,
40 | React.ComponentPropsWithoutRef
41 | >(({ className, ...props }, ref) => (
42 |
43 |
44 |
52 |
53 | ))
54 |
55 | CommandInput.displayName = CommandPrimitive.Input.displayName
56 |
57 | const CommandList = React.forwardRef<
58 | React.ElementRef,
59 | React.ComponentPropsWithoutRef
60 | >(({ className, ...props }, ref) => (
61 |
66 | ))
67 |
68 | CommandList.displayName = CommandPrimitive.List.displayName
69 |
70 | const CommandEmpty = React.forwardRef<
71 | React.ElementRef,
72 | React.ComponentPropsWithoutRef
73 | >((props, ref) => (
74 |
79 | ))
80 |
81 | CommandEmpty.displayName = CommandPrimitive.Empty.displayName
82 |
83 | const CommandGroup = React.forwardRef<
84 | React.ElementRef,
85 | React.ComponentPropsWithoutRef
86 | >(({ className, ...props }, ref) => (
87 |
95 | ))
96 |
97 | CommandGroup.displayName = CommandPrimitive.Group.displayName
98 |
99 | const CommandSeparator = React.forwardRef<
100 | React.ElementRef,
101 | React.ComponentPropsWithoutRef
102 | >(({ className, ...props }, ref) => (
103 |
108 | ))
109 | CommandSeparator.displayName = CommandPrimitive.Separator.displayName
110 |
111 | const CommandItem = React.forwardRef<
112 | React.ElementRef,
113 | React.ComponentPropsWithoutRef
114 | >(({ className, ...props }, ref) => (
115 |
123 | ))
124 |
125 | CommandItem.displayName = CommandPrimitive.Item.displayName
126 |
127 | const CommandShortcut = ({
128 | className,
129 | ...props
130 | }: React.HTMLAttributes) => {
131 | return (
132 |
139 | )
140 | }
141 | CommandShortcut.displayName = "CommandShortcut"
142 |
143 | export {
144 | Command,
145 | CommandDialog,
146 | CommandInput,
147 | CommandList,
148 | CommandEmpty,
149 | CommandGroup,
150 | CommandItem,
151 | CommandShortcut,
152 | CommandSeparator,
153 | }
154 |
--------------------------------------------------------------------------------