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:
@@ -50,6 +50,12 @@ export default function Header({ route, user }: Data) {
|
||||
label: 'Photos',
|
||||
}
|
||||
: null,
|
||||
isAppEnabled('expenses')
|
||||
? {
|
||||
url: '/expenses',
|
||||
label: 'Expenses',
|
||||
}
|
||||
: null,
|
||||
];
|
||||
|
||||
const menuItems = potentialMenuItems.filter(Boolean) as MenuItem[];
|
||||
@@ -67,6 +73,10 @@ export default function Header({ route, user }: Data) {
|
||||
pageLabel = 'Settings';
|
||||
}
|
||||
|
||||
if (route.startsWith('/expenses')) {
|
||||
pageLabel = 'Budgets & Expenses';
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
|
||||
119
components/expenses/BudgetModal.tsx
Normal file
119
components/expenses/BudgetModal.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
249
components/expenses/ExpenseModal.tsx
Normal file
249
components/expenses/ExpenseModal.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
138
components/expenses/ListBudgets.tsx
Normal file
138
components/expenses/ListBudgets.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
components/expenses/ListExpenses.tsx
Normal file
65
components/expenses/ListExpenses.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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