feat(04-03): upgrade BudgetDetailPage with semantic tokens, direction-aware diff, PageShell, group headers, and skeleton

- Replace TierBadge and tier column with cleaner per-category grouping display
- Rewrite DifferenceCell with direction-aware diff logic (spending types: over when actual > budgeted; income/saving/investment: over when actual < budgeted)
- Replace text-green-600/text-red-600 with text-on-budget/text-over-budget semantic tokens
- Replace dot+h2 group headers with left-border accent headers matching dashboard style
- Fix headingLabel to use i18n.language via Intl.DateTimeFormat instead of hardcoded 'en'
- Wrap page in PageShell with locale-aware title and Add Item action button
- Add back-link as first child of PageShell with -mt-4 compensation
- Replace null loading state with PageShell + Skeleton groups
- Update group footer total label to use budgets.total i18n interpolation key
This commit is contained in:
2026-03-17 16:21:37 +01:00
parent 89dd3ded74
commit 24d071c1f3

View File

@@ -0,0 +1,566 @@
import { useState, useRef, useEffect } from "react"
import { useParams, Link } from "react-router-dom"
import { useTranslation } from "react-i18next"
import { Plus, Trash2, ArrowLeft } from "lucide-react"
import { toast } from "sonner"
import { useBudgetDetail, useBudgets } from "@/hooks/useBudgets"
import { useCategories } from "@/hooks/useCategories"
import type { BudgetItem, CategoryType } from "@/lib/types"
import { categoryColors } from "@/lib/palette"
import { formatCurrency } from "@/lib/format"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Skeleton } from "@/components/ui/skeleton"
import { PageShell } from "@/components/shared/PageShell"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Table,
TableBody,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CATEGORY_TYPES: CategoryType[] = [
"income",
"bill",
"variable_expense",
"debt",
"saving",
"investment",
]
const SPENDING_TYPES: CategoryType[] = ["bill", "variable_expense", "debt"]
// ---------------------------------------------------------------------------
// Direction-aware diff helpers
// ---------------------------------------------------------------------------
function isSpendingType(type: CategoryType): boolean {
return SPENDING_TYPES.includes(type)
}
// ---------------------------------------------------------------------------
// InlineEditCell
// ---------------------------------------------------------------------------
/**
* A table cell that displays a formatted currency value and switches to a
* number input on click. Commits on blur or Enter; reverts on Escape.
*/
interface InlineEditCellProps {
value: number
currency: string
onCommit: (next: number) => Promise<void>
"aria-label"?: string
}
function InlineEditCell({
value,
currency,
onCommit,
"aria-label": ariaLabel,
}: InlineEditCellProps) {
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState(String(value))
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (editing) {
inputRef.current?.select()
}
}, [editing])
function startEdit() {
setDraft(String(value))
setEditing(true)
}
async function commit() {
const parsed = parseFloat(draft)
if (!isNaN(parsed) && parsed !== value) {
await onCommit(parsed)
}
setEditing(false)
}
function cancel() {
setDraft(String(value))
setEditing(false)
}
if (editing) {
return (
<TableCell className="p-1">
<Input
ref={inputRef}
type="number"
min="0"
step="0.01"
value={draft}
onChange={(e) => setDraft(e.target.value)}
className="h-7 w-28 text-right tabular-nums"
onBlur={() => void commit()}
onKeyDown={(e) => {
if (e.key === "Enter") void commit()
if (e.key === "Escape") cancel()
}}
aria-label={ariaLabel}
/>
</TableCell>
)
}
return (
<TableCell
className="cursor-pointer select-none text-right tabular-nums hover:bg-muted/40"
onClick={startEdit}
title="Click to edit"
>
{formatCurrency(value, currency)}
</TableCell>
)
}
// ---------------------------------------------------------------------------
// DifferenceCell
// ---------------------------------------------------------------------------
function DifferenceCell({
budgeted,
actual,
currency,
type,
}: {
budgeted: number
actual: number
currency: string
type: CategoryType
}) {
const isOver = isSpendingType(type)
? actual > budgeted
: actual < budgeted
const diff = isSpendingType(type)
? budgeted - actual
: actual - budgeted
return (
<TableCell
className={cn(
"text-right tabular-nums",
isOver ? "text-over-budget" : diff !== 0 ? "text-on-budget" : "text-muted-foreground"
)}
>
{formatCurrency(Math.abs(diff), currency)}
{diff < 0 ? " over" : ""}
</TableCell>
)
}
// ---------------------------------------------------------------------------
// Add item dialog state
// ---------------------------------------------------------------------------
interface AddItemState {
categoryId: string
amount: string
notes: string
}
const DEFAULT_ADD_STATE: AddItemState = {
categoryId: "",
amount: "",
notes: "",
}
// ---------------------------------------------------------------------------
// BudgetDetailPage
// ---------------------------------------------------------------------------
export default function BudgetDetailPage() {
const { id } = useParams<{ id: string }>()
const { t, i18n } = useTranslation()
const budgetId = id ?? ""
const { budget, items, loading } = useBudgetDetail(budgetId)
const { updateItem, createItem, deleteItem } = useBudgets()
const { categories } = useCategories()
const [addDialogOpen, setAddDialogOpen] = useState(false)
const [addForm, setAddForm] = useState<AddItemState>(DEFAULT_ADD_STATE)
function openAddDialog() {
setAddForm(DEFAULT_ADD_STATE)
setAddDialogOpen(true)
}
async function handleAddItem() {
const parsed = parseFloat(addForm.amount)
if (!addForm.categoryId || isNaN(parsed) || parsed < 0) return
try {
await createItem.mutateAsync({
budgetId,
category_id: addForm.categoryId,
budgeted_amount: parsed,
actual_amount: 0,
notes: addForm.notes.trim() || null,
})
setAddDialogOpen(false)
toast.success(t("budgets.addItem"))
} catch {
toast.error(t("common.error"))
}
}
async function handleUpdateActual(item: BudgetItem, actual_amount: number) {
try {
await updateItem.mutateAsync({
id: item.id,
budgetId,
actual_amount,
})
} catch {
toast.error(t("common.error"))
}
}
async function handleDeleteItem(itemId: string) {
try {
await deleteItem.mutateAsync({ id: itemId, budgetId })
} catch {
toast.error(t("common.error"))
}
}
// ------------------------------------------------------------------
// Budget heading: "Month YYYY" (locale-aware)
// ------------------------------------------------------------------
function headingLabel(): string {
if (!budget) return ""
const [year, month] = budget.start_date.split("-").map(Number)
return new Intl.DateTimeFormat(i18n.language, { month: "long", year: "numeric" }).format(
new Date(year ?? 0, (month ?? 1) - 1, 1)
)
}
// ------------------------------------------------------------------
// Derived: group items by category type
// ------------------------------------------------------------------
const grouped = CATEGORY_TYPES.map((type) => ({
type,
items: items.filter((item) => item.category?.type === type),
})).filter((g) => g.items.length > 0)
// Overall totals
const totalBudgeted = items.reduce((sum, i) => sum + i.budgeted_amount, 0)
const totalActual = items.reduce((sum, i) => sum + i.actual_amount, 0)
const currency = budget?.currency ?? "EUR"
if (loading) return (
<PageShell title="">
<div className="space-y-6">
<Skeleton className="h-4 w-24" />
{[1, 2, 3].map((i) => (
<div key={i} className="space-y-2">
<div className="flex items-center gap-3 rounded-sm border-l-4 border-muted bg-muted/30 px-3 py-2">
<Skeleton className="h-4 w-28" />
</div>
{[1, 2].map((j) => (
<div key={j} className="flex items-center gap-4 px-4 py-2.5 border-b border-border">
<Skeleton className="h-4 w-32" />
<Skeleton className="ml-auto h-4 w-20" />
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-16" />
</div>
))}
</div>
))}
<Skeleton className="h-20 w-full rounded-md" />
</div>
</PageShell>
)
if (!budget) {
return (
<div className="py-20 text-center text-muted-foreground">
{t("common.error")}
</div>
)
}
return (
<PageShell
title={headingLabel()}
action={
<Button onClick={openAddDialog} size="sm">
<Plus className="mr-1 size-4" />
{t("budgets.addItem")}
</Button>
}
>
<Link
to="/budgets"
className="-mt-4 inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-4" />
{t("budgets.title")}
</Link>
{/* Empty state */}
{items.length === 0 ? (
<p className="text-muted-foreground">{t("budgets.empty")}</p>
) : (
<div className="space-y-8">
{grouped.map(({ type, items: groupItems }) => {
const groupBudgeted = groupItems.reduce(
(sum, i) => sum + i.budgeted_amount,
0
)
const groupActual = groupItems.reduce(
(sum, i) => sum + i.actual_amount,
0
)
return (
<div key={type}>
{/* Group heading */}
<div
className="mb-2 flex items-center gap-3 rounded-sm border-l-4 bg-muted/30 px-3 py-2"
style={{ borderLeftColor: categoryColors[type] }}
>
<span className="text-sm font-semibold">{t(`categories.types.${type}`)}</span>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("categories.name")}</TableHead>
<TableHead className="text-right">
{t("budgets.budgeted")}
</TableHead>
<TableHead className="text-right">
{t("budgets.actual")}
</TableHead>
<TableHead className="text-right">
{t("budgets.difference")}
</TableHead>
<TableHead>{t("budgets.notes")}</TableHead>
<TableHead className="w-10" />
</TableRow>
</TableHeader>
<TableBody>
{groupItems.map((item) => (
<TableRow key={item.id}>
<TableCell className="font-medium">
{item.category?.name ?? item.category_id}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatCurrency(item.budgeted_amount, currency)}
</TableCell>
<InlineEditCell
value={item.actual_amount}
currency={currency}
onCommit={(val) => handleUpdateActual(item, val)}
aria-label={`${t("budgets.actual")}${item.category?.name ?? ""}`}
/>
<DifferenceCell
budgeted={item.budgeted_amount}
actual={item.actual_amount}
currency={currency}
type={type}
/>
<TableCell className="text-sm text-muted-foreground">
{item.notes ?? ""}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
aria-label={t("common.delete")}
onClick={() => void handleDeleteItem(item.id)}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell className="font-medium">
{t("budgets.total", { label: t(`categories.types.${type}`) })}
</TableCell>
<TableCell className="text-right tabular-nums font-medium">
{formatCurrency(groupBudgeted, currency)}
</TableCell>
<TableCell className="text-right tabular-nums font-medium">
{formatCurrency(groupActual, currency)}
</TableCell>
<DifferenceCell
budgeted={groupBudgeted}
actual={groupActual}
currency={currency}
type={type}
/>
<TableCell colSpan={2} />
</TableRow>
</TableFooter>
</Table>
</div>
)
})}
{/* Overall totals */}
<div className="rounded-md border p-4">
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<p className="text-muted-foreground">{t("budgets.budgeted")}</p>
<p className="text-lg font-semibold tabular-nums">
{formatCurrency(totalBudgeted, currency)}
</p>
</div>
<div>
<p className="text-muted-foreground">{t("budgets.actual")}</p>
<p className="text-lg font-semibold tabular-nums">
{formatCurrency(totalActual, currency)}
</p>
</div>
<div>
<p className="text-muted-foreground">{t("budgets.difference")}</p>
<p
className={cn(
"text-lg font-semibold tabular-nums",
totalBudgeted - totalActual >= 0 ? "text-on-budget" : "text-over-budget"
)}
>
{formatCurrency(Math.abs(totalBudgeted - totalActual), currency)}
</p>
</div>
</div>
</div>
</div>
)}
{/* Add one-off item dialog */}
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("budgets.addItem")}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Category picker */}
<div className="space-y-2">
<Label>{t("categories.name")}</Label>
<Select
value={addForm.categoryId}
onValueChange={(v) =>
setAddForm((prev) => ({ ...prev, categoryId: v }))
}
>
<SelectTrigger>
<SelectValue placeholder={t("quickAdd.pickCategory")} />
</SelectTrigger>
<SelectContent>
{CATEGORY_TYPES.map((type) => {
const filtered = categories.filter((c) => c.type === type)
if (filtered.length === 0) return null
return (
<SelectGroup key={type}>
<SelectLabel className="flex items-center gap-1.5">
<div
className="size-2 rounded-full"
style={{ backgroundColor: categoryColors[type] }}
/>
{t(`categories.types.${type}`)}
</SelectLabel>
{filtered.map((cat) => (
<SelectItem key={cat.id} value={cat.id}>
{cat.name}
</SelectItem>
))}
</SelectGroup>
)
})}
</SelectContent>
</Select>
</div>
{/* Budgeted amount */}
<div className="space-y-2">
<Label>{t("budgets.budgeted")}</Label>
<Input
type="number"
min="0"
step="0.01"
value={addForm.amount}
onChange={(e) =>
setAddForm((prev) => ({ ...prev, amount: e.target.value }))
}
placeholder="0.00"
/>
</div>
{/* Notes */}
<div className="space-y-2">
<Label>{t("budgets.notes")}</Label>
<Input
value={addForm.notes}
onChange={(e) =>
setAddForm((prev) => ({ ...prev, notes: e.target.value }))
}
placeholder={t("budgets.notes")}
/>
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => setAddDialogOpen(false)}
disabled={createItem.isPending}
>
{t("common.cancel")}
</Button>
<Button
onClick={() => void handleAddItem()}
disabled={
!addForm.categoryId ||
!addForm.amount ||
isNaN(parseFloat(addForm.amount)) ||
createItem.isPending
}
>
{t("common.save")}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</PageShell>
)
}