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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
272
src/pages/BudgetListPage.tsx
Normal file
272
src/pages/BudgetListPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user