DEV Community

Cover image for Expense monitor App
Deepak Singh Kushwah
Deepak Singh Kushwah

Posted on

Expense monitor App

Hello Devs, recently I have learned ReactJS and I am creating apps to learn more. I've create an app "Expense Monitor" which can store expense and income entries in json file and show them as list. It's a beginners project and hope help it will others who are learning ReactJS. Full source code is available at following url.

https://bitbucket.org/deepaksinghkushwah/expense-monitor/src/master/

I have used following packages...
axios, bootstrap,concurrently, json-server, moment, react-bootstrap, react-icons, react-modal, react-moment, react-toastify

Lets start this with updateing App.js file....

App.js

import { ToastContainer } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";
import "./App.css";
import "bootstrap/dist/css/bootstrap.min.css";
import EntryForm from "./components/EntryForm";
import { EntryProider } from "./context/entries/EntryContext";
import EntryList from "./components/EntryList";
import Modal from "react-modal";
import { useState } from "react";

function App() {
  // set app element to root
  Modal.setAppElement("#root");

  // set state for open and close modal
  const [modelIsOpen, setModelIsOpen] = useState(false);

  // open modal function
  const openModal = () => {
    setModelIsOpen(true);
  };

  // close modal function
  const closeModal = () => {
    setModelIsOpen(false);
  };

  return (
    <div className="App">
      {/** Entry provider for entry context */}
      <EntryProider>
        {/** Modal config start */}
        <Modal
          isOpen={modelIsOpen}
          onRequestClose={closeModal}
          className="customModal mt-5 p-2"
        >
          <button
            onClick={closeModal}
            className="btn btn-sm btn-danger float-end"
          >
            close
          </button>
          <EntryForm />
        </Modal>
        <h1 className="mt-3 ms-3">
          Expense Monitor
          <span className="float-end me-3">
            <button
              type="button"
              className="btn btn-primary btn-sm"
              onClick={openModal}
            >
              Add Entry
            </button>
          </span>
        </h1>

        {/** Expense entries module */}
        <EntryList />

        {/** Toast container to show toast notifications */}
        <ToastContainer />
      </EntryProider>
    </div>
  );
}

export default App;

Enter fullscreen mode Exit fullscreen mode

Now update the App.css

.App{    
    width: 800px;
    margin: auto;
    padding: auto;    
}
.customModal{
    top: 50%;
    left: 50%;
    margin: auto;
    width: 400px;
    height: 350px;
    background-color: burlywood;
    text-align: center;    
}


Enter fullscreen mode Exit fullscreen mode

Next create folder name "components" and create following files with code.
compomnents/EntryForm.jsx

import React, { useContext, useState } from 'react'
import { toast } from 'react-toastify'
import { addEntry, getEntries } from '../context/entries/EntryAction';
import EntryContext from '../context/entries/EntryContext';

function EntryForm() {
  /** set states for form */
  const [title, setTitle] = useState("");
  const [amount, setAmount] = useState(0);
  const [item_type, setItemType] = useState("income");

  /** using entry context dispatch */
  const { dispatch } = useContext(EntryContext);


  /** handle form submit function */
  const handleSubmit = async(e) => {
    e.preventDefault();
    dispatch({ type: 'SET_LOADING' });
    if (title === "" || amount === "") {
      toast.error("You must provide the title and amount");
      return false;
    }
    await addEntry(title, amount, item_type);
    toast.success("Entry added");
    setTitle("");
    setAmount("");
    setItemType("income");


    const allEntries = await getEntries();
    dispatch({ type: 'GET_ENTRIES', payload: allEntries });

  }
  return (
    <form onSubmit={handleSubmit}>
      <table className="table table-bordered">
        <tbody>
          <tr>
            <td><input type="text" className='form-control' id="title" name="title" value={title} placeholder="Title" onChange={(e) => setTitle(e.target.value)} /></td>
          </tr>
          <tr>
            <td><input type="number" step=".1" min="0" className='form-control' id="amount" name="amount" value={amount} placeholder="Amount" onChange={(e) => setAmount(e.target.value)} /></td>
          </tr>
          <tr>
            <td>
              <select name="item_type" className='form-control' value={item_type} id="item_type" onChange={(e) => setItemType(e.target.value)}>
                <option value="income">Income</option>
                <option value="expense">Expense</option>
              </select>
            </td>
          </tr>
        </tbody>
      </table>
      <button className='btn btn-primary' type="submit">Send</button>
    </form>
  )
}

export default EntryForm
Enter fullscreen mode Exit fullscreen mode

components/EntryList.jsx

