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:
@@ -1,9 +1,11 @@
|
||||
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 type { BudgetDetail } from '@/lib/api'
|
||||
import { formatCurrency } from '@/lib/format'
|
||||
import { headerGradient, amountColorClass } from '@/lib/palette'
|
||||
import { headerGradient, amountColorClass, palette } from '@/lib/palette'
|
||||
import { InlineEditCell } from '@/components/InlineEditCell'
|
||||
|
||||
interface Props {
|
||||
@@ -15,6 +17,38 @@ export function BillsTracker({ budget, onUpdate }: Props) {
|
||||
const { t } = useTranslation()
|
||||
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 (
|
||||
<Card>
|
||||
<CardHeader style={headerGradient('bill')}>
|
||||
@@ -31,7 +65,17 @@ export function BillsTracker({ budget, onUpdate }: Props) {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{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 className="text-right">
|
||||
{formatCurrency(item.budgeted_amount, budget.currency)}
|
||||
@@ -40,6 +84,8 @@ export function BillsTracker({ budget, onUpdate }: Props) {
|
||||
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: 'bill', actual: item.actual_amount, budgeted: item.budgeted_amount })}
|
||||
/>
|
||||
</TableRow>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
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 type { BudgetDetail } from '@/lib/api'
|
||||
import { formatCurrency } from '@/lib/format'
|
||||
import { headerGradient, amountColorClass } from '@/lib/palette'
|
||||
import { headerGradient, amountColorClass, palette } from '@/lib/palette'
|
||||
import { InlineEditCell } from '@/components/InlineEditCell'
|
||||
|
||||
interface Props {
|
||||
@@ -15,7 +17,37 @@ export function DebtTracker({ budget, onUpdate }: Props) {
|
||||
const { t } = useTranslation()
|
||||
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 (
|
||||
<Card>
|
||||
@@ -33,7 +65,17 @@ export function DebtTracker({ budget, onUpdate }: Props) {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{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 className="text-right">
|
||||
{formatCurrency(item.budgeted_amount, budget.currency)}
|
||||
@@ -42,6 +84,8 @@ export function DebtTracker({ budget, onUpdate }: Props) {
|
||||
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: 'debt', actual: item.actual_amount, budgeted: item.budgeted_amount })}
|
||||
/>
|
||||
</TableRow>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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'
|
||||
@@ -16,12 +18,44 @@ 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')}>
|
||||
@@ -41,7 +75,17 @@ export function VariableExpenses({ budget, onUpdate }: Props) {
|
||||
{expenses.map((item) => {
|
||||
const remaining = item.budgeted_amount - item.actual_amount
|
||||
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 className="text-right">
|
||||
{formatCurrency(item.budgeted_amount, budget.currency)}
|
||||
@@ -50,6 +94,8 @@ export function VariableExpenses({ budget, onUpdate }: Props) {
|
||||
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' : ''}`}>
|
||||
|
||||
Reference in New Issue
Block a user