- 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
484 lines
15 KiB
TypeScript
484 lines
15 KiB
TypeScript
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>
|
|
)
|
|
}
|