import React, { useEffect } from 'react'
import { useContext } from 'react';
import { getEntries, removeEntry } from '../context/entries/EntryAction';
import EntryContext from "../context/entries/EntryContext";
import { FaTrash } from 'react-icons/fa';
import { toast } from 'react-toastify';
import moment from "moment";
function EntryList() {
    /** use entry context to get fields */
    const { entries, dispatch, totalIncome, totalExpense, loading } = useContext(EntryContext);

    useEffect(() => {
        dispatch({ type: 'SET_LOADING' });
        const fetchEntries = async () => {
            const r = await getEntries();
            dispatch({ type: 'GET_ENTRIES', payload: r });

        }
        fetchEntries();

    }, [dispatch]);

    /** handle delete event */
    const handleDelete = async (id) => {
        if (window.confirm("Are you sure want to remove this entry?")) {
            dispatch({ type: 'SET_LOADING' });
            await removeEntry(id);
            toast.success("Item deleted");
            const r = await getEntries();
            dispatch({ type: 'GET_ENTRIES', payload: r });
        }


    }

    if (loading) {
        return "Loading...";
    }
    return (
        <>
            {/** return entries if entries have rows */}
            {entries && entries.length > 0 ? (
                <table className='table table-hover table-small'>
                    <thead>
                        <tr>
                            <th>Item</th>
                            <th>Amount</th>
                            <th>Date</th>
                            <th>Action</th>
                        </tr>
                    </thead>
                    <tbody>
                        {entries.map((item) => (
                            <tr key={item.id} className={item.item_type === 'expense' ? "table-danger" : "table-primary"} title={item.item_type}>
                                <td>
                                    {item.title}
                                </td>
                                <td>
                                    ${item.amount}
                                </td>
                                <td>{moment(item.date).format("MMMM Do YYYY, h:mm:ss a")}</td>
                                <td>
                                    <span className='float-end pe-3' onClick={() => handleDelete(item.id)}><FaTrash /></span>
                                </td>
                            </tr>
                        ))}
                    </tbody>
                    <tfoot className='table-secondary'>
                        <tr>
                            <th>Total Income</th>
                            <th>${totalIncome}</th>
                            <th></th>
                            <th></th>
                        </tr>
                        <tr>
                            <th>Total Expense</th>
                            <th>${totalExpense}</th>
                            <th></th>
                            <th></th>
                        </tr>

                    </tfoot>
                </table>

            ) : 'No entries found'}
        </>

    )
}

export default EntryList
Enter fullscreen mode Exit fullscreen mode

Now we move to context. Create context folder in src folder and write these files...
context/entries/EntryAction.js

import axios from "axios"
import moment from "moment";
const http = axios.create({
    baseURL: 'http://localhost:5000'
}); 

export const addEntry = async(title, amount, item_type) => {
    const date = moment().format('LLLL');    
    const params = new URLSearchParams({title, amount, item_type, date });
    const response = await http.post('/entries',params);
    const data = await response.data;
    return data;
}

export const getEntries = async() => {
    const r = await http.get('/entries');
    const data = await r.data;
    return data;
}

export const removeEntry = async(id) => {
    const response = await http.delete(`/entries/${id}`)
    const data = await response.data;
    return data;
}
Enter fullscreen mode Exit fullscreen mode

EntryContext.js

import { createContext, useReducer } from "react";
import EntryReducter from "./EntryReducer";

const EntryContext = createContext();

export const EntryProider = ({children}) => {
    const initalState = {
        entries: [],
        totalExpense: 0,
        totalIncome: 0,
        loading: true,
    }
    const [state, dispatch] = useReducer(EntryReducter, initalState);
    return <EntryContext.Provider value={{
        ...state,
        dispatch
    }}>
        {children}
    </EntryContext.Provider>
}

export default EntryContext;
Enter fullscreen mode Exit fullscreen mode

EntryReducer.js

const EntryReducter = (state, action) => {
    let expense = 0;
    let income = 0;
    switch(action.type){
        case 'GET_ENTRIES':
            expense = setTotal('expense', action.payload);
            income = setTotal('income', action.payload)
            return {                
                entries: action.payload,
                loading: false,
                totalExpense: expense,
                totalIncome: income
            }

        case 'SET_LOADING':
            return {
                loading: true
            }
        default:
            return state;
    }
}

function setTotal(type, entries){
    let total = 0.00;
    console.log(entries);
    entries.map((item) => {
        if(item.item_type === type){
            total += parseFloat(item.amount);
        }
    })
    return total;    

}

export default EntryReducter;
Enter fullscreen mode Exit fullscreen mode

Top comments (0)