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,119 @@
import { useSignal } from '@preact/signals';
import { useEffect } from 'preact/hooks';
import { Budget } from '/lib/types.ts';
interface BudgetModalProps {
isOpen: boolean;
budget: Budget | null;
onClickSave: (newBudgetName: string, newBudgetMonth: string, newBudgetValue: number) => Promise<void>;
onClickDelete: () => Promise<void>;
onClose: () => void;
}
export default function BudgetModal(
{ isOpen, budget, onClickSave, onClickDelete, onClose }: BudgetModalProps,
) {
const newBudgetName = useSignal<string>(budget?.name ?? '');
const newBudgetMonth = useSignal<string>(budget?.month ?? new Date().toISOString().substring(0, 10));
const newBudgetValue = useSignal<number>(budget?.value ?? 100);
useEffect(() => {
if (budget) {
newBudgetName.value = budget.name;
newBudgetMonth.value = `${budget.month}-15`;
newBudgetValue.value = budget.value;
} else {
newBudgetName.value = '';
newBudgetMonth.value = new Date().toISOString().substring(0, 10);
newBudgetValue.value = 100;
}
}, [budget]);
return (
<>
<section
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`}
>
</section>
<section
class={`fixed ${
isOpen ? 'block' : 'hidden'
} z-50 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 bg-slate-600 text-white rounded-md px-8 py-6 drop-shadow-lg overflow-y-scroll max-h-[80%]`}
>
<h1 class='text-2xl font-semibold my-5'>{budget ? 'Edit Budget' : 'Create New Budget'}</h1>
<section class='py-5 my-2 border-y border-slate-500'>
<fieldset class='block mb-2'>
<label class='text-slate-300 block pb-1' for='budget_name'>Name</label>
<input
class='input-field'
type='text'
name='budget_name'
id='budget_name'
value={newBudgetName.value}
onInput={(event) => {
newBudgetName.value = event.currentTarget.value;
}}
placeholder='Amazing'
/>
</fieldset>
<fieldset class='block mb-2'>
<label class='text-slate-300 block pb-1' for='budget_month'>Month</label>
<input
class='input-field'
type='date'
name='budget_month'
id='budget_month'
value={newBudgetMonth.value}
onInput={(event) => {
newBudgetMonth.value = event.currentTarget.value;
}}
placeholder='2025-01-01'
/>
</fieldset>
<fieldset class='block mb-2'>
<label class='text-slate-300 block pb-1' for='budget_value'>Value</label>
<input
class='input-field'
type='text'
name='budget_value'
id='budget_value'
value={newBudgetValue.value}
onInput={(event) => {
newBudgetValue.value = Number(event.currentTarget.value);
}}
placeholder='100'
/>
</fieldset>
</section>
<footer class='flex justify-between'>
{budget
? (
<button
class='px-5 py-2 bg-slate-600 hover:bg-red-600 text-white cursor-pointer rounded-md mr-2'
onClick={() => onClickDelete()}
>
Delete
</button>
)
: null}
<button
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md mr-2'
onClick={() => onClickSave(newBudgetName.value, newBudgetMonth.value.substring(0, 7), newBudgetValue.value)}
>
{budget ? 'Update' : 'Create'}
</button>
<button
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md ml-2'
onClick={() => onClose()}
>
{budget ? 'Cancel' : 'Close'}
</button>
</footer>
</section>
</>
);
}

View File

@@ -0,0 +1,249 @@
import { useSignal } from '@preact/signals';
import { useEffect } from 'preact/hooks';
import { Budget, Expense } from '/lib/types.ts';
import {
RequestBody as SuggestionsRequestBody,
ResponseBody as SuggestionsResponse,
} from '/routes/api/expenses/auto-complete.tsx';
interface ExpenseModalProps {
isOpen: boolean;
expense: Expense | null;
budgets: Budget[];
onClickSave: (
newExpenseCost: number,
newExpenseDescription: string,
newExpenseBudget: string,
newExpenseDate: string,
newExpenseIsRecurring: boolean,
) => Promise<void>;
onClickDelete: () => Promise<void>;
onClose: () => void;
}
export default function ExpenseModal(
{ isOpen, expense, budgets, onClickSave, onClickDelete, onClose }: ExpenseModalProps,
) {
const newExpenseCost = useSignal<number | ''>(expense?.cost ?? '');
const newExpenseDescription = useSignal<string>(expense?.description ?? '');
const newExpenseBudget = useSignal<string>(expense?.budget ?? 'Misc');
const newExpenseDate = useSignal<string>(expense?.date ?? '');
const newExpenseIsRecurring = useSignal<boolean>(expense?.is_recurring ?? false);
const suggestions = useSignal<string[]>([]);
const showSuggestions = useSignal<boolean>(false);
useEffect(() => {
if (expense) {
newExpenseCost.value = expense.cost;
newExpenseDescription.value = expense.description;
newExpenseBudget.value = expense.budget;
newExpenseDate.value = expense.date;
newExpenseIsRecurring.value = expense.is_recurring;
showSuggestions.value = false;
} else {
newExpenseCost.value = '';
newExpenseDescription.value = '';
newExpenseBudget.value = 'Misc';
newExpenseDate.value = '';
newExpenseIsRecurring.value = false;
showSuggestions.value = false;
}
}, [expense]);
const sortedBudgetNames = budgets.map((budget) => budget.name).sort();
if (!sortedBudgetNames.includes('Misc')) {
sortedBudgetNames.push('Misc');
sortedBudgetNames.sort();
}
const fetchSuggestions = async (name: string) => {
if (name.length < 2) {
suggestions.value = [];
showSuggestions.value = false;
return;
}
try {
const requestBody: SuggestionsRequestBody = {
name,
};
const response = await fetch(`/api/expenses/auto-complete`, {
method: 'POST',
body: JSON.stringify(requestBody),
});
if (response.ok) {
const result = await response.json() as SuggestionsResponse;
suggestions.value = result.suggestions;
showSuggestions.value = true;
}
} catch (error) {
console.error('Failed to fetch suggestions:', error);
suggestions.value = [];
showSuggestions.value = false;
}
};
return (
<>
<section
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`}
>
</section>
<section
class={`fixed ${
isOpen ? 'block' : 'hidden'
} z-50 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 bg-slate-600 text-white rounded-md px-8 py-6 drop-shadow-lg overflow-y-scroll max-h-[80%]`}
>
<h1 class='text-2xl font-semibold my-5'>{expense ? 'Edit Expense' : 'Create New Expense'}</h1>
<section class='py-5 my-2 border-y border-slate-500'>
<fieldset class='block mb-2'>
<label class='text-slate-300 block pb-1' for='expense_cost'>Cost</label>
<input
class='input-field'
type='number'
name='expense_cost'
id='expense_cost'
value={newExpenseCost.value}
onInput={(event) => {
newExpenseCost.value = Number(event.currentTarget.value);
}}
placeholder='10.99'
/>
</fieldset>
<fieldset class='block mb-2 relative'>
<label class='text-slate-300 block pb-1' for='expense_description'>Description</label>
<input
class='input-field'
type='text'
name='expense_description'
id='expense_description'
value={newExpenseDescription.value}
onInput={(event) => {
newExpenseDescription.value = event.currentTarget.value;
fetchSuggestions(event.currentTarget.value);
}}
onFocus={() => {
if (suggestions.value.length > 0) {
showSuggestions.value = true;
}
}}
onBlur={() => {
setTimeout(() => {
showSuggestions.value = false;
}, 200);
}}
placeholder='Lunch'
/>
{showSuggestions.value && suggestions.value.length > 0 && (
<ul class='absolute z-50 w-full bg-slate-700 rounded-md mt-1 max-h-40 overflow-y-auto ring-1 ring-slate-800 shadow-lg'>
{suggestions.value.map((suggestion) => (
<li
key={suggestion}
class='px-4 py-2 hover:bg-slate-600 cursor-pointer'
onClick={() => {
newExpenseDescription.value = suggestion;
showSuggestions.value = false;
suggestions.value = [];
}}
>
{suggestion}
</li>
))}
</ul>
)}
</fieldset>
<fieldset class='block mb-2'>
<label class='text-slate-300 block pb-1' for='expense_budget'>Budget</label>
<select
class='input-field'
name='expense_budget'
id='expense_budget'
value={newExpenseBudget.value}
onSelect={(event) => {
newExpenseBudget.value = event.currentTarget.value;
}}
placeholder='Misc'
>
{sortedBudgetNames.map((budget) => (
<option value={budget} selected={newExpenseBudget.value === budget}>{budget}</option>
))}
</select>
</fieldset>
<fieldset class='block mb-2'>
<label class='text-slate-300 block pb-1' for='expense_date'>Date</label>
<input
class='input-field'
type='date'
name='expense_date'
id='expense_date'
value={newExpenseDate.value}
onInput={(event) => {
newExpenseDate.value = event.currentTarget.value;
}}
placeholder='2025-01-01'
/>
</fieldset>
{expense
? (
<fieldset class='block mb-2'>
<label class='text-slate-300 block pb-1' for='expense_is_recurring'>Is Recurring?</label>
<input
class='input-field'
type='checkbox'
name='expense_is_recurring'
id='expense_is_recurring'
value='true'
checked={newExpenseIsRecurring.value}
onInput={(event) => {
newExpenseIsRecurring.value = event.currentTarget.checked;
}}
/>
</fieldset>
)
: null}
</section>
<footer class='flex justify-between'>
{expense
? (
<button
class='px-5 py-2 bg-slate-600 hover:bg-red-600 text-white cursor-pointer rounded-md mr-2'
onClick={() => onClickDelete()}
>
Delete
</button>
)
: null}
<button
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md mr-2'
onClick={() =>
onClickSave(
newExpenseCost.value as number,
newExpenseDescription.value,
newExpenseBudget.value,
newExpenseDate.value,
newExpenseIsRecurring.value,
)}
>
{expense ? 'Update' : 'Create'}
</button>
<button
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md ml-2'
onClick={() => onClose()}
>
{expense ? 'Cancel' : 'Close'}
</button>
</footer>
</section>
</>
);
}

