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:
566
src/pages/BudgetDetailPage.tsx
Normal file
566
src/pages/BudgetDetailPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user