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 { 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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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' : ''}`}>
|
||||||
|
|||||||
Reference in New Issue
Block a user