View File

@@ -0,0 +1,138 @@
import { useSignal } from '@preact/signals';
import { useEffect, useRef } from 'preact/hooks';
import { Chart } from 'chart.js';
import { formatNumber } from '/lib/utils/misc.ts';
import { Budget, SupportedCurrencySymbol } from '/lib/types.ts';
interface ListBudgetsProps {
budgets: Budget[];
month: string;
currency: SupportedCurrencySymbol;
onClickEditBudget: (budgetId: string) => void;
}
export default function ListBudgets(
{
budgets,
month,
currency,
onClickEditBudget,
}: ListBudgetsProps,
) {
const view = useSignal<'list' | 'chart'>('list');
const chartRef = useRef<HTMLCanvasElement>(null);
// Calculate a total budget to show before all others
const totalBudget: Omit<Budget, 'user_id' | 'created_at'> = {
id: 'total',
name: 'Total',
month,
value: budgets.reduce((accumulatedValue, budget) => accumulatedValue + budget.value, 0),
extra: {
usedValue: budgets.reduce((accumulatedValue, budget) => accumulatedValue + budget.extra.usedValue, 0),
availableValue: budgets.reduce((accumulatedValue, budget) => accumulatedValue + budget.extra.availableValue, 0),
},
};
function swapView(newView: 'list' | 'chart') {
view.value = view.value === newView ? 'list' : newView;
}
useEffect(() => {
if (view.value === 'chart') {
const budgetColors = [totalBudget, ...budgets].map((_, index) =>
index === 0
? 'rgba(59, 130, 246, 0.8)'
: `hsl(${(index - 1) * (360 / budgets.length)}, ${index % 2 ? 85 : 70}%, ${index % 2 ? 55 : 65}%, 0.8)`
);
new Chart(chartRef.current as HTMLCanvasElement, {
type: 'doughnut',
data: {
labels: [{ name: 'Available' }, ...budgets].map((budget) => budget.name),
datasets: [{
label: '',
data: [totalBudget, ...budgets].map((budget) =>
budget.id === 'total' ? budget.extra.availableValue : budget.extra.usedValue
),
backgroundColor: budgetColors,
borderWidth: 1,
borderColor: '#222',
}],
},
options: {
backgroundColor: '#222',
plugins: {
legend: {
position: 'bottom',
fullSize: true,
labels: {
usePointStyle: true,
pointStyle: 'circle',
padding: 10,
},
},
},
},
});
}
}, [view.value]);
return (
<section class='mx-auto max-w-7xl my-8'>
{budgets.length === 0
? (
<article class='px-6 py-4 font-normal text-center w-full'>
<div class='font-medium text-slate-400 text-md'>No budgets to show for {month}</div>
</article>
)
: (
<section class='w-full flex flex-wrap gap-4 justify-center items-center'>
{view.value === 'list'
? [totalBudget, ...budgets].map((budget) => {
let backgroundColorClass = 'bg-green-600';
let usedValuePercentage = Math.ceil(100 * budget.extra.usedValue / budget.value);
if (usedValuePercentage >= 100) {
usedValuePercentage = 100;
backgroundColorClass = 'bg-red-600';
}
return (
<div
onClick={() => budget.id === 'total' ? swapView('chart') : onClickEditBudget(budget.id)}
class='flex max-w-sm gap-y-4 gap-x-4 rounded shadow-md bg-slate-700 relative cursor-pointer py-4 px-6 hover:opacity-80'
>
<article class='order-first tracking-tight flex flex-col text-base mr-4'>
<span class='font-bold text-lg' title='Amount used from budgeted amount'>
{formatNumber(currency, budget.extra.usedValue)} of {formatNumber(currency, budget.value)}
</span>
<span
class='bg-gray-600 h-1.5 w-full block rounded-full mt-2 mx-0'
title={`${usedValuePercentage}% of budget used`}
>
<span
class={`${backgroundColorClass} w-0 block h-1.5 rounded-full`}
style={{ width: `${usedValuePercentage}%` }}
>
</span>
</span>
<span class='mt-2 font-normal text-gray-400'>{budget.name}</span>
</article>
<span class='text-lg text-right text-gray-200' title='Amount available from budgeted amount'>
{formatNumber(currency, budget.extra.availableValue)}
</span>
</div>
);
})
: (
<section class='p-4 rounded-lg shadow-sm cursor-pointer' onClick={() => swapView('list')}>
<canvas ref={chartRef}></canvas>
</section>
)}
</section>
)}
</section>
);
}

