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:
232
src/pages/CategoriesPage.tsx
Normal file
232
src/pages/CategoriesPage.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import { useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Plus, Pencil, Trash2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { useCategories } from "@/hooks/useCategories"
|
||||
import type { Category, CategoryType } from "@/lib/types"
|
||||
import { categoryColors } from "@/lib/palette"
|
||||
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,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { PageShell } from "@/components/shared/PageShell"
|
||||
|
||||
const CATEGORY_TYPES: CategoryType[] = [
|
||||
"income",
|
||||
"bill",
|
||||
"variable_expense",
|
||||
"debt",
|
||||
"saving",
|
||||
"investment",
|
||||
]
|
||||
|
||||
export default function CategoriesPage() {
|
||||
const { t } = useTranslation()
|
||||
const { categories, loading, create, update, remove } = useCategories()
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<Category | null>(null)
|
||||
const [name, setName] = useState("")
|
||||
const [type, setType] = useState<CategoryType>("bill")
|
||||
const [icon, setIcon] = useState("")
|
||||
|
||||
function openCreate() {
|
||||
setEditing(null)
|
||||
setName("")
|
||||
setType("bill")
|
||||
setIcon("")
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
function openEdit(cat: Category) {
|
||||
setEditing(cat)
|
||||
setName(cat.name)
|
||||
setType(cat.type)
|
||||
setIcon(cat.icon ?? "")
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
try {
|
||||
if (editing) {
|
||||
await update.mutateAsync({ id: editing.id, name, type, icon: icon || null })
|
||||
} else {
|
||||
await create.mutateAsync({ name, type, icon: icon || undefined })
|
||||
}
|
||||
setDialogOpen(false)
|
||||
} catch {
|
||||
toast.error(t("common.error"))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
try {
|
||||
await remove.mutateAsync(id)
|
||||
} catch {
|
||||
toast.error(t("categories.inUse"))
|
||||
}
|
||||
}
|
||||
|
||||
const grouped = CATEGORY_TYPES.map((type) => ({
|
||||
type,
|
||||
items: categories.filter((c) => c.type === type),
|
||||
})).filter((g) => g.items.length > 0)
|
||||
|
||||
if (loading) return (
|
||||
<PageShell title={t("categories.title")}>
|
||||
<div className="space-y-6">
|
||||
{[1, 2, 3].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].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-7 w-7 rounded-md" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
title={t("categories.title")}
|
||||
action={
|
||||
<Button onClick={openCreate} size="sm">
|
||||
<Plus className="mr-1 size-4" />
|
||||
{t("categories.add")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{categories.length === 0 ? (
|
||||
<p className="text-muted-foreground">{t("categories.empty")}</p>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{grouped.map(({ type, items }) => (
|
||||
<div key={type}>
|
||||
<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.icon")}</TableHead>
|
||||
<TableHead className="w-24" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((cat) => (
|
||||
<TableRow key={cat.id}>
|
||||
<TableCell className="font-medium">{cat.name}</TableCell>
|
||||
<TableCell>
|
||||
{cat.icon && <Badge variant="secondary">{cat.icon}</Badge>}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => openEdit(cat)}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(cat.id)}
|
||||
>
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editing ? t("categories.edit") : t("categories.add")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("categories.name")}</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("categories.type")}</Label>
|
||||
<Select
|
||||
value={type}
|
||||
onValueChange={(v) => setType(v as CategoryType)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CATEGORY_TYPES.map((ct) => (
|
||||
<SelectItem key={ct} value={ct}>
|
||||
{t(`categories.types.${ct}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("categories.icon")}</Label>
|
||||
<Input
|
||||
value={icon}
|
||||
onChange={(e) => setIcon(e.target.value)}
|
||||
placeholder="e.g. emoji or icon name"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!name.trim()}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
483
src/pages/TemplatePage.tsx
Normal file
483
src/pages/TemplatePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user