I was able to reproduce the problem by setting my system to a timezone, and my `TZ` to a different one. Since it'll default to `UTC`, and to avoid having to pass it around from the system to the client (since we don't really care about the timezone), we simply force the timezone to UTC in the formatting as well, because, again, we don't store timezones or care about them for expenses. Fixes #88
843 lines
25 KiB
TypeScript
843 lines
25 KiB
TypeScript
import { useSignal } from '@preact/signals';
|
|
import { useCallback, 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 shouldResetExpenseModal = useSignal<boolean>(false);
|
|
const shouldResetBudgetModal = useSignal<boolean>(false);
|
|
const searchTimeout = useSignal<ReturnType<typeof setTimeout>>(0);
|
|
|
|
const dateFormatOptions: Intl.DateTimeFormatOptions = {
|
|
year: 'numeric',
|
|
month: 'long',
|
|
timeZone: 'UTC', // Expense dates are stored without timezone info, so we need to force to UTC so it's consistent across db, server, and client
|
|
};
|
|
|
|
const dateFormat = new Intl.DateTimeFormat('en-GB', dateFormatOptions);
|
|
|
|
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),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to export expenses. ${response.statusText} ${await response.text()}`);
|
|
}
|
|
|
|
const result = await response.json() as ExportResponseBody;
|
|
|
|
if (!result.success) {
|
|
throw new Error('Failed to export expenses!');
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
shouldResetExpenseModal.value = false;
|
|
editingExpense.value = null;
|
|
isExpenseModalOpen.value = true;
|
|
}
|
|
|
|
function onClickCreateBudget() {
|
|
areNewOptionsOption.value = false;
|
|
|
|
if (isBudgetModalOpen.value) {
|
|
isBudgetModalOpen.value = false;
|
|
return;
|
|
}
|
|
|
|
shouldResetBudgetModal.value = false;
|
|
editingBudget.value = null;
|
|
isBudgetModalOpen.value = true;
|
|
}
|
|
|
|
function onClickEditExpense(expenseId: string) {
|
|
areNewOptionsOption.value = false;
|
|
|
|
if (isExpenseModalOpen.value) {
|
|
isExpenseModalOpen.value = false;
|
|
return;
|
|
}
|
|
|
|
shouldResetExpenseModal.value = false;
|
|
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;
|
|
}
|
|
|
|
shouldResetBudgetModal.value = false;
|
|
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;
|
|
shouldResetExpenseModal.value = true;
|
|
} 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;
|
|
shouldResetExpenseModal.value = true;
|
|
} 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;
|
|
shouldResetBudgetModal.value = true;
|
|
} 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;
|
|
shouldResetBudgetModal.value = true;
|
|
} 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;
|
|
shouldResetExpenseModal.value = true;
|
|
} 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;
|
|
shouldResetBudgetModal.value = true;
|
|
} catch (error) {
|
|
console.error(error);
|
|
alert(error);
|
|
}
|
|
|
|
isSaving.value = false;
|
|
}
|
|
|
|
function onCloseExpense() {
|
|
isExpenseModalOpen.value = false;
|
|
editingExpense.value = null;
|
|
shouldResetExpenseModal.value = true;
|
|
}
|
|
|
|
function onCloseBudget() {
|
|
isBudgetModalOpen.value = false;
|
|
editingBudget.value = null;
|
|
shouldResetBudgetModal.value = true;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
// Open the expense modal if the window is small
|
|
const handleWindowResize = useCallback(() => {
|
|
if (globalThis.innerWidth < 600 && !isExpenseModalOpen.value && !editingExpense.value) {
|
|
isExpenseModalOpen.value = true;
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (searchTimeout.value) {
|
|
clearTimeout(searchTimeout.value);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
handleWindowResize();
|
|
globalThis.addEventListener('resize', handleWindowResize);
|
|
|
|
return () => {
|
|
globalThis.removeEventListener('resize', handleWindowResize);
|
|
};
|
|
}, [handleWindowResize]);
|
|
|
|
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()}
|
|
type='button'
|
|
>
|
|
New Expense
|
|
</button>
|
|
<button
|
|
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
|
|
onClick={() => onClickCreateBudget()}
|
|
type='button'
|
|
>
|
|
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()}
|
|
type='button'
|
|
>
|
|
Import
|
|
</button>
|
|
<button
|
|
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
|
|
onClick={() => onClickExportFile()}
|
|
type='button'
|
|
>
|
|
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}
|
|
shouldResetForm={shouldResetExpenseModal.value}
|
|
/>
|
|
|
|
<BudgetModal
|
|
isOpen={isBudgetModalOpen.value}
|
|
budget={editingBudget.value}
|
|
onClickSave={onClickSaveBudget}
|
|
onClickDelete={onClickDeleteBudget}
|
|
onClose={onCloseBudget}
|
|
shouldResetForm={shouldResetBudgetModal.value}
|
|
/>
|
|
</>
|
|
);
|
|
}
|