mirror of
https://github.com/bewcloud/bewcloud.git
synced 2026-03-11 08:54:49 +00:00
198 lines
6.6 KiB
JavaScript
198 lines
6.6 KiB
JavaScript
|
|
import { useSignal } from '@preact/signals';
|
||
|
|
import { useEffect } from 'preact/hooks';
|
||
|
|
import { formatInputToNumber } from '/public/ts/utils/misc.ts';
|
||
|
|
export default function ExpenseModal({
|
||
|
|
isOpen,
|
||
|
|
expense,
|
||
|
|
budgets,
|
||
|
|
onClickSave,
|
||
|
|
onClickDelete,
|
||
|
|
onClose,
|
||
|
|
shouldResetForm
|
||
|
|
}) {
|
||
|
|
const newExpenseCost = useSignal(expense?.cost ?? '');
|
||
|
|
const newExpenseDescription = useSignal(expense?.description ?? '');
|
||
|
|
const newExpenseBudget = useSignal(expense?.budget ?? 'Misc');
|
||
|
|
const newExpenseDate = useSignal(expense?.date ?? '');
|
||
|
|
const newExpenseIsRecurring = useSignal(expense?.is_recurring ?? false);
|
||
|
|
const suggestions = useSignal([]);
|
||
|
|
const showSuggestions = useSignal(false);
|
||
|
|
const resetForm = () => {
|
||
|
|
newExpenseCost.value = '';
|
||
|
|
newExpenseDescription.value = '';
|
||
|
|
newExpenseBudget.value = 'Misc';
|
||
|
|
newExpenseDate.value = '';
|
||
|
|
newExpenseIsRecurring.value = 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;
|
||
|
|
}
|
||
|
|
if (shouldResetForm) {
|
||
|
|
resetForm();
|
||
|
|
}
|
||
|
|
}, [expense, shouldResetForm]);
|
||
|
|
const sortedBudgetNames = budgets.map(budget => budget.name).sort();
|
||
|
|
if (!sortedBudgetNames.includes('Misc')) {
|
||
|
|
sortedBudgetNames.push('Misc');
|
||
|
|
sortedBudgetNames.sort();
|
||
|
|
}
|
||
|
|
const fetchSuggestions = async name => {
|
||
|
|
if (name.length < 2) {
|
||
|
|
suggestions.value = [];
|
||
|
|
showSuggestions.value = false;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
const requestBody = {
|
||
|
|
name
|
||
|
|
};
|
||
|
|
const response = await fetch(`/api/expenses/auto-complete`, {
|
||
|
|
method: 'POST',
|
||
|
|
body: JSON.stringify(requestBody)
|
||
|
|
});
|
||
|
|
if (response.ok) {
|
||
|
|
const result = await response.json();
|
||
|
|
suggestions.value = result.suggestions;
|
||
|
|
showSuggestions.value = true;
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error('Failed to fetch suggestions:', error);
|
||
|
|
suggestions.value = [];
|
||
|
|
showSuggestions.value = false;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
return h(Fragment, null, h("section", {
|
||
|
|
class: `fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900/60`
|
||
|
|
}), h("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%]`
|
||
|
|
}, h("h1", {
|
||
|
|
class: "text-2xl font-semibold my-5"
|
||
|
|
}, expense ? 'Edit Expense' : 'Create New Expense'), h("section", {
|
||
|
|
class: "py-5 my-2 border-y border-slate-500"
|
||
|
|
}, h("fieldset", {
|
||
|
|
class: "block mb-2"
|
||
|
|
}, h("label", {
|
||
|
|
class: "text-slate-300 block pb-1",
|
||
|
|
for: "expense_cost"
|
||
|
|
}, "Cost"), h("input", {
|
||
|
|
class: "input-field",
|
||
|
|
type: "text",
|
||
|
|
name: "expense_cost",
|
||
|
|
id: "expense_cost",
|
||
|
|
value: newExpenseCost.value,
|
||
|
|
onInput: event => {
|
||
|
|
newExpenseCost.value = event.currentTarget.value;
|
||
|
|
},
|
||
|
|
inputmode: "decimal",
|
||
|
|
placeholder: "10.99"
|
||
|
|
})), h("fieldset", {
|
||
|
|
class: "block mb-2 relative"
|
||
|
|
}, h("label", {
|
||
|
|
class: "text-slate-300 block pb-1",
|
||
|
|
for: "expense_description"
|
||
|
|
}, "Description"), h("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);
|
||
|
|
},
|
||
|
|
onKeyDown: event => {
|
||
|
|
if (event.key === 'Enter') {
|
||
|
|
onClickSave(formatInputToNumber(newExpenseCost.value), newExpenseDescription.value, newExpenseBudget.value, newExpenseDate.value, newExpenseIsRecurring.value);
|
||
|
|
}
|
||
|
|
},
|
||
|
|
onFocus: () => {
|
||
|
|
if (suggestions.value.length > 0) {
|
||
|
|
showSuggestions.value = true;
|
||
|
|
}
|
||
|
|
},
|
||
|
|
onBlur: () => {
|
||
|
|
setTimeout(() => {
|
||
|
|
showSuggestions.value = false;
|
||
|
|
}, 200);
|
||
|
|
},
|
||
|
|
placeholder: "Lunch"
|
||
|
|
}), showSuggestions.value && suggestions.value.length > 0 ? h("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 => h("li", {
|
||
|
|
key: suggestion,
|
||
|
|
class: "px-4 py-2 hover:bg-slate-600 cursor-pointer",
|
||
|
|
onClick: () => {
|
||
|
|
newExpenseDescription.value = suggestion;
|
||
|
|
showSuggestions.value = false;
|
||
|
|
suggestions.value = [];
|
||
|
|
}
|
||
|
|
}, suggestion))) : null), h("fieldset", {
|
||
|
|
class: "block mb-2"
|
||
|
|
}, h("label", {
|
||
|
|
class: "text-slate-300 block pb-1",
|
||
|
|
for: "expense_budget"
|
||
|
|
}, "Budget"), h("select", {
|
||
|
|
class: "input-field",
|
||
|
|
name: "expense_budget",
|
||
|
|
id: "expense_budget",
|
||
|
|
value: newExpenseBudget.value,
|
||
|
|
onChange: event => {
|
||
|
|
newExpenseBudget.value = event.currentTarget.value;
|
||
|
|
}
|
||
|
|
}, sortedBudgetNames.map(budget => h("option", {
|
||
|
|
value: budget,
|
||
|
|
selected: newExpenseBudget.value === budget
|
||
|
|
}, budget)))), h("fieldset", {
|
||
|
|
class: "block mb-2"
|
||
|
|
}, h("label", {
|
||
|
|
class: "text-slate-300 block pb-1",
|
||
|
|
for: "expense_date"
|
||
|
|
}, "Date"), h("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"
|
||
|
|
})), expense ? h("fieldset", {
|
||
|
|
class: "block mb-2"
|
||
|
|
}, h("label", {
|
||
|
|
class: "text-slate-300 block pb-1",
|
||
|
|
for: "expense_is_recurring"
|
||
|
|
}, "Is Recurring?"), h("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;
|
||
|
|
}
|
||
|
|
})) : null), h("footer", {
|
||
|
|
class: "flex justify-between"
|
||
|
|
}, expense ? h("button", {
|
||
|
|
class: "px-5 py-2 bg-red-600 text-white cursor-pointer rounded-md mr-2 opacity-30 hover:opacity-100",
|
||
|
|
onClick: () => onClickDelete(),
|
||
|
|
type: "button"
|
||
|
|
}, "Delete") : null, h("button", {
|
||
|
|
class: "px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md mr-2",
|
||
|
|
onClick: () => onClose(),
|
||
|
|
type: "button"
|
||
|
|
}, expense ? 'Cancel' : 'Close'), h("button", {
|
||
|
|
class: "px-5 py-2 bg-slate-700 hover:bg-slate-500 text-white cursor-pointer rounded-md ml-2",
|
||
|
|
onClick: () => {
|
||
|
|
onClickSave(formatInputToNumber(newExpenseCost.value), newExpenseDescription.value, newExpenseBudget.value, newExpenseDate.value, newExpenseIsRecurring.value);
|
||
|
|
},
|
||
|
|
type: "button"
|
||
|
|
}, expense ? 'Update' : 'Create'))));
|
||
|
|
}
|