143 lines
4.9 KiB
TypeScript
143 lines
4.9 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 { Input } from '@/components/ui/input'
|
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'
|
|
import type { BudgetDetail } from '@/lib/api'
|
|
import { formatCurrency } from '@/lib/format'
|
|
|
|
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 chartData = expenses.map((item) => ({
|
|
name: item.category_name,
|
|
[t('dashboard.budget')]: item.budgeted_amount,
|
|
[t('dashboard.actual')]: item.actual_amount,
|
|
}))
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader className="bg-gradient-to-r from-amber-50 to-yellow-50">
|
|
<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) => (
|
|
<InlineEditRow
|
|
key={item.id}
|
|
label={item.category_name}
|
|
budgeted={item.budgeted_amount}
|
|
actual={item.actual_amount}
|
|
currency={budget.currency}
|
|
onSave={(actual) => onUpdate(item.id, { actual_amount: actual })}
|
|
/>
|
|
))}
|
|
<TableRow className="border-t-2 font-bold bg-amber-50/50">
|
|
<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="#fcd34d" radius={[4, 4, 0, 0]} />
|
|
<Bar dataKey={t('dashboard.actual')} fill="#f9a8d4" radius={[4, 4, 0, 0]} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
function InlineEditRow({
|
|
label,
|
|
budgeted,
|
|
actual,
|
|
currency,
|
|
onSave,
|
|
}: {
|
|
label: string
|
|
budgeted: number
|
|
actual: number
|
|
currency: string
|
|
onSave: (value: number) => Promise<void>
|
|
}) {
|
|
const [editing, setEditing] = useState(false)
|
|
const [value, setValue] = useState(String(actual))
|
|
|
|
const handleBlur = async () => {
|
|
const num = parseFloat(value)
|
|
if (!isNaN(num) && num !== actual) {
|
|
await onSave(num)
|
|
}
|
|
setEditing(false)
|
|
}
|
|
|
|
const remaining = budgeted - actual
|
|
|
|
return (
|
|
<TableRow>
|
|
<TableCell>{label}</TableCell>
|
|
<TableCell className="text-right">{formatCurrency(budgeted, currency)}</TableCell>
|
|
<TableCell className="text-right">
|
|
{editing ? (
|
|
<Input
|
|
type="number"
|
|
step="0.01"
|
|
value={value}
|
|
onChange={(e) => setValue(e.target.value)}
|
|
onBlur={handleBlur}
|
|
onKeyDown={(e) => e.key === 'Enter' && handleBlur()}
|
|
className="ml-auto w-28 text-right"
|
|
autoFocus
|
|
/>
|
|
) : (
|
|
<span
|
|
className="cursor-pointer rounded px-2 py-1 hover:bg-muted"
|
|
onClick={() => { setValue(String(actual)); setEditing(true) }}
|
|
>
|
|
{formatCurrency(actual, currency)}
|
|
</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className={`text-right ${remaining < 0 ? 'text-destructive' : ''}`}>
|
|
{formatCurrency(remaining, currency)}
|
|
</TableCell>
|
|
</TableRow>
|
|
)
|
|
}
|