Files
bewcloud/components/expenses/ListBudgets.tsx
Bruno Bernardino 874ab006f9 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.
2025-02-26 17:43:53 +00:00

139 lines
5.0 KiB
TypeScript

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>
);
}