Add Expenses app

A UI based on [Budget Zen](https://github.com/BrunoBernardino/budgetzen-web) but slightly updated and adjusted for bewCloud. It also features a chart with available money and spent by budgets.

This is useful for envelope-based budgeting.
This commit is contained in:
Bruno Bernardino
2025-02-26 17:43:53 +00:00
parent 869e712432
commit 874ab006f9
29 changed files with 2677 additions and 12 deletions

View File

@@ -0,0 +1,794 @@
import { useSignal } from '@preact/signals';
import { useEffect } from 'preact/hooks';
import { Budget, Expense, SupportedCurrencySymbol } from '/lib/types.ts';
import {
RequestBody as ImportRequestBody,
ResponseBody as ImportResponseBody,
} from '/routes/api/expenses/import-expenses.tsx';
import {
RequestBody as ExportRequestBody,
ResponseBody as ExportResponseBody,
} from '/routes/api/expenses/export-expenses.tsx';
import {
RequestBody as AddExpenseRequestBody,
ResponseBody as AddExpenseResponseBody,
} from '/routes/api/expenses/add-expense.tsx';
import {
RequestBody as AddBudgetRequestBody,
ResponseBody as AddBudgetResponseBody,
} from '/routes/api/expenses/add-budget.tsx';
import {
RequestBody as UpdateExpenseRequestBody,
ResponseBody as UpdateExpenseResponseBody,
} from '/routes/api/expenses/update-expense.tsx';
import {
RequestBody as UpdateBudgetRequestBody,
ResponseBody as UpdateBudgetResponseBody,
} from '/routes/api/expenses/update-budget.tsx';
import {
RequestBody as DeleteExpenseRequestBody,
ResponseBody as DeleteExpenseResponseBody,
} from '/routes/api/expenses/delete-expense.tsx';
import {
RequestBody as DeleteBudgetRequestBody,
ResponseBody as DeleteBudgetResponseBody,
} from '/routes/api/expenses/delete-budget.tsx';
import ListBudgets from '/components/expenses/ListBudgets.tsx';
import ListExpenses from '/components/expenses/ListExpenses.tsx';
import ExpenseModal from './ExpenseModal.tsx';
import BudgetModal from './BudgetModal.tsx';
interface MainExpensesProps {
initialBudgets: Budget[];
initialExpenses: Expense[];
initialMonth: string;
currency: SupportedCurrencySymbol;
}
export default function MainExpenses({ initialBudgets, initialExpenses, initialMonth, currency }: MainExpensesProps) {
const isSaving = useSignal<boolean>(false);
const isImporting = useSignal<boolean>(false);
const isExporting = useSignal<boolean>(false);
const isSearching = useSignal<boolean>(false);
const budgets = useSignal<Budget[]>(initialBudgets);
const expenses = useSignal<Expense[]>(initialExpenses);
const currentMonth = useSignal<string>(initialMonth);
const areNewOptionsOption = useSignal<boolean>(false);
const isExpenseModalOpen = useSignal<boolean>(false);
const editingExpense = useSignal<Expense | null>(null);
const isBudgetModalOpen = useSignal<boolean>(false);
const editingBudget = useSignal<Budget | null>(null);
const searchTimeout = useSignal<ReturnType<typeof setTimeout>>(0);
const dateFormat = new Intl.DateTimeFormat('en-GB', { year: 'numeric', month: 'long' });
const thisMonth = new Date().toISOString().substring(0, 7);
function onClickImportFile() {
areNewOptionsOption.value = false;
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.multiple = true;
fileInput.accept = 'text/pain,application/json,.json';
fileInput.ariaLabel = 'Import your budgets and expenses';
fileInput.click();
fileInput.onchange = async (event) => {
const chosenFilesList = (event.target as HTMLInputElement)?.files!;
const chosenFiles = Array.from(chosenFilesList);
isImporting.value = true;
for (const chosenFile of chosenFiles) {
if (!chosenFile) {
continue;
}
areNewOptionsOption.value = false;
let importedFileData: { budgets?: ImportRequestBody['budgets']; expenses?: ImportRequestBody['expenses'] } = {};
try {
importedFileData = JSON.parse(await chosenFile.text());
} catch (_error) {
importedFileData = {};
}
if (
!Object.prototype.hasOwnProperty.call(importedFileData, 'budgets') &&
!Object.prototype.hasOwnProperty.call(importedFileData, 'expenses')
) {
alert('Could not parse the file. Please confirm what you chose is correct.');
return;
}
const budgetsToImport = importedFileData.budgets || [];
const expensesToImport = importedFileData.expenses || [];
const mergeOrReplace = prompt(
'Do you want to merge or replace the existing expenses and budgets? (merge/replace)',
);
if (!mergeOrReplace || (mergeOrReplace !== 'merge' && mergeOrReplace !== 'replace')) {
alert('Invalid input. Please enter "merge" or "replace".');
return;
}
try {
const requestBody: ImportRequestBody = {
budgets: budgetsToImport,
expenses: expensesToImport,
month: currentMonth.value,
replace: mergeOrReplace === 'replace',
};
const response = await fetch(`/api/expenses/import-expenses`, {
method: 'POST',
body: JSON.stringify(requestBody),
});
if (!response.ok) {
throw new Error(`Failed to import expenses and budgets! ${response.statusText} ${await response.text()}`);
}
const result = await response.json() as ImportResponseBody;
if (!result.success) {
throw new Error('Failed to import expenses and budgets!');
}
budgets.value = [...result.newBudgets];
expenses.value = [...result.newExpenses];
} catch (error) {
console.error(error);
alert(error);
}
}
isImporting.value = false;
};
}
async function onClickExportFile() {
areNewOptionsOption.value = false;
isExporting.value = true;
const fileName = `expenses-data-export-${new Date().toISOString().substring(0, 19).replace(/:/g, '-')}.json`;
try {
const requestBody: ExportRequestBody = {};
const response = await fetch(`/api/expenses/export-expenses`, {
method: 'POST',
body: JSON.stringify(requestBody),
});
const result = await response.json() as ExportResponseBody;
if (!result.success) {
throw new Error('Failed to get contact!');
}
const exportContents = JSON.stringify(result.jsonContents, null, 2);
// Add content-type
const jsonContent = `data:application/json; charset=utf-8,${encodeURIComponent(exportContents)}`;
// Download the file
const data = jsonContent;
const link = document.createElement('a');
link.setAttribute('href', data);
link.setAttribute('download', fileName);
link.click();
link.remove();
} catch (error) {
console.error(error);
alert(error);
}
isExporting.value = false;
}
function onClickCreateExpense() {
areNewOptionsOption.value = false;
if (isExpenseModalOpen.value) {
isExpenseModalOpen.value = false;
return;
}
editingExpense.value = null;
isExpenseModalOpen.value = true;
}
function onClickCreateBudget() {
areNewOptionsOption.value = false;
if (isBudgetModalOpen.value) {
isBudgetModalOpen.value = false;
return;
}
editingBudget.value = null;
isBudgetModalOpen.value = true;
}
function onClickEditExpense(expenseId: string) {
areNewOptionsOption.value = false;
if (isExpenseModalOpen.value) {
isExpenseModalOpen.value = false;
return;
}
editingExpense.value = expenses.value.find((expense) => expense.id === expenseId)!;
isExpenseModalOpen.value = true;
}
function onClickEditBudget(budgetId: string) {
areNewOptionsOption.value = false;
if (isBudgetModalOpen.value) {
isBudgetModalOpen.value = false;
return;
}
// Can't edit the total budget
if (budgetId === 'total') {
return;
}
editingBudget.value = budgets.value.find((budget) => budget.id === budgetId)!;
isBudgetModalOpen.value = true;
}
async function onClickSaveExpense(
newExpenseCost: number,
newExpenseDescription: string,
newExpenseBudget: string,
newExpenseDate: string,
newExpenseIsRecurring: boolean,
) {
if (isSaving.value) {
return;
}
if (!newExpenseCost || Number.isNaN(newExpenseCost) || !newExpenseDescription) {
return;
}
areNewOptionsOption.value = false;
isSaving.value = true;
if (editingExpense.value) {
const requestBody: UpdateExpenseRequestBody = {
id: editingExpense.value.id,
cost: newExpenseCost,
description: newExpenseDescription,
budget: newExpenseBudget,
date: newExpenseDate,
is_recurring: newExpenseIsRecurring,
month: currentMonth.value,
};
try {
const response = await fetch(`/api/expenses/update-expense`, {
method: 'POST',
body: JSON.stringify(requestBody),
});
if (!response.ok) {
throw new Error(`Failed to update expense! ${response.statusText} ${await response.text()}`);
}
const result = await response.json() as UpdateExpenseResponseBody;
if (!result.success) {
throw new Error('Failed to update expense!');
}
expenses.value = [...result.newExpenses];
budgets.value = [...result.newBudgets];
isExpenseModalOpen.value = false;
editingExpense.value = null;
} catch (error) {
console.error(error);
alert(error);
}
} else {
const requestBody: AddExpenseRequestBody = {
cost: newExpenseCost,
description: newExpenseDescription,
budget: newExpenseBudget,
date: newExpenseDate,
is_recurring: newExpenseIsRecurring,
month: currentMonth.value,
};
try {
const response = await fetch(`/api/expenses/add-expense`, {
method: 'POST',
body: JSON.stringify(requestBody),
});
if (!response.ok) {
throw new Error(`Failed to add expense! ${response.statusText} ${await response.text()}`);
}
const result = await response.json() as AddExpenseResponseBody;
if (!result.success) {
throw new Error('Failed to add expense!');
}
expenses.value = [...result.newExpenses];
budgets.value = [...result.newBudgets];
isExpenseModalOpen.value = false;
} catch (error) {
console.error(error);
alert(error);
}
}
isSaving.value = false;
}
async function onClickSaveBudget(newBudgetName: string, newBudgetMonth: string, newBudgetValue: number) {
if (isSaving.value) {
return;
}
if (
!newBudgetName || !newBudgetMonth || !newBudgetMonth.match(/^\d{4}-\d{2}$/) || !newBudgetValue ||
Number.isNaN(newBudgetValue)
) {
return;
}
areNewOptionsOption.value = false;
isSaving.value = true;
if (editingBudget.value) {
const requestBody: UpdateBudgetRequestBody = {
id: editingBudget.value.id,
name: newBudgetName,
month: newBudgetMonth,
value: newBudgetValue,
currentMonth: currentMonth.value,
};
try {
const response = await fetch(`/api/expenses/update-budget`, {
method: 'POST',
body: JSON.stringify(requestBody),
});
if (!response.ok) {
throw new Error(`Failed to update budget! ${response.statusText} ${await response.text()}`);
}
const result = await response.json() as UpdateBudgetResponseBody;
if (!result.success) {
throw new Error('Failed to update budget!');
}
budgets.value = [...result.newBudgets];
isBudgetModalOpen.value = false;
editingBudget.value = null;
} catch (error) {
console.error(error);
alert(error);
}
} else {
const requestBody: AddBudgetRequestBody = {
name: newBudgetName,
month: newBudgetMonth,
value: newBudgetValue,
currentMonth: currentMonth.value,
};
try {
const response = await fetch(`/api/expenses/add-budget`, {
method: 'POST',
body: JSON.stringify(requestBody),
});
if (!response.ok) {
throw new Error(`Failed to add budget! ${response.statusText} ${await response.text()}`);
}
const result = await response.json() as AddBudgetResponseBody;
if (!result.success) {
throw new Error('Failed to add budget!');
}
budgets.value = [...result.newBudgets];
isBudgetModalOpen.value = false;
} catch (error) {
console.error(error);
alert(error);
}
}
isSaving.value = false;
}
async function onClickDeleteExpense() {
if (isSaving.value) {
return;
}
if (!editingExpense.value) {
return;
}
if (!confirm('Are you sure you want to delete this expense?')) {
return;
}
isSaving.value = true;
const requestBody: DeleteExpenseRequestBody = {
id: editingExpense.value.id,
month: currentMonth.value,
};
try {
const response = await fetch(`/api/expenses/delete-expense`, {
method: 'POST',
body: JSON.stringify(requestBody),
});
if (!response.ok) {
throw new Error(`Failed to delete expense! ${response.statusText} ${await response.text()}`);
}
const result = await response.json() as DeleteExpenseResponseBody;
if (!result.success) {
throw new Error('Failed to delete expense!');
}
expenses.value = [...result.newExpenses];
budgets.value = [...result.newBudgets];
isExpenseModalOpen.value = false;
editingExpense.value = null;
} catch (error) {
console.error(error);
alert(error);
}
isSaving.value = false;
}
async function onClickDeleteBudget() {
if (isSaving.value) {
return;
}
if (!editingBudget.value) {
return;
}
if (!confirm('Are you sure you want to delete this budget?')) {
return;
}
isSaving.value = true;
const requestBody: DeleteBudgetRequestBody = {
id: editingBudget.value.id,
currentMonth: currentMonth.value,
};
try {
const response = await fetch(`/api/expenses/delete-budget`, {
method: 'POST',
body: JSON.stringify(requestBody),
});
if (!response.ok) {
throw new Error(`Failed to delete budget! ${response.statusText} ${await response.text()}`);
}
const result = await response.json() as DeleteBudgetResponseBody;
if (!result.success) {
throw new Error('Failed to delete budget!');
}
budgets.value = [...result.newBudgets];
isBudgetModalOpen.value = false;
editingBudget.value = null;
} catch (error) {
console.error(error);
alert(error);
}
isSaving.value = false;
}
function onCloseExpense() {
isExpenseModalOpen.value = false;
editingExpense.value = null;
}
function onCloseBudget() {
isBudgetModalOpen.value = false;
editingBudget.value = null;
}
function toggleNewOptionsDropdown() {
areNewOptionsOption.value = !areNewOptionsOption.value;
}
function onClickChangeMonth(changeTo: 'previous' | 'next' | 'today') {
const previousMonth = new Date(
new Date(`${currentMonth.value}-15`).setUTCMonth(new Date(`${currentMonth.value}-15`).getUTCMonth() - 1),
).toISOString()
.substring(0, 7);
const nextMonth = new Date(
new Date(`${currentMonth.value}-15`).setUTCMonth(new Date(`${currentMonth.value}-15`).getUTCMonth() + 1),
).toISOString()
.substring(0, 7);
if (changeTo === 'today') {
if (thisMonth === currentMonth.value) {
return;
}
window.location.href = `/expenses?month=${thisMonth}`;
return;
}
if (changeTo === 'previous') {
const newStartDate = previousMonth;
if (newStartDate === currentMonth.value) {
return;
}
window.location.href = `/expenses?month=${newStartDate}`;
return;
}
const newStartDate = nextMonth;
if (newStartDate === currentMonth.value) {
return;
}
window.location.href = `/expenses?month=${newStartDate}`;
}
function searchExpenses(searchTerm: string) {
if (searchTimeout.value) {
clearTimeout(searchTimeout.value);
}
if (searchTerm.trim().length < 2) {
expenses.value = initialExpenses;
return;
}
searchTimeout.value = setTimeout(() => {
isSearching.value = true;
try {
const normalizedSearchTerm = searchTerm.trim().normalize().toLowerCase();
const filteredExpenses = initialExpenses.filter((expense) => {
const descriptionMatch = expense.description.toLowerCase().includes(normalizedSearchTerm);
const budgetMatch = expense.budget.toLowerCase().includes(normalizedSearchTerm);
return descriptionMatch || budgetMatch;
});
expenses.value = filteredExpenses;
} catch (error) {
console.error(error);
alert(error);
expenses.value = initialExpenses;
}
isSearching.value = false;
}, 500);
}
useEffect(() => {
return () => {
if (searchTimeout.value) {
clearTimeout(searchTimeout.value);
}
};
}, []);
return (
<>
<section class='block md:flex flex-row items-center justify-between mb-4'>
<section class='relative inline-block text-left ml-2 md:ml-0 mr-0 md:mr-2 mb-4 md:mb-0'>
<section class='flex flex-row items-center justify-start w-72'>
<input
class='input-field mr-2'
type='search'
name='search'
placeholder='Filter expenses...'
onInput={(event) => searchExpenses(event.currentTarget.value)}
/>
{isSearching.value ? <img src='/images/loading.svg' class='white mr-2' width={18} height={18} /> : null}
</section>
</section>
<section class='flex items-center justify-end w-full'>
<h3 class='text-base font-semibold text-white whitespace-nowrap mr-2'>
<time datetime={`${currentMonth.value}-15`}>{dateFormat.format(new Date(`${currentMonth.value}-15`))}</time>
</h3>
<section class='ml-2 relative flex items-center rounded-md bg-slate-700 shadow-sm md:items-stretch'>
<button
type='button'
class='flex h-9 w-12 items-center justify-center rounded-l-md text-white hover:bg-slate-600 focus:relative'
onClick={() => onClickChangeMonth('previous')}
>
<span class='sr-only'>Previous month</span>
<svg class='h-5 w-5' viewBox='0 0 20 20' fill='currentColor' aria-hidden='true'>
<path
fill-rule='evenodd'
d='M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z'
clip-rule='evenodd'
/>
</svg>
</button>
<button
type='button'
class='px-3.5 text-sm font-semibold text-white hover:bg-slate-600 focus:relative'
onClick={() => onClickChangeMonth('today')}
>
Today
</button>
<button
type='button'
class='flex h-9 w-12 items-center justify-center rounded-r-md text-white hover:bg-slate-600 pl-1 focus:relative'
onClick={() => onClickChangeMonth('next')}
>
<span class='sr-only'>Next month</span>
<svg class='h-5 w-5' viewBox='0 0 20 20' fill='currentColor' aria-hidden='true'>
<path
fill-rule='evenodd'
d='M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z'
clip-rule='evenodd'
/>
</svg>
</button>
</section>
<section class='relative inline-block text-left ml-2 mr-4 md:mr-0'>
<div>
<button
class='inline-block justify-center gap-x-1.5 rounded-md bg-[#51A4FB] px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-sky-400 ml-2 min-w-10'
type='button'
title='Add new expense or budget'
id='new-button'
aria-expanded='true'
aria-haspopup='true'
onClick={() => toggleNewOptionsDropdown()}
>
<img
src='/images/add.svg'
alt='Add new expense or budget'
class={`white ${isSaving.value || isImporting.value ? 'animate-spin' : ''}`}
width={20}
height={20}
/>
</button>
</div>
<div
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${
!areNewOptionsOption.value ? 'hidden' : ''
}`}
role='menu'
aria-orientation='vertical'
aria-labelledby='new-button'
tabindex={-1}
>
<div class='py-1'>
<button
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
onClick={() => onClickCreateExpense()}
>
New Expense
</button>
<button
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
onClick={() => onClickCreateBudget()}
>
New Budget
</button>
<section class='flex items-center justify-center my-1'>
<div class='w-full border-t border-slate-600 mx-4' />
</section>
<button
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
onClick={() => onClickImportFile()}
>
Import
</button>
<button
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
onClick={() => onClickExportFile()}
>
Export
</button>
</div>
</div>
</section>
</section>
</section>
<section class='mx-auto max-w-7xl my-8'>
<ListBudgets
budgets={budgets.value}
month={currentMonth.value}
currency={currency}
onClickEditBudget={onClickEditBudget}
/>
<ListExpenses
expenses={expenses.value}
currency={currency}
onClickEditExpense={onClickEditExpense}
/>
<span
class={`flex justify-end items-center text-sm mt-1 mx-2 text-slate-100`}
>
{isSaving.value
? (
<>
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Saving...
</>
)
: null}
{isImporting.value
? (
<>
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Importing...
</>
)
: null}
{isExporting.value
? (
<>
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Exporting...
</>
)
: null}
{!isSaving.value && !isImporting.value && !isExporting.value ? <>&nbsp;</> : null}
</span>
</section>
<ExpenseModal
isOpen={isExpenseModalOpen.value}
expense={editingExpense.value}
budgets={budgets.value}
onClickSave={onClickSaveExpense}
onClickDelete={onClickDeleteExpense}
onClose={onCloseExpense}
/>
<BudgetModal
isOpen={isBudgetModalOpen.value}
budget={editingBudget.value}
onClickSave={onClickSaveBudget}
onClickDelete={onClickDeleteBudget}
onClose={onCloseBudget}
/>
</>
);
}