feat(03-03): wire row flash feedback into all three tracker components

- Add flashRowId/errorRowId state and triggerFlash helper to BillsTracker, VariableExpenses, DebtTracker
- Apply inline color-mix style to data rows for green/red 600ms flash on save success/error
- Wire onSaveSuccess/onSaveError callbacks to InlineEditCell in all three components
- Add tinted skeleton placeholder (palette.*.light) when no items exist for the section
This commit is contained in:
2026-03-11 22:36:01 +01:00
parent f22eef837f
commit 4ef10dae46
3 changed files with 142 additions and 6 deletions

View File

@@ -1,9 +1,11 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Skeleton } from '@/components/ui/skeleton'
import type { BudgetDetail } from '@/lib/api' import type { BudgetDetail } from '@/lib/api'
import { formatCurrency } from '@/lib/format' import { formatCurrency } from '@/lib/format'
import { headerGradient, amountColorClass } from '@/lib/palette' import { headerGradient, amountColorClass, palette } from '@/lib/palette'
import { InlineEditCell } from '@/components/InlineEditCell' import { InlineEditCell } from '@/components/InlineEditCell'
interface Props { interface Props {
@@ -15,6 +17,38 @@ export function BillsTracker({ budget, onUpdate }: Props) {
const { t } = useTranslation() const { t } = useTranslation()
const bills = budget.items.filter((i) => i.category_type === 'bill') const bills = budget.items.filter((i) => i.category_type === 'bill')
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)
}
}
if (bills.length === 0) {
return (
<Card>
<CardHeader style={headerGradient('bill')}>
<CardTitle>{t('dashboard.billsTracker')}</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.bill.light }}
/>
))}
</CardContent>
</Card>
)
}
return ( return (
<Card> <Card>
<CardHeader style={headerGradient('bill')}> <CardHeader style={headerGradient('bill')}>
@@ -31,7 +65,17 @@ export function BillsTracker({ budget, onUpdate }: Props) {
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{bills.map((item) => ( {bills.map((item) => (
<TableRow key={item.id}> <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}</TableCell> <TableCell>{item.category_name}</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
{formatCurrency(item.budgeted_amount, budget.currency)} {formatCurrency(item.budgeted_amount, budget.currency)}
@@ -40,6 +84,8 @@ export function BillsTracker({ budget, onUpdate }: Props) {
value={item.actual_amount} value={item.actual_amount}
currency={budget.currency} currency={budget.currency}
onSave={(actual) => onUpdate(item.id, { actual_amount: actual })} onSave={(actual) => onUpdate(item.id, { actual_amount: actual })}
onSaveSuccess={() => triggerFlash(item.id, 'success')}
onSaveError={() => triggerFlash(item.id, 'error')}
className={amountColorClass({ type: 'bill', actual: item.actual_amount, budgeted: item.budgeted_amount })} className={amountColorClass({ type: 'bill', actual: item.actual_amount, budgeted: item.budgeted_amount })}
/> />
</TableRow> </TableRow>

View File

@@ -1,9 +1,11 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Skeleton } from '@/components/ui/skeleton'
import type { BudgetDetail } from '@/lib/api' import type { BudgetDetail } from '@/lib/api'
import { formatCurrency } from '@/lib/format' import { formatCurrency } from '@/lib/format'
import { headerGradient, amountColorClass } from '@/lib/palette' import { headerGradient, amountColorClass, palette } from '@/lib/palette'
import { InlineEditCell } from '@/components/InlineEditCell' import { InlineEditCell } from '@/components/InlineEditCell'
interface Props { interface Props {
@@ -15,7 +17,37 @@ export function DebtTracker({ budget, onUpdate }: Props) {
const { t } = useTranslation() const { t } = useTranslation()
const debts = budget.items.filter((i) => i.category_type === 'debt') const debts = budget.items.filter((i) => i.category_type === 'debt')
if (debts.length === 0) return null 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)
}
}
if (debts.length === 0) {
return (
<Card>
<CardHeader style={headerGradient('debt')}>
<CardTitle>{t('dashboard.debtTracker')}</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.debt.light }}
/>
))}
</CardContent>
</Card>
)
}
return ( return (
<Card> <Card>
@@ -33,7 +65,17 @@ export function DebtTracker({ budget, onUpdate }: Props) {
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{debts.map((item) => ( {debts.map((item) => (
<TableRow key={item.id}> <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}</TableCell> <TableCell>{item.category_name}</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
{formatCurrency(item.budgeted_amount, budget.currency)} {formatCurrency(item.budgeted_amount, budget.currency)}
@@ -42,6 +84,8 @@ export function DebtTracker({ budget, onUpdate }: Props) {
value={item.actual_amount} value={item.actual_amount}
currency={budget.currency} currency={budget.currency}
onSave={(actual) => onUpdate(item.id, { actual_amount: actual })} onSave={(actual) => onUpdate(item.id, { actual_amount: actual })}
onSaveSuccess={() => triggerFlash(item.id, 'success')}
onSaveError={() => triggerFlash(item.id, 'error')}
className={amountColorClass({ type: 'debt', actual: item.actual_amount, budgeted: item.budgeted_amount })} className={amountColorClass({ type: 'debt', actual: item.actual_amount, budgeted: item.budgeted_amount })}
/> />
</TableRow> </TableRow>

View File

@@ -1,6 +1,8 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' 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 { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'
import type { BudgetDetail } from '@/lib/api' import type { BudgetDetail } from '@/lib/api'
import { formatCurrency } from '@/lib/format' import { formatCurrency } from '@/lib/format'
@@ -16,12 +18,44 @@ export function VariableExpenses({ budget, onUpdate }: Props) {
const { t } = useTranslation() const { t } = useTranslation()
const expenses = budget.items.filter((i) => i.category_type === 'variable_expense') 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) => ({ const chartData = expenses.map((item) => ({
name: item.category_name, name: item.category_name,
[t('dashboard.budget')]: item.budgeted_amount, [t('dashboard.budget')]: item.budgeted_amount,
[t('dashboard.actual')]: item.actual_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 ( return (
<Card> <Card>
<CardHeader style={headerGradient('variable_expense')}> <CardHeader style={headerGradient('variable_expense')}>
@@ -41,7 +75,17 @@ export function VariableExpenses({ budget, onUpdate }: Props) {
{expenses.map((item) => { {expenses.map((item) => {
const remaining = item.budgeted_amount - item.actual_amount const remaining = item.budgeted_amount - item.actual_amount
return ( return (
<TableRow key={item.id}> <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}</TableCell> <TableCell>{item.category_name}</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
{formatCurrency(item.budgeted_amount, budget.currency)} {formatCurrency(item.budgeted_amount, budget.currency)}
@@ -50,6 +94,8 @@ export function VariableExpenses({ budget, onUpdate }: Props) {
value={item.actual_amount} value={item.actual_amount}
currency={budget.currency} currency={budget.currency}
onSave={(actual) => onUpdate(item.id, { actual_amount: actual })} 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 })} className={amountColorClass({ type: 'variable_expense', actual: item.actual_amount, budgeted: item.budgeted_amount })}
/> />
<TableCell className={`text-right ${remaining < 0 ? 'text-destructive' : ''}`}> <TableCell className={`text-right ${remaining < 0 ? 'text-destructive' : ''}`}>