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:
794
components/expenses/MainExpenses.tsx
Normal file
794
components/expenses/MainExpenses.tsx
Normal 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 ? <> </> : 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user