P4 - Making a Sleek UI for our Expense Tracker
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
tofalse
to indicate the request is complete.
Handle Submit
The handleSubmit
function adds or updates an expense based on whether isEditing
is true.
It checks if all required fields are filled; if not, it shows an error message.
It creates a GraphQL mutation query, either to add or update an expense.
It sends the mutation with necessary variables to the
/api/graphql
endpoint.If there’s an error in the response, it displays the error message.
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:
It enables editing mode and saves the
expense.id
It populates the form fields with the selected expense's details (amount, message, type, category, mode of payment).
It opens the modal for editing using
handleOpen()
.
Handle Delete
The handleDelete
function deletes an expense:
It sends a GraphQL mutation request to delete an expense by
id
.If successful, it removes the deleted expense from the local state.
Shows a success message in a Snackbar.
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 thetype
.
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 🥳😉
Subscribe to my newsletter
Read articles from Kush Munot directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by