View File

@@ -0,0 +1,65 @@
import { Expense, SupportedCurrencySymbol } from '/lib/types.ts';
import { formatNumber } from '/lib/utils/misc.ts';
interface ListExpensesProps {
expenses: Expense[];
currency: SupportedCurrencySymbol;
onClickEditExpense: (expenseId: string) => void;
}
export default function ListExpenses(
{
expenses,
currency,
onClickEditExpense,
}: ListExpensesProps,
) {
const dateFormat = new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
});
return (
<section class='mx-auto max-w-7xl my-8 mt-12'>
{expenses.length === 0
? (
<article class='px-6 py-4 font-normal text-center w-full'>
<div class='font-medium text-slate-400 text-md'>No expenses to show</div>
</article>
)
: (
<section class='w-full overflow-x-auto'>
<table class='w-full border-collapse text-gray-200 rounded-lg overflow-hidden'>
<thead class='bg-slate-900 hidden md:table-header-group'>
<tr>
<th class='px-6 py-3 text-left text-sm font-normal'>Description</th>
<th class='px-6 py-3 text-left text-sm font-normal'>Budget</th>
<th class='px-6 py-3 text-left text-sm font-normal'>Date</th>
<th class='px-6 py-3 text-left text-sm font-normal'>Cost</th>
</tr>
</thead>
<tbody>
{expenses.map((expense) => (
<tr
key={expense.id}
class='text-white border-t border-slate-700 hover:bg-slate-600 transition-colors even:bg-slate-700 odd:bg-slate-800 cursor-pointer flex md:table-row flex-row flex-wrap my-4 mx-4 md:my-0 md:mx-0 rounded md:rounded-none shadow-md md:shadow-none relative py-4 md:py-0 px-6 md:px-0'
onClick={() => onClickEditExpense(expense.id)}
>
<td class='md:px-6 md:py-3 flex-[50] mx-2 md:mx-0'>{expense.description}</td>
<td class='md:px-6 md:py-3 flex-[20] mx-2 md:mx-0 text-gray-400 md:text-gray-300'>
{expense.budget}
</td>
<td class='md:px-6 md:py-3 flex-[15] mx-2 md:mx-0 text-gray-400 md:text-gray-300'>
{dateFormat.format(new Date(expense.date))}
</td>
<td class='md:px-6 md:py-3 flex-[15] mx-2 md:mx-0 font-bold md:font-semibold'>
{formatNumber(currency, expense.cost)}
</td>
</tr>
))}
</tbody>
</table>
</section>
)}
</section>
);
}

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}
/>
</>
);
}