├── .gitignore ├── frontend ├── src │ ├── index.css │ ├── utils │ │ ├── cn.js │ │ └── formatDate.js │ ├── components │ │ ├── ui │ │ │ ├── GridBackgroun.jsx │ │ │ └── Header.jsx │ │ ├── InputField.jsx │ │ ├── skeletons │ │ │ └── TransactionFormSkeleton.jsx │ │ ├── Cards.jsx │ │ ├── RadioButton.jsx │ │ ├── Card.jsx │ │ └── TransactionForm.jsx │ ├── graphql │ │ ├── mutations │ │ │ ├── user.mutation.js │ │ │ └── transcation.mutation.js │ │ └── queries │ │ │ ├── user.query.js │ │ │ └── transaction.query.js │ ├── pages │ │ ├── NotFoundPage.jsx │ │ ├── LoginPage.jsx │ │ ├── SignUpPage.jsx │ │ ├── HomePage.jsx │ │ └── TransactionPage.jsx │ ├── main.jsx │ └── App.jsx ├── postcss.config.js ├── vite.config.js ├── .gitignore ├── index.html ├── README.md ├── .eslintrc.cjs ├── package.json ├── public │ ├── vite.svg │ └── 404.svg └── tailwind.config.js ├── backend ├── resolvers │ ├── index.js │ ├── user.resolver.js │ └── transaction.resolver.js ├── db │ └── connectDB.js ├── models │ ├── user.model.js │ └── transaction.model.js ├── typeDefs │ ├── user.typeDef.js │ ├── index.js │ └── transaction.typeDef.js ├── cron.js ├── passport │ └── passport.config.js ├── dummyData │ └── data.js └── index.js ├── package.json ├── README.md ├── NOTES.md └── COPY-PASTE.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/utils/cn.js: -------------------------------------------------------------------------------- 1 | import { clsx } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /frontend/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | server: { 8 | port: 3000, 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /backend/resolvers/index.js: -------------------------------------------------------------------------------- 1 | import { mergeResolvers } from "@graphql-tools/merge"; 2 | 3 | import userResolver from "./user.resolver.js"; 4 | import transactionResolver from "./transaction.resolver.js"; 5 | 6 | const mergedResolvers = mergeResolvers([userResolver, transactionResolver]); 7 | 8 | export default mergedResolvers; 9 | -------------------------------------------------------------------------------- /backend/db/connectDB.js: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | 3 | export const connectDB = async () => { 4 | try { 5 | const conn = await mongoose.connect(process.env.MONGO_URI); 6 | console.log(`MongoDB Connected: ${conn.connection.host}`); 7 | } catch (err) { 8 | console.error(`Error: ${err.message}`); 9 | process.exit(1); 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /frontend/src/components/ui/GridBackgroun.jsx: -------------------------------------------------------------------------------- 1 | const GridBackground = ({ children }) => { 2 | return ( 3 |
4 |
5 | {children} 6 |
7 | ); 8 | }; 9 | export default GridBackground; 10 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # React + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | -------------------------------------------------------------------------------- /frontend/src/utils/formatDate.js: -------------------------------------------------------------------------------- 1 | export function formatDate(timestamp) { 2 | const date = new Date(parseInt(timestamp)); // Parse the timestamp to ensure it's an integer representing milliseconds 3 | const options = { day: "2-digit", month: "short", year: "numeric" }; 4 | return date.toLocaleDateString("en-US", options); 5 | } 6 | 7 | // Example usage: 8 | const timestamp = 1704067200000; 9 | const formattedDate = formatDate(timestamp); 10 | console.log(formattedDate); // Output: "12 Dec 2023" 11 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/user.mutation.js: -------------------------------------------------------------------------------- 1 | import { gql } from "@apollo/client"; 2 | 3 | export const SIGN_UP = gql` 4 | mutation SignUp($input: SignUpInput!) { 5 | signUp(input: $input) { 6 | _id 7 | name 8 | username 9 | } 10 | } 11 | `; 12 | 13 | export const LOGIN = gql` 14 | mutation Login($input: LoginInput!) { 15 | login(input: $input) { 16 | _id 17 | name 18 | username 19 | } 20 | } 21 | `; 22 | 23 | export const LOGOUT = gql` 24 | mutation Logout { 25 | logout { 26 | message 27 | } 28 | } 29 | `; 30 | -------------------------------------------------------------------------------- /frontend/src/components/InputField.jsx: -------------------------------------------------------------------------------- 1 | const InputField = ({ label, id, name, type = "text", onChange, value }) => { 2 | return ( 3 |
4 | 7 | 15 |
16 | ); 17 | }; 18 | 19 | export default InputField; 20 | -------------------------------------------------------------------------------- /backend/models/user.model.js: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | 3 | const userSchema = new mongoose.Schema( 4 | { 5 | username: { 6 | type: String, 7 | required: true, 8 | unique: true, 9 | }, 10 | name: { 11 | type: String, 12 | required: true, 13 | }, 14 | password: { 15 | type: String, 16 | required: true, 17 | }, 18 | profilePicture: { 19 | type: String, 20 | default: "", 21 | }, 22 | gender: { 23 | type: String, 24 | enum: ["male", "female"], 25 | }, 26 | }, 27 | { timestamps: true } 28 | ); 29 | 30 | const User = mongoose.model("User", userSchema); 31 | 32 | export default User; 33 | -------------------------------------------------------------------------------- /frontend/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | "eslint:recommended", 6 | "plugin:react/recommended", 7 | "plugin:react/jsx-runtime", 8 | "plugin:react-hooks/recommended", 9 | ], 10 | ignorePatterns: ["dist", ".eslintrc.cjs"], 11 | parserOptions: { ecmaVersion: "latest", sourceType: "module" }, 12 | settings: { react: { version: "18.2" } }, 13 | plugins: ["react-refresh"], 14 | rules: { 15 | "react/jsx-no-target-blank": "off", 16 | "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], 17 | "react/prop-types": "off", 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /frontend/src/graphql/queries/user.query.js: -------------------------------------------------------------------------------- 1 | import { gql } from "@apollo/client"; 2 | 3 | export const GET_AUTHENTICATED_USER = gql` 4 | query GetAuthenticatedUser { 5 | authUser { 6 | _id 7 | username 8 | name 9 | profilePicture 10 | } 11 | } 12 | `; 13 | 14 | export const GET_USER_AND_TRANSACTIONS = gql` 15 | query GetUserAndTransactions($userId: ID!) { 16 | user(userId: $userId) { 17 | _id 18 | name 19 | username 20 | profilePicture 21 | # relationships 22 | transactions { 23 | _id 24 | description 25 | paymentType 26 | category 27 | amount 28 | location 29 | date 30 | } 31 | } 32 | } 33 | `; 34 | -------------------------------------------------------------------------------- /frontend/src/pages/NotFoundPage.jsx: -------------------------------------------------------------------------------- 1 | const NotFound = () => { 2 | return ( 3 |
4 |
5 |
6 |
7 |
8 | 404 9 |
10 |

11 | The stuff you were looking for {"doesn't"} exist 12 |

13 | 17 | Take me home 18 | 19 |
20 |
21 |
22 |
23 | ); 24 | }; 25 | export default NotFound; 26 | -------------------------------------------------------------------------------- /frontend/src/graphql/queries/transaction.query.js: -------------------------------------------------------------------------------- 1 | import { gql } from "@apollo/client"; 2 | 3 | export const GET_TRANSACTIONS = gql` 4 | query GetTransactions { 5 | transactions { 6 | _id 7 | description 8 | paymentType 9 | category 10 | amount 11 | location 12 | date 13 | } 14 | } 15 | `; 16 | 17 | export const GET_TRANSACTION = gql` 18 | query GetTransaction($id: ID!) { 19 | transaction(transactionId: $id) { 20 | _id 21 | description 22 | paymentType 23 | category 24 | amount 25 | location 26 | date 27 | user { 28 | name 29 | username 30 | profilePicture 31 | } 32 | } 33 | } 34 | `; 35 | 36 | export const GET_TRANSACTION_STATISTICS = gql` 37 | query GetTransactionStatistics { 38 | categoryStatistics { 39 | category 40 | totalAmount 41 | } 42 | } 43 | `; 44 | -------------------------------------------------------------------------------- /backend/models/transaction.model.js: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | 3 | const transactionSchema = new mongoose.Schema({ 4 | userId: { 5 | type: mongoose.Schema.Types.ObjectId, 6 | ref: "User", 7 | required: true, 8 | }, 9 | description: { 10 | type: String, 11 | required: true, 12 | }, 13 | paymentType: { 14 | type: String, 15 | enum: ["cash", "card"], 16 | required: true, 17 | }, 18 | category: { 19 | type: String, 20 | enum: ["saving", "expense", "investment"], 21 | required: true, 22 | }, 23 | amount: { 24 | type: Number, 25 | required: true, 26 | }, 27 | location: { 28 | type: String, 29 | default: "Unknown", 30 | }, 31 | date: { 32 | type: Date, 33 | required: true, 34 | }, 35 | }); 36 | 37 | const Transaction = mongoose.model("Transaction", transactionSchema); 38 | 39 | export default Transaction; 40 | -------------------------------------------------------------------------------- /backend/typeDefs/user.typeDef.js: -------------------------------------------------------------------------------- 1 | const userTypeDef = `#graphql 2 | type User { 3 | _id: ID! 4 | username: String! 5 | name: String! 6 | password: String! 7 | profilePicture: String 8 | gender: String! 9 | transactions: [Transaction!] 10 | } 11 | 12 | type Query { 13 | authUser: User 14 | user(userId:ID!): User 15 | } 16 | 17 | type Mutation { 18 | signUp(input: SignUpInput!): User 19 | login(input: LoginInput!): User 20 | logout: LogoutResponse 21 | } 22 | 23 | input SignUpInput { 24 | username: String! 25 | name: String! 26 | password: String! 27 | gender: String! 28 | } 29 | 30 | input LoginInput { 31 | username: String! 32 | password: String! 33 | } 34 | 35 | type LogoutResponse { 36 | message: String! 37 | } 38 | `; 39 | 40 | export default userTypeDef; 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "expense-tracker-gql", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "NODE_ENV=production node backend/index.js", 8 | "dev": "NODE_ENV=development nodemon backend/index.js", 9 | "build": "npm install && npm install --prefix frontend && npm run build --prefix frontend" 10 | }, 11 | "type": "module", 12 | "keywords": [], 13 | "author": "", 14 | "license": "ISC", 15 | "dependencies": { 16 | "@apollo/server": "^4.10.0", 17 | "@graphql-tools/merge": "^9.0.3", 18 | "bcryptjs": "^2.4.3", 19 | "connect-mongodb-session": "^5.0.0", 20 | "cron": "^3.1.6", 21 | "dotenv": "^16.4.5", 22 | "express": "^4.18.2", 23 | "express-session": "^1.18.0", 24 | "graphql": "^16.8.1", 25 | "graphql-passport": "^0.6.8", 26 | "mongoose": "^8.2.0", 27 | "passport": "^0.7.0" 28 | }, 29 | "devDependencies": { 30 | "nodemon": "^3.1.0" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/transcation.mutation.js: -------------------------------------------------------------------------------- 1 | import { gql } from "@apollo/client"; 2 | 3 | export const CREATE_TRANSACTION = gql` 4 | mutation CreateTransaction($input: CreateTransactionInput!) { 5 | createTransaction(input: $input) { 6 | _id 7 | description 8 | paymentType 9 | category 10 | amount 11 | location 12 | date 13 | } 14 | } 15 | `; 16 | 17 | export const UPDATE_TRANSACTION = gql` 18 | mutation UpdateTransaction($input: UpdateTransactionInput!) { 19 | updateTransaction(input: $input) { 20 | _id 21 | description 22 | paymentType 23 | category 24 | amount 25 | location 26 | date 27 | } 28 | } 29 | `; 30 | export const DELETE_TRANSACTION = gql` 31 | mutation DeleteTransaction($transactionId: ID!) { 32 | deleteTransaction(transactionId: $transactionId) { 33 | _id 34 | description 35 | paymentType 36 | category 37 | amount 38 | location 39 | date 40 | } 41 | } 42 | `; 43 | -------------------------------------------------------------------------------- /backend/typeDefs/index.js: -------------------------------------------------------------------------------- 1 | import { mergeTypeDefs } from "@graphql-tools/merge"; 2 | 3 | // typeDefs 4 | import userTypeDef from "./user.typeDef.js"; 5 | import transactionTypeDef from "./transaction.typeDef.js"; 6 | 7 | const mergedTypeDefs = mergeTypeDefs([userTypeDef, transactionTypeDef]); 8 | 9 | export default mergedTypeDefs; 10 | 11 | // Why Merge Type Definitions? 🤔 12 | 13 | // Modularity: Merging type definitions allows you to keep related schema components in separate files, promoting modularity and organization. 14 | 15 | // Easier Collaboration: If multiple developers are working on different parts of the schema, merging separate type definitions can make it easier to collaborate without conflicts. 16 | 17 | // Reuse: You can reuse type definitions across different parts of the schema, potentially reducing duplication. 18 | 19 | // Clear Separation of Concerns: Each file can focus on a specific domain or type of data, making it easier to understand and maintain. 20 | -------------------------------------------------------------------------------- /frontend/src/components/ui/Header.jsx: -------------------------------------------------------------------------------- 1 | import { Link } from "react-router-dom"; 2 | 3 | const Header = () => { 4 | return ( 5 |
6 |

7 | Expense GQL 8 |

9 |
10 | {/* Gradients */} 11 |
12 |
13 |
14 |
15 |
16 |
17 | ); 18 | }; 19 | export default Header; 20 | -------------------------------------------------------------------------------- /frontend/src/components/skeletons/TransactionFormSkeleton.jsx: -------------------------------------------------------------------------------- 1 | const TransactionFormSkeleton = () => { 2 | return ( 3 |
4 |

5 | 6 |
    7 |
  • 8 |
  • 9 |
  • 10 |
11 |
    12 |
  • 13 |
  • 14 |
15 |
    16 |
  • 17 |
18 |
19 | ); 20 | }; 21 | export default TransactionFormSkeleton; 22 | -------------------------------------------------------------------------------- /frontend/src/main.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./App.jsx"; 4 | import "./index.css"; 5 | import { BrowserRouter } from "react-router-dom"; 6 | import GridBackground from "./components/ui/GridBackgroun.jsx"; 7 | import { ApolloClient, InMemoryCache, ApolloProvider } from "@apollo/client"; 8 | 9 | const client = new ApolloClient({ 10 | // TODO => Update the uri on production 11 | uri: import.meta.env.VITE_NODE_ENV === "development" ? "http://localhost:4000/graphql" : "/graphql", // the URL of our GraphQL server. 12 | cache: new InMemoryCache(), // Apollo Client uses to cache query results after fetching them. 13 | credentials: "include", // This tells Apollo Client to send cookies along with every request to the server. 14 | }); 15 | 16 | ReactDOM.createRoot(document.getElementById("root")).render( 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | ); 27 | -------------------------------------------------------------------------------- /backend/cron.js: -------------------------------------------------------------------------------- 1 | import cron from "cron"; 2 | import https from "https"; 3 | 4 | const URL = "https://graphql-crash-course.onrender.com"; 5 | 6 | const job = new cron.CronJob("*/14 * * * *", function () { 7 | https 8 | .get(URL, (res) => { 9 | if (res.statusCode === 200) { 10 | console.log("GET request sent successfully"); 11 | } else { 12 | console.log("GET request failed", res.statusCode); 13 | } 14 | }) 15 | .on("error", (e) => { 16 | console.error("Error while sending request", e); 17 | }); 18 | }); 19 | 20 | export default job; 21 | 22 | // CRON JOB EXPLANATION: 23 | // Cron jobs are scheduled tasks that run periodically at fixed intervals or specific times 24 | // send 1 GET request for every 14 minutes 25 | 26 | // Schedule: 27 | // You define a schedule using a cron expression, which consists of five fields representing: 28 | 29 | //! MINUTE, HOUR, DAY OF THE MONTH, MONTH, DAY OF THE WEEK 30 | 31 | //? EXAMPLES && EXPLANATION: 32 | //* 14 * * * * - Every 14 minutes 33 | //* 0 0 * * 0 - At midnight on every Sunday 34 | //* 30 3 15 * * - At 3:30 AM, on the 15th of every month 35 | //* 0 0 1 1 * - At midnight, on January 1st 36 | //* 0 * * * * - Every hour 37 | -------------------------------------------------------------------------------- /backend/passport/passport.config.js: -------------------------------------------------------------------------------- 1 | import passport from "passport"; 2 | import bcrypt from "bcryptjs"; 3 | 4 | import User from "../models/user.model.js"; 5 | import { GraphQLLocalStrategy } from "graphql-passport"; 6 | 7 | export const configurePassport = async () => { 8 | passport.serializeUser((user, done) => { 9 | console.log("Serializing user"); 10 | done(null, user.id); 11 | }); 12 | 13 | passport.deserializeUser(async (id, done) => { 14 | console.log("Deserializing user"); 15 | try { 16 | const user = await User.findById(id); 17 | done(null, user); 18 | } catch (err) { 19 | done(err); 20 | } 21 | }); 22 | 23 | passport.use( 24 | new GraphQLLocalStrategy(async (username, password, done) => { 25 | try { 26 | const user = await User.findOne({ username }); 27 | if (!user) { 28 | throw new Error("Invalid username or password"); 29 | } 30 | const validPassword = await bcrypt.compare(password, user.password); 31 | 32 | if (!validPassword) { 33 | throw new Error("Invalid username or password"); 34 | } 35 | 36 | return done(null, user); 37 | } catch (err) { 38 | return done(err); 39 | } 40 | }) 41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GraphQL Crash Course - Build an Expense Tracker App 2 | 3 | ![Demo App](https://i.ibb.co/WHyMscm/Screenshot-42.png) 4 | 5 | [Video Tutorial on Youtube](https://youtu.be/Vr-QHtbmd38) 6 | 7 | Welcome to the MERN GraphQL Expense Tracker App! This project is designed to help you manage your expenses efficiently using a modern tech stack and GraphQL. 8 | 9 | ## Features: 10 | 11 | - 🌟 Tech stack: MERN (MongoDB, Express.js, React.js, Node.js) + Apollo GraphQL 12 | - 📝 Learn type definitions and resolvers for defining GraphQL schema and data fetching logic 13 | - 🔄 Mutations for modifying data in the GraphQL API and establishing graph relations 14 | - 🎃 Authentication with Passport.js and MongoDB session store 15 | - 🚀 Global state management with Apollo Client 16 | - 🐞 Error handling both on the server and on the client 17 | - ⭐ Deployment made easy with a platform called Render 18 | - 👾 Cron jobs for scheduled tasks and automation 19 | - ⏳ And much more! 20 | 21 | ### Setup .env file 22 | 23 | ```js 24 | MONGO_URI=... 25 | SESSION_SECRET=... 26 | ``` 27 | 28 | ### Build the app 29 | 30 | ```shell 31 | npm run build 32 | ``` 33 | 34 | ### Start the app 35 | 36 | ```shell 37 | npm start 38 | ``` 39 | -------------------------------------------------------------------------------- /backend/typeDefs/transaction.typeDef.js: -------------------------------------------------------------------------------- 1 | const transactionTypeDef = `#graphql 2 | type Transaction { 3 | _id: ID! 4 | userId: ID! 5 | description: String! 6 | paymentType: String! 7 | category: String! 8 | amount: Float! 9 | location: String 10 | date: String! 11 | user: User! 12 | } 13 | 14 | type Query { 15 | transactions: [Transaction!] 16 | transaction(transactionId:ID!): Transaction 17 | categoryStatistics: [CategoryStatistics!] 18 | } 19 | 20 | type Mutation { 21 | createTransaction(input: CreateTransactionInput!): Transaction! 22 | updateTransaction(input: UpdateTransactionInput!): Transaction! 23 | deleteTransaction(transactionId:ID!): Transaction! 24 | } 25 | 26 | type CategoryStatistics { 27 | category: String! 28 | totalAmount: Float! 29 | } 30 | 31 | input CreateTransactionInput { 32 | description: String! 33 | paymentType: String! 34 | category: String! 35 | amount: Float! 36 | date: String! 37 | location: String 38 | } 39 | 40 | input UpdateTransactionInput { 41 | transactionId: ID! 42 | description: String 43 | paymentType: String 44 | category: String 45 | amount: Float 46 | location: String 47 | date: String 48 | } 49 | `; 50 | 51 | export default transactionTypeDef; 52 | -------------------------------------------------------------------------------- /frontend/src/App.jsx: -------------------------------------------------------------------------------- 1 | import { Navigate, Route, Routes } from "react-router-dom"; 2 | import HomePage from "./pages/HomePage"; 3 | import LoginPage from "./pages/LoginPage"; 4 | import SignUpPage from "./pages/SignUpPage"; 5 | import TransactionPage from "./pages/TransactionPage"; 6 | import NotFoundPage from "./pages/NotFoundPage"; 7 | import Header from "./components/ui/Header"; 8 | import { useQuery } from "@apollo/client"; 9 | import { GET_AUTHENTICATED_USER } from "./graphql/queries/user.query"; 10 | import { Toaster } from "react-hot-toast"; 11 | 12 | function App() { 13 | const { loading, data } = useQuery(GET_AUTHENTICATED_USER); 14 | 15 | if (loading) return null; 16 | 17 | return ( 18 | <> 19 | {data?.authUser &&
} 20 | 21 | : } /> 22 | : } /> 23 | : } /> 24 | : } 27 | /> 28 | } /> 29 | 30 | 31 | 32 | ); 33 | } 34 | 35 | export default App; 36 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@apollo/client": "^3.9.5", 14 | "@tailwindcss/aspect-ratio": "^0.4.2", 15 | "chart.js": "^4.4.1", 16 | "clsx": "^2.1.0", 17 | "expense-tracker-gql": "file:..", 18 | "framer-motion": "^11.0.6", 19 | "graphql": "^16.8.1", 20 | "mini-svg-data-uri": "^1.4.4", 21 | "react": "^18.2.0", 22 | "react-chartjs-2": "^5.2.0", 23 | "react-dom": "^18.2.0", 24 | "react-hot-toast": "^2.4.1", 25 | "react-icons": "^5.0.1", 26 | "react-router-dom": "^6.22.1", 27 | "tailwind-merge": "^2.2.1" 28 | }, 29 | "devDependencies": { 30 | "@types/react": "^18.2.56", 31 | "@types/react-dom": "^18.2.19", 32 | "@vitejs/plugin-react": "^4.2.1", 33 | "autoprefixer": "^10.4.17", 34 | "eslint": "^8.56.0", 35 | "eslint-plugin-react": "^7.33.2", 36 | "eslint-plugin-react-hooks": "^4.6.0", 37 | "eslint-plugin-react-refresh": "^0.4.5", 38 | "postcss": "^8.4.35", 39 | "tailwindcss": "^3.4.1", 40 | "vite": "^5.1.4" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /frontend/src/components/Cards.jsx: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@apollo/client"; 2 | import Card from "./Card"; 3 | import { GET_TRANSACTIONS } from "../graphql/queries/transaction.query"; 4 | import { GET_AUTHENTICATED_USER, GET_USER_AND_TRANSACTIONS } from "../graphql/queries/user.query"; 5 | 6 | const Cards = () => { 7 | const { data, loading } = useQuery(GET_TRANSACTIONS); 8 | const { data: authUser } = useQuery(GET_AUTHENTICATED_USER); 9 | 10 | const { data: userAndTransactions } = useQuery(GET_USER_AND_TRANSACTIONS, { 11 | variables: { 12 | userId: authUser?.authUser?._id, 13 | }, 14 | }); 15 | 16 | console.log("userAndTransactions:", userAndTransactions); 17 | 18 | console.log("cards:", data); 19 | 20 | // TODO => ADD RELATIONSHIPS 21 | return ( 22 |
23 |

History

24 |
25 | {!loading && 26 | data.transactions.map((transaction) => ( 27 | 28 | ))} 29 |
30 | {!loading && data?.transactions?.length === 0 && ( 31 |

No transaction history found.

32 | )} 33 |
34 | ); 35 | }; 36 | export default Cards; 37 | -------------------------------------------------------------------------------- /frontend/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/components/RadioButton.jsx: -------------------------------------------------------------------------------- 1 | const RadioButton = ({ id, label, onChange, value, checked }) => { 2 | return ( 3 |
4 | 25 | 28 |
29 | ); 30 | }; 31 | 32 | export default RadioButton; 33 | -------------------------------------------------------------------------------- /frontend/tailwind.config.js: -------------------------------------------------------------------------------- 1 | import svgToDataUri from "mini-svg-data-uri"; 2 | import { default as flattenColorPalette } from "tailwindcss/lib/util/flattenColorPalette"; 3 | 4 | /** @type {import('tailwindcss').Config} */ 5 | export default { 6 | content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], 7 | darkMode: "class", 8 | theme: { 9 | // rest of the code 10 | }, 11 | plugins: [ 12 | addVariablesForColors, 13 | function ({ matchUtilities, theme }) { 14 | matchUtilities( 15 | { 16 | "bg-grid": (value) => ({ 17 | backgroundImage: `url("${svgToDataUri( 18 | `` 19 | )}")`, 20 | }), 21 | "bg-grid-small": (value) => ({ 22 | backgroundImage: `url("${svgToDataUri( 23 | `` 24 | )}")`, 25 | }), 26 | "bg-dot": (value) => ({ 27 | backgroundImage: `url("${svgToDataUri( 28 | `` 29 | )}")`, 30 | }), 31 | }, 32 | { values: flattenColorPalette(theme("backgroundColor")), type: "color" } 33 | ); 34 | }, 35 | ], 36 | }; 37 | 38 | function addVariablesForColors({ addBase, theme }) { 39 | let allColors = flattenColorPalette(theme("colors")); 40 | let newVars = Object.fromEntries(Object.entries(allColors).map(([key, val]) => [`--${key}`, val])); 41 | 42 | addBase({ 43 | ":root": newVars, 44 | }); 45 | } 46 | -------------------------------------------------------------------------------- /backend/dummyData/data.js: -------------------------------------------------------------------------------- 1 | // Hardcoded array of 5 users 2 | const users = [ 3 | { 4 | _id: "1", 5 | username: "user1", 6 | name: "User One", 7 | password: "password1", 8 | profilePicture: "profile1.jpg", 9 | gender: "male", 10 | }, 11 | { 12 | _id: "2", 13 | username: "user2", 14 | name: "User Two", 15 | password: "password2", 16 | profilePicture: "profile2.jpg", 17 | gender: "female", 18 | }, 19 | { 20 | _id: "3", 21 | username: "user3", 22 | name: "User Three", 23 | password: "password3", 24 | profilePicture: "profile3.jpg", 25 | gender: "male", 26 | }, 27 | { 28 | _id: "4", 29 | username: "user4", 30 | name: "User Four", 31 | password: "password4", 32 | profilePicture: "profile4.jpg", 33 | gender: "female", 34 | }, 35 | { 36 | _id: "5", 37 | username: "user5", 38 | name: "User Five", 39 | password: "password5", 40 | profilePicture: "profile5.jpg", 41 | gender: "male", 42 | }, 43 | ]; 44 | 45 | // Hardcoded array of 5 transactions 46 | const transactions = [ 47 | { 48 | _id: "1", 49 | userId: "1", 50 | description: "Transaction One", 51 | paymentType: "CASH", 52 | category: "Category One", 53 | amount: 100.0, 54 | location: "Location One", 55 | date: "2024-01-01", 56 | }, 57 | { 58 | _id: "2", 59 | userId: "2", 60 | description: "Transaction Two", 61 | paymentType: "CARD", 62 | category: "Category Two", 63 | amount: 200.0, 64 | location: "Location Two", 65 | date: "2024-01-02", 66 | }, 67 | { 68 | _id: "3", 69 | userId: "3", 70 | description: "Transaction Three", 71 | paymentType: "CASH", 72 | category: "Category Three", 73 | amount: 300.0, 74 | location: "Location Three", 75 | date: "2024-01-03", 76 | }, 77 | { 78 | _id: "4", 79 | userId: "4", 80 | description: "Transaction Four", 81 | paymentType: "CARD", 82 | category: "Category Four", 83 | amount: 400.0, 84 | location: "Location Four", 85 | date: "2024-01-04", 86 | }, 87 | { 88 | _id: "5", 89 | userId: "5", 90 | description: "Transaction Five", 91 | paymentType: "CASH", 92 | category: "Category Five", 93 | amount: 500.0, 94 | location: "Location Five", 95 | date: "2024-01-05", 96 | }, 97 | ]; 98 | 99 | // Export the arrays 100 | export { users, transactions }; 101 | -------------------------------------------------------------------------------- /frontend/src/pages/LoginPage.jsx: -------------------------------------------------------------------------------- 1 | import { Link } from "react-router-dom"; 2 | import { useState } from "react"; 3 | import InputField from "../components/InputField"; 4 | import { useMutation } from "@apollo/client"; 5 | import { LOGIN } from "../graphql/mutations/user.mutation"; 6 | import toast from "react-hot-toast"; 7 | 8 | const LoginPage = () => { 9 | const [loginData, setLoginData] = useState({ 10 | username: "", 11 | password: "", 12 | }); 13 | 14 | const [login, { loading }] = useMutation(LOGIN, { 15 | refetchQueries: ["GetAuthenticatedUser"], 16 | }); 17 | 18 | const handleChange = (e) => { 19 | const { name, value } = e.target; 20 | setLoginData((prevData) => ({ 21 | ...prevData, 22 | [name]: value, 23 | })); 24 | }; 25 | 26 | const handleSubmit = async (e) => { 27 | e.preventDefault(); 28 | if (!loginData.username || !loginData.password) return toast.error("Please fill in all fields"); 29 | try { 30 | await login({ variables: { input: loginData } }); 31 | } catch (error) { 32 | console.error("Error logging in:", error); 33 | toast.error(error.message); 34 | } 35 | }; 36 | 37 | return ( 38 |
39 |
40 |
41 |
42 |

Login

43 |

44 | Welcome back! Log in to your account 45 |

46 |
47 | 54 | 55 | 63 |
64 | 72 |
73 | 74 |
75 |

76 | {"Don't"} have an account?{" "} 77 | 78 | Sign Up 79 | 80 |

81 |
82 |
83 |
84 |
85 |
86 | ); 87 | }; 88 | export default LoginPage; 89 | -------------------------------------------------------------------------------- /backend/index.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import http from "http"; 3 | import cors from "cors"; 4 | import dotenv from "dotenv"; 5 | import path from "path"; 6 | import passport from "passport"; 7 | import session from "express-session"; 8 | import connectMongo from "connect-mongodb-session"; 9 | 10 | import { ApolloServer } from "@apollo/server"; 11 | import { expressMiddleware } from "@apollo/server/express4"; 12 | import { ApolloServerPluginDrainHttpServer } from "@apollo/server/plugin/drainHttpServer"; 13 | 14 | import { buildContext } from "graphql-passport"; 15 | 16 | import mergedResolvers from "./resolvers/index.js"; 17 | import mergedTypeDefs from "./typeDefs/index.js"; 18 | 19 | import { connectDB } from "./db/connectDB.js"; 20 | import { configurePassport } from "./passport/passport.config.js"; 21 | 22 | import job from "./cron.js"; 23 | 24 | dotenv.config(); 25 | configurePassport(); 26 | 27 | job.start(); 28 | 29 | const __dirname = path.resolve(); 30 | const app = express(); 31 | 32 | const httpServer = http.createServer(app); 33 | 34 | const MongoDBStore = connectMongo(session); 35 | 36 | const store = new MongoDBStore({ 37 | uri: process.env.MONGO_URI, 38 | collection: "sessions", 39 | }); 40 | 41 | store.on("error", (err) => console.log(err)); 42 | 43 | app.use( 44 | session({ 45 | secret: process.env.SESSION_SECRET, 46 | resave: false, // this option specifies whether to save the session to the store on every request 47 | saveUninitialized: false, // option specifies whether to save uninitialized sessions 48 | cookie: { 49 | maxAge: 1000 * 60 * 60 * 24 * 7, 50 | httpOnly: true, // this option prevents the Cross-Site Scripting (XSS) attacks 51 | }, 52 | store: store, 53 | }) 54 | ); 55 | 56 | app.use(passport.initialize()); 57 | app.use(passport.session()); 58 | 59 | const server = new ApolloServer({ 60 | typeDefs: mergedTypeDefs, 61 | resolvers: mergedResolvers, 62 | plugins: [ApolloServerPluginDrainHttpServer({ httpServer })], 63 | }); 64 | 65 | // Ensure we wait for our server to start 66 | await server.start(); 67 | 68 | // Set up our Express middleware to handle CORS, body parsing, 69 | // and our expressMiddleware function. 70 | app.use( 71 | "/graphql", 72 | cors({ 73 | origin: "http://localhost:3000", 74 | credentials: true, 75 | }), 76 | express.json(), 77 | // expressMiddleware accepts the same arguments: 78 | // an Apollo Server instance and optional configuration options 79 | expressMiddleware(server, { 80 | context: async ({ req, res }) => buildContext({ req, res }), 81 | }) 82 | ); 83 | 84 | // npm run build will build your frontend app, and it will the optimized version of your app 85 | app.use(express.static(path.join(__dirname, "frontend/dist"))); 86 | 87 | app.get("*", (req, res) => { 88 | res.sendFile(path.join(__dirname, "frontend/dist", "index.html")); 89 | }); 90 | 91 | // Modified server startup 92 | await new Promise((resolve) => httpServer.listen({ port: 4000 }, resolve)); 93 | await connectDB(); 94 | 95 | console.log(`🚀 Server ready at http://localhost:4000/graphql`); 96 | -------------------------------------------------------------------------------- /frontend/src/components/Card.jsx: -------------------------------------------------------------------------------- 1 | import { FaLocationDot } from "react-icons/fa6"; 2 | import { BsCardText } from "react-icons/bs"; 3 | import { MdOutlinePayments } from "react-icons/md"; 4 | import { FaSackDollar } from "react-icons/fa6"; 5 | import { FaTrash } from "react-icons/fa"; 6 | import { HiPencilAlt } from "react-icons/hi"; 7 | import { Link } from "react-router-dom"; 8 | import { formatDate } from "../utils/formatDate"; 9 | import toast from "react-hot-toast"; 10 | import { useMutation } from "@apollo/client"; 11 | import { DELETE_TRANSACTION } from "../graphql/mutations/transcation.mutation"; 12 | 13 | const categoryColorMap = { 14 | saving: "from-green-700 to-green-400", 15 | expense: "from-pink-800 to-pink-600", 16 | investment: "from-blue-700 to-blue-400", 17 | // Add more categories and corresponding color classes as needed 18 | }; 19 | 20 | const Card = ({ transaction, authUser }) => { 21 | let { category, amount, location, date, paymentType, description } = transaction; 22 | const cardClass = categoryColorMap[category]; 23 | const [deleteTransaction, { loading }] = useMutation(DELETE_TRANSACTION, { 24 | refetchQueries: ["GetTransactions", "GetTransactionStatistics"], 25 | }); 26 | 27 | // Capitalize the first letter of the description 28 | description = description[0]?.toUpperCase() + description.slice(1); 29 | category = category[0]?.toUpperCase() + category.slice(1); 30 | paymentType = paymentType[0]?.toUpperCase() + paymentType.slice(1); 31 | 32 | const formattedDate = formatDate(date); 33 | 34 | const handleDelete = async () => { 35 | try { 36 | await deleteTransaction({ variables: { transactionId: transaction._id } }); 37 | toast.success("Transaction deleted successfully"); 38 | } catch (error) { 39 | console.error("Error deleting transaction:", error); 40 | toast.error(error.message); 41 | } 42 | }; 43 | 44 | return ( 45 |
46 |
47 |
48 |

{category}

49 |
50 | {!loading && } 51 | {loading &&
} 52 | 53 | 54 | 55 |
56 |
57 |

58 | 59 | Description: {description} 60 |

61 |

62 | 63 | Payment Type: {paymentType} 64 |

65 |

66 | 67 | Amount: ${amount} 68 |

69 |

70 | 71 | Location: {location || "N/A"} 72 |

73 |
74 |

{formattedDate}

75 | 76 |
77 |
78 |
79 | ); 80 | }; 81 | export default Card; 82 | -------------------------------------------------------------------------------- /backend/resolvers/user.resolver.js: -------------------------------------------------------------------------------- 1 | import Transaction from "../models/transaction.model.js"; 2 | import User from "../models/user.model.js"; 3 | import bcrypt from "bcryptjs"; 4 | 5 | const userResolver = { 6 | Mutation: { 7 | signUp: async (_, { input }, context) => { 8 | try { 9 | const { username, name, password, gender } = input; 10 | 11 | if (!username || !name || !password || !gender) { 12 | throw new Error("All fields are required"); 13 | } 14 | const existingUser = await User.findOne({ username }); 15 | if (existingUser) { 16 | throw new Error("User already exists"); 17 | } 18 | 19 | const salt = await bcrypt.genSalt(10); 20 | const hashedPassword = await bcrypt.hash(password, salt); 21 | 22 | // https://avatar-placeholder.iran.liara.run/ 23 | const boyProfilePic = `https://avatar.iran.liara.run/public/boy?username=${username}`; 24 | const girlProfilePic = `https://avatar.iran.liara.run/public/girl?username=${username}`; 25 | 26 | const newUser = new User({ 27 | username, 28 | name, 29 | password: hashedPassword, 30 | gender, 31 | profilePicture: gender === "male" ? boyProfilePic : girlProfilePic, 32 | }); 33 | 34 | await newUser.save(); 35 | await context.login(newUser); 36 | return newUser; 37 | } catch (err) { 38 | console.error("Error in signUp: ", err); 39 | throw new Error(err.message || "Internal server error"); 40 | } 41 | }, 42 | 43 | login: async (_, { input }, context) => { 44 | try { 45 | const { username, password } = input; 46 | if (!username || !password) throw new Error("All fields are required"); 47 | const { user } = await context.authenticate("graphql-local", { username, password }); 48 | 49 | await context.login(user); 50 | return user; 51 | } catch (err) { 52 | console.error("Error in login:", err); 53 | throw new Error(err.message || "Internal server error"); 54 | } 55 | }, 56 | logout: async (_, __, context) => { 57 | try { 58 | await context.logout(); 59 | context.req.session.destroy((err) => { 60 | if (err) throw err; 61 | }); 62 | context.res.clearCookie("connect.sid"); 63 | 64 | return { message: "Logged out successfully" }; 65 | } catch (err) { 66 | console.error("Error in logout:", err); 67 | throw new Error(err.message || "Internal server error"); 68 | } 69 | }, 70 | }, 71 | Query: { 72 | authUser: async (_, __, context) => { 73 | try { 74 | const user = await context.getUser(); 75 | return user; 76 | } catch (err) { 77 | console.error("Error in authUser: ", err); 78 | throw new Error("Internal server error"); 79 | } 80 | }, 81 | user: async (_, { userId }) => { 82 | try { 83 | const user = await User.findById(userId); 84 | return user; 85 | } catch (err) { 86 | console.error("Error in user query:", err); 87 | throw new Error(err.message || "Error getting user"); 88 | } 89 | }, 90 | }, 91 | User: { 92 | transactions: async (parent) => { 93 | try { 94 | const transactions = await Transaction.find({ userId: parent._id }); 95 | return transactions; 96 | } catch (err) { 97 | console.log("Error in user.transactions resolver: ", err); 98 | throw new Error(err.message || "Internal server error"); 99 | } 100 | }, 101 | }, 102 | }; 103 | 104 | export default userResolver; 105 | -------------------------------------------------------------------------------- /backend/resolvers/transaction.resolver.js: -------------------------------------------------------------------------------- 1 | import Transaction from "../models/transaction.model.js"; 2 | import User from "../models/user.model.js"; 3 | 4 | const transactionResolver = { 5 | Query: { 6 | transactions: async (_, __, context) => { 7 | try { 8 | if (!context.getUser()) throw new Error("Unauthorized"); 9 | const userId = await context.getUser()._id; 10 | 11 | const transactions = await Transaction.find({ userId }); 12 | return transactions; 13 | } catch (err) { 14 | console.error("Error getting transactions:", err); 15 | throw new Error("Error getting transactions"); 16 | } 17 | }, 18 | transaction: async (_, { transactionId }) => { 19 | try { 20 | const transaction = await Transaction.findById(transactionId); 21 | return transaction; 22 | } catch (err) { 23 | console.error("Error getting transaction:", err); 24 | throw new Error("Error getting transaction"); 25 | } 26 | }, 27 | categoryStatistics: async (_, __, context) => { 28 | if (!context.getUser()) throw new Error("Unauthorized"); 29 | 30 | const userId = context.getUser()._id; 31 | const transactions = await Transaction.find({ userId }); 32 | const categoryMap = {}; 33 | 34 | // const transactions = [ 35 | // { category: "expense", amount: 50 }, 36 | // { category: "expense", amount: 75 }, 37 | // { category: "investment", amount: 100 }, 38 | // { category: "saving", amount: 30 }, 39 | // { category: "saving", amount: 20 } 40 | // ]; 41 | 42 | transactions.forEach((transaction) => { 43 | if (!categoryMap[transaction.category]) { 44 | categoryMap[transaction.category] = 0; 45 | } 46 | categoryMap[transaction.category] += transaction.amount; 47 | }); 48 | 49 | // categoryMap = { expense: 125, investment: 100, saving: 50 } 50 | 51 | return Object.entries(categoryMap).map(([category, totalAmount]) => ({ category, totalAmount })); 52 | // return [ { category: "expense", totalAmount: 125 }, { category: "investment", totalAmount: 100 }, { category: "saving", totalAmount: 50 } ] 53 | }, 54 | }, 55 | Mutation: { 56 | createTransaction: async (_, { input }, context) => { 57 | try { 58 | const newTransaction = new Transaction({ 59 | ...input, 60 | userId: context.getUser()._id, 61 | }); 62 | await newTransaction.save(); 63 | return newTransaction; 64 | } catch (err) { 65 | console.error("Error creating transaction:", err); 66 | throw new Error("Error creating transaction"); 67 | } 68 | }, 69 | updateTransaction: async (_, { input }) => { 70 | try { 71 | const updatedTransaction = await Transaction.findByIdAndUpdate(input.transactionId, input, { 72 | new: true, 73 | }); 74 | return updatedTransaction; 75 | } catch (err) { 76 | console.error("Error updating transaction:", err); 77 | throw new Error("Error updating transaction"); 78 | } 79 | }, 80 | deleteTransaction: async (_, { transactionId }) => { 81 | try { 82 | const deletedTransaction = await Transaction.findByIdAndDelete(transactionId); 83 | return deletedTransaction; 84 | } catch (err) { 85 | console.error("Error deleting transaction:", err); 86 | throw new Error("Error deleting transaction"); 87 | } 88 | }, 89 | }, 90 | Transaction: { 91 | user: async (parent) => { 92 | const userId = parent.userId; 93 | try { 94 | const user = await User.findById(userId); 95 | return user; 96 | } catch (err) { 97 | console.error("Error getting user:", err); 98 | throw new Error("Error getting user"); 99 | } 100 | }, 101 | }, 102 | }; 103 | 104 | export default transactionResolver; 105 | -------------------------------------------------------------------------------- /NOTES.md: -------------------------------------------------------------------------------- 1 | # graphql package => 2 | 3 | - It is the core GraphQL implementation in **JavaScript**. 4 | - It provides the functionality to define GraphQL schemas, parse and validate GraphQL queries, execute queries against a schema, and format responses. 5 | - graphql is not tied to any specific server or client framework; it's a standalone library that can be used in various JavaScript environments. 6 | 7 | # @apollo/server => 8 | 9 | - This package is part of the Apollo ecosystem and is used for building GraphQL servers in Node.js. 10 | - It provides tools and utilities to create and manage GraphQL schemas, handle incoming GraphQL requests, execute queries, and send responses. 11 | - @apollo/server is built on top of the popular express framework, making it easy to integrate GraphQL into existing Node.js web applications. 12 | - Overall, @apollo/server simplifies the process of creating and maintaining GraphQL servers in Node.js environments. 13 | 14 | # What is GraphQL Schema? 15 | 16 | - A GraphQL schema is a fundamental concept in GraphQL. 17 | - It defines the structure of the data that clients can query and the operations they can perform. A schema in GraphQL typically consists of two main parts: typeDefs and resolvers. 18 | 19 | # What are TypeDefs? (or Type Definitions) 20 | 21 | - Type definitions define the shape of the data available in the GraphQL API. They specify the types of objects that can be queried and the relationships between them. 22 | 23 | # What are Resolvers? 24 | 25 | - Resolvers are functions that determine how to fetch the data associated with each field in the schema. 26 | 27 | ## Apollo Client 28 | 29 | - Apollo Client is a comprehensive state management library for JavaScript that enables you to manage both local and remote data with GraphQL. Use it to fetch, cache, and modify application data, all while automatically updating your UI. 30 | 31 | # Features 32 | 33 | - Declarative data fetching: Write a query and receive data without manually tracking loading states. 34 | - Excellent developer experience: Enjoy helpful tooling for TypeScript, Chrome / Firefox devtools, and VS Code. 35 | - Designed for modern React: Take advantage of the latest React features, such as hooks. 36 | - Incrementally adoptable: Drop Apollo into any JavaScript app and incorporate it feature by feature. 37 | - Universally compatible: Use any build setup and any GraphQL API. 38 | - Community driven: Share knowledge with thousands of developers in the GraphQL community. 39 | 40 | ### Declarative Data Fetching 41 | 42 | - Apollo Client handles the request cycle from start to finish, including tracking loading and error states. There's no middleware or boilerplate code to set up before making your first request, and you don't need to worry about transforming or caching responses. All you have to do is describe the data your component needs and let Apollo Client do the heavy lifting. 43 | 44 | ```jsx 45 | function ShowDogs() { 46 | // The useQuery hook supports advanced features like an optimistic UI, refetching, and pagination. 47 | const { loading, error, data } = useQuery(GET_DOGS); 48 | if (error) return ; 49 | if (loading) return ; 50 | 51 | return ; 52 | } 53 | ``` 54 | 55 | ### Caching a graph is not an easy task, but they have spent years solving this problem. 56 | 57 | ```jsx 58 | import { ApolloClient, InMemoryCache } from "@apollo/client"; 59 | 60 | const client = new ApolloClient({ 61 | cache: new InMemoryCache(), 62 | }); 63 | ``` 64 | 65 | # Installation 66 | 67 | ```bash 68 | npm install @apollo/client graphql 69 | ``` 70 | 71 | - **@apollo/client:** This single package contains virtually everything you need to set up Apollo Client. It includes the in-memory cache, local state management, error handling, and a React-based view layer. 72 | 73 | - **graphql:** This package provides logic for parsing GraphQL queries. 74 | -------------------------------------------------------------------------------- /frontend/src/pages/SignUpPage.jsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { Link } from "react-router-dom"; 3 | import RadioButton from "../components/RadioButton"; 4 | import InputField from "../components/InputField"; 5 | import { useMutation } from "@apollo/client"; 6 | import { SIGN_UP } from "../graphql/mutations/user.mutation"; 7 | import toast from "react-hot-toast"; 8 | 9 | const SignUpPage = () => { 10 | const [signUpData, setSignUpData] = useState({ 11 | name: "", 12 | username: "", 13 | password: "", 14 | gender: "", 15 | }); 16 | 17 | const [signup, { loading }] = useMutation(SIGN_UP, { 18 | refetchQueries: ["GetAuthenticatedUser"], 19 | }); 20 | 21 | const handleSubmit = async (e) => { 22 | e.preventDefault(); 23 | try { 24 | await signup({ 25 | variables: { 26 | input: signUpData, 27 | }, 28 | }); 29 | } catch (error) { 30 | console.error("Error:", error); 31 | toast.error(error.message); 32 | } 33 | }; 34 | 35 | const handleChange = (e) => { 36 | const { name, value, type } = e.target; 37 | 38 | if (type === "radio") { 39 | setSignUpData((prevData) => ({ 40 | ...prevData, 41 | gender: value, 42 | })); 43 | } else { 44 | setSignUpData((prevData) => ({ 45 | ...prevData, 46 | [name]: value, 47 | })); 48 | } 49 | }; 50 | 51 | return ( 52 |
53 |
54 |
55 |
56 |

Sign Up

57 |

58 | Join to keep track of your expenses 59 |

60 |
61 | 68 | 75 | 76 | 84 |
85 | 93 | 101 |
102 | 103 |
104 | 111 |
112 | 113 |
114 |

115 | Already have an account?{" "} 116 | 117 | Login here 118 | 119 |

120 |
121 |
122 |
123 |
124 |
125 | ); 126 | }; 127 | 128 | export default SignUpPage; 129 | -------------------------------------------------------------------------------- /frontend/src/pages/HomePage.jsx: -------------------------------------------------------------------------------- 1 | import { Doughnut } from "react-chartjs-2"; 2 | import { Chart as ChartJS, ArcElement, Tooltip, Legend } from "chart.js"; 3 | 4 | import Cards from "../components/Cards"; 5 | import TransactionForm from "../components/TransactionForm"; 6 | 7 | import { MdLogout } from "react-icons/md"; 8 | import toast from "react-hot-toast"; 9 | import { useMutation, useQuery } from "@apollo/client"; 10 | import { LOGOUT } from "../graphql/mutations/user.mutation"; 11 | import { GET_TRANSACTION_STATISTICS } from "../graphql/queries/transaction.query"; 12 | import { GET_AUTHENTICATED_USER } from "../graphql/queries/user.query"; 13 | import { useEffect, useState } from "react"; 14 | 15 | // const chartData = { 16 | // labels: ["Saving", "Expense", "Investment"], 17 | // datasets: [ 18 | // { 19 | // label: "%", 20 | // data: [13, 8, 3], 21 | // backgroundColor: ["rgba(75, 192, 192)", "rgba(255, 99, 132)", "rgba(54, 162, 235)"], 22 | // borderColor: ["rgba(75, 192, 192)", "rgba(255, 99, 132)", "rgba(54, 162, 235, 1)"], 23 | // borderWidth: 1, 24 | // borderRadius: 30, 25 | // spacing: 10, 26 | // cutout: 130, 27 | // }, 28 | // ], 29 | // }; 30 | 31 | ChartJS.register(ArcElement, Tooltip, Legend); 32 | 33 | const HomePage = () => { 34 | const { data } = useQuery(GET_TRANSACTION_STATISTICS); 35 | const { data: authUserData } = useQuery(GET_AUTHENTICATED_USER); 36 | 37 | const [logout, { loading, client }] = useMutation(LOGOUT, { 38 | refetchQueries: ["GetAuthenticatedUser"], 39 | }); 40 | 41 | const [chartData, setChartData] = useState({ 42 | labels: [], 43 | datasets: [ 44 | { 45 | label: "$", 46 | data: [], 47 | backgroundColor: [], 48 | borderColor: [], 49 | borderWidth: 1, 50 | borderRadius: 30, 51 | spacing: 10, 52 | cutout: 130, 53 | }, 54 | ], 55 | }); 56 | 57 | useEffect(() => { 58 | if (data?.categoryStatistics) { 59 | const categories = data.categoryStatistics.map((stat) => stat.category); 60 | const totalAmounts = data.categoryStatistics.map((stat) => stat.totalAmount); 61 | 62 | const backgroundColors = []; 63 | const borderColors = []; 64 | 65 | categories.forEach((category) => { 66 | if (category === "saving") { 67 | backgroundColors.push("rgba(75, 192, 192)"); 68 | borderColors.push("rgba(75, 192, 192)"); 69 | } else if (category === "expense") { 70 | backgroundColors.push("rgba(255, 99, 132)"); 71 | borderColors.push("rgba(255, 99, 132)"); 72 | } else if (category === "investment") { 73 | backgroundColors.push("rgba(54, 162, 235)"); 74 | borderColors.push("rgba(54, 162, 235)"); 75 | } 76 | }); 77 | 78 | setChartData((prev) => ({ 79 | labels: categories, 80 | datasets: [ 81 | { 82 | ...prev.datasets[0], 83 | data: totalAmounts, 84 | backgroundColor: backgroundColors, 85 | borderColor: borderColors, 86 | }, 87 | ], 88 | })); 89 | } 90 | }, [data]); 91 | 92 | const handleLogout = async () => { 93 | try { 94 | await logout(); 95 | // Clear the Apollo Client cache FROM THE DOCS 96 | // https://www.apollographql.com/docs/react/caching/advanced-topics/#:~:text=Resetting%20the%20cache,any%20of%20your%20active%20queries 97 | client.resetStore(); 98 | } catch (error) { 99 | console.error("Error logging out:", error); 100 | toast.error(error.message); 101 | } 102 | }; 103 | 104 | return ( 105 | <> 106 |
107 |
108 |

109 | Spend wisely, track wisely 110 |

111 | Avatar 116 | {!loading && } 117 | {/* loading spinner */} 118 | {loading &&
} 119 |
120 |
121 | {data?.categoryStatistics.length > 0 && ( 122 |
123 | 124 |
125 | )} 126 | 127 | 128 |
129 | 130 |
131 | 132 | ); 133 | }; 134 | export default HomePage; 135 | -------------------------------------------------------------------------------- /frontend/src/components/TransactionForm.jsx: -------------------------------------------------------------------------------- 1 | import { useMutation } from "@apollo/client"; 2 | import { CREATE_TRANSACTION } from "../graphql/mutations/transcation.mutation"; 3 | import toast from "react-hot-toast"; 4 | 5 | const TransactionForm = () => { 6 | // TODO => WHEN RELATIONSHIPS ARE ADDED, CHANGE THE REFETCH QUERY A BIT 7 | const [createTransaction, { loading }] = useMutation(CREATE_TRANSACTION, { 8 | refetchQueries: ["GetTransactions", "GetTransactionStatistics"], 9 | }); 10 | 11 | const handleSubmit = async (e) => { 12 | e.preventDefault(); 13 | 14 | const form = e.target; 15 | const formData = new FormData(form); 16 | const transactionData = { 17 | description: formData.get("description"), 18 | paymentType: formData.get("paymentType"), 19 | category: formData.get("category"), 20 | amount: parseFloat(formData.get("amount")), 21 | location: formData.get("location"), 22 | date: formData.get("date"), 23 | }; 24 | 25 | try { 26 | await createTransaction({ variables: { input: transactionData } }); 27 | 28 | form.reset(); 29 | toast.success("Transaction created successfully"); 30 | } catch (error) { 31 | toast.error(error.message); 32 | } 33 | }; 34 | 35 | return ( 36 |
37 | {/* TRANSACTION */} 38 |
39 |
40 | 46 | 54 |
55 |
56 | {/* PAYMENT TYPE */} 57 |
58 |
59 | 65 |
66 | 74 |
75 | 80 | 81 | 82 |
83 |
84 |
85 | 86 | {/* CATEGORY */} 87 |
88 | 94 |
95 | 104 |
105 | 110 | 111 | 112 |
113 |
114 |
115 | 116 | {/* AMOUNT */} 117 |
118 | 121 | 128 |
129 |
130 | 131 | {/* LOCATION */} 132 |
133 |
134 | 140 | 147 |
148 | 149 | {/* DATE */} 150 |
151 | 154 | 162 |
163 |
164 | {/* SUBMIT BUTTON */} 165 | 174 |
175 | ); 176 | }; 177 | 178 | export default TransactionForm; 179 | -------------------------------------------------------------------------------- /frontend/src/pages/TransactionPage.jsx: -------------------------------------------------------------------------------- 1 | import { useMutation, useQuery } from "@apollo/client"; 2 | import { useEffect, useState } from "react"; 3 | import { useParams } from "react-router-dom"; 4 | import { GET_TRANSACTION, GET_TRANSACTION_STATISTICS } from "../graphql/queries/transaction.query"; 5 | import { UPDATE_TRANSACTION } from "../graphql/mutations/transcation.mutation"; 6 | import toast from "react-hot-toast"; 7 | import TransactionFormSkeleton from "../components/skeletons/TransactionFormSkeleton"; 8 | 9 | const TransactionPage = () => { 10 | const { id } = useParams(); 11 | const { loading, data } = useQuery(GET_TRANSACTION, { 12 | variables: { id: id }, 13 | }); 14 | 15 | console.log("Transaction", data); 16 | 17 | const [updateTransaction, { loading: loadingUpdate }] = useMutation(UPDATE_TRANSACTION, { 18 | // https://github.com/apollographql/apollo-client/issues/5419 => refetchQueries is not working, and here is how we fixed it 19 | refetchQueries: [{ query: GET_TRANSACTION_STATISTICS }], 20 | }); 21 | 22 | const [formData, setFormData] = useState({ 23 | description: data?.transaction?.description || "", 24 | paymentType: data?.transaction?.paymentType || "", 25 | category: data?.transaction?.category || "", 26 | amount: data?.transaction?.amount || "", 27 | location: data?.transaction?.location || "", 28 | date: data?.transaction?.date || "", 29 | }); 30 | 31 | const handleSubmit = async (e) => { 32 | e.preventDefault(); 33 | const amount = parseFloat(formData.amount); // convert amount to number bc by default it is string 34 | // and the reason it's coming from an input field 35 | try { 36 | await updateTransaction({ 37 | variables: { 38 | input: { 39 | ...formData, 40 | amount, 41 | transactionId: id, 42 | }, 43 | }, 44 | }); 45 | toast.success("Transaction updated successfully"); 46 | } catch (error) { 47 | toast.error(error.message); 48 | } 49 | }; 50 | 51 | const handleInputChange = (e) => { 52 | const { name, value } = e.target; 53 | setFormData((prevFormData) => ({ 54 | ...prevFormData, 55 | [name]: value, 56 | })); 57 | }; 58 | 59 | useEffect(() => { 60 | if (data) { 61 | setFormData({ 62 | description: data?.transaction?.description, 63 | paymentType: data?.transaction?.paymentType, 64 | category: data?.transaction?.category, 65 | amount: data?.transaction?.amount, 66 | location: data?.transaction?.location, 67 | date: new Date(+data.transaction.date).toISOString().substr(0, 10), 68 | }); 69 | } 70 | }, [data]); 71 | 72 | if (loading) return ; 73 | 74 | return ( 75 |
76 |

77 | Update this transaction 78 |

79 |
80 | {/* TRANSACTION */} 81 |
82 |
83 | 89 | 98 |
99 |
100 | {/* PAYMENT TYPE */} 101 |
102 |
103 | 109 |
110 | 120 |
121 | 126 | 127 | 128 |
129 |
130 |
131 | 132 | {/* CATEGORY */} 133 |
134 | 140 |
141 | 152 |
153 | 158 | 159 | 160 |
161 |
162 |
163 | 164 | {/* AMOUNT */} 165 |
166 | 169 | 178 |
179 |
180 | 181 | {/* LOCATION */} 182 |
183 |
184 | 190 | 199 |
200 | 201 | {/* DATE */} 202 |
203 | 209 | 219 |
220 |
221 | {/* SUBMIT BUTTON */} 222 | 230 |
231 |
232 | ); 233 | }; 234 | export default TransactionPage; 235 | -------------------------------------------------------------------------------- /COPY-PASTE.md: -------------------------------------------------------------------------------- 1 | # COPY && PASTE LIST IF YOU FOLLOW ALONG THE TUTORIAL 2 | 3 | # Backend Dependencies 4 | 5 | ```bash 6 | npm install express express-session graphql @apollo/server @graphql-tools/merge bcryptjs connect-mongodb-session dotenv graphql-passport passport mongoose 7 | ``` 8 | 9 | # Frontend Dependencies 10 | 11 | ```bash 12 | npm install graphql @apollo/client react-router-dom react-icons react-hot-toast tailwind-merge @tailwindcss/aspect-ratio clsx chart.js react-chartjs-2 mini-svg-data-uri framer-motion 13 | ``` 14 | 15 | # Main.jsx 16 | 17 | ```jsx 18 | ReactDOM.createRoot(document.getElementById("root")).render( 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | ); 27 | ``` 28 | 29 | # GridBackground.jsx 30 | 31 | ```jsx 32 | const GridBackground = ({ children }) => { 33 | return ( 34 |
35 |
36 | {children} 37 |
38 | ); 39 | }; 40 | export default GridBackground; 41 | ``` 42 | 43 | # App.jsx 44 | 45 | ```jsx 46 | function App() { 47 | const authUser = true; 48 | return ( 49 | <> 50 | {authUser &&
} 51 | 52 | } /> 53 | } /> 54 | } /> 55 | } /> 56 | } /> 57 | 58 | 59 | ); 60 | } 61 | export default App; 62 | ``` 63 | 64 | # Header.jsx 65 | 66 | ```jsx 67 | import { Link } from "react-router-dom"; 68 | 69 | const Header = () => { 70 | return ( 71 |
72 |

73 | Expense GQL 74 |

75 |
76 | {/* Gradients */} 77 |
78 |
79 |
80 |
81 |
82 |
83 | ); 84 | }; 85 | export default Header; 86 | ``` 87 | 88 | # SignUpPage.jsx 89 | 90 | ```jsx 91 | import { useState } from "react"; 92 | import { Link } from "react-router-dom"; 93 | import RadioButton from "../components/RadioButton"; 94 | import InputField from "../components/InputField"; 95 | 96 | const SignUpPage = () => { 97 | const [signUpData, setSignUpData] = useState({ 98 | name: "", 99 | username: "", 100 | password: "", 101 | gender: "", 102 | }); 103 | 104 | const handleChange = (e) => { 105 | const { name, value, type } = e.target; 106 | 107 | if (type === "radio") { 108 | setSignUpData((prevData) => ({ 109 | ...prevData, 110 | gender: value, 111 | })); 112 | } else { 113 | setSignUpData((prevData) => ({ 114 | ...prevData, 115 | [name]: value, 116 | })); 117 | } 118 | }; 119 | 120 | const handleSubmit = async (e) => { 121 | e.preventDefault(); 122 | console.log(signUpData); 123 | }; 124 | 125 | return ( 126 |
127 |
128 |
129 |
130 |

Sign Up

131 |

132 | Join to keep track of your expenses 133 |

134 |
135 | 142 | 149 | 150 | 158 |
159 | 167 | 175 |
176 | 177 |
178 | 184 |
185 | 186 |
187 |

188 | Already have an account?{" "} 189 | 190 | Login here 191 | 192 |

193 |
194 |
195 |
196 |
197 |
198 | ); 199 | }; 200 | 201 | export default SignUpPage; 202 | ``` 203 | 204 | # InputField.jsx 205 | 206 | ```jsx 207 | const InputField = ({ label, id, name, type = "text", onChange, value }) => { 208 | return ( 209 |
210 | 213 | 221 |
222 | ); 223 | }; 224 | 225 | export default InputField; 226 | ``` 227 | 228 | # RadioButton.jsx 229 | 230 | ```jsx 231 | const RadioButton = ({ id, label, onChange, value, checked }) => { 232 | return ( 233 |
234 | 255 | 258 |
259 | ); 260 | }; 261 | 262 | export default RadioButton; 263 | ``` 264 | 265 | # LoginPage.jsx 266 | 267 | ```jsx 268 | import { Link } from "react-router-dom"; 269 | import { useState } from "react"; 270 | import InputField from "../components/InputField"; 271 | 272 | const LoginPage = () => { 273 | const [loginData, setLoginData] = useState({ 274 | username: "", 275 | password: "", 276 | }); 277 | 278 | const handleChange = (e) => { 279 | const { name, value } = e.target; 280 | setLoginData((prevData) => ({ 281 | ...prevData, 282 | [name]: value, 283 | })); 284 | }; 285 | 286 | const handleSubmit = (e) => { 287 | e.preventDefault(); 288 | console.log(loginData); 289 | }; 290 | 291 | return ( 292 |
293 |
294 |
295 |
296 |

Login

297 |

298 | Welcome back! Log in to your account 299 |

300 |
301 | 308 | 309 | 317 |
318 | 326 |
327 | 328 |
329 |

330 | {"Don't"} have an account?{" "} 331 | 332 | Sign Up 333 | 334 |

335 |
336 |
337 |
338 |
339 |
340 | ); 341 | }; 342 | export default LoginPage; 343 | ``` 344 | 345 | # TransactionPage.jsx 346 | 347 | ```jsx 348 | import { useState } from "react"; 349 | 350 | const TransactionPage = () => { 351 | const [formData, setFormData] = useState({ 352 | description: "", 353 | paymentType: "", 354 | category: "", 355 | amount: "", 356 | location: "", 357 | date: "", 358 | }); 359 | 360 | const handleSubmit = async (e) => { 361 | e.preventDefault(); 362 | console.log("formData", formData); 363 | }; 364 | const handleInputChange = (e) => { 365 | const { name, value } = e.target; 366 | setFormData((prevFormData) => ({ 367 | ...prevFormData, 368 | [name]: value, 369 | })); 370 | }; 371 | 372 | // if (loading) return ; 373 | 374 | return ( 375 |
376 |

377 | Update this transaction 378 |

379 |
380 | {/* TRANSACTION */} 381 |
382 |
383 | 389 | 398 |
399 |
400 | {/* PAYMENT TYPE */} 401 |
402 |
403 | 409 |
410 | 420 |
421 | 426 | 427 | 428 |
429 |
430 |
431 | 432 | {/* CATEGORY */} 433 |
434 | 440 |
441 | 452 |
453 | 458 | 459 | 460 |
461 |
462 |
463 | 464 | {/* AMOUNT */} 465 |
466 | 469 | 478 |
479 |
480 | 481 | {/* LOCATION */} 482 |
483 |
484 | 490 | 499 |
500 | 501 | {/* DATE */} 502 |
503 | 509 | 519 |
520 |
521 | {/* SUBMIT BUTTON */} 522 | 529 |
530 |
531 | ); 532 | }; 533 | export default TransactionPage; 534 | ``` 535 | 536 | # TransactionFormSkeleton.jsx 537 | 538 | ```jsx 539 | const TransactionFormSkeleton = () => { 540 | return ( 541 |
542 |

543 | 544 |
    545 |
  • 546 |
  • 547 |
  • 548 |
549 |
    550 |
  • 551 |
  • 552 |
553 |
    554 |
  • 555 |
556 |
557 | ); 558 | }; 559 | export default TransactionFormSkeleton; 560 | ``` 561 | 562 | # NotFound.jsx 563 | 564 | ```jsx 565 | const NotFound = () => { 566 | return ( 567 |
568 |
569 |
570 |
571 |
572 | 404 573 |
574 |

575 | The stuff you were looking for doesn't exist 576 |

577 | 581 | Take me home 582 | 583 |
584 |
585 |
586 |
587 | ); 588 | }; 589 | export default NotFound; 590 | ``` 591 | 592 | # HomePage.jsx 593 | 594 | ```jsx 595 | import { Doughnut } from "react-chartjs-2"; 596 | import { Chart as ChartJS, ArcElement, Tooltip, Legend } from "chart.js"; 597 | 598 | import Cards from "../components/Cards"; 599 | import TransactionForm from "../components/TransactionForm"; 600 | 601 | import { MdLogout } from "react-icons/md"; 602 | 603 | ChartJS.register(ArcElement, Tooltip, Legend); 604 | 605 | const HomePage = () => { 606 | const chartData = { 607 | labels: ["Saving", "Expense", "Investment"], 608 | datasets: [ 609 | { 610 | label: "%", 611 | data: [13, 8, 3], 612 | backgroundColor: ["rgba(75, 192, 192)", "rgba(255, 99, 132)", "rgba(54, 162, 235)"], 613 | borderColor: ["rgba(75, 192, 192)", "rgba(255, 99, 132)", "rgba(54, 162, 235, 1)"], 614 | borderWidth: 1, 615 | borderRadius: 30, 616 | spacing: 10, 617 | cutout: 130, 618 | }, 619 | ], 620 | }; 621 | 622 | const handleLogout = () => { 623 | console.log("Logging out..."); 624 | }; 625 | 626 | const loading = false; 627 | 628 | return ( 629 | <> 630 |
631 |
632 |

633 | Spend wisely, track wisely 634 |

635 | Avatar 640 | {!loading && } 641 | {/* loading spinner */} 642 | {loading &&
} 643 |
644 |
645 |
646 | 647 |
648 | 649 | 650 |
651 | 652 |
653 | 654 | ); 655 | }; 656 | export default HomePage; 657 | ``` 658 | 659 | # TransactionForm.jsx 660 | 661 | ```jsx 662 | const TransactionForm = () => { 663 | const handleSubmit = async (e) => { 664 | e.preventDefault(); 665 | 666 | const form = e.target; 667 | const formData = new FormData(form); 668 | const transactionData = { 669 | description: formData.get("description"), 670 | paymentType: formData.get("paymentType"), 671 | category: formData.get("category"), 672 | amount: parseFloat(formData.get("amount")), 673 | location: formData.get("location"), 674 | date: formData.get("date"), 675 | }; 676 | console.log("transactionData", transactionData); 677 | }; 678 | 679 | return ( 680 |
681 | {/* TRANSACTION */} 682 |
683 |
684 | 690 | 698 |
699 |
700 | {/* PAYMENT TYPE */} 701 |
702 |
703 | 709 |
710 | 718 |
719 | 724 | 725 | 726 |
727 |
728 |
729 | 730 | {/* CATEGORY */} 731 |
732 | 738 |
739 | 748 |
749 | 754 | 755 | 756 |
757 |
758 |
759 | 760 | {/* AMOUNT */} 761 |
762 | 765 | 772 |
773 |
774 | 775 | {/* LOCATION */} 776 |
777 |
778 | 784 | 791 |
792 | 793 | {/* DATE */} 794 |
795 | 798 | 806 |
807 |
808 | {/* SUBMIT BUTTON */} 809 | 817 |
818 | ); 819 | }; 820 | 821 | export default TransactionForm; 822 | ``` 823 | 824 | # Cards.jsx 825 | 826 | ```jsx 827 | import Card from "./Card"; 828 | 829 | const Cards = () => { 830 | return ( 831 |
832 |

History

833 |
834 | 835 | 836 | 837 | 838 | 839 | 840 |
841 |
842 | ); 843 | }; 844 | export default Cards; 845 | ``` 846 | 847 | # Card.jsx 848 | 849 | ```jsx 850 | import { FaLocationDot } from "react-icons/fa6"; 851 | import { BsCardText } from "react-icons/bs"; 852 | import { MdOutlinePayments } from "react-icons/md"; 853 | import { FaSackDollar } from "react-icons/fa6"; 854 | import { FaTrash } from "react-icons/fa"; 855 | import { HiPencilAlt } from "react-icons/hi"; 856 | import { Link } from "react-router-dom"; 857 | 858 | const categoryColorMap = { 859 | saving: "from-green-700 to-green-400", 860 | expense: "from-pink-800 to-pink-600", 861 | investment: "from-blue-700 to-blue-400", 862 | // Add more categories and corresponding color classes as needed 863 | }; 864 | 865 | const Card = ({ cardType }) => { 866 | const cardClass = categoryColorMap[cardType]; 867 | 868 | return ( 869 |
870 |
871 |
872 |

Saving

873 |
874 | 875 | 876 | 877 | 878 |
879 |
880 |

881 | 882 | Description: Salary 883 |

884 |

885 | 886 | Payment Type: Cash 887 |

888 |

889 | 890 | Amount: $150 891 |

892 |

893 | 894 | Location: New York 895 |

896 |
897 |

21 Sep, 2001

898 | 903 |
904 |
905 |
906 | ); 907 | }; 908 | export default Card; 909 | ``` 910 | -------------------------------------------------------------------------------- /frontend/public/404.svg: -------------------------------------------------------------------------------- 1 | 8 | 9 | 13 | 17 | 21 | 25 | 30 | 35 | 40 | 44 | 48 | 53 | 57 | 62 | 66 | 70 | 74 | 78 | 82 | 86 | 90 | 94 | 98 | 102 | 107 | 112 | 117 | 121 | 125 | 130 | 135 | 139 | 144 | 148 | 153 | 157 | 162 | 166 | 171 | 176 | 180 | 185 | 189 | 194 | 198 | 203 | 207 | 211 | 215 | 220 | 225 | 230 | 234 | 238 | 242 | 247 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | --------------------------------------------------------------------------------