Files
SimpleFinanceDash/frontend/src/components/VariableExpenses.tsx
Jean-Luc Makiola 234a7d913a feat(06-02): display item_tier badge in tracker table rows
- 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
2026-03-12 13:09:27 +01:00

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