aboutsummaryrefslogtreecommitdiffstats
path: root/public/projects/js-small-apps/budget-app
diff options
context:
space:
mode:
authorArmand Philippot <git@armandphilippot.com>2022-02-20 16:11:50 +0100
committerArmand Philippot <git@armandphilippot.com>2022-02-20 16:15:08 +0100
commit73a5c7fae9ffbe9ada721148c8c454a643aceebe (patch)
treec8fad013ed9b5dd589add87f8d45cf02bbfc6e91 /public/projects/js-small-apps/budget-app
parentb01239fbdcc5bbc5921f73ec0e8fee7bedd5c8e8 (diff)
chore!: restructure repo
I separated public files from the config/dev files. It improves repo readability. I also moved dotenv helper to public/inc directory and extract the Matomo tracker in the same directory.
Diffstat (limited to 'public/projects/js-small-apps/budget-app')
-rw-r--r--public/projects/js-small-apps/budget-app/README.md17
-rw-r--r--public/projects/js-small-apps/budget-app/app.js311
-rw-r--r--public/projects/js-small-apps/budget-app/index.html232
-rw-r--r--public/projects/js-small-apps/budget-app/lib/class-budget-app.js144
-rw-r--r--public/projects/js-small-apps/budget-app/lib/class-budget.js44
-rw-r--r--public/projects/js-small-apps/budget-app/lib/class-category.js37
-rw-r--r--public/projects/js-small-apps/budget-app/lib/class-notification.js89
-rw-r--r--public/projects/js-small-apps/budget-app/lib/class-transaction.js76
-rw-r--r--public/projects/js-small-apps/budget-app/lib/class-user.js79
-rw-r--r--public/projects/js-small-apps/budget-app/lib/utils/currency.js47
-rw-r--r--public/projects/js-small-apps/budget-app/style.css366
11 files changed, 1442 insertions, 0 deletions
diff --git a/public/projects/js-small-apps/budget-app/README.md b/public/projects/js-small-apps/budget-app/README.md
new file mode 100644
index 0000000..fe8f09f
--- /dev/null
+++ b/public/projects/js-small-apps/budget-app/README.md
@@ -0,0 +1,17 @@
+# Budget app
+
+A simple budget app.
+
+## Description
+
+You can define some categories for your expenses and incomes. Then you can add/edit/remove each type of transaction.
+
+The app will show you the remaining budget and the total expenses based on the initial budget.
+
+## Preview
+
+You can see a live preview here: https://demo.armandphilippot.com/#budget-app
+
+## License
+
+This project is open-source and available under the [MIT License](../LICENSE).
diff --git a/public/projects/js-small-apps/budget-app/app.js b/public/projects/js-small-apps/budget-app/app.js
new file mode 100644
index 0000000..2fb0cba
--- /dev/null
+++ b/public/projects/js-small-apps/budget-app/app.js
@@ -0,0 +1,311 @@
+import BudgetApp from "./lib/class-budget-app.js";
+import Notification from "./lib/class-notification.js";
+import getCurrencyFormat from "./lib/utils/currency.js";
+
+const app = new BudgetApp("Budget", "Anonymous");
+const ui = {
+ budget: {
+ remaining: document.getElementById("budget-remaining"),
+ spent: document.getElementById("budget-spent"),
+ },
+ buttons: {
+ categories: {
+ add: document.querySelector(".manage-categories .btn--add"),
+ delete: document.querySelector(".manage-categories .btn--delete"),
+ rename: document.querySelector(".manage-categories .btn--rename"),
+ },
+ register: document.querySelector(".register .btn--register"),
+ reset: document.querySelector(".footer .btn--reset"),
+ transactions: {
+ update: document.querySelector(".manage-transactions .btn--update"),
+ },
+ },
+ form: {
+ categories: {
+ add: document.getElementById("add-category"),
+ rename: document.getElementById("rename-category"),
+ select: document.getElementById("select-category"),
+ },
+ register: {
+ budget: document.getElementById("register-budget"),
+ language: document.getElementById("register-locale"),
+ username: document.getElementById("register-username"),
+ },
+ transactions: {
+ amount: document.getElementById("transaction-amount"),
+ category: document.getElementById("transaction-category"),
+ date: document.getElementById("transaction-date"),
+ id: document.getElementById("transaction-id"),
+ name: document.getElementById("transaction-name"),
+ type: document.getElementById("transaction-type"),
+ },
+ },
+ history: {
+ body: document.querySelector(".app__history .table__body"),
+ },
+ title: document.querySelector(".branding__title"),
+};
+
+function initApp() {
+ const register = document.querySelector(".register");
+ const application = document.querySelector(".app");
+ const budget = ui.form.register.budget.value;
+ const locale = ui.form.register.language.value;
+ const username = ui.form.register.username.value;
+
+ if (budget && locale && username) {
+ app.user.username = username;
+ app.user.budget = budget;
+ app.user.locale = locale;
+ register.style.display = "none";
+ application.style.display = "block";
+ } else {
+ notify("You must complete all fields!", "error", 3000);
+ }
+}
+
+function notify(message, type, duration, position = "bottom") {
+ const notification = new Notification(message, type);
+ notification.duration = duration;
+ notification.position = position;
+ return notification.notify();
+}
+
+function getSelectOptions(select, options) {
+ select.innerHTML = "";
+ options.forEach((option) => {
+ select.add(new Option(option.name, option.id));
+ });
+
+ return select;
+}
+
+function findName(id, array) {
+ const object = array.find((item) => item.id === Number(id));
+ if (object) {
+ return object.name;
+ } else {
+ return "(deleted)";
+ }
+}
+
+function resetTransactionForm() {
+ ui.form.transactions.amount.value = "";
+ ui.form.transactions.category.value = "";
+ ui.form.transactions.date.value = "";
+ ui.form.transactions.id.value = "";
+ ui.form.transactions.name.value = "";
+ ui.form.transactions.type.value = "";
+}
+
+function setTransactionForm(transaction) {
+ ui.form.transactions.amount.value = transaction.amount;
+ ui.form.transactions.category.value = transaction.category;
+ ui.form.transactions.date.valueAsDate = transaction.date;
+ ui.form.transactions.id.value = transaction.id;
+ ui.form.transactions.name.value = transaction.name;
+ ui.form.transactions.type.value = transaction.type;
+}
+
+function manageHistory(target) {
+ const tr = target.parentElement.parentElement;
+ const transactionId = Number(tr.id.replace("transaction-", ""));
+ const array = tr.classList.contains("table__row--expense")
+ ? app.expenses
+ : app.incomes;
+ if (target.classList.contains("btn--delete")) {
+ const tbody = tr.parentElement;
+ app.remove(transactionId, array);
+ updateBudget();
+ updateHistory(tbody);
+ } else if (target.classList.contains("btn--edit")) {
+ const index = array.findIndex(
+ (transaction) => transaction.id === transactionId
+ );
+ setTransactionForm(array[index]);
+ }
+}
+
+function getTransactionButton(type) {
+ const btn = document.createElement("button");
+ let text = "";
+
+ switch (type) {
+ case "delete":
+ text = "Delete";
+ break;
+ case "edit":
+ text = "Edit";
+ break;
+ default:
+ break;
+ }
+
+ btn.textContent = text;
+ btn.classList.add("btn", `btn--${type}`);
+ btn.addEventListener("click", (event) => manageHistory(event.target));
+
+ return btn;
+}
+
+function getTransactionCell(data = "") {
+ const td = document.createElement("td");
+ td.classList.add("table__item");
+ td.textContent = data;
+
+ return td;
+}
+
+function getTransactionRow(transaction) {
+ const tr = document.createElement("tr");
+ const amount =
+ transaction.type === "expense"
+ ? transaction.amount * -1
+ : transaction.amount;
+ const localizedAmount = getCurrencyFormat(amount, app.user.locale);
+ const categoryName = findName(transaction.category, app.categories);
+ const date = transaction.date.toLocaleDateString(app.user.locale);
+
+ const dateCell = getTransactionCell(date);
+ const nameCell = getTransactionCell(transaction.name);
+ const categoryCell = getTransactionCell(categoryName);
+ const typeCell = getTransactionCell(transaction.type);
+ const amountCell = getTransactionCell(localizedAmount);
+ const manageCell = getTransactionCell();
+ const editButton = getTransactionButton("edit");
+ const deleteButton = getTransactionButton("delete");
+
+ manageCell.append(editButton, deleteButton);
+ tr.classList.add("table__row", `table__row--${transaction.type}`);
+ tr.id = `transaction-${transaction.id}`;
+ tr.append(dateCell, nameCell, categoryCell, typeCell, amountCell, manageCell);
+
+ return tr;
+}
+
+function getHistory(tbody) {
+ const transactions = app.getOrderedTransactions("oldest");
+ transactions.forEach((transaction) => {
+ tbody.appendChild(getTransactionRow(transaction));
+ });
+}
+
+function updateBudget() {
+ app.updateUserBudget();
+ ui.budget.remaining.textContent = getCurrencyFormat(
+ app.user.budget.remaining(),
+ app.user.locale
+ );
+ ui.budget.spent.textContent = getCurrencyFormat(
+ app.user.budget.spent,
+ app.user.locale
+ );
+}
+
+function updateCategories() {
+ getSelectOptions(ui.form.categories.select, app.categories);
+ getSelectOptions(ui.form.transactions.category, app.categories);
+}
+
+function updateHistory() {
+ ui.history.body.innerHTML = "";
+ getHistory(ui.history.body, app);
+}
+
+function updateAll() {
+ ui.title.textContent = `${app.user.username} ${app.title}`;
+ updateBudget();
+ updateCategories();
+ updateHistory();
+}
+
+function listen() {
+ for (const [name, element] of Object.entries(ui.buttons.categories)) {
+ element.addEventListener("click", (event) => {
+ event.preventDefault();
+ const id = Number(ui.form.categories.select.value);
+
+ switch (name) {
+ case "add":
+ if (ui.form.categories.add.value) {
+ app.addCategory(ui.form.categories.add.value);
+ ui.form.categories.add.value = "";
+ } else {
+ notify("Category name must be filled!", "error", 3000);
+ }
+ break;
+ case "delete":
+ if (id) {
+ app.remove(id, app.categories);
+ updateHistory();
+ } else {
+ notify("A category must be selected!", "error", 3000);
+ }
+ break;
+ case "rename":
+ const newName = ui.form.categories.rename.value;
+ if (newName && id) {
+ app.renameCategory(id, newName);
+ updateHistory();
+ ui.form.categories.rename.value = "";
+ } else {
+ notify(
+ "You need to select a category and enter a new name first!",
+ "error",
+ 3000
+ );
+ }
+ break;
+ default:
+ break;
+ }
+
+ updateCategories();
+ });
+ }
+
+ ui.buttons.transactions.update.addEventListener("click", (event) => {
+ event.preventDefault();
+ const transactionId = Number(ui.form.transactions.id.value);
+ const transaction = {
+ date: ui.form.transactions.date.value,
+ name: ui.form.transactions.name.value,
+ type: ui.form.transactions.type.value,
+ category: ui.form.transactions.category.value,
+ amount: ui.form.transactions.amount.value,
+ };
+ const error = [];
+ for (const value in transaction) {
+ const element = transaction[value];
+ !element ? error.push(value) : "";
+ }
+ if (error.length === 0) {
+ transactionId
+ ? app.editTransaction({ id: transactionId, ...transaction })
+ : app.addTransaction(transaction);
+ updateBudget();
+ updateHistory();
+ resetTransactionForm();
+ } else {
+ const errorMsg = `These fields (${error.join(", ")}) must be filled!`;
+ notify(errorMsg, "error", 3000);
+ }
+ });
+
+ ui.buttons.register.addEventListener("click", (event) => {
+ event.preventDefault();
+ initApp();
+ updateAll();
+ });
+
+ ui.buttons.reset.addEventListener("click", (event) => {
+ event.preventDefault();
+ if (confirm("Are you sure?")) {
+ notify("Reset!", "warning", 2000);
+ app.reset();
+ }
+ updateAll();
+ });
+}
+
+listen();
diff --git a/public/projects/js-small-apps/budget-app/index.html b/public/projects/js-small-apps/budget-app/index.html
new file mode 100644
index 0000000..90f5d12
--- /dev/null
+++ b/public/projects/js-small-apps/budget-app/index.html
@@ -0,0 +1,232 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <meta http-equiv="X-UA-Compatible" content="IE=edge" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>Budget App</title>
+ <link rel="stylesheet" href="style.css" />
+ </head>
+ <body>
+ <header class="branding">
+ <h1 class="branding__title">Budget App</h1>
+ </header>
+ <main class="main">
+ <div class="register">
+ <form action="#" class="register__form form">
+ <p class="form__item">
+ <label for="register-username" class="form__label"
+ >Enter a username:</label
+ >
+ <input
+ type="text"
+ id="register-username"
+ name="register-username"
+ class="form__field"
+ required
+ />
+ </p>
+ <p class="form__item">
+ <label for="register-budget" class="form__label"
+ >Enter a budget:</label
+ >
+ <input
+ type="number"
+ id="register-budget"
+ name="register-budget"
+ class="form__field"
+ required
+ />
+ </p>
+ <p class="form__item">
+ <label for="register-locale" class="form__label"
+ >Select a language:</label
+ >
+ <select
+ name="register-locale"
+ id="register-locale"
+ class="form__select"
+ required
+ >
+ <option value="en-US">Anglais</option>
+ <option value="fr-FR">Français</option>
+ </select>
+ </p>
+ <button type="submit" class="btn btn--register">Register</button>
+ </form>
+ </div>
+ <div class="app">
+ <div class="app__management">
+ <div class="app__categories">
+ <h2 class="app__title">Categories</h2>
+ <form action="#" class="manage-categories form">
+ <fieldset class="form__fieldset">
+ <legend class="form__legend">Add a new category</legend>
+ <p class="form__item">
+ <label for="add-category" class="form__label"
+ >Category name:</label
+ >
+ <input
+ type="text"
+ name=""
+ id="add-category"
+ class="form__field"
+ required
+ />
+ <button type="submit" class="form__submit btn btn--add">
+ Add
+ </button>
+ </p>
+ </fieldset>
+ <fieldset class="form__fieldset">
+ <legend class="form__legend">Manage existing categories</legend>
+ <p class="form__item">
+ <label for="select-category" class="form__label"
+ >Select a category:</label
+ >
+ <select
+ name="select-category"
+ id="select-category"
+ class="form__select"
+ >
+ <option value="undefined" class="form__option">
+ Undefined
+ </option>
+ </select>
+ <button type="submit" class="form__submit btn btn--delete">
+ Delete
+ </button>
+ </p>
+ <p class="form__item">
+ <label for="rename-category" class="form__label"
+ >New name:</label
+ >
+ <input
+ type="text"
+ name="rename-category"
+ id="rename-category"
+ class="form__field"
+ />
+ <button type="submit" class="form__submit btn btn--rename">
+ Rename
+ </button>
+ </p>
+ </fieldset>
+ </form>
+ </div>
+ <div class="app__transactions">
+ <h2 class="app__title">Transactions</h2>
+ <form action="#" class="manage-transactions form">
+ <fieldset class="form__fieldset">
+ <legend class="form__legend">Manage transactions</legend>
+ <p class="form__item">
+ <label for="transaction-date" class="form__label"
+ >Select a date:</label
+ >
+ <input
+ type="date"
+ name="transaction-date"
+ id="transaction-date"
+ class="form__field"
+ required
+ />
+ </p>
+ <p class="form__item">
+ <label for="" class="form__label">Choose a type:</label>
+ <select
+ name="transaction-type"
+ id="transaction-type"
+ class="form__select"
+ >
+ <option value=""></option>
+ <option value="expense">Expense</option>
+ <option value="income">Income</option>
+ </select>
+ </p>
+ <p class="form__item">
+ <label for="" class="form__label">Choose a category:</label>
+ <select
+ name="transaction-category"
+ id="transaction-category"
+ class="form__select"
+ >
+ <option value="undefined">Undefined</option>
+ </select>
+ </p>
+ <p class="form__item">
+ <label for="transaction-name" class="form__label"
+ >Name:</label
+ >
+ <input
+ type="text"
+ name="transaction-name"
+ id="transaction-name"
+ class="form__field"
+ required
+ />
+ </p>
+ <p class="form__item">
+ <label for="transaction-amount" class="form__label"
+ >Amount:</label
+ >
+ <input
+ type="number"
+ name="transaction-amount"
+ id="transaction-amount"
+ step="0.01"
+ class="form__field"
+ required
+ />
+ </p>
+ <input
+ type="hidden"
+ name="transaction-id"
+ id="transaction-id"
+ />
+ <button type="submit" class="form__submit btn btn--update">
+ Update
+ </button>
+ </fieldset>
+ </form>
+ </div>
+ <div class="app__budget">
+ <h2 class="app__title">Budget state</h2>
+ <table class="table">
+ <thead class="table__header">
+ <tr>
+ <td class="table__item">Spent</td>
+ <td class="table__item">Remaining</td>
+ </tr>
+ </thead>
+ <tbody class="table__body">
+ <tr>
+ <td id="budget-spent" class="table__item">0</td>
+ <td id="budget-remaining" class="table__item">0</td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ </div>
+ <div class="app__history">
+ <h2 class="app__title">History</h2>
+ <table class="table">
+ <thead class="table__header">
+ <td class="table__item">Date</td>
+ <td class="table__item">Name</td>
+ <td class="table__item">Category</td>
+ <td class="table__item">Type</td>
+ <td class="table__item">Amount</td>
+ <td class="table__item">Manage</td>
+ </thead>
+ <tbody class="table__body"></tbody>
+ </table>
+ </div>
+ </div>
+ </main>
+ <footer class="footer container">
+ <button type="button" class="btn btn--reset">Reset</button>
+ <p class="footer__copyright">Budget App. MIT 2021. Armand Philippot.</p>
+ </footer>
+ <script src="app.js" type="module"></script>
+ </body>
+</html>
diff --git a/public/projects/js-small-apps/budget-app/lib/class-budget-app.js b/public/projects/js-small-apps/budget-app/lib/class-budget-app.js
new file mode 100644
index 0000000..45097a0
--- /dev/null
+++ b/public/projects/js-small-apps/budget-app/lib/class-budget-app.js
@@ -0,0 +1,144 @@
+import Category from "./class-category.js";
+import Transaction from "./class-transaction.js";
+import User from "./class-user.js";
+
+class BudgetApp {
+ #title = "Budget App";
+ #categoryId = 1;
+ #categories = [];
+ #transactionId = 1;
+ #incomes = [];
+ #expenses = [];
+ #userId = 1;
+ #user = {};
+
+ constructor(title, username) {
+ this.#title = title;
+ this.#user = new User(this.#userId++, username);
+ }
+
+ set title(string) {
+ this.#title = string;
+ }
+
+ get title() {
+ return this.#title;
+ }
+
+ set categories(array) {
+ this.#categories = array;
+ }
+
+ get categories() {
+ return this.#categories;
+ }
+
+ set incomes(array) {
+ this.#incomes = array;
+ }
+
+ get incomes() {
+ return this.#incomes;
+ }
+
+ set expenses(array) {
+ this.#expenses = array;
+ }
+
+ get expenses() {
+ return this.#expenses;
+ }
+
+ set user(username) {
+ this.#user = new User(this.#userId++, username);
+ }
+
+ get user() {
+ return this.#user;
+ }
+
+ remove(id, from) {
+ const index = from.findIndex((object) => object.id === id);
+ from.splice(index, 1);
+ }
+
+ addCategory(name) {
+ this.#categories.push(new Category(this.#categoryId++, name));
+ }
+
+ renameCategory(id, newName) {
+ const index = this.categories.findIndex((object) => object.id === id);
+ this.categories[index].name = newName;
+ }
+
+ addTransaction(transaction) {
+ const array =
+ transaction.type === "income" ? this.#incomes : this.#expenses;
+ array.push(
+ new Transaction(
+ this.#transactionId++,
+ transaction.date,
+ transaction.name,
+ transaction.type,
+ transaction.category,
+ transaction.amount
+ )
+ );
+ }
+
+ editTransaction(transaction) {
+ const array = transaction.type === "income" ? this.incomes : this.expenses;
+ const index = array.findIndex((object) => {
+ return object.id === Number(transaction.id);
+ });
+ if (index !== -1) {
+ array[index] = new Transaction(...Object.values(transaction));
+ } else {
+ const oldArray = array === this.incomes ? this.expenses : this.incomes;
+ array.push(new Transaction(...Object.values(transaction)));
+ this.remove(transaction.id, oldArray);
+ }
+ }
+
+ getOrderedTransactions(order) {
+ const transactions = [...this.expenses, ...this.incomes];
+
+ switch (order) {
+ case "newest":
+ transactions.sort((a, b) => b.date - a.date);
+ break;
+ case "oldest":
+ transactions.sort((a, b) => a.date - b.date);
+ break;
+ default:
+ break;
+ }
+
+ return transactions;
+ }
+
+ total(transaction) {
+ const array = transaction === "expense" ? this.#expenses : this.#incomes;
+ let total = 0;
+ array.forEach((item) => {
+ total += item.amount;
+ });
+ return total;
+ }
+
+ updateUserBudget() {
+ this.user.budget.spent = this.total("expense");
+ this.user.budget.profit = this.total("income");
+ }
+
+ reset() {
+ this.#categoryId = 1;
+ this.#transactionId = 1;
+ this.#categories = [];
+ this.#incomes = [];
+ this.#expenses = [];
+ this.updateUserBudget();
+ }
+}
+
+export default BudgetApp;
diff --git a/public/projects/js-small-apps/budget-app/lib/class-budget.js b/public/projects/js-small-apps/budget-app/lib/class-budget.js
new file mode 100644
index 0000000..cde22fd
--- /dev/null
+++ b/public/projects/js-small-apps/budget-app/lib/class-budget.js
@@ -0,0 +1,44 @@
+/**
+ * Budget class
+ *
+ * Create a new budget.
+ */
+class Budget {
+ #initial = 0;
+ #spent = 0;
+ #profit = 0;
+
+ constructor(initial) {
+ this.#initial = Number.parseFloat(initial);
+ }
+
+ set initial(number) {
+ this.#initial = Number.parseFloat(number);
+ }
+
+ get initial() {
+ return this.#initial;
+ }
+
+ set spent(number) {
+ this.#spent = Number.parseFloat(number);
+ }
+
+ get spent() {
+ return this.#spent;
+ }
+
+ set profit(number) {
+ this.#profit = Number.parseFloat(number);
+ }
+
+ get profit() {
+ return this.#profit;
+ }
+
+ remaining() {
+ return this.initial + this.profit - this.spent;
+ }
+}
+
+export default Budget;
diff --git a/public/projects/js-small-apps/budget-app/lib/class-category.js b/public/projects/js-small-apps/budget-app/lib/class-category.js
new file mode 100644
index 0000000..7b2f1b2
--- /dev/null
+++ b/public/projects/js-small-apps/budget-app/lib/class-category.js
@@ -0,0 +1,37 @@
+/**
+ * Category class.
+ *
+ * Create a new category with id, name and attachments.
+ */
+class Category {
+ #id = 0;
+ #name = "";
+ #attachments = [];
+
+ constructor(id, name) {
+ this.#id = Number(id);
+ this.#name = name;
+ }
+
+ set name(name) {
+ this.#name = name;
+ }
+
+ get name() {
+ return this.#name;
+ }
+
+ get id() {
+ return this.#id;
+ }
+
+ set attachments(attachment) {
+ this.#attachments.push(Number(attachment));
+ }
+
+ get attachments() {
+ return this.#attachments;
+ }
+}
+
+export default Category;
diff --git a/public/projects/js-small-apps/budget-app/lib/class-notification.js b/public/projects/js-small-apps/budget-app/lib/class-notification.js
new file mode 100644
index 0000000..17a32a0
--- /dev/null
+++ b/public/projects/js-small-apps/budget-app/lib/class-notification.js
@@ -0,0 +1,89 @@
+class Notification {
+ #id = 0;
+ #title = "";
+ #message = "";
+ #type = "";
+ #duration = 0;
+ #position = "";
+
+ constructor(message, type) {
+ this.#message = message;
+ this.#type = type;
+ }
+
+ get id() {
+ return this.#id;
+ }
+
+ set title(string) {
+ this.#title = string;
+ }
+
+ get title() {
+ return this.#title;
+ }
+
+ set message(text) {
+ this.#message = text;
+ }
+
+ get message() {
+ return this.#message;
+ }
+
+ set type(string) {
+ this.#type = string;
+ }
+
+ get type() {
+ return this.#type;
+ }
+
+ set duration(number) {
+ this.#duration = number;
+ }
+
+ get duration() {
+ return this.#duration;
+ }
+
+ set position(string) {
+ this.#position = string;
+ }
+
+ get position() {
+ return this.#position;
+ }
+
+ #getWrapper() {
+ let wrapper = document.getElementById("notifications-center");
+
+ if (!wrapper) {
+ wrapper = document.createElement("div");
+ wrapper.id = "notifications-center";
+ wrapper.classList = "notifications";
+ document.body.appendChild(wrapper);
+ }
+
+ return wrapper;
+ }
+
+ notify() {
+ const notification = document.createElement("div");
+ notification.textContent = this.message;
+ notification.classList.add("notification", `notification--${this.type}`);
+
+ const wrapper = this.#getWrapper();
+ document.body.style.position = "relative";
+ wrapper.style.cssText = `position: fixed;${this.position}: 1rem;right: 1rem;display: flex;flex-flow: column wrap;gap: 1rem;`;
+ wrapper.appendChild(notification);
+
+ if (this.duration && Number(this.duration) !== 0) {
+ setTimeout(() => {
+ notification.remove();
+ }, this.duration);
+ }
+ }
+}
+
+export default Notification;
diff --git a/public/projects/js-small-apps/budget-app/lib/class-transaction.js b/public/projects/js-small-apps/budget-app/lib/class-transaction.js
new file mode 100644
index 0000000..6807372
--- /dev/null
+++ b/public/projects/js-small-apps/budget-app/lib/class-transaction.js
@@ -0,0 +1,76 @@
+/**
+ * Transaction class
+ *
+ * Create a new transaction with id, date, name, type, category and amount.
+ */
+class Transaction {
+ #id = 0;
+ #date = new Date();
+ #name = "";
+ #type = "";
+ #category = 0;
+ #amount = 0;
+
+ constructor(id, date, name, type, category, amount) {
+ this.#id = Number(id);
+ this.#date = new Date(date);
+ this.#name = name;
+ this.#type = type;
+ this.#category = Number(category);
+ this.#amount = Number.parseFloat(amount);
+ }
+
+ get id() {
+ return this.#id;
+ }
+
+ set date(datetime) {
+ this.#date = datetime;
+ }
+
+ get date() {
+ return this.#date;
+ }
+
+ set name(string) {
+ this.#name = string;
+ }
+
+ get name() {
+ return this.#name;
+ }
+
+ set type(string) {
+ this.#type = string;
+ }
+
+ get type() {
+ return this.#type;
+ }
+
+ set category(string) {
+ this.#category = string;
+ }
+
+ get category() {
+ return this.#category;
+ }
+
+ set amount(number) {
+ this.#amount = number;
+ }
+
+ get amount() {
+ return this.#amount;
+ }
+
+ update(date, name, type, category, amount) {
+ this.date = date;
+ this.name = name;
+ this.type = type;
+ this.category = category;
+ this.amount = amount;
+ }
+}
+
+export default Transaction;
diff --git a/public/projects/js-small-apps/budget-app/lib/class-user.js b/public/projects/js-small-apps/budget-app/lib/class-user.js
new file mode 100644
index 0000000..e0e137b
--- /dev/null
+++ b/public/projects/js-small-apps/budget-app/lib/class-user.js
@@ -0,0 +1,79 @@
+import Budget from "./class-budget.js";
+
+class User {
+ #id = 0;
+ #username = "Anonymous";
+ #firstName = "John";
+ #lastName = "Doe";
+ #role = "admin";
+ #locale = "en-US";
+ #accountCreation = new Date();
+ #budget = 0;
+
+ constructor(id, username) {
+ this.#id = id;
+ this.#username = username;
+ }
+
+ get id() {
+ return this.#id;
+ }
+
+ set username(name) {
+ this.#username = name;
+ }
+
+ get username() {
+ return this.#username;
+ }
+
+ set firstName(name) {
+ this.#firstName = name;
+ }
+
+ get firstName() {
+ return this.#firstName;
+ }
+
+ set lastName(name) {
+ this.#lastName = name;
+ }
+
+ get lastName() {
+ return this.#lastName;
+ }
+
+ set role(string) {
+ this.#role = string;
+ }
+
+ get role() {
+ return this.#role;
+ }
+
+ set locale(code) {
+ this.#locale = code;
+ }
+
+ get locale() {
+ return this.#locale;
+ }
+
+ get accountCreation() {
+ return this.#accountCreation;
+ }
+
+ set budget(number) {
+ this.#budget = new Budget(number);
+ }
+
+ get budget() {
+ return this.#budget;
+ }
+
+ name() {
+ return `${this.#firstName} ${this.#lastName}`;
+ }
+}
+
+export default User;
diff --git a/public/projects/js-small-apps/budget-app/lib/utils/currency.js b/public/projects/js-small-apps/budget-app/lib/utils/currency.js
new file mode 100644
index 0000000..04d3ad0
--- /dev/null
+++ b/public/projects/js-small-apps/budget-app/lib/utils/currency.js
@@ -0,0 +1,47 @@
+/**
+ * Convert a number to fr_FR locale.
+ * @param {Number} number A number to format.
+ * @returns A number formatted with fr_FR locale.
+ */
+const getCurrencyFR = (number) => {
+ const formatted =
+ Number.parseFloat(number)
+ .toFixed(2)
+ .replace(".", ",")
+ .replace(/(\d)(?=(\d{3})+(?!\d))/g, "$1 ") + " €";
+ return formatted;
+};
+
+/**
+ * Convert a number to en_US locale.
+ * @param {Number} number A number to format.
+ * @returns A number formatted with en_US locale.
+ */
+const getCurrencyUS = (number) => {
+ const formatted =
+ "$" +
+ Number.parseFloat(number)
+ .toFixed(2)
+ .replace(/(\d)(?=(\d{3})+(?!\d))/g, "$1,");
+ return formatted;
+};
+
+/**
+ * Get a number formatted based on a locale.
+ * @param {Number} number A number to format.
+ * @param {String} format A language code.
+ * @returns A formatted number.
+ */
+const getCurrencyFormat = (number, format) => {
+ switch (format) {
+ case "fr-FR":
+ return getCurrencyFR(number);
+ case "en-US":
+ return getCurrencyUS(number);
+ default:
+ console.log("Not supported!");
+ break;
+ }
+};
+
+export default getCurrencyFormat;
diff --git a/public/projects/js-small-apps/budget-app/style.css b/public/projects/js-small-apps/budget-app/style.css
new file mode 100644
index 0000000..009fbb7
--- /dev/null
+++ b/public/projects/js-small-apps/budget-app/style.css
@@ -0,0 +1,366 @@
+/*
+ * Base
+ */
+
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+body {
+ background: hsl(207, 52%, 95%);
+ color: #000;
+ font-family: Arial, Helvetica, sans-serif;
+ font-size: 1rem;
+ line-height: 1.618;
+ display: flex;
+ flex-flow: column nowrap;
+ min-height: 100vh;
+ max-width: 100vw;
+}
+
+button {
+ display: block;
+ font-family: inherit;
+ font-size: 100%;
+ line-height: 1.15;
+ cursor: pointer;
+}
+
+select {
+ cursor: pointer;
+}
+
+table {
+ width: 100%;
+}
+
+/*
+ * Layout
+ */
+
+.branding {
+ background: hsl(207, 35%, 90%);
+ border-bottom: 1px solid hsl(207, 35%, 80%);
+ box-shadow: 0 2px 0 1px hsl(207, 25%, 70%);
+ padding: 2rem 0;
+ margin-bottom: clamp(2rem, 3vw, 4rem);
+}
+
+.branding__title {
+ color: hsl(207, 85%, 27%);
+ font-size: clamp(1.9rem, 5vw, 2.2rem);
+ text-align: center;
+ text-shadow: 0 0 1px hsl(207, 100%, 50%);
+ text-transform: uppercase;
+}
+
+.footer {
+ background: hsl(207, 35%, 90%);
+ border-top: 1px solid hsl(207, 35%, 80%);
+ margin-top: clamp(3rem, 5vw, 4rem);
+ padding: 2rem 0 1.5rem;
+}
+
+.footer .btn--reset {
+ margin-bottom: 2rem;
+}
+
+.footer__copyright {
+ font-size: 0.9rem;
+ text-align: center;
+}
+
+.main {
+ flex: 1;
+ padding: 0 clamp(1rem, 3vw, 2rem);
+}
+
+.register {
+ display: flex;
+ flex-flow: column nowrap;
+ justify-content: center;
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100vh;
+ background: hsl(202, 19%, 89%);
+}
+
+.register__form {
+ margin: auto;
+}
+
+.app {
+ display: none;
+}
+
+.app__title {
+ background: hsl(207, 25%, 92%);
+ border: 1px solid hsl(207, 25%, 70%);
+ box-shadow: 0 2px 0 1px hsl(207, 25%, 70%);
+ grid-column: 1 / -1;
+ color: hsl(207, 85%, 27%);
+ font-size: clamp(1.4rem, 4vw, 1.7rem);
+ margin-bottom: 1rem;
+ padding: 0.5rem 1rem;
+}
+
+.app__management {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, min(calc(100vw - 2 * 1rem), 21rem));
+ justify-content: center;
+ column-gap: 2rem;
+}
+
+.app__budget {
+ margin-bottom: clamp(1rem, 3vw, 2rem);
+}
+
+.form__fieldset {
+ border: 2px solid hsl(207, 85%, 27%);
+ margin-bottom: clamp(1rem, 3vw, 2rem);
+ padding: 0 1rem 1rem;
+}
+
+.form__legend {
+ background: hsl(207, 85%, 37%);
+ border: 2px solid hsl(207, 85%, 27%);
+ color: #fff;
+ font-weight: 600;
+ width: 100%;
+ margin-bottom: 1rem;
+ padding: 0.5rem 1rem;
+}
+
+.form__item:not(:last-child) {
+ margin-bottom: 1rem;
+}
+
+.form__label {
+ display: block;
+ cursor: pointer;
+ font-size: 0.8rem;
+ font-weight: 600;
+ letter-spacing: 1px;
+ text-transform: uppercase;
+ margin-bottom: 0.1rem;
+}
+
+.form__field,
+.form__select {
+ border: 1px solid hsl(207, 85%, 27%);
+ box-shadow: 2px 2px 0 0 hsl(0, 0%, 20%);
+ font-size: inherit;
+ padding: 0.5rem clamp(0.5rem, 3vw, 0.8rem);
+ width: 100%;
+ transition: all 0.3s ease-in-out 0s;
+}
+
+.form__field:hover,
+.form__field:focus,
+.form__select:hover,
+.form__select:focus {
+ transform: scaleX(1.05);
+}
+
+.form__field:focus,
+.form__select:focus {
+ outline: none;
+ box-shadow: 2px 2px 0 0 hsl(0, 0%, 20%), 1px 1px 0 4px hsl(207, 85%, 60%);
+}
+
+.manage-categories .form__item {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) max-content;
+ column-gap: 1rem;
+}
+
+.manage-categories .form__label {
+ grid-column: 1 / -1;
+}
+
+.manage-categories .form__select,
+.manage-categories .form__field {
+ grid-column: 1;
+}
+
+.manage-categories .form__submit {
+ grid-column: 2;
+}
+
+.table {
+ border: 2px solid hsl(207, 85%, 27%);
+ border-collapse: collapse;
+ width: 100%;
+ overflow-x: auto;
+}
+
+.table__header {
+ background: hsl(207, 85%, 37%);
+ color: #fff;
+ font-weight: 600;
+}
+
+.table__item {
+ border: 1px solid hsl(207, 85%, 27%);
+ padding: 0.5rem 0.7rem;
+ width: max-content;
+}
+
+.table__item .btn {
+ display: inline-flex;
+ margin: 0.5rem;
+}
+
+.app__budget .table {
+ table-layout: fixed;
+}
+
+.app__budget .table__item {
+ font-weight: 600;
+ text-align: center;
+}
+
+.app__budget .table__body {
+ font-size: 1.3rem;
+}
+
+.app__history {
+ max-width: 100%;
+ overflow-x: auto;
+}
+
+.app__history .app__title {
+ position: sticky;
+ left: 0;
+}
+
+/*
+ * Components
+ */
+
+.btn {
+ border-radius: 5px;
+ font-weight: 600;
+ margin: auto;
+ padding: 0.6rem 0.8rem;
+ transition: all 0.35s ease-in-out 0s;
+}
+
+.btn:hover,
+.btn:focus {
+ transform: scaleX(1.2) scaleY(1.1);
+}
+
+.btn:focus {
+ outline: none;
+}
+
+.btn:active {
+ transform: scale(0.95);
+}
+
+.btn--register {
+ background: hsl(202, 67%, 84%);
+ border: 2px solid hsl(202, 67%, 34%);
+ box-shadow: 2px 2px 0 0 hsl(202, 67%, 24%);
+}
+
+.btn--add,
+.btn--update {
+ background: hsl(120, 33%, 84%);
+ border: 2px solid hsl(120, 33%, 34%);
+ box-shadow: 2px 2px 0 0 hsl(120, 33%, 24%);
+}
+
+.btn--add:focus,
+.btn--update:focus {
+ box-shadow: 2px 2px 0 0 hsl(120, 33%, 24%), 1px 1px 0 4px hsl(120, 33%, 81%);
+}
+
+.btn--add:active,
+.btn--update:active {
+ box-shadow: 2px 2px 0 0 hsl(120, 33%, 24%);
+}
+
+.btn--delete,
+.btn--reset {
+ background: hsl(0, 33%, 84%);
+ border: 2px solid hsl(0, 33%, 34%);
+ box-shadow: 2px 2px 0 0 hsl(0, 33%, 24%);
+}
+
+.btn--delete:focus,
+.btn--reset:focus {
+ box-shadow: 2px 2px 0 0 hsl(120, 33%, 24%), 1px 1px 0 4px hsl(0, 33%, 81%);
+}
+
+.btn--delete:active,
+.btn--reset:active {
+ box-shadow: 2px 2px 0 0 hsl(0, 33%, 24%);
+}
+
+.btn--rename,
+.btn--edit {
+ background: hsl(193, 33%, 84%);
+ border: 2px solid hsl(193, 33%, 34%);
+ box-shadow: 2px 2px 0 0 hsl(193, 33%, 24%);
+}
+
+.btn--rename:focus,
+.btn--edit:focus {
+ box-shadow: 2px 2px 0 0 hsl(120, 33%, 24%), 1px 1px 0 4px hsl(193, 33%, 81%);
+}
+
+.btn--delete:active,
+.btn--edit:active {
+ box-shadow: 2px 2px 0 0 hsl(193, 33%, 24%);
+}
+
+.notification {
+ background: #fff;
+ border: 3px solid hsl(207, 85%, 27%);
+ border-radius: 3px;
+ box-shadow: 1px 1px 3px 0 hsla(0, 0%, 0%, 0.7);
+ font-weight: 600;
+ padding: 1rem;
+}
+
+.notification--error {
+ color: hsl(0, 49%, 39%);
+ border-color: hsl(0, 49%, 39%);
+}
+
+.notification--info {
+ color: hsl(212, 76%, 38%);
+ border-color: hsl(212, 76%, 38%);
+}
+
+.notification--warning {
+ color: hsl(32, 76%, 38%);
+ border-color: hsl(32, 76%, 38%);
+}
+
+/*
+ * Media Queries
+ */
+@media screen and (min-width: 1200px) {
+ .main {
+ margin: auto;
+ max-width: 1200px;
+ }
+ .app__management {
+ grid-template-columns: repeat(3, min(calc(100vw - 2 * 1rem), 21rem));
+ }
+
+ .app__budget {
+ position: sticky;
+ top: 1rem;
+ height: max-content;
+ }
+}