P4 - Making a Sleek UI for our Expense Tracker

Kush MunotKush Munot
12 min read

Configuring _app.tsx

This is the starting point of our project from where all pages will get rendered. We will define fonts in this file and render component code here.

/* eslint-disable @next/next/no-page-custom-font */
import { ApolloProvider, InMemoryCache, ApolloClient } from '@apollo/client';
import { AppProps } from 'next/app'; // Import types for Next.js
import Head from 'next/head';

// Initialize Apollo Client
const client = new ApolloClient({
    uri: '/',
    cache: new InMemoryCache(),
});

function MyApp({ Component, pageProps }: AppProps) {
    return (
        <ApolloProvider client={client}>
            <Head>
                <link rel="preconnect" href="https://fonts.googleapis.com" />
                <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
                <link href="https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" rel="stylesheet" />
            </Head>
            <Component {...pageProps} />
        </ApolloProvider>

    );
}

export default MyApp;

Configuring index.ts

This is the file where we will decide how our UI should look and accumulate all components here. The UI will have three broad sections - Navbar, AppScreen, and Footer. All new transactions will appear on the top for ease of validation.

import React from 'react'
import Navbar from './components/Navbar'
import Footer from './components/Footer'
import Dashbaord from './components/Dashbaord'

const index = () => {
    return (
        <div>
            <Navbar />
            <Dashbaord />
            <Footer />
        </div>
    )
}

export default index

globalStyles.ts

We will have a Global Styles file which will have our global styles for all components

export function btn(buttonWidth: string) {
    return {
        marginRight: "20px",
        color: "white",
        fontFamily: 'Nunito',
        backgroundColor: "#1976d2",
        height: "30px",
        width: buttonWidth,
        textTransform: 'none',
        borderRadius: '5px',
        "&:hover": {
            backgroundColor: "#915831",
            color: "white",
        },
        "@media (max-width:780px)": {
            fontSize: '13px',
            height: '32px',
            width: buttonWidth,
        },
    };
}
export const navbarTitle = {
    color: "#915831",
    fontWeight: 700,
    fontSize: 22,
    width: "auto",
    ml: 2,
    fontFamily: 'Nunito'
}

export const navToolbar = {
    m: 2,
    backgroundColor: "#FAFAFF",
    borderRadius: "10px",
    py: 0,
    boxShadow: "1px 1px 1px 1px #DADDD8",
}

export const navAppBar = {
    backgroundColor: "transparent",
    height: "auto",
    boxShadow: "none",
}

export function footerHeadings(fontWeight: number, fontSize: number) {
    return {
        fontFamily: 'Nunito',
        fontStyle: 'normal',
        fontWeight: fontWeight,
        fontSize: fontSize,
    };
}

export const style = {
    position: 'absolute',
    top: '50%',
    left: '50%',
    transform: 'translate(-50%, -50%)',
    width: 400,
    bgcolor: 'background.paper',
    borderRadius: '5px',
    boxShadow: 24,
    p: 4,
    "@media (max-width:780px)": {
        width: '80%',
    },
};

export const editDeleteButtonGrid = {
    marginLeft: '0', marginTop: '0', display: 'flex', justifyContent: 'space-around', alignItems: 'center',
    "@media (max-width:760px)": {
        paddingRight: '0.2rem'
    }
}

export const iconStyles = { cursor: 'pointer', color: '#374151', fontSize: '1.1rem' }
export const chipStyles = {
    width: 'fit-content', padding: '0 0.05rem', fontSize: '0.8rem', fontWeight: '500', height: '25px', borderRadius: '5px',
    "@media (max-width:600px)": {
        display: 'none'
    },
}

export const expenseMsgStyle = {
    margin: '0 5%', fontSize: '1rem', fontWeight: '500', fontFamily: 'Inter', display: 'flex', alignItems: 'center',
    "@media (max-width:600px)": {
        fontSize: '1.2rem'
    },
}

export const expenseAmountStyle = { height: '25px', width: 'auto', margin: '0 0.5rem', color: '#2563eb', fontFamily: 'Nunito', display: 'flex', justifyContent: 'center', alignItems: 'center' }
export const expenseAmtStack = { backgroundColor: '#dbeafe', borderRadius: '10px', display: 'flex', justifyContent: 'center', alignItems: 'center' }
export const addExpenseGrid = {
    marginLeft: '0', marginTop: '0', display: 'flex',
    alignItems: 'center',
    justifyContent: 'right',
    padding: '0 2rem',
    "@media (max-width:760px)": { justifyContent: 'right' }
}

export const masterGrid = {
    width: '100%', marginLeft: '0', marginTop: '0', border: '1px solid #1976d2', borderRadius: '10px', fontFamily: 'Inter',
    "@media (max-width:760px)": { padding: '0' }
}

Navbar

This is a navigation bar that we can use in our application to navigate between pages. Right now, we will have a single-page application, but the same component can also be used in the future.

Since we are using the Material UI library there the Navbar is an Appbar component.

Make a new folder in the pages folders named as components. In this folder we will have all Components that we will use across the whole project. In this components folder make a file Navbar.js

"use client";
import React from 'react';
import {
    AppBar,
    Grid,
    Typography,
    Toolbar,
    Link,
} from "@mui/material";
import { navAppBar, navbarTitle, navToolbar } from '../globalStyles';

export default function Navbar() {

    return (
        <Grid container>
            <AppBar
                component="nav"
                sx={navAppBar}
            >
                <Toolbar
                    sx={navToolbar}
                >
                    <Grid
                        container
                        sx={{
                            alignItems: "center",
                            justifyContent: "space-between",
                            display: { md: "flex" },
                        }}
                    >
                        <Link href="/" style={{ textDecoration: "none", color: "black" }}>
                            <Grid
                                container
                                xs={12}
                                sx={{
                                    alignItems: "center",
                                    justifyContent: "space-between",
                                }}
                            >
                                <Typography sx={navbarTitle}>
                                    Expense Tracker
                                </Typography>
                            </Grid>
                        </Link>
                    </Grid>
                </Toolbar>
            </AppBar>
        </Grid>
    );
}

Footer

import React from "react";
import { Grid, Typography } from "@mui/material";
import { footerHeadings } from "../globalStyles";

const Footer = () => {
    return (
        <Grid
            container
            sx={{
                background: "#F8F8F8",
                p: "3%",
            }}
        >
            <Grid xs={12} md={6} lg={6}>
                <Typography sx={footerHeadings(700, 18)}>
                    © 2024 Expense Tracker
                </Typography>
                <Typography sx={footerHeadings(400,14)}>
                    Simple Expense Tracking App Built with Next.js and GraphQL
                </Typography>
            </Grid>
        </Grid >
    );
};

export default Footer;

Dashboard

This is the main component that we would be coding to see all our expenses and the CRUD Functionlity also.
Here is a functional description of all major functions followd by the code

Fetch Expenses

The fetchExpenses function is an asynchronous function that retrieves a list of expenses from a GraphQL API. It sends a POST request with a GraphQL query to /api/graphql, which fetches details like id, category, modeOfPayment, amount, message, and type of each expense.

  • If the response contains errors, it sets an error message to display in a Snackbar, informing the user of the issue.

  • If successful, it updates expenseData with the fetched data.

  • If any error occurs during the fetch operation, it catches the error, updates the error state, and displays the error in the Snackbar.

  • Finally, it sets loading to false to indicate the request is complete.

Handle Submit

The handleSubmit function adds or updates an expense based on whether isEditing is true.

  1. It checks if all required fields are filled; if not, it shows an error message.

  2. It creates a GraphQL mutation query, either to add or update an expense.

  3. It sends the mutation with necessary variables to the /api/graphql endpoint.

  4. If there’s an error in the response, it displays the error message.

  5. If successful, it clears the form, shows a success message, closes the modal, and refreshes the expenses list by calling fetchExpenses.

Handle Edit

The handleEdit function prepares the form for editing an existing expense:

  1. It enables editing mode and saves the expense.id

  2. It populates the form fields with the selected expense's details (amount, message, type, category, mode of payment).

  3. It opens the modal for editing using handleOpen().

Handle Delete

The handleDelete function deletes an expense:

  1. It sends a GraphQL mutation request to delete an expense by id.

  2. If successful, it removes the deleted expense from the local state.

  3. Shows a success message in a Snackbar.

  4. If there’s an error, it displays an error message in the Snackbar.

Total Expense Calculation

The calculateTotal function computes the total amount from an array of expenses:

  • It uses the reduce method to sum the amounts, adding income amounts and subtracting expense amounts based on the type.

The formatTotal function formats a numeric total into a currency string:

  • It converts the total to an absolute value and formats it as Indian Rupees (INR) using the Intl.NumberFormat object, ensuring no decimal places are shown.

UI Components Used

We have used a lot of UI components in this project some of them are - Grids, Stack, Chip, Box, Typography, Button, Divider, Alert, Snackbar, Modal etc. You can find all components on the Official Documentation of Material UI website.

Component Code

import { Alert, Box, Button, Chip, Divider, FormControl, Grid, InputLabel, MenuItem, Modal, Select, Snackbar, Stack, TextField, Typography } from '@mui/material';
import React, { useEffect, useState } from 'react'
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import { addExpenseGrid, btn, chipStyles, editDeleteButtonGrid, expenseAmountStyle, expenseAmtStack, expenseMsgStyle, iconStyles, masterGrid, style } from '../globalStyles';

const Dashbaord = () => {
    const [open, setOpen] = React.useState(false);
    const handleOpen = () => setOpen(true);
    const handleClose = () => {
        setOpen(false);
        // Clear form
        setAmount(0);
        setMessage('');
        setExpenseType('');
        setCategory('');
        setModeOfPayment('');
        // Reset editing state
        setIsEditing(false);
        setEditingId(null);
    };

    const [openSnackbar, setOpenSnackbar] = useState(false);
    const [msg, setMsg] = useState('');
    const [severity, setSeverity] = useState('');
    const handleCloseSnackbar = () => setOpenSnackbar(false);

    const [message, setMessage] = React.useState('');
    const [amount, setAmount] = React.useState(0);
    const [expenseType, setExpenseType] = React.useState('');
    const [category, setCategory] = React.useState('');
    const [modeOfPayment, setModeOfPayment] = React.useState('');
    const handleMessage = (event: { target: { value: React.SetStateAction<string>; }; }) => setMessage(event.target.value);
    const handleAmount = (event: { target: { value: React.SetStateAction<number>; }; }) => setAmount(event.target.value);
    const handleExpenseType = (event: { target: { value: React.SetStateAction<string>; }; }) => setExpenseType(event.target.value);
    const handleCategory = (event: { target: { value: React.SetStateAction<string>; }; }) => setCategory(event.target.value);
    const handleModeOfPayment = (event: { target: { value: React.SetStateAction<string>; }; }) => setModeOfPayment(event.target.value);

    const [expenseData, setExpenseData] = React.useState([]);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState('');
    const [isEditing, setIsEditing] = useState(false);
    const [editingId, setEditingId] = useState(null);

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

    const fetchExpenses = async () => {
        try {
            const response = await fetch('/api/graphql', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify({
                    query: `query GetExpenses {
                        getExpenses {
                            id
                            category
                            modeOfPayment
                            amount
                            message
                            type
                        }
                    }
                    `
                }),
            });

            const { data, errors } = await response.json();

            if (errors) {
                setError(errors[0].message);
                setMsg('An error occurred. Please try again - ' + error);
                setOpenSnackbar(true);
                setSeverity('error');
            } else {
                setExpenseData(data.getExpenses);
            }
        } catch (err) {
            setError("An error occurred while fetching expenses -" + err);
            setMsg(error);
            setOpenSnackbar(true);
            setSeverity('error');
        } finally {
            setLoading(false);
        }
    };
    if (loading) return <p>Loading...</p>;
    if (error) return <p>Error: {error}</p>;


    const handleSubmit = async () => {
        try {
            // If any required field is empty, show error
            if (!amount || !message || !expenseType || !category || !modeOfPayment) {
                setMsg('Please fill all fields');
                setSeverity('error');
                setOpenSnackbar(true);
                return;
            }

            const mutation = isEditing ?
                `mutation UpdateExpense($id: ID!, $category: String!, $modeOfPayment: String!, $amount: Float!, $message: String!, $type: String!) {
                    updateExpense(
                        id: $id,
                        category: $category,
                        modeOfPayment: $modeOfPayment,
                        amount: $amount,
                        message: $message,
                        type: $type
                    ) {
                        id
                        category
                        modeOfPayment
                        amount
                        message
                        type
                    }
                }`
                :
                `mutation AddExpense($category: String!, $modeOfPayment: String!, $amount: Float!, $message: String!, $type: String!) {
                    addExpense(
                        category: $category,
                        modeOfPayment: $modeOfPayment,
                        amount: $amount,
                        message: $message,
                        type: $type
                    ) {
                        id
                        category
                        modeOfPayment
                        amount
                        message
                        type
                    }
                }`

            const variables = {
                ...(isEditing && { id: editingId }),
                category,
                modeOfPayment,
                amount: parseFloat(amount),
                message,
                type: expenseType
            };

            const response = await fetch('/api/graphql', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify({
                    query: mutation,
                    variables
                }),
            });

            const { data, errors } = await response.json();

            if (errors) {
                throw new Error(errors[0].message);
            }

            if (data) {
                // Clear form
                setAmount(0);
                setMessage('');
                setExpenseType('');
                setCategory('');
                setModeOfPayment('');
                setEditingId(null);
                setIsEditing(false);

                // Show success message
                setMsg(isEditing ? 'Expense updated successfully!' : 'Expense added successfully!');
                setSeverity('success');
                setOpenSnackbar(true);

                // Close modal
                handleClose();

                // Refresh expenses list
                fetchExpenses();
            }
        } catch (err) {
            setMsg(err instanceof Error ? err.message : 'Failed to save expense');
            setSeverity('error');
            setOpenSnackbar(true);
        }
    };

    const handleEdit = (expense: any) => {
        // Set editing mode
        setIsEditing(true);
        setEditingId(expense.id);

        // Populate form with existing data
        setAmount(expense.amount);
        setMessage(expense.message);
        setExpenseType(expense.type);
        setCategory(expense.category);
        setModeOfPayment(expense.modeOfPayment);

        // Open modal
        handleOpen();
    };

    const handleDelete = async (id: any) => {
        try {
            const response = await fetch('/api/graphql', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify({
                    query:
                        `mutation DeleteExpense($id: ID!) {
                            deleteExpense(id: $id) {
                                id
                            }
                        }`
                    ,
                    variables: {
                        id: id
                    }
                }),
            });

            const { data, errors } = await response.json();

            if (errors) {
                throw new Error(errors[0].message);
            }

            if (data) {
                // Update the local state to remove the deleted expense
                setExpenseData(prevExpenses =>
                    prevExpenses.filter(expense => expense.id !== id)
                );

                setMsg('Expense deleted successfully!');
                setSeverity('success');
                setOpenSnackbar(true);
            }
        } catch (err) {
            setMsg(err instanceof Error ? err.message : 'Failed to delete expense');
            setSeverity('error');
            setOpenSnackbar(true);
        }
    };

    const typez = ['Income', 'Expense'];
    const categoryz = [
        'Entertainment & Leisure',
        'Food & Beverages',
        'Health & Personal Care',
        'Investments',
        'Miscellaneous',
        'Shopping',
        'Transportation',
        'Travel',
        'Utilities & Bills'
    ]
    const modeOfPaymentz = ['Cash', 'Credit Card', 'UPI']

    const calculateTotal = (expenses: any[]) => {
        return expenses.reduce((total, expense) => {
            const amount = parseFloat(expense.amount);
            return total + (expense.type === 'Income' ? amount : -amount);
        }, 0);
    };

    const formatTotal = (total: number) => {
        const absoluteTotal = Math.abs(total);
        return new Intl.NumberFormat('en-IN', {
            style: 'currency',
            currency: 'INR',
            maximumFractionDigits: 0,
        }).format(absoluteTotal);
    };

    return (
        <>
            <Box style={{ padding: '8rem 0rem', minHeight: 'calc(100vh - 440px)', }}>
                <Box sx={{ padding: '0 15%', "@media (max-width:760px)": { padding: '0' }, }}>
                    <Grid container spacing={2} sx={masterGrid}>
                        <Grid sx={{ marginLeft: '0', marginTop: '0', padding: '0 2rem' }} md={6} sm={6} xs={6}>
                            <h6 style={{ margin: '0.4rem 0 0 0', fontWeight: '400' }}>Total Spends</h6>
                            <h2 style={{ margin: '0.4rem 0' }}>{formatTotal(calculateTotal(expenseData))}</h2>
                        </Grid>
                        <Grid sx={addExpenseGrid} md={6} sm={6} xs={6}>
                            <Button onClick={handleOpen} sx={btn("140px")}>+ Expense</Button>
                        </Grid>
                        {expenseData.slice().reverse().map((expense, index) => {
                            return (
                                <Grid key={index} container spacing={2} sx={{
                                    marginLeft: '0', marginTop: '0',
                                    fontFamily: 'Inter'
                                }}>
                                    <Divider sx={{ width: '100%', backgroundColor: '#1976d2' }} />
                                    <Grid sx={{ marginLeft: '0', marginTop: '0', padding: '0.5rem' }} md={10} sm={10} xs={10}>
                                        <Box sx={{ display: 'flex', alignItems: 'center' }}>
                                            <Stack sx={expenseAmtStack}>
                                                <Typography sx={{ ...expenseAmountStyle, fontSize: '0.85rem', fontWeight: '500' }}>
                                                    INR
                                                </Typography>
                                                <Typography sx={{ ...expenseAmountStyle, fontSize: '1rem', fontWeight: '700' }}>
                                                    {expense.amount}
                                                </Typography>
                                            </Stack>
                                            <Stack sx={{ width: '100%' }}>

                                                <Typography sx={expenseMsgStyle}>
                                                    {expense.message}
                                                </Typography>
                                            </Stack>
                                            <Chip sx={{
                                                ...chipStyles, color: '#fff', margin: '0 0 0 5%', backgroundColor: '#FF5722',

                                            }} label={expense.category} />
                                            <Chip sx={{
                                                ...chipStyles, color: '#000', margin: '0 5px', backgroundColor: '#FFC107',

                                            }} label={expense.modeOfPayment} />


                                        </Box>
                                    </Grid>
                                    <Grid md={2} sm={2} xs={2} sx={editDeleteButtonGrid}>
                                        <EditIcon
                                            onClick={() => handleEdit(expense)}
                                            sx={iconStyles}
                                        />
                                        <DeleteIcon
                                            onClick={() => handleDelete(expense.id)}
                                            sx={iconStyles}
                                        />
                                    </Grid>

                                </Grid>
                            )
                        })}
                    </Grid>
                </Box>
            </Box>

            <Modal
                open={open}
                onClose={handleClose}
            >
                <Box sx={style}>
                    <Typography sx={{ fontFamily: 'Inter', fontSize: '1.5rem', fontWeight: '700' }}>Add New Expense 💰</Typography>
                    <TextField fullWidth value={amount} id="outlined-basic" label="Amount" variant="outlined" sx={{ margin: '0.5rem 0' }} onChange={handleAmount} />
                    <TextField fullWidth value={message} id="outlined-basic" label="Message" variant="outlined" sx={{ margin: '0.5rem 0' }} onChange={handleMessage} />
                    <FormControl fullWidth sx={{ margin: '0.5rem 0' }}>
                        <InputLabel id="expense-type-label">Select Expense Type</InputLabel>
                        <Select
                            labelId="Expense Type"
                            id="expense-type"
                            value={expenseType}
                            label="Type"
                            onChange={handleExpenseType}
                        >
                            {typez.map((d, index) => (
                                <MenuItem key={index} value={d}>
                                    {d}
                                </MenuItem>
                            ))}
                        </Select>
                    </FormControl>
                    <FormControl fullWidth sx={{ margin: '0.5rem 0' }}>
                        <InputLabel id="category-label">Select Category</InputLabel>
                        <Select
                            labelId="Category"
                            id="category"
                            value={category}
                            label="Category"
                            onChange={handleCategory}
                        >
                            {categoryz.map((d, index) => (
                                <MenuItem key={index} value={d}>
                                    {d}
                                </MenuItem>
                            ))}
                        </Select>
                    </FormControl>
                    <FormControl fullWidth sx={{ margin: '0.5rem 0' }}>
                        <InputLabel id="modeOfPayment-label">Select Mode Of Payment</InputLabel>
                        <Select
                            labelId="modeOfPayment"
                            id="modeOfPayment"
                            value={modeOfPayment}
                            label="Mode Of Payment"
                            onChange={handleModeOfPayment}
                        >
                            {modeOfPaymentz.map((d, index) => (
                                <MenuItem key={index} value={d}>
                                    {d}
                                </MenuItem>
                            ))}
                        </Select>
                    </FormControl>
                    <Button onClick={handleSubmit} sx={btn("130px")}>
                        {isEditing ? 'Update' : 'Submit'}
                    </Button>
                </Box>
            </Modal>
            <Snackbar open={openSnackbar} autoHideDuration={6000} onClose={handleCloseSnackbar}>
                <Alert onClose={handleCloseSnackbar} severity={severity} sx={{ width: '100%' }}>
                    {msg}
                </Alert>
            </Snackbar>

        </>
    )
}

export default Dashbaord

Project Repository

Using Git and Github for Version control will always help you track the changes of your project. Follow along with this repository in case you want to see the whole code. GITHUB REPO LINK
Please show some love by giving a star to this repo, and Feel free to create pull requests to add new features, such as Analytics, to the app.

Platform Images

Tablets

Mobiles

Laptops

Up Next ⏭️

This was my very first blog about making a full project. Do let me know if any changes or additions are required that might help me in future blogs. Thanks for reading all the 4 parts 🥳😉

1
Subscribe to my newsletter

Read articles from Kush Munot directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Kush Munot
Kush Munot