- Add Badge import to BillsTracker, VariableExpenses, DebtTracker - Render outline Badge after category name showing tier (Fixed/Variable/One-off) - Use i18n keys template.fixed, template.variable, template.oneOff - Badge is purely informational with variant="outline" for subtle appearance
147 lines
6.0 KiB
TypeScript
147 lines
6.0 KiB
TypeScript
import { useState } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
|
import { Skeleton } from '@/components/ui/skeleton'
|
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'
|
|
import type { BudgetDetail } from '@/lib/api'
|
|
import { formatCurrency } from '@/lib/format'
|
|
import { headerGradient, amountColorClass, palette } from '@/lib/palette'
|
|
import { InlineEditCell } from '@/components/InlineEditCell'
|
|
import { Badge } from '@/components/ui/badge'
|
|
|
|
interface Props {
|
|
budget: BudgetDetail
|
|
onUpdate: (itemId: string, data: { actual_amount?: number }) => Promise<void>
|
|
}
|
|
|
|
export function VariableExpenses({ budget, onUpdate }: Props) {
|
|
const { t } = useTranslation()
|
|
const expenses = budget.items.filter((i) => i.category_type === 'variable_expense')
|
|
|
|
const [flashRowId, setFlashRowId] = useState<string | null>(null)
|
|
const [errorRowId, setErrorRowId] = useState<string | null>(null)
|
|
|
|
const triggerFlash = (id: string, type: 'success' | 'error') => {
|
|
if (type === 'success') {
|
|
setFlashRowId(id)
|
|
setTimeout(() => setFlashRowId(null), 600)
|
|
} else {
|
|
setErrorRowId(id)
|
|
setTimeout(() => setErrorRowId(null), 600)
|
|
}
|
|
}
|
|
|
|
const chartData = expenses.map((item) => ({
|
|
name: item.category_name,
|
|
[t('dashboard.budget')]: item.budgeted_amount,
|
|
[t('dashboard.actual')]: item.actual_amount,
|
|
}))
|
|
|
|
if (expenses.length === 0) {
|
|
return (
|
|
<Card>
|
|
<CardHeader style={headerGradient('variable_expense')}>
|
|
<CardTitle>{t('dashboard.variableExpenses')}</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="flex flex-col gap-2 p-4">
|
|
{[1, 2, 3].map((i) => (
|
|
<Skeleton
|
|
key={i}
|
|
className="h-10 w-full rounded-md"
|
|
style={{ backgroundColor: palette.variable_expense.light }}
|
|
/>
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader style={headerGradient('variable_expense')}>
|
|
<CardTitle>{t('dashboard.variableExpenses')}</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="flex flex-col gap-6 pt-4">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead />
|
|
<TableHead className="text-right">{t('dashboard.budget')}</TableHead>
|
|
<TableHead className="text-right">{t('dashboard.actual')}</TableHead>
|
|
<TableHead className="text-right">{t('dashboard.remaining')}</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{expenses.map((item) => {
|
|
const remaining = item.budgeted_amount - item.actual_amount
|
|
return (
|
|
<TableRow
|
|
key={item.id}
|
|
className="transition-colors duration-500"
|
|
style={
|
|
flashRowId === item.id
|
|
? { backgroundColor: 'color-mix(in oklch, var(--success) 20%, transparent)' }
|
|
: errorRowId === item.id
|
|
? { backgroundColor: 'color-mix(in oklch, var(--destructive) 20%, transparent)' }
|
|
: undefined
|
|
}
|
|
>
|
|
<TableCell>
|
|
{item.category_name}
|
|
<Badge variant="outline" className="ml-2 text-xs font-normal">
|
|
{t(`template.${item.item_tier === 'one_off' ? 'oneOff' : item.item_tier}`)}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
{formatCurrency(item.budgeted_amount, budget.currency)}
|
|
</TableCell>
|
|
<InlineEditCell
|
|
value={item.actual_amount}
|
|
currency={budget.currency}
|
|
onSave={(actual) => onUpdate(item.id, { actual_amount: actual })}
|
|
onSaveSuccess={() => triggerFlash(item.id, 'success')}
|
|
onSaveError={() => triggerFlash(item.id, 'error')}
|
|
className={amountColorClass({ type: 'variable_expense', actual: item.actual_amount, budgeted: item.budgeted_amount })}
|
|
/>
|
|
<TableCell className={`text-right ${remaining < 0 ? 'text-destructive' : ''}`}>
|
|
{formatCurrency(remaining, budget.currency)}
|
|
</TableCell>
|
|
</TableRow>
|
|
)
|
|
})}
|
|
<TableRow className="border-t-2 font-bold">
|
|
<TableCell>{t('dashboard.budget')}</TableCell>
|
|
<TableCell className="text-right">
|
|
{formatCurrency(expenses.reduce((s, i) => s + i.budgeted_amount, 0), budget.currency)}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
{formatCurrency(expenses.reduce((s, i) => s + i.actual_amount, 0), budget.currency)}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
{formatCurrency(expenses.reduce((s, i) => s + (i.budgeted_amount - i.actual_amount), 0), budget.currency)}
|
|
</TableCell>
|
|
</TableRow>
|
|
</TableBody>
|
|
</Table>
|
|
|
|
{chartData.length > 0 && (
|
|
<div className="h-64">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart data={chartData}>
|
|
<CartesianGrid strokeDasharray="3 3" />
|
|
<XAxis dataKey="name" tick={{ fontSize: 12 }} />
|
|
<YAxis />
|
|
<Tooltip />
|
|
<Legend />
|
|
<Bar dataKey={t('dashboard.budget')} fill={palette.variable_expense.light} radius={[4, 4, 0, 0]} />
|
|
<Bar dataKey={t('dashboard.actual')} fill={palette.variable_expense.base} radius={[4, 4, 0, 0]} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|