feat(04-02): upgrade CategoriesPage and TemplatePage with PageShell, skeletons, and group headers

- CategoriesPage: adopt PageShell for header with title and Add Category button
- CategoriesPage: replace return null with skeleton loading state
- CategoriesPage: upgrade dot group headers to left-border accent style
- TemplatePage: mirror PageShell layout (flex-col gap-6) for inline-editable header
- TemplatePage: replace return null with skeleton loading state
- TemplatePage: upgrade dot group headers to left-border accent style
- TemplateName h1: add tracking-tight to match PageShell h1 style
This commit is contained in:
2026-03-17 16:15:04 +01:00
parent b15a14dea0
commit e9497e42a7
2 changed files with 715 additions and 0 deletions

483
src/pages/TemplatePage.tsx Normal file
View File

@@ -0,0 +1,483 @@
import { useState, useRef } from "react"
import { useTranslation } from "react-i18next"
import { Plus, Pencil, Trash2, Check, X } from "lucide-react"
import { toast } from "sonner"
import { useTemplate } from "@/hooks/useTemplate"
import { useCategories } from "@/hooks/useCategories"
import type { TemplateItem, CategoryType } from "@/lib/types"
import { categoryColors } from "@/lib/palette"
import { formatCurrency } from "@/lib/format"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
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,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CATEGORY_TYPES: CategoryType[] = [
"income",
"bill",
"variable_expense",
"debt",
"saving",
"investment",
]
const TIERS: TemplateItem["item_tier"][] = ["fixed", "variable"]
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
/** Inline-editable template name heading. */
function TemplateName({
name,
onSave,
}: {
name: string
onSave: (next: string) => Promise<void>
}) {
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState(name)
const inputRef = useRef<HTMLInputElement>(null)
function startEdit() {
setDraft(name)
setEditing(true)
// Let React commit the input before focusing.
setTimeout(() => inputRef.current?.select(), 0)
}
async function commit() {
if (draft.trim() && draft.trim() !== name) {
await onSave(draft.trim())
}
setEditing(false)
}
function cancel() {
setDraft(name)
setEditing(false)
}
if (editing) {
return (
<div className="flex items-center gap-2">
<Input
ref={inputRef}
value={draft}
onChange={(e) => setDraft(e.target.value)}
className="h-8 text-xl font-semibold"
onKeyDown={(e) => {
if (e.key === "Enter") void commit()
if (e.key === "Escape") cancel()
}}
/>
<Button variant="ghost" size="icon" onClick={() => void commit()}>
<Check className="size-4" />
</Button>
<Button variant="ghost" size="icon" onClick={cancel}>
<X className="size-4" />
</Button>
</div>
)
}
return (
<button
type="button"
onClick={startEdit}
className="group flex items-center gap-2 text-left"
aria-label="Edit template name"
>
<h1 className="text-2xl font-semibold tracking-tight group-hover:underline">{name}</h1>
<Pencil className="size-4 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
</button>
)
}
// ---------------------------------------------------------------------------
// Dialog state types
// ---------------------------------------------------------------------------
type DialogMode = "create" | "edit"
interface DialogState {
mode: DialogMode
item?: TemplateItem
categoryId: string
tier: TemplateItem["item_tier"]
amount: string
}
const DEFAULT_DIALOG: DialogState = {
mode: "create",
categoryId: "",
tier: "fixed",
amount: "",
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export default function TemplatePage() {
const { t } = useTranslation()
const { template, items, loading, updateName, createItem, updateItem, deleteItem } =
useTemplate()
const { categories } = useCategories()
const [dialogOpen, setDialogOpen] = useState(false)
const [dialog, setDialog] = useState<DialogState>(DEFAULT_DIALOG)
// ------------------------------------------------------------------
// Dialog helpers
// ------------------------------------------------------------------
function openCreate() {
setDialog({ ...DEFAULT_DIALOG, mode: "create" })
setDialogOpen(true)
}
function openEdit(item: TemplateItem) {
setDialog({
mode: "edit",
item,
categoryId: item.category_id,
tier: item.item_tier,
amount: String(item.budgeted_amount),
})
setDialogOpen(true)
}
function closeDialog() {
setDialogOpen(false)
}
async function handleSave() {
const parsed = parseFloat(dialog.amount)
if (!dialog.categoryId || isNaN(parsed) || parsed < 0) return
try {
if (dialog.mode === "edit" && dialog.item) {
await updateItem.mutateAsync({
id: dialog.item.id,
item_tier: dialog.tier,
budgeted_amount: parsed,
})
} else {
await createItem.mutateAsync({
category_id: dialog.categoryId,
item_tier: dialog.tier,
budgeted_amount: parsed,
})
}
closeDialog()
} catch {
toast.error(t("common.error"))
}
}
async function handleDelete(id: string) {
try {
await deleteItem.mutateAsync(id)
} catch {
toast.error(t("common.error"))
}
}
async function handleNameSave(name: string) {
try {
await updateName.mutateAsync(name)
} catch {
toast.error(t("common.error"))
}
}
// ------------------------------------------------------------------
// Derived data: group items by the category type
// ------------------------------------------------------------------
const grouped = CATEGORY_TYPES.map((type) => ({
type,
items: items.filter((item) => item.category?.type === type),
})).filter((g) => g.items.length > 0)
// Categories already belonging to a template item (for the "create"
// dialog we still allow duplicates, as the spec doesn't forbid them).
const isSaving =
createItem.isPending || updateItem.isPending || deleteItem.isPending
// ------------------------------------------------------------------
// Render
// ------------------------------------------------------------------
if (loading) return (
<div className="flex flex-col gap-6">
<div className="flex items-start justify-between gap-4">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-9 w-24" />
</div>
<div className="space-y-6">
{[1, 2].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, 3].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-36" />
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="ml-auto h-4 w-20" />
<Skeleton className="h-7 w-7 rounded-md" />
</div>
))}
</div>
))}
</div>
</div>
)
return (
<div className="flex flex-col gap-6">
{/* Header — mirrors PageShell's flex items-start justify-between gap-4 layout */}
<div className="flex items-start justify-between gap-4">
<TemplateName
name={template?.name ?? t("template.title")}
onSave={handleNameSave}
/>
<div className="shrink-0">
<Button onClick={openCreate} size="sm" disabled={isSaving}>
<Plus className="mr-1 size-4" />
{t("template.addItem")}
</Button>
</div>
</div>
{/* Empty state */}
{items.length === 0 ? (
<p className="text-muted-foreground">{t("template.empty")}</p>
) : (
<div className="space-y-6">
{grouped.map(({ type, items: groupItems }) => (
<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>{t("categories.type")}</TableHead>
<TableHead className="text-right">
{t("template.budgetedAmount")}
</TableHead>
<TableHead className="w-24" />
</TableRow>
</TableHeader>
<TableBody>
{groupItems.map((item) => (
<TableRow key={item.id}>
<TableCell className="font-medium">
{item.category?.name ?? item.category_id}
</TableCell>
<TableCell>
<TierBadge tier={item.item_tier} t={t} />
</TableCell>
<TableCell className="text-right tabular-nums">
{formatCurrency(item.budgeted_amount)}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="icon"
aria-label={t("common.edit")}
onClick={() => openEdit(item)}
disabled={isSaving}
>
<Pencil className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
aria-label={t("common.delete")}
onClick={() => void handleDelete(item.id)}
disabled={isSaving}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
))}
</div>
)}
{/* Add / Edit dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{dialog.mode === "edit"
? t("common.edit")
: t("template.addItem")}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Category picker — only shown when creating */}
{dialog.mode === "create" && (
<div className="space-y-2">
<Label>{t("categories.name")}</Label>
<Select
value={dialog.categoryId}
onValueChange={(v) =>
setDialog((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>
)}
{/* Tier */}
<div className="space-y-2">
<Label>{t("categories.type")}</Label>
<Select
value={dialog.tier}
onValueChange={(v) =>
setDialog((prev) => ({
...prev,
tier: v as TemplateItem["item_tier"],
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIERS.map((tier) => (
<SelectItem key={tier} value={tier}>
{t(`template.${tier}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Budgeted amount */}
<div className="space-y-2">
<Label>{t("template.budgetedAmount")}</Label>
<Input
type="number"
min="0"
step="0.01"
value={dialog.amount}
onChange={(e) =>
setDialog((prev) => ({ ...prev, amount: e.target.value }))
}
placeholder="0.00"
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={closeDialog}>
{t("common.cancel")}
</Button>
<Button
onClick={() => void handleSave()}
disabled={
!dialog.categoryId ||
!dialog.amount ||
isNaN(parseFloat(dialog.amount)) ||
createItem.isPending ||
updateItem.isPending
}
>
{t("common.save")}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
)
}
// ---------------------------------------------------------------------------
// Small helper component
// ---------------------------------------------------------------------------
function TierBadge({
tier,
t,
}: {
tier: TemplateItem["item_tier"]
t: (key: string) => string
}) {
return (
<Badge variant={tier === "fixed" ? "default" : "secondary"}>
{t(`template.${tier}`)}
</Badge>
)
}