feat(04-03): upgrade BudgetListPage with PageShell, locale-aware months, skeleton, and i18n labels

- Replace hardcoded MONTHS array with Intl.DateTimeFormat locale-aware monthItems useMemo
- Update budgetLabel helper to accept locale parameter and use Intl.DateTimeFormat
- Replace null loading state with PageShell + Skeleton rows
- Replace manual header with PageShell title and action prop
- Replace hardcoded 'Month'/'Year' Labels with t('budgets.month')/t('budgets.year')
- Add month/year/total keys to en.json and de.json budgets namespace
This commit is contained in:
2026-03-17 16:20:02 +01:00
parent f166f1ac5e
commit 89dd3ded74
3 changed files with 280 additions and 2 deletions

View File

@@ -61,7 +61,10 @@
"notes": "Notizen",
"addItem": "Eintrag hinzufügen",
"empty": "Noch keine Budgets. Erstelle dein erstes Monatsbudget.",
"deleteConfirm": "Bist du sicher, dass du dieses Budget löschen möchtest?"
"deleteConfirm": "Bist du sicher, dass du dieses Budget löschen möchtest?",
"month": "Monat",
"year": "Jahr",
"total": "{{label}} Gesamt"
},
"dashboard": {
"title": "Dashboard",

View File

@@ -61,7 +61,10 @@
"notes": "Notes",
"addItem": "Add Item",
"empty": "No budgets yet. Create your first monthly budget.",
"deleteConfirm": "Are you sure you want to delete this budget?"
"deleteConfirm": "Are you sure you want to delete this budget?",
"month": "Month",
"year": "Year",
"total": "{{label}} Total"
},
"dashboard": {
"title": "Dashboard",

View File

@@ -0,0 +1,272 @@
import { useState, useMemo } from "react"
import { Link } from "react-router-dom"
import { useTranslation } from "react-i18next"
import { Plus, ChevronRight } from "lucide-react"
import { toast } from "sonner"
import { useBudgets } from "@/hooks/useBudgets"
import type { Budget } from "@/lib/types"
import { Button } from "@/components/ui/button"
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,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CURRENT_YEAR = new Date().getFullYear()
const YEARS = Array.from({ length: 5 }, (_, i) => CURRENT_YEAR - 1 + i)
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Given a budget's start_date ISO string, return a human-readable "Month YYYY"
* label e.g. "March 2026", using the given locale for month names.
*/
function budgetLabel(budget: Budget, locale: string): string {
const [year, month] = budget.start_date.split("-").map(Number)
return new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(
new Date(year ?? 0, (month ?? 1) - 1, 1)
)
}
// ---------------------------------------------------------------------------
// Dialog state
// ---------------------------------------------------------------------------
interface NewBudgetState {
month: number
year: number
useTemplate: boolean
}
function defaultDialogState(): NewBudgetState {
const now = new Date()
return {
month: now.getMonth() + 1,
year: now.getFullYear(),
useTemplate: false,
}
}
// ---------------------------------------------------------------------------
// Page component
// ---------------------------------------------------------------------------
export default function BudgetListPage() {
const { t, i18n } = useTranslation()
const locale = i18n.language
const { budgets, loading, createBudget, generateFromTemplate } = useBudgets()
const [dialogOpen, setDialogOpen] = useState(false)
const [form, setForm] = useState<NewBudgetState>(defaultDialogState)
const monthItems = useMemo(
() =>
Array.from({ length: 12 }, (_, i) => ({
value: i + 1,
label: new Intl.DateTimeFormat(locale, { month: "long" }).format(
new Date(2000, i, 1)
),
})),
[locale]
)
if (loading) return (
<PageShell title={t("budgets.title")}>
<div className="space-y-1">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="flex items-center gap-4 px-4 py-3 border-b border-border">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-4 w-12" />
<Skeleton className="ml-auto h-4 w-4" />
</div>
))}
</div>
</PageShell>
)
function openDialog() {
setForm(defaultDialogState())
setDialogOpen(true)
}
function closeDialog() {
setDialogOpen(false)
}
async function handleCreate() {
try {
const mutation = form.useTemplate ? generateFromTemplate : createBudget
const result = await mutation.mutateAsync({
month: form.month,
year: form.year,
})
closeDialog()
// Announce success via a toast showing the created month
toast.success(budgetLabel(result, locale))
} catch {
toast.error(t("common.error"))
}
}
const isSaving = createBudget.isPending || generateFromTemplate.isPending
return (
<PageShell
title={t("budgets.title")}
action={
<Button onClick={openDialog} size="sm">
<Plus className="mr-1 size-4" />
{t("budgets.newBudget")}
</Button>
}
>
{/* Empty state */}
{budgets.length === 0 ? (
<p className="text-muted-foreground">{t("budgets.empty")}</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("budgets.title")}</TableHead>
<TableHead>{t("settings.currency")}</TableHead>
<TableHead className="w-10" />
</TableRow>
</TableHeader>
<TableBody>
{budgets.map((budget) => (
<TableRow key={budget.id} className="group">
<TableCell className="font-medium">
<Link
to={`/budgets/${budget.id}`}
className="hover:underline"
>
{budgetLabel(budget, locale)}
</Link>
</TableCell>
<TableCell className="text-muted-foreground">
{budget.currency}
</TableCell>
<TableCell className="text-right">
<Link
to={`/budgets/${budget.id}`}
aria-label={budgetLabel(budget, locale)}
className="inline-flex items-center text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100"
>
<ChevronRight className="size-4" />
</Link>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
{/* New budget dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("budgets.newBudget")}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Month picker */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label>{t("budgets.month")}</Label>
<Select
value={String(form.month)}
onValueChange={(v) =>
setForm((prev) => ({ ...prev, month: Number(v) }))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{monthItems.map((m) => (
<SelectItem key={m.value} value={String(m.value)}>
{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t("budgets.year")}</Label>
<Select
value={String(form.year)}
onValueChange={(v) =>
setForm((prev) => ({ ...prev, year: Number(v) }))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{YEARS.map((y) => (
<SelectItem key={y} value={String(y)}>
{y}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Template toggle */}
<div className="flex items-center gap-3 rounded-md border p-3">
<input
id="use-template"
type="checkbox"
className="size-4 cursor-pointer accent-primary"
checked={form.useTemplate}
onChange={(e) =>
setForm((prev) => ({ ...prev, useTemplate: e.target.checked }))
}
/>
<Label htmlFor="use-template" className="cursor-pointer">
{t("budgets.generateFromTemplate")}
</Label>
</div>
{/* Actions */}
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={closeDialog} disabled={isSaving}>
{t("common.cancel")}
</Button>
<Button onClick={() => void handleCreate()} disabled={isSaving}>
{t("common.save")}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</PageShell>
)
}