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:
@@ -14,7 +14,7 @@ PASSWORD_SALT="fake"
|
|||||||
BREVO_API_KEY="fake"
|
BREVO_API_KEY="fake"
|
||||||
|
|
||||||
CONFIG_ALLOW_SIGNUPS="false"
|
CONFIG_ALLOW_SIGNUPS="false"
|
||||||
CONFIG_ENABLED_APPS="news,notes,photos" # dashboard and files cannot be disabled
|
CONFIG_ENABLED_APPS="news,notes,photos,expenses" # dashboard and files cannot be disabled
|
||||||
CONFIG_FILES_ROOT_PATH="data-files"
|
CONFIG_FILES_ROOT_PATH="data-files"
|
||||||
CONFIG_ENABLE_EMAILS="false" # if true, email verification will be required for signups (using Brevo)
|
CONFIG_ENABLE_EMAILS="false" # if true, email verification will be required for signups (using Brevo)
|
||||||
CONFIG_ENABLE_FOREVER_SIGNUP="true" # if true, all signups become active for 100 years
|
CONFIG_ENABLE_FOREVER_SIGNUP="true" # if true, all signups become active for 100 years
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ export default function Header({ route, user }: Data) {
|
|||||||
label: 'Photos',
|
label: 'Photos',
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
isAppEnabled('expenses')
|
||||||
|
? {
|
||||||
|
url: '/expenses',
|
||||||
|
label: 'Expenses',
|
||||||
|
}
|
||||||
|
: null,
|
||||||
];
|
];
|
||||||
|
|
||||||
const menuItems = potentialMenuItems.filter(Boolean) as MenuItem[];
|
const menuItems = potentialMenuItems.filter(Boolean) as MenuItem[];
|
||||||
@@ -67,6 +73,10 @@ export default function Header({ route, user }: Data) {
|
|||||||
pageLabel = 'Settings';
|
pageLabel = 'Settings';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (route.startsWith('/expenses')) {
|
||||||
|
pageLabel = 'Budgets & Expenses';
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
db-migrations/003-expenses-app.pgsql
Normal file
40
db-migrations/003-expenses-app.pgsql
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
SET statement_timeout = 0;
|
||||||
|
SET lock_timeout = 0;
|
||||||
|
SET idle_in_transaction_session_timeout = 0;
|
||||||
|
SET client_encoding = 'UTF8';
|
||||||
|
SET standard_conforming_strings = on;
|
||||||
|
SELECT pg_catalog.set_config('search_path', '', false);
|
||||||
|
SET check_function_bodies = false;
|
||||||
|
SET xmloption = content;
|
||||||
|
SET client_min_messages = warning;
|
||||||
|
SET row_security = off;
|
||||||
|
|
||||||
|
CREATE TABLE public.bewcloud_budgets (
|
||||||
|
id uuid DEFAULT gen_random_uuid(),
|
||||||
|
user_id uuid DEFAULT gen_random_uuid(),
|
||||||
|
name text NOT NULL,
|
||||||
|
month character varying NOT NULL,
|
||||||
|
value numeric NOT NULL,
|
||||||
|
extra jsonb NOT NULL,
|
||||||
|
created_at timestamp with time zone DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.bewcloud_budgets ADD CONSTRAINT bewcloud_budgets_pkey PRIMARY KEY (id);
|
||||||
|
ALTER TABLE ONLY public.bewcloud_budgets ADD CONSTRAINT bewcloud_budgets_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.bewcloud_users(id);
|
||||||
|
ALTER TABLE ONLY public.bewcloud_budgets ADD CONSTRAINT bewcloud_budgets_user_id_name_month_unique UNIQUE (user_id, name, month);
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE public.bewcloud_expenses (
|
||||||
|
id uuid DEFAULT gen_random_uuid(),
|
||||||
|
user_id uuid DEFAULT gen_random_uuid(),
|
||||||
|
cost numeric NOT NULL,
|
||||||
|
description text NOT NULL,
|
||||||
|
budget text NOT NULL,
|
||||||
|
date character varying NOT NULL,
|
||||||
|
is_recurring boolean NOT NULL,
|
||||||
|
extra jsonb NOT NULL,
|
||||||
|
created_at timestamp with time zone DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.bewcloud_expenses ADD CONSTRAINT bewcloud_expenses_pkey PRIMARY KEY (id);
|
||||||
|
ALTER TABLE ONLY public.bewcloud_expenses ADD CONSTRAINT bewcloud_expenses_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.bewcloud_users(id);
|
||||||
@@ -35,6 +35,7 @@
|
|||||||
"tailwindcss/": "npm:/tailwindcss@3.4.15/",
|
"tailwindcss/": "npm:/tailwindcss@3.4.15/",
|
||||||
"tailwindcss/plugin": "npm:/tailwindcss@3.4.15/plugin.js",
|
"tailwindcss/plugin": "npm:/tailwindcss@3.4.15/plugin.js",
|
||||||
"std/": "https://deno.land/std@0.224.0/",
|
"std/": "https://deno.land/std@0.224.0/",
|
||||||
"$std/": "https://deno.land/std@0.224.0/"
|
"$std/": "https://deno.land/std@0.224.0/",
|
||||||
|
"chart.js": "https://esm.sh/chart.js@4.4.7/auto"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
22
fresh.gen.ts
22
fresh.gen.ts
@@ -7,6 +7,15 @@ import * as $_app from './routes/_app.tsx';
|
|||||||
import * as $_middleware from './routes/_middleware.tsx';
|
import * as $_middleware from './routes/_middleware.tsx';
|
||||||
import * as $api_dashboard_save_links from './routes/api/dashboard/save-links.tsx';
|
import * as $api_dashboard_save_links from './routes/api/dashboard/save-links.tsx';
|
||||||
import * as $api_dashboard_save_notes from './routes/api/dashboard/save-notes.tsx';
|
import * as $api_dashboard_save_notes from './routes/api/dashboard/save-notes.tsx';
|
||||||
|
import * as $api_expenses_add_budget from './routes/api/expenses/add-budget.tsx';
|
||||||
|
import * as $api_expenses_add_expense from './routes/api/expenses/add-expense.tsx';
|
||||||
|
import * as $api_expenses_auto_complete from './routes/api/expenses/auto-complete.tsx';
|
||||||
|
import * as $api_expenses_delete_budget from './routes/api/expenses/delete-budget.tsx';
|
||||||
|
import * as $api_expenses_delete_expense from './routes/api/expenses/delete-expense.tsx';
|
||||||
|
import * as $api_expenses_export_expenses from './routes/api/expenses/export-expenses.tsx';
|
||||||
|
import * as $api_expenses_import_expenses from './routes/api/expenses/import-expenses.tsx';
|
||||||
|
import * as $api_expenses_update_budget from './routes/api/expenses/update-budget.tsx';
|
||||||
|
import * as $api_expenses_update_expense from './routes/api/expenses/update-expense.tsx';
|
||||||
import * as $api_files_create_directory from './routes/api/files/create-directory.tsx';
|
import * as $api_files_create_directory from './routes/api/files/create-directory.tsx';
|
||||||
import * as $api_files_delete_directory from './routes/api/files/delete-directory.tsx';
|
import * as $api_files_delete_directory from './routes/api/files/delete-directory.tsx';
|
||||||
import * as $api_files_delete from './routes/api/files/delete.tsx';
|
import * as $api_files_delete from './routes/api/files/delete.tsx';
|
||||||
@@ -26,6 +35,7 @@ import * as $api_news_refresh_articles from './routes/api/news/refresh-articles.
|
|||||||
import * as $api_notes_save from './routes/api/notes/save.tsx';
|
import * as $api_notes_save from './routes/api/notes/save.tsx';
|
||||||
import * as $dashboard from './routes/dashboard.tsx';
|
import * as $dashboard from './routes/dashboard.tsx';
|
||||||
import * as $dav from './routes/dav.tsx';
|
import * as $dav from './routes/dav.tsx';
|
||||||
|
import * as $expenses from './routes/expenses.tsx';
|
||||||
import * as $files from './routes/files.tsx';
|
import * as $files from './routes/files.tsx';
|
||||||
import * as $files_open_fileName_ from './routes/files/open/[fileName].tsx';
|
import * as $files_open_fileName_ from './routes/files/open/[fileName].tsx';
|
||||||
import * as $index from './routes/index.tsx';
|
import * as $index from './routes/index.tsx';
|
||||||
@@ -42,6 +52,7 @@ import * as $signup from './routes/signup.tsx';
|
|||||||
import * as $Settings from './islands/Settings.tsx';
|
import * as $Settings from './islands/Settings.tsx';
|
||||||
import * as $dashboard_Links from './islands/dashboard/Links.tsx';
|
import * as $dashboard_Links from './islands/dashboard/Links.tsx';
|
||||||
import * as $dashboard_Notes from './islands/dashboard/Notes.tsx';
|
import * as $dashboard_Notes from './islands/dashboard/Notes.tsx';
|
||||||
|
import * as $expenses_ExpensesWrapper from './islands/expenses/ExpensesWrapper.tsx';
|
||||||
import * as $files_FilesWrapper from './islands/files/FilesWrapper.tsx';
|
import * as $files_FilesWrapper from './islands/files/FilesWrapper.tsx';
|
||||||
import * as $news_Articles from './islands/news/Articles.tsx';
|
import * as $news_Articles from './islands/news/Articles.tsx';
|
||||||
import * as $news_Feeds from './islands/news/Feeds.tsx';
|
import * as $news_Feeds from './islands/news/Feeds.tsx';
|
||||||
@@ -57,6 +68,15 @@ const manifest = {
|
|||||||
'./routes/_middleware.tsx': $_middleware,
|
'./routes/_middleware.tsx': $_middleware,
|
||||||
'./routes/api/dashboard/save-links.tsx': $api_dashboard_save_links,
|
'./routes/api/dashboard/save-links.tsx': $api_dashboard_save_links,
|
||||||
'./routes/api/dashboard/save-notes.tsx': $api_dashboard_save_notes,
|
'./routes/api/dashboard/save-notes.tsx': $api_dashboard_save_notes,
|
||||||
|
'./routes/api/expenses/add-budget.tsx': $api_expenses_add_budget,
|
||||||
|
'./routes/api/expenses/add-expense.tsx': $api_expenses_add_expense,
|
||||||
|
'./routes/api/expenses/auto-complete.tsx': $api_expenses_auto_complete,
|
||||||
|
'./routes/api/expenses/delete-budget.tsx': $api_expenses_delete_budget,
|
||||||
|
'./routes/api/expenses/delete-expense.tsx': $api_expenses_delete_expense,
|
||||||
|
'./routes/api/expenses/export-expenses.tsx': $api_expenses_export_expenses,
|
||||||
|
'./routes/api/expenses/import-expenses.tsx': $api_expenses_import_expenses,
|
||||||
|
'./routes/api/expenses/update-budget.tsx': $api_expenses_update_budget,
|
||||||
|
'./routes/api/expenses/update-expense.tsx': $api_expenses_update_expense,
|
||||||
'./routes/api/files/create-directory.tsx': $api_files_create_directory,
|
'./routes/api/files/create-directory.tsx': $api_files_create_directory,
|
||||||
'./routes/api/files/delete-directory.tsx': $api_files_delete_directory,
|
'./routes/api/files/delete-directory.tsx': $api_files_delete_directory,
|
||||||
'./routes/api/files/delete.tsx': $api_files_delete,
|
'./routes/api/files/delete.tsx': $api_files_delete,
|
||||||
@@ -76,6 +96,7 @@ const manifest = {
|
|||||||
'./routes/api/notes/save.tsx': $api_notes_save,
|
'./routes/api/notes/save.tsx': $api_notes_save,
|
||||||
'./routes/dashboard.tsx': $dashboard,
|
'./routes/dashboard.tsx': $dashboard,
|
||||||
'./routes/dav.tsx': $dav,
|
'./routes/dav.tsx': $dav,
|
||||||
|
'./routes/expenses.tsx': $expenses,
|
||||||
'./routes/files.tsx': $files,
|
'./routes/files.tsx': $files,
|
||||||
'./routes/files/open/[fileName].tsx': $files_open_fileName_,
|
'./routes/files/open/[fileName].tsx': $files_open_fileName_,
|
||||||
'./routes/index.tsx': $index,
|
'./routes/index.tsx': $index,
|
||||||
@@ -94,6 +115,7 @@ const manifest = {
|
|||||||
'./islands/Settings.tsx': $Settings,
|
'./islands/Settings.tsx': $Settings,
|
||||||
'./islands/dashboard/Links.tsx': $dashboard_Links,
|
'./islands/dashboard/Links.tsx': $dashboard_Links,
|
||||||
'./islands/dashboard/Notes.tsx': $dashboard_Notes,
|
'./islands/dashboard/Notes.tsx': $dashboard_Notes,
|
||||||
|
'./islands/expenses/ExpensesWrapper.tsx': $expenses_ExpensesWrapper,
|
||||||
'./islands/files/FilesWrapper.tsx': $files_FilesWrapper,
|
'./islands/files/FilesWrapper.tsx': $files_FilesWrapper,
|
||||||
'./islands/news/Articles.tsx': $news_Articles,
|
'./islands/news/Articles.tsx': $news_Articles,
|
||||||
'./islands/news/Feeds.tsx': $news_Feeds,
|
'./islands/news/Feeds.tsx': $news_Feeds,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { convertObjectToFormData, helpEmail } from '/lib/utils/misc.ts';
|
import { convertObjectToFormData, helpEmail } from '/lib/utils/misc.ts';
|
||||||
import { FormField, generateFieldHtml, getFormDataField } from '/lib/form-utils.tsx';
|
import { FormField, generateFieldHtml, getFormDataField } from '/lib/form-utils.tsx';
|
||||||
|
import { currencyMap, SupportedCurrencySymbol } from '/lib/types.ts';
|
||||||
|
import { isAppEnabled } from '/lib/config.ts';
|
||||||
|
|
||||||
interface SettingsProps {
|
interface SettingsProps {
|
||||||
formData: Record<string, any>;
|
formData: Record<string, any>;
|
||||||
@@ -11,6 +13,7 @@ interface SettingsProps {
|
|||||||
title: string;
|
title: string;
|
||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
currency?: SupportedCurrencySymbol;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Action =
|
export type Action =
|
||||||
@@ -18,7 +21,8 @@ export type Action =
|
|||||||
| 'verify-change-email'
|
| 'verify-change-email'
|
||||||
| 'change-password'
|
| 'change-password'
|
||||||
| 'change-dav-password'
|
| 'change-dav-password'
|
||||||
| 'delete-account';
|
| 'delete-account'
|
||||||
|
| 'change-currency';
|
||||||
|
|
||||||
export const actionWords = new Map<Action, string>([
|
export const actionWords = new Map<Action, string>([
|
||||||
['change-email', 'change email'],
|
['change-email', 'change email'],
|
||||||
@@ -26,9 +30,10 @@ export const actionWords = new Map<Action, string>([
|
|||||||
['change-password', 'change password'],
|
['change-password', 'change password'],
|
||||||
['change-dav-password', 'change WebDav password'],
|
['change-dav-password', 'change WebDav password'],
|
||||||
['delete-account', 'delete account'],
|
['delete-account', 'delete account'],
|
||||||
|
['change-currency', 'change currency'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function formFields(action: Action, formData: FormData) {
|
function formFields(action: Action, formData: FormData, currency?: SupportedCurrencySymbol) {
|
||||||
const fields: FormField[] = [
|
const fields: FormField[] = [
|
||||||
{
|
{
|
||||||
name: 'action',
|
name: 'action',
|
||||||
@@ -98,11 +103,23 @@ function formFields(action: Action, formData: FormData) {
|
|||||||
description: 'You need to input your password in order to delete your account.',
|
description: 'You need to input your password in order to delete your account.',
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
|
} else if (action === 'change-currency') {
|
||||||
|
fields.push({
|
||||||
|
name: 'currency',
|
||||||
|
label: 'Currency',
|
||||||
|
type: 'select',
|
||||||
|
options: Array.from(currencyMap.keys()).map((currencySymbol) => ({
|
||||||
|
value: currencySymbol,
|
||||||
|
label: `${currencySymbol} (${currencyMap.get(currencySymbol)})`,
|
||||||
|
})),
|
||||||
|
value: getFormDataField(formData, 'currency') || currency,
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return fields;
|
return fields;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Settings({ formData: formDataObject, error, notice }: SettingsProps) {
|
export default function Settings({ formData: formDataObject, error, notice, currency }: SettingsProps) {
|
||||||
const formData = convertObjectToFormData(formDataObject);
|
const formData = convertObjectToFormData(formDataObject);
|
||||||
|
|
||||||
const action = getFormDataField(formData, 'action') as Action;
|
const action = getFormDataField(formData, 'action') as Action;
|
||||||
@@ -157,6 +174,24 @@ export default function Settings({ formData: formDataObject, error, notice }: Se
|
|||||||
</section>
|
</section>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{isAppEnabled('expenses')
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
<h2 class='text-2xl mb-4 text-left px-4 max-w-screen-md mx-auto lg:min-w-96'>Change your currency</h2>
|
||||||
|
<p class='text-left mt-2 mb-6 px-4 max-w-screen-md mx-auto lg:min-w-96'>
|
||||||
|
This is only used in the expenses app, visually. It changes nothing about the stored data or values.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method='POST' class='mb-12'>
|
||||||
|
{formFields('change-currency', formData, currency).map((field) => generateFieldHtml(field, formData))}
|
||||||
|
<section class='flex justify-end mt-8 mb-4'>
|
||||||
|
<button class='button-secondary' type='submit'>Change currency</button>
|
||||||
|
</section>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
|
||||||
<h2 class='text-2xl mb-4 text-left px-4 max-w-screen-md mx-auto lg:min-w-96'>Delete your account</h2>
|
<h2 class='text-2xl mb-4 text-left px-4 max-w-screen-md mx-auto lg:min-w-96'>Delete your account</h2>
|
||||||
<p class='text-left mt-2 mb-6 px-4 max-w-screen-md mx-auto lg:min-w-96'>
|
<p class='text-left mt-2 mb-6 px-4 max-w-screen-md mx-auto lg:min-w-96'>
|
||||||
Deleting your account is instant and deletes all your data. If you need help, please{' '}
|
Deleting your account is instant and deletes all your data. If you need help, please{' '}
|
||||||
|
|||||||
23
islands/expenses/ExpensesWrapper.tsx
Normal file
23
islands/expenses/ExpensesWrapper.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Budget, Expense, SupportedCurrencySymbol } from '/lib/types.ts';
|
||||||
|
import MainExpenses from '/components/expenses/MainExpenses.tsx';
|
||||||
|
|
||||||
|
interface ExpensesWrapperProps {
|
||||||
|
initialBudgets: Budget[];
|
||||||
|
initialExpenses: Expense[];
|
||||||
|
initialMonth: string;
|
||||||
|
currency: SupportedCurrencySymbol;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This wrapper is necessary because islands need to be the first frontend component, but they don't support functions as props, so the more complex logic needs to live in the component itself
|
||||||
|
export default function ExpensesWrapper(
|
||||||
|
{ initialBudgets, initialExpenses, initialMonth, currency }: ExpensesWrapperProps,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<MainExpenses
|
||||||
|
initialBudgets={initialBudgets}
|
||||||
|
initialExpenses={initialExpenses}
|
||||||
|
initialMonth={initialMonth}
|
||||||
|
currency={currency}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ export async function isSignupAllowed() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isAppEnabled(app: 'news' | 'notes' | 'photos') {
|
export function isAppEnabled(app: 'news' | 'notes' | 'photos' | 'expenses') {
|
||||||
const enabledApps = (Deno.env.get('CONFIG_ENABLED_APPS') || '').split(',') as typeof app[];
|
const enabledApps = (Deno.env.get('CONFIG_ENABLED_APPS') || '').split(',') as typeof app[];
|
||||||
|
|
||||||
return enabledApps.includes(app);
|
return enabledApps.includes(app);
|
||||||
|
|||||||
469
lib/data/expenses.ts
Normal file
469
lib/data/expenses.ts
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
import Database, { sql } from '/lib/interfaces/database.ts';
|
||||||
|
import Locker from '/lib/interfaces/locker.ts';
|
||||||
|
import { Budget, Expense } from '/lib/types.ts';
|
||||||
|
|
||||||
|
const db = new Database();
|
||||||
|
|
||||||
|
export async function getBudgets(
|
||||||
|
userId: string,
|
||||||
|
month: string,
|
||||||
|
{ skipRecalculation = false }: { skipRecalculation?: boolean } = {},
|
||||||
|
) {
|
||||||
|
if (!skipRecalculation) {
|
||||||
|
await recalculateMonthBudgets(userId, month);
|
||||||
|
}
|
||||||
|
|
||||||
|
const budgets = await db.query<Budget>(
|
||||||
|
sql`SELECT * FROM "bewcloud_budgets" WHERE "user_id" = $1 AND "month" = $2 ORDER BY cast("extra"->>'availableValue' as numeric) DESC, "value" DESC, "name" ASC`,
|
||||||
|
[
|
||||||
|
userId,
|
||||||
|
month,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Numeric values come as strings, so we need to convert them to numbers
|
||||||
|
return budgets.map((budget) => ({
|
||||||
|
...budget,
|
||||||
|
value: Number(budget.value),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBudgetByName(userId: string, month: string, name: string) {
|
||||||
|
const budget = (await db.query<Budget>(
|
||||||
|
sql`SELECT * FROM "bewcloud_budgets" WHERE "user_id" = $1 AND "month" = $2 AND LOWER("name") = LOWER($3)`,
|
||||||
|
[
|
||||||
|
userId,
|
||||||
|
month,
|
||||||
|
name,
|
||||||
|
],
|
||||||
|
))[0];
|
||||||
|
|
||||||
|
if (!budget) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Numeric values come as strings, so we need to convert them to numbers
|
||||||
|
return {
|
||||||
|
...budget,
|
||||||
|
value: Number(budget.value),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBudgetById(userId: string, id: string) {
|
||||||
|
const budget = (await db.query<Budget>(
|
||||||
|
sql`SELECT * FROM "bewcloud_budgets" WHERE "user_id" = $1 AND "id" = $2`,
|
||||||
|
[userId, id],
|
||||||
|
))[0];
|
||||||
|
|
||||||
|
if (!budget) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Numeric values come as strings, so we need to convert them to numbers
|
||||||
|
return {
|
||||||
|
...budget,
|
||||||
|
value: Number(budget.value),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllBudgetsForExport(
|
||||||
|
userId: string,
|
||||||
|
): Promise<(Omit<Budget, 'id' | 'user_id' | 'created_at' | 'extra'> & { extra: Record<never, never> })[]> {
|
||||||
|
const budgets = await db.query<Budget>(
|
||||||
|
sql`SELECT * FROM "bewcloud_budgets" WHERE "user_id" = $1 ORDER BY "month" DESC, "name" ASC`,
|
||||||
|
[
|
||||||
|
userId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return budgets.map((budget) => ({
|
||||||
|
name: budget.name,
|
||||||
|
month: budget.month,
|
||||||
|
// Numeric values come as strings, so we need to convert them to numbers
|
||||||
|
value: Number(budget.value),
|
||||||
|
extra: {},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getExpenses(userId: string, month: string) {
|
||||||
|
const expenses = await db.query<Expense>(
|
||||||
|
sql`SELECT * FROM "bewcloud_expenses" WHERE "user_id" = $1 AND "date" >= $2 AND "date" <= $3 ORDER BY "date" DESC, "created_at" DESC`,
|
||||||
|
[
|
||||||
|
userId,
|
||||||
|
`${month}-01`,
|
||||||
|
`${month}-31`,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Numeric values come as strings, so we need to convert them to numbers
|
||||||
|
return expenses.map((expense) => ({
|
||||||
|
...expense,
|
||||||
|
cost: Number(expense.cost),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getExpenseByName(userId: string, name: string) {
|
||||||
|
const expense = (await db.query<Expense>(
|
||||||
|
sql`SELECT * FROM "bewcloud_expenses" WHERE "user_id" = $1 AND LOWER("description") = LOWER($2) ORDER BY "date" DESC, "created_at" DESC`,
|
||||||
|
[
|
||||||
|
userId,
|
||||||
|
name,
|
||||||
|
],
|
||||||
|
))[0];
|
||||||
|
|
||||||
|
if (!expense) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Numeric values come as strings, so we need to convert them to numbers
|
||||||
|
return {
|
||||||
|
...expense,
|
||||||
|
cost: Number(expense.cost),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getExpenseSuggestions(userId: string, name: string) {
|
||||||
|
const expenses = await db.query<Pick<Expense, 'description'>>(
|
||||||
|
sql`SELECT DISTINCT "description" FROM "bewcloud_expenses" WHERE "user_id" = $1 AND LOWER("description") ILIKE LOWER($2) ORDER BY "description" ASC`,
|
||||||
|
[
|
||||||
|
userId,
|
||||||
|
`%${name}%`,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return expenses.map((expense) => expense.description);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllExpensesForExport(
|
||||||
|
userId: string,
|
||||||
|
): Promise<(Omit<Expense, 'id' | 'user_id' | 'created_at' | 'extra'> & { extra: Record<never, never> })[]> {
|
||||||
|
const expenses = await db.query<Expense>(
|
||||||
|
sql`SELECT * FROM "bewcloud_expenses" WHERE "user_id" = $1 ORDER BY "date" DESC, "created_at" DESC`,
|
||||||
|
[
|
||||||
|
userId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return expenses.map((expense) => ({
|
||||||
|
description: expense.description,
|
||||||
|
budget: expense.budget,
|
||||||
|
date: expense.date,
|
||||||
|
is_recurring: expense.is_recurring,
|
||||||
|
// Numeric values come as strings, so we need to convert them to numbers
|
||||||
|
cost: Number(expense.cost),
|
||||||
|
extra: {},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getExpenseById(userId: string, id: string) {
|
||||||
|
const expense = (await db.query<Expense>(
|
||||||
|
sql`SELECT * FROM "bewcloud_expenses" WHERE "user_id" = $1 AND "id" = $2`,
|
||||||
|
[userId, id],
|
||||||
|
))[0];
|
||||||
|
|
||||||
|
if (!expense) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Numeric values come as strings, so we need to convert them to numbers
|
||||||
|
return {
|
||||||
|
...expense,
|
||||||
|
cost: Number(expense.cost),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createBudget(userId: string, name: string, month: string, value: number) {
|
||||||
|
const extra: Budget['extra'] = {
|
||||||
|
usedValue: 0,
|
||||||
|
availableValue: value,
|
||||||
|
};
|
||||||
|
|
||||||
|
const newBudget = (await db.query<Budget>(
|
||||||
|
sql`INSERT INTO "bewcloud_budgets" (
|
||||||
|
"user_id",
|
||||||
|
"name",
|
||||||
|
"month",
|
||||||
|
"value",
|
||||||
|
"extra"
|
||||||
|
) VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
userId,
|
||||||
|
name,
|
||||||
|
month,
|
||||||
|
value,
|
||||||
|
JSON.stringify(extra),
|
||||||
|
],
|
||||||
|
))[0];
|
||||||
|
|
||||||
|
// Numeric values come as strings, so we need to convert them to numbers
|
||||||
|
return {
|
||||||
|
...newBudget,
|
||||||
|
value: Number(newBudget.value),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateBudget(
|
||||||
|
budget: Budget,
|
||||||
|
{ skipRecalculation = false }: { skipRecalculation?: boolean } = {},
|
||||||
|
) {
|
||||||
|
await db.query(
|
||||||
|
sql`UPDATE "bewcloud_budgets" SET
|
||||||
|
"name" = $2,
|
||||||
|
"month" = $3,
|
||||||
|
"value" = $4,
|
||||||
|
"extra" = $5
|
||||||
|
WHERE "id" = $1`,
|
||||||
|
[
|
||||||
|
budget.id,
|
||||||
|
budget.name,
|
||||||
|
budget.month,
|
||||||
|
budget.value,
|
||||||
|
JSON.stringify(budget.extra),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!skipRecalculation) {
|
||||||
|
await recalculateMonthBudgets(budget.user_id, budget.month);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteBudget(userId: string, id: string) {
|
||||||
|
await db.query(
|
||||||
|
sql`DELETE FROM "bewcloud_budgets" WHERE "id" = $1 AND "user_id" = $2`,
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAllBudgetsAndExpenses(userId: string) {
|
||||||
|
await db.query(
|
||||||
|
sql`DELETE FROM "bewcloud_expenses" WHERE "user_id" = $1`,
|
||||||
|
[
|
||||||
|
userId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.query(
|
||||||
|
sql`DELETE FROM "bewcloud_budgets" WHERE "user_id" = $1`,
|
||||||
|
[
|
||||||
|
userId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createExpense(
|
||||||
|
userId: string,
|
||||||
|
cost: number,
|
||||||
|
description: string,
|
||||||
|
budget: string,
|
||||||
|
date: string,
|
||||||
|
is_recurring: boolean,
|
||||||
|
{ skipRecalculation = false, skipBudgetMatching = false, skipBudgetCreation = false }: {
|
||||||
|
skipRecalculation?: boolean;
|
||||||
|
skipBudgetMatching?: boolean;
|
||||||
|
skipBudgetCreation?: boolean;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const extra: Expense['extra'] = {};
|
||||||
|
|
||||||
|
if (!budget.trim()) {
|
||||||
|
budget = 'Misc';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match budget to an existing expense "by default"
|
||||||
|
if (!skipBudgetMatching && budget === 'Misc') {
|
||||||
|
const existingExpense = await getExpenseByName(userId, description);
|
||||||
|
|
||||||
|
if (existingExpense) {
|
||||||
|
budget = existingExpense.budget;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!skipBudgetCreation) {
|
||||||
|
const existingBudgetInMonth = await getBudgetByName(userId, date.substring(0, 7), budget);
|
||||||
|
|
||||||
|
if (!existingBudgetInMonth) {
|
||||||
|
await createBudget(userId, budget, date.substring(0, 7), 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newExpense = (await db.query<Expense>(
|
||||||
|
sql`INSERT INTO "bewcloud_expenses" (
|
||||||
|
"user_id",
|
||||||
|
"cost",
|
||||||
|
"description",
|
||||||
|
"budget",
|
||||||
|
"date",
|
||||||
|
"is_recurring",
|
||||||
|
"extra"
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
userId,
|
||||||
|
cost,
|
||||||
|
description,
|
||||||
|
budget,
|
||||||
|
date,
|
||||||
|
is_recurring,
|
||||||
|
JSON.stringify(extra),
|
||||||
|
],
|
||||||
|
))[0];
|
||||||
|
|
||||||
|
if (!skipRecalculation) {
|
||||||
|
await recalculateMonthBudgets(userId, date.substring(0, 7));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Numeric values come as strings, so we need to convert them to numbers
|
||||||
|
return {
|
||||||
|
...newExpense,
|
||||||
|
cost: Number(newExpense.cost),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateExpense(expense: Expense) {
|
||||||
|
const existingBudgetInMonth = await getBudgetByName(expense.user_id, expense.date.substring(0, 7), expense.budget);
|
||||||
|
|
||||||
|
if (!existingBudgetInMonth) {
|
||||||
|
await createBudget(expense.user_id, expense.budget, expense.date.substring(0, 7), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.query(
|
||||||
|
sql`UPDATE "bewcloud_expenses" SET
|
||||||
|
"cost" = $2,
|
||||||
|
"description" = $3,
|
||||||
|
"budget" = $4,
|
||||||
|
"date" = $5,
|
||||||
|
"is_recurring" = $6,
|
||||||
|
"extra" = $7
|
||||||
|
WHERE "id" = $1`,
|
||||||
|
[
|
||||||
|
expense.id,
|
||||||
|
expense.cost,
|
||||||
|
expense.description,
|
||||||
|
expense.budget,
|
||||||
|
expense.date,
|
||||||
|
expense.is_recurring,
|
||||||
|
JSON.stringify(expense.extra),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await recalculateMonthBudgets(expense.user_id, expense.date.substring(0, 7));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteExpense(userId: string, id: string) {
|
||||||
|
const expense = await getExpenseById(userId, id);
|
||||||
|
|
||||||
|
await db.query(
|
||||||
|
sql`DELETE FROM "bewcloud_expenses" WHERE "id" = $1 AND "user_id" = $2`,
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await recalculateMonthBudgets(userId, expense!.date.substring(0, 7));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMonthlyBudgetsAndExpenses(userId: string, month: string) {
|
||||||
|
const lock = new Locker(`expenses:${userId}:${month}`);
|
||||||
|
|
||||||
|
await lock.acquire();
|
||||||
|
|
||||||
|
let addedBudgetsCount = 0;
|
||||||
|
let addedExpensesCount = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Confirm there are no budgets or expenses for this month
|
||||||
|
const monthBudgets = await getBudgets(userId, month, { skipRecalculation: true });
|
||||||
|
const monthExpenses = await getExpenses(userId, month);
|
||||||
|
|
||||||
|
if (monthBudgets.length > 0 || monthExpenses.length > 0) {
|
||||||
|
throw new Error('Budgets and expenses already exist for this month!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the previous month's budgets, to copy over
|
||||||
|
const previousMonthDate = new Date(month);
|
||||||
|
previousMonthDate.setMonth(previousMonthDate.getMonth() - 1);
|
||||||
|
|
||||||
|
const previousMonth = previousMonthDate.toISOString().substring(0, 7);
|
||||||
|
|
||||||
|
const budgets = await getBudgets(userId, previousMonth, { skipRecalculation: true });
|
||||||
|
|
||||||
|
for (const budget of budgets) {
|
||||||
|
await createBudget(userId, budget.name, month, budget.value);
|
||||||
|
|
||||||
|
addedBudgetsCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the recurring expenses for the previous month, to copy over
|
||||||
|
const recurringExpenses = (await getExpenses(userId, previousMonth)).filter((expense) => expense.is_recurring);
|
||||||
|
|
||||||
|
for (const expense of recurringExpenses) {
|
||||||
|
await createExpense(
|
||||||
|
userId,
|
||||||
|
expense.cost,
|
||||||
|
expense.description,
|
||||||
|
expense.budget,
|
||||||
|
expense.date.replace(previousMonth, month),
|
||||||
|
expense.is_recurring,
|
||||||
|
{ skipRecalculation: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
addedExpensesCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info(`Added ${addedBudgetsCount} new budgets and ${addedExpensesCount} new expenses for ${month}`);
|
||||||
|
|
||||||
|
lock.release();
|
||||||
|
} catch (error) {
|
||||||
|
lock.release();
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recalculateMonthBudgets(userId: string, month: string) {
|
||||||
|
const lock = new Locker(`expenses:${userId}:${month}`);
|
||||||
|
|
||||||
|
await lock.acquire();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const budgets = await getBudgets(userId, month, { skipRecalculation: true });
|
||||||
|
const expenses = await getExpenses(userId, month);
|
||||||
|
|
||||||
|
// Calculate total expenses for each budget
|
||||||
|
const budgetExpenses = new Map<string, number>();
|
||||||
|
for (const expense of expenses) {
|
||||||
|
const currentTotal = Number(budgetExpenses.get(expense.budget) || 0);
|
||||||
|
budgetExpenses.set(expense.budget, Number(currentTotal + expense.cost));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update each budget with new calculations
|
||||||
|
for (const budget of budgets) {
|
||||||
|
const usedValue = Number(budgetExpenses.get(budget.name) || 0);
|
||||||
|
const availableValue = Number(budget.value - usedValue);
|
||||||
|
|
||||||
|
const updatedBudget: Budget = {
|
||||||
|
...budget,
|
||||||
|
extra: {
|
||||||
|
...budget.extra,
|
||||||
|
usedValue,
|
||||||
|
availableValue,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (budget.extra.usedValue !== usedValue || budget.extra.availableValue !== availableValue) {
|
||||||
|
await updateBudget(updatedBudget, { skipRecalculation: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lock.release();
|
||||||
|
} catch (error) {
|
||||||
|
lock.release();
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
39
lib/types.ts
39
lib/types.ts
@@ -12,6 +12,7 @@ export interface User {
|
|||||||
is_email_verified: boolean;
|
is_email_verified: boolean;
|
||||||
is_admin?: boolean;
|
is_admin?: boolean;
|
||||||
dav_hashed_password?: string;
|
dav_hashed_password?: string;
|
||||||
|
expenses_currency?: SupportedCurrencySymbol;
|
||||||
};
|
};
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
}
|
}
|
||||||
@@ -81,7 +82,7 @@ export interface NewsFeedArticle {
|
|||||||
article_summary: string;
|
article_summary: string;
|
||||||
article_date: Date;
|
article_date: Date;
|
||||||
is_read: boolean;
|
is_read: boolean;
|
||||||
extra: Record<never, never>;
|
extra: Record<never, never>; // NOTE: Here for potential future fields
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,3 +105,39 @@ export interface DirectoryFile {
|
|||||||
updated_at: Date;
|
updated_at: Date;
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Budget {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
name: string;
|
||||||
|
month: string;
|
||||||
|
value: number;
|
||||||
|
extra: {
|
||||||
|
usedValue: number;
|
||||||
|
availableValue: number;
|
||||||
|
};
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Expense {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
cost: number;
|
||||||
|
description: string;
|
||||||
|
budget: string;
|
||||||
|
date: string;
|
||||||
|
is_recurring: boolean;
|
||||||
|
extra: Record<never, never>; // NOTE: Here for potential future fields
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SupportedCurrencySymbol = '$' | '€' | '£' | '¥' | '₹';
|
||||||
|
type SupportedCurrency = 'USD' | 'EUR' | 'GBP' | 'JPY' | 'INR';
|
||||||
|
|
||||||
|
export const currencyMap = new Map<SupportedCurrencySymbol, SupportedCurrency>([
|
||||||
|
['$', 'USD'],
|
||||||
|
['€', 'EUR'],
|
||||||
|
['£', 'GBP'],
|
||||||
|
['¥', 'JPY'],
|
||||||
|
['₹', 'INR'],
|
||||||
|
]);
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { currencyMap } from '/lib/types.ts';
|
||||||
|
import { SupportedCurrencySymbol } from '/lib/types.ts';
|
||||||
|
|
||||||
let BASE_URL = typeof window !== 'undefined' && window.location
|
let BASE_URL = typeof window !== 'undefined' && window.location
|
||||||
? `${window.location.protocol}//${window.location.host}`
|
? `${window.location.protocol}//${window.location.host}`
|
||||||
: '';
|
: '';
|
||||||
@@ -256,3 +259,12 @@ export const capitalizeWord = (string: string) => {
|
|||||||
export function getRandomItem<T>(items: Readonly<Array<T>>): T {
|
export function getRandomItem<T>(items: Readonly<Array<T>>): T {
|
||||||
return items[Math.floor(Math.random() * items.length)];
|
return items[Math.floor(Math.random() * items.length)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatNumber(currency: SupportedCurrencySymbol, number: number) {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: currencyMap.get(currency) || 'USD',
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(Number.parseFloat(`${number}`.replace(',', '.')));
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { assertEquals } from 'std/assert/assert_equals.ts';
|
import { assertEquals } from 'std/assert/assert_equals.ts';
|
||||||
|
|
||||||
|
import { SupportedCurrencySymbol } from '/lib/types.ts';
|
||||||
import {
|
import {
|
||||||
convertFormDataToObject,
|
convertFormDataToObject,
|
||||||
convertObjectToFormData,
|
convertObjectToFormData,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
|
formatNumber,
|
||||||
generateHash,
|
generateHash,
|
||||||
generateRandomCode,
|
generateRandomCode,
|
||||||
isRunningLocally,
|
isRunningLocally,
|
||||||
@@ -269,3 +272,22 @@ Deno.test('that isRunningLocally works', () => {
|
|||||||
assertEquals(result, test.expected);
|
assertEquals(result, test.expected);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test('that formatNumber works', () => {
|
||||||
|
const tests: { currency: SupportedCurrencySymbol; number: number; expected: string }[] = [
|
||||||
|
{ currency: '$', number: 10000, expected: '$10,000' },
|
||||||
|
{ currency: '$', number: 10000.5, expected: '$10,000.5' },
|
||||||
|
{ currency: '€', number: 10000, expected: '€10,000' },
|
||||||
|
{ currency: '€', number: 900.999, expected: '€901' },
|
||||||
|
{ currency: '€', number: 900.991, expected: '€900.99' },
|
||||||
|
{ currency: '$', number: 50.11, expected: '$50.11' },
|
||||||
|
{ currency: '£', number: 900.999, expected: '£901' },
|
||||||
|
{ currency: '£', number: 900.991, expected: '£900.99' },
|
||||||
|
{ currency: '£', number: 50.11, expected: '£50.11' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const test of tests) {
|
||||||
|
const result = formatNumber(test.currency, test.number);
|
||||||
|
assertEquals(result, test.expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
57
routes/api/expenses/add-budget.tsx
Normal file
57
routes/api/expenses/add-budget.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { Handlers } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { Budget, FreshContextState } from '/lib/types.ts';
|
||||||
|
import { createBudget, getBudgets } from '/lib/data/expenses.ts';
|
||||||
|
|
||||||
|
interface Data {}
|
||||||
|
|
||||||
|
export interface RequestBody {
|
||||||
|
name: string;
|
||||||
|
month: string;
|
||||||
|
value: number;
|
||||||
|
currentMonth: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResponseBody {
|
||||||
|
success: boolean;
|
||||||
|
newBudgets: Budget[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler: Handlers<Data, FreshContextState> = {
|
||||||
|
async POST(request, context) {
|
||||||
|
if (!context.state.user) {
|
||||||
|
return new Response('Unauthorized', { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody = await request.clone().json() as RequestBody;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!requestBody.name || !requestBody.month || !requestBody.month.match(/^\d{4}-\d{2}$/) || !requestBody.value ||
|
||||||
|
Number.isNaN(requestBody.value) || !requestBody.currentMonth || !requestBody.currentMonth.match(/^\d{4}-\d{2}$/)
|
||||||
|
) {
|
||||||
|
return new Response('Bad request', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newBudget = await createBudget(
|
||||||
|
context.state.user.id,
|
||||||
|
requestBody.name,
|
||||||
|
requestBody.month,
|
||||||
|
requestBody.value,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!newBudget) {
|
||||||
|
throw new Error('Failed to add budget!');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return new Response(`${error}`, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const newBudgets = await getBudgets(context.state.user.id, requestBody.currentMonth);
|
||||||
|
|
||||||
|
const responseBody: ResponseBody = { success: true, newBudgets };
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(responseBody));
|
||||||
|
},
|
||||||
|
};
|
||||||
80
routes/api/expenses/add-expense.tsx
Normal file
80
routes/api/expenses/add-expense.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { Handlers } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { Budget, Expense, FreshContextState } from '/lib/types.ts';
|
||||||
|
import { createExpense, getBudgets, getExpenses } from '/lib/data/expenses.ts';
|
||||||
|
|
||||||
|
interface Data {}
|
||||||
|
|
||||||
|
export interface RequestBody {
|
||||||
|
cost: number;
|
||||||
|
description: string;
|
||||||
|
budget: string;
|
||||||
|
date: string;
|
||||||
|
is_recurring: boolean;
|
||||||
|
month: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResponseBody {
|
||||||
|
success: boolean;
|
||||||
|
newExpenses: Expense[];
|
||||||
|
newBudgets: Budget[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler: Handlers<Data, FreshContextState> = {
|
||||||
|
async POST(request, context) {
|
||||||
|
if (!context.state.user) {
|
||||||
|
return new Response('Unauthorized', { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody = await request.clone().json() as RequestBody;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!requestBody.cost || Number.isNaN(requestBody.cost) || !requestBody.description || !requestBody.month ||
|
||||||
|
!requestBody.month.match(/^\d{4}-\d{2}$/)
|
||||||
|
) {
|
||||||
|
return new Response('Bad request', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!requestBody.budget) {
|
||||||
|
requestBody.budget = 'Misc';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!requestBody.date) {
|
||||||
|
requestBody.date = new Date().toISOString().substring(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!requestBody.date.match(/^\d{4}-\d{2}-\d{2}$/)) {
|
||||||
|
return new Response('Bad request', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!requestBody.is_recurring) {
|
||||||
|
requestBody.is_recurring = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newExpense = await createExpense(
|
||||||
|
context.state.user.id,
|
||||||
|
requestBody.cost,
|
||||||
|
requestBody.description,
|
||||||
|
requestBody.budget,
|
||||||
|
requestBody.date,
|
||||||
|
requestBody.is_recurring,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!newExpense) {
|
||||||
|
throw new Error('Failed to add expense!');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return new Response(`${error}`, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const newExpenses = await getExpenses(context.state.user.id, requestBody.month);
|
||||||
|
|
||||||
|
const newBudgets = await getBudgets(context.state.user.id, requestBody.month);
|
||||||
|
|
||||||
|
const responseBody: ResponseBody = { success: true, newExpenses, newBudgets };
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(responseBody));
|
||||||
|
},
|
||||||
|
};
|
||||||
38
routes/api/expenses/auto-complete.tsx
Normal file
38
routes/api/expenses/auto-complete.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Handlers } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { FreshContextState } from '/lib/types.ts';
|
||||||
|
import { getExpenseSuggestions } from '/lib/data/expenses.ts';
|
||||||
|
|
||||||
|
interface Data {}
|
||||||
|
|
||||||
|
export interface RequestBody {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResponseBody {
|
||||||
|
success: boolean;
|
||||||
|
suggestions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler: Handlers<Data, FreshContextState> = {
|
||||||
|
async POST(request, context) {
|
||||||
|
if (!context.state.user) {
|
||||||
|
return new Response('Unauthorized', { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody = await request.clone().json() as RequestBody;
|
||||||
|
|
||||||
|
if (!requestBody.name || requestBody.name.length < 2) {
|
||||||
|
return new Response('Bad request', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestions = await getExpenseSuggestions(
|
||||||
|
context.state.user.id,
|
||||||
|
requestBody.name,
|
||||||
|
);
|
||||||
|
|
||||||
|
const responseBody: ResponseBody = { success: true, suggestions };
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(responseBody));
|
||||||
|
},
|
||||||
|
};
|
||||||
51
routes/api/expenses/delete-budget.tsx
Normal file
51
routes/api/expenses/delete-budget.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { Handlers } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { Budget, FreshContextState } from '/lib/types.ts';
|
||||||
|
import { deleteBudget, getBudgetById, getBudgets } from '/lib/data/expenses.ts';
|
||||||
|
|
||||||
|
interface Data {}
|
||||||
|
|
||||||
|
export interface RequestBody {
|
||||||
|
id: string;
|
||||||
|
currentMonth: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResponseBody {
|
||||||
|
success: boolean;
|
||||||
|
newBudgets: Budget[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler: Handlers<Data, FreshContextState> = {
|
||||||
|
async POST(request, context) {
|
||||||
|
if (!context.state.user) {
|
||||||
|
return new Response('Unauthorized', { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody = await request.clone().json() as RequestBody;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!requestBody.id || !requestBody.currentMonth || !requestBody.currentMonth.match(/^\d{4}-\d{2}$/)
|
||||||
|
) {
|
||||||
|
return new Response('Bad request', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const budget = await getBudgetById(context.state.user.id, requestBody.id);
|
||||||
|
|
||||||
|
if (!budget) {
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteBudget(context.state.user.id, requestBody.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return new Response(`${error}`, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const newBudgets = await getBudgets(context.state.user.id, requestBody.currentMonth);
|
||||||
|
|
||||||
|
const responseBody: ResponseBody = { success: true, newBudgets };
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(responseBody));
|
||||||
|
},
|
||||||
|
};
|
||||||
54
routes/api/expenses/delete-expense.tsx
Normal file
54
routes/api/expenses/delete-expense.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { Handlers } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { Budget, Expense, FreshContextState } from '/lib/types.ts';
|
||||||
|
import { deleteExpense, getBudgets, getExpenseById, getExpenses } from '/lib/data/expenses.ts';
|
||||||
|
|
||||||
|
interface Data {}
|
||||||
|
|
||||||
|
export interface RequestBody {
|
||||||
|
id: string;
|
||||||
|
month: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResponseBody {
|
||||||
|
success: boolean;
|
||||||
|
newExpenses: Expense[];
|
||||||
|
newBudgets: Budget[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler: Handlers<Data, FreshContextState> = {
|
||||||
|
async POST(request, context) {
|
||||||
|
if (!context.state.user) {
|
||||||
|
return new Response('Unauthorized', { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody = await request.clone().json() as RequestBody;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!requestBody.id || !requestBody.month || !requestBody.month.match(/^\d{4}-\d{2}$/)
|
||||||
|
) {
|
||||||
|
return new Response('Bad request', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const expense = await getExpenseById(context.state.user.id, requestBody.id);
|
||||||
|
|
||||||
|
if (!expense) {
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteExpense(context.state.user.id, requestBody.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return new Response(`${error}`, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const newExpenses = await getExpenses(context.state.user.id, requestBody.month);
|
||||||
|
|
||||||
|
const newBudgets = await getBudgets(context.state.user.id, requestBody.month);
|
||||||
|
|
||||||
|
const responseBody: ResponseBody = { success: true, newExpenses, newBudgets };
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(responseBody));
|
||||||
|
},
|
||||||
|
};
|
||||||
32
routes/api/expenses/export-expenses.tsx
Normal file
32
routes/api/expenses/export-expenses.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Handlers } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { Budget, Expense, FreshContextState } from '/lib/types.ts';
|
||||||
|
import { getAllBudgetsForExport, getAllExpensesForExport } from '/lib/data/expenses.ts';
|
||||||
|
|
||||||
|
interface Data {}
|
||||||
|
|
||||||
|
export interface RequestBody {}
|
||||||
|
|
||||||
|
export interface ResponseBody {
|
||||||
|
success: boolean;
|
||||||
|
jsonContents: {
|
||||||
|
budgets: (Omit<Budget, 'id' | 'user_id' | 'created_at' | 'extra'> & { extra: Record<never, never> })[];
|
||||||
|
expenses: (Omit<Expense, 'id' | 'user_id' | 'created_at' | 'extra'> & { extra: Record<never, never> })[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler: Handlers<Data, FreshContextState> = {
|
||||||
|
async POST(request, context) {
|
||||||
|
if (!context.state.user) {
|
||||||
|
return new Response('Unauthorized', { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const newExpenses = await getAllExpensesForExport(context.state.user.id);
|
||||||
|
|
||||||
|
const newBudgets = await getAllBudgetsForExport(context.state.user.id);
|
||||||
|
|
||||||
|
const responseBody: ResponseBody = { success: true, jsonContents: { expenses: newExpenses, budgets: newBudgets } };
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(responseBody));
|
||||||
|
},
|
||||||
|
};
|
||||||
82
routes/api/expenses/import-expenses.tsx
Normal file
82
routes/api/expenses/import-expenses.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { Handlers } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { Budget, Expense, FreshContextState } from '/lib/types.ts';
|
||||||
|
import { concurrentPromises } from '/lib/utils/misc.ts';
|
||||||
|
import {
|
||||||
|
createBudget,
|
||||||
|
createExpense,
|
||||||
|
deleteAllBudgetsAndExpenses,
|
||||||
|
getBudgets,
|
||||||
|
getExpenses,
|
||||||
|
} from '/lib/data/expenses.ts';
|
||||||
|
|
||||||
|
interface Data {}
|
||||||
|
|
||||||
|
export interface RequestBody {
|
||||||
|
budgets: Pick<Budget, 'name' | 'month' | 'value'>[];
|
||||||
|
expenses: Pick<Expense, 'cost' | 'description' | 'budget' | 'date' | 'is_recurring'>[];
|
||||||
|
month: string;
|
||||||
|
replace: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResponseBody {
|
||||||
|
success: boolean;
|
||||||
|
newBudgets: Budget[];
|
||||||
|
newExpenses: Expense[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler: Handlers<Data, FreshContextState> = {
|
||||||
|
async POST(request, context) {
|
||||||
|
if (!context.state.user) {
|
||||||
|
return new Response('Unauthorized', { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody = await request.clone().json() as RequestBody;
|
||||||
|
|
||||||
|
if (
|
||||||
|
(requestBody.budgets.length === 0 && requestBody.expenses.length === 0) || !requestBody.month ||
|
||||||
|
!requestBody.month.match(/^\d{4}-\d{2}$/)
|
||||||
|
) {
|
||||||
|
return new Response('Bad request', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestBody.replace) {
|
||||||
|
await deleteAllBudgetsAndExpenses(context.state.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await concurrentPromises(
|
||||||
|
requestBody.budgets.map((budget) => () =>
|
||||||
|
createBudget(context.state.user!.id, budget.name, budget.month, budget.value)
|
||||||
|
),
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
|
||||||
|
await concurrentPromises(
|
||||||
|
requestBody.expenses.map((expense) => () =>
|
||||||
|
createExpense(
|
||||||
|
context.state.user!.id,
|
||||||
|
expense.cost,
|
||||||
|
expense.description,
|
||||||
|
expense.budget,
|
||||||
|
expense.date,
|
||||||
|
expense.is_recurring ?? false,
|
||||||
|
{ skipRecalculation: true, skipBudgetMatching: true, skipBudgetCreation: true },
|
||||||
|
)
|
||||||
|
),
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return new Response(`${error}`, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const newExpenses = await getExpenses(context.state.user.id, requestBody.month);
|
||||||
|
|
||||||
|
const newBudgets = await getBudgets(context.state.user.id, requestBody.month);
|
||||||
|
|
||||||
|
const responseBody: ResponseBody = { success: true, newExpenses, newBudgets };
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(responseBody));
|
||||||
|
},
|
||||||
|
};
|
||||||
60
routes/api/expenses/update-budget.tsx
Normal file
60
routes/api/expenses/update-budget.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { Handlers } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { Budget, FreshContextState } from '/lib/types.ts';
|
||||||
|
import { getBudgetById, getBudgets, updateBudget } from '/lib/data/expenses.ts';
|
||||||
|
|
||||||
|
interface Data {}
|
||||||
|
|
||||||
|
export interface RequestBody {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
month: string;
|
||||||
|
value: number;
|
||||||
|
currentMonth: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResponseBody {
|
||||||
|
success: boolean;
|
||||||
|
newBudgets: Budget[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler: Handlers<Data, FreshContextState> = {
|
||||||
|
async POST(request, context) {
|
||||||
|
if (!context.state.user) {
|
||||||
|
return new Response('Unauthorized', { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody = await request.clone().json() as RequestBody;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!requestBody.id || !requestBody.name || !requestBody.month || !requestBody.month.match(/^\d{4}-\d{2}$/) ||
|
||||||
|
!requestBody.value || Number.isNaN(requestBody.value) || !requestBody.currentMonth ||
|
||||||
|
!requestBody.currentMonth.match(/^\d{4}-\d{2}$/)
|
||||||
|
) {
|
||||||
|
return new Response('Bad request', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const budget = await getBudgetById(context.state.user.id, requestBody.id);
|
||||||
|
|
||||||
|
if (!budget) {
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
budget.name = requestBody.name;
|
||||||
|
budget.month = requestBody.month;
|
||||||
|
budget.value = requestBody.value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateBudget(budget);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return new Response(`${error}`, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const newBudgets = await getBudgets(context.state.user.id, requestBody.currentMonth);
|
||||||
|
|
||||||
|
const responseBody: ResponseBody = { success: true, newBudgets };
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(responseBody));
|
||||||
|
},
|
||||||
|
};
|
||||||
82
routes/api/expenses/update-expense.tsx
Normal file
82
routes/api/expenses/update-expense.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { Handlers } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { Budget, Expense, FreshContextState } from '/lib/types.ts';
|
||||||
|
import { getBudgets, getExpenseById, getExpenses, updateExpense } from '/lib/data/expenses.ts';
|
||||||
|
|
||||||
|
interface Data {}
|
||||||
|
|
||||||
|
export interface RequestBody {
|
||||||
|
id: string;
|
||||||
|
cost: number;
|
||||||
|
description: string;
|
||||||
|
budget: string;
|
||||||
|
date: string;
|
||||||
|
is_recurring: boolean;
|
||||||
|
month: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResponseBody {
|
||||||
|
success: boolean;
|
||||||
|
newExpenses: Expense[];
|
||||||
|
newBudgets: Budget[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler: Handlers<Data, FreshContextState> = {
|
||||||
|
async POST(request, context) {
|
||||||
|
if (!context.state.user) {
|
||||||
|
return new Response('Unauthorized', { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody = await request.clone().json() as RequestBody;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!requestBody.id || !requestBody.cost || Number.isNaN(requestBody.cost) || !requestBody.description ||
|
||||||
|
!requestBody.month || !requestBody.month.match(/^\d{4}-\d{2}$/)
|
||||||
|
) {
|
||||||
|
return new Response('Bad request', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const expense = await getExpenseById(context.state.user.id, requestBody.id);
|
||||||
|
|
||||||
|
if (!expense) {
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!requestBody.budget) {
|
||||||
|
requestBody.budget = 'Misc';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!requestBody.date) {
|
||||||
|
requestBody.date = new Date().toISOString().substring(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!requestBody.date.match(/^\d{4}-\d{2}-\d{2}$/)) {
|
||||||
|
return new Response('Bad request', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!requestBody.is_recurring) {
|
||||||
|
requestBody.is_recurring = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
expense.cost = requestBody.cost;
|
||||||
|
expense.description = requestBody.description;
|
||||||
|
expense.budget = requestBody.budget;
|
||||||
|
expense.date = requestBody.date;
|
||||||
|
expense.is_recurring = requestBody.is_recurring;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateExpense(expense);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return new Response(`${error}`, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const newExpenses = await getExpenses(context.state.user.id, requestBody.month);
|
||||||
|
|
||||||
|
const newBudgets = await getBudgets(context.state.user.id, requestBody.month);
|
||||||
|
|
||||||
|
const responseBody: ResponseBody = { success: true, newExpenses, newBudgets };
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(responseBody));
|
||||||
|
},
|
||||||
|
};
|
||||||
73
routes/expenses.tsx
Normal file
73
routes/expenses.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { Handlers, PageProps } from 'fresh/server.ts';
|
||||||
|
|
||||||
|
import { Budget, Expense, FreshContextState, SupportedCurrencySymbol } from '/lib/types.ts';
|
||||||
|
import { isAppEnabled } from '/lib/config.ts';
|
||||||
|
import { generateMonthlyBudgetsAndExpenses, getBudgets, getExpenses } from '/lib/data/expenses.ts';
|
||||||
|
import ExpensesWrapper from '/islands/expenses/ExpensesWrapper.tsx';
|
||||||
|
|
||||||
|
interface Data {
|
||||||
|
userBudgets: Budget[];
|
||||||
|
userExpenses: Expense[];
|
||||||
|
initialMonth: string;
|
||||||
|
currency: SupportedCurrencySymbol;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler: Handlers<Data, FreshContextState> = {
|
||||||
|
async GET(request, context) {
|
||||||
|
if (!context.state.user) {
|
||||||
|
return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAppEnabled('expenses')) {
|
||||||
|
return new Response('Redirect', { status: 303, headers: { 'Location': `/files` } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchParams = new URL(request.url).searchParams;
|
||||||
|
|
||||||
|
let initialMonth = searchParams.get('month') || new Date().toISOString().substring(0, 7);
|
||||||
|
|
||||||
|
const currentMonth = new Date().toISOString().substring(0, 7);
|
||||||
|
const nextMonth = new Date(new Date(currentMonth).setUTCMonth(new Date(currentMonth).getUTCMonth() + 1))
|
||||||
|
.toISOString()
|
||||||
|
.substring(0, 7);
|
||||||
|
|
||||||
|
// Send invalid months (format) back to current month
|
||||||
|
if (!initialMonth.match(/^\d{4}-\d{2}$/)) {
|
||||||
|
initialMonth = currentMonth;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset to next month if the selected month is too far in the future
|
||||||
|
if (initialMonth > nextMonth) {
|
||||||
|
initialMonth = nextMonth;
|
||||||
|
}
|
||||||
|
|
||||||
|
let userBudgets = await getBudgets(context.state.user.id, initialMonth);
|
||||||
|
|
||||||
|
let userExpenses = await getExpenses(context.state.user.id, initialMonth);
|
||||||
|
|
||||||
|
// If there are no budgets or expenses, and the selected month is in the future, generate the month's budgets and expenses
|
||||||
|
if (userBudgets.length === 0 && userExpenses.length === 0 && initialMonth >= currentMonth) {
|
||||||
|
await generateMonthlyBudgetsAndExpenses(context.state.user.id, initialMonth);
|
||||||
|
|
||||||
|
userBudgets = await getBudgets(context.state.user.id, initialMonth);
|
||||||
|
userExpenses = await getExpenses(context.state.user.id, initialMonth);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currency = context.state.user.extra.expenses_currency || '$';
|
||||||
|
|
||||||
|
return await context.render({ userBudgets, userExpenses, initialMonth, currency });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ExpensesPage({ data }: PageProps<Data, FreshContextState>) {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<ExpensesWrapper
|
||||||
|
initialBudgets={data.userBudgets}
|
||||||
|
initialExpenses={data.userExpenses}
|
||||||
|
initialMonth={data.initialMonth}
|
||||||
|
currency={data.currency}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Handlers, PageProps } from 'fresh/server.ts';
|
import { Handlers, PageProps } from 'fresh/server.ts';
|
||||||
|
|
||||||
import { FreshContextState } from '/lib/types.ts';
|
import { currencyMap, FreshContextState, SupportedCurrencySymbol } from '/lib/types.ts';
|
||||||
import { PASSWORD_SALT } from '/lib/auth.ts';
|
import { PASSWORD_SALT } from '/lib/auth.ts';
|
||||||
import {
|
import {
|
||||||
createVerificationCode,
|
createVerificationCode,
|
||||||
@@ -25,6 +25,7 @@ interface Data {
|
|||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
formData: Record<string, any>;
|
formData: Record<string, any>;
|
||||||
|
currency?: SupportedCurrencySymbol;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const handler: Handlers<Data, FreshContextState> = {
|
export const handler: Handlers<Data, FreshContextState> = {
|
||||||
@@ -33,7 +34,10 @@ export const handler: Handlers<Data, FreshContextState> = {
|
|||||||
return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } });
|
return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } });
|
||||||
}
|
}
|
||||||
|
|
||||||
return await context.render();
|
return await context.render({
|
||||||
|
formData: {},
|
||||||
|
currency: context.state.user.extra.expenses_currency,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
async POST(request, context) {
|
async POST(request, context) {
|
||||||
if (!context.state.user) {
|
if (!context.state.user) {
|
||||||
@@ -48,9 +52,9 @@ export const handler: Handlers<Data, FreshContextState> = {
|
|||||||
|
|
||||||
const formData = await request.clone().formData();
|
const formData = await request.clone().formData();
|
||||||
|
|
||||||
try {
|
const { user } = context.state;
|
||||||
const { user } = context.state;
|
|
||||||
|
|
||||||
|
try {
|
||||||
action = getFormDataField(formData, 'action') as Action;
|
action = getFormDataField(formData, 'action') as Action;
|
||||||
|
|
||||||
if (action !== 'change-email' && action !== 'verify-change-email') {
|
if (action !== 'change-email' && action !== 'verify-change-email') {
|
||||||
@@ -154,6 +158,19 @@ export const handler: Handlers<Data, FreshContextState> = {
|
|||||||
status: 303,
|
status: 303,
|
||||||
headers: { 'location': `/signup?success=delete` },
|
headers: { 'location': `/signup?success=delete` },
|
||||||
});
|
});
|
||||||
|
} else if (action === 'change-currency') {
|
||||||
|
const newCurrencySymbol = getFormDataField(formData, 'currency') as SupportedCurrencySymbol;
|
||||||
|
|
||||||
|
if (!currencyMap.has(newCurrencySymbol)) {
|
||||||
|
throw new Error(`Invalid currency.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
user.extra.expenses_currency = newCurrencySymbol;
|
||||||
|
|
||||||
|
await updateUser(user);
|
||||||
|
|
||||||
|
successTitle = 'Currency changed!';
|
||||||
|
successMessage = 'Currency changed successfully.';
|
||||||
}
|
}
|
||||||
|
|
||||||
const notice = successTitle
|
const notice = successTitle
|
||||||
@@ -166,6 +183,7 @@ export const handler: Handlers<Data, FreshContextState> = {
|
|||||||
return await context.render({
|
return await context.render({
|
||||||
notice,
|
notice,
|
||||||
formData: convertFormDataToObject(formData),
|
formData: convertFormDataToObject(formData),
|
||||||
|
currency: user.extra.expenses_currency,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -175,6 +193,7 @@ export const handler: Handlers<Data, FreshContextState> = {
|
|||||||
return await context.render({
|
return await context.render({
|
||||||
error: { title: errorTitle, message: errorMessage },
|
error: { title: errorTitle, message: errorMessage },
|
||||||
formData: convertFormDataToObject(formData),
|
formData: convertFormDataToObject(formData),
|
||||||
|
currency: user.extra.expenses_currency,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -183,7 +202,7 @@ export const handler: Handlers<Data, FreshContextState> = {
|
|||||||
export default function SettingsPage({ data }: PageProps<Data, FreshContextState>) {
|
export default function SettingsPage({ data }: PageProps<Data, FreshContextState>) {
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
<Settings formData={data?.formData} error={data?.error} notice={data?.notice} />
|
<Settings formData={data?.formData} error={data?.error} notice={data?.notice} currency={data?.currency} />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
1
static/images/expenses.svg
Normal file
1
static/images/expenses.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><path stroke-linecap="round" d="M12 6v12m3-8.5C15 8.12 13.657 7 12 7S9 8.12 9 9.5s1.343 2.5 3 2.5s3 1.12 3 2.5s-1.343 2.5-3 2.5s-3-1.12-3-2.5"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 325 B |
Reference in New Issue
Block a user