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",
|
"notes": "Notizen",
|
||||||
"addItem": "Eintrag hinzufügen",
|
"addItem": "Eintrag hinzufügen",
|
||||||
"empty": "Noch keine Budgets. Erstelle dein erstes Monatsbudget.",
|
"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": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
|
|||||||
@@ -61,7 +61,10 @@
|
|||||||
"notes": "Notes",
|
"notes": "Notes",
|
||||||
"addItem": "Add Item",
|
"addItem": "Add Item",
|
||||||
"empty": "No budgets yet. Create your first monthly budget.",
|
"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": {
|
"dashboard": {
|
||||||
"title": "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