feat(04-03): upgrade BudgetDetailPage with semantic tokens, direction-aware diff, PageShell, group headers, and skeleton
- Replace TierBadge and tier column with cleaner per-category grouping display - Rewrite DifferenceCell with direction-aware diff logic (spending types: over when actual > budgeted; income/saving/investment: over when actual < budgeted) - Replace text-green-600/text-red-600 with text-on-budget/text-over-budget semantic tokens - Replace dot+h2 group headers with left-border accent headers matching dashboard style - Fix headingLabel to use i18n.language via Intl.DateTimeFormat instead of hardcoded 'en' - Wrap page in PageShell with locale-aware title and Add Item action button - Add back-link as first child of PageShell with -mt-4 compensation - Replace null loading state with PageShell + Skeleton groups - Update group footer total label to use budgets.total i18n interpolation key
This commit is contained in:
566
src/pages/BudgetDetailPage.tsx
Normal file
566
src/pages/BudgetDetailPage.tsx
Normal file
@@ -0,0 +1,566 @@
|
|||||||
|
import { useState, useRef, useEffect } from "react"
|
||||||
|
import { useParams, Link } from "react-router-dom"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { Plus, Trash2, ArrowLeft } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { useBudgetDetail, useBudgets } from "@/hooks/useBudgets"
|
||||||
|
import { useCategories } from "@/hooks/useCategories"
|
||||||
|
import type { BudgetItem, CategoryType } from "@/lib/types"
|
||||||
|
import { categoryColors } from "@/lib/palette"
|
||||||
|
import { formatCurrency } from "@/lib/format"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
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,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table"
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const CATEGORY_TYPES: CategoryType[] = [
|
||||||
|
"income",
|
||||||
|
"bill",
|
||||||
|
"variable_expense",
|
||||||
|
"debt",
|
||||||
|
"saving",
|
||||||
|
"investment",
|
||||||
|
]
|
||||||
|
|
||||||
|
const SPENDING_TYPES: CategoryType[] = ["bill", "variable_expense", "debt"]
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Direction-aware diff helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function isSpendingType(type: CategoryType): boolean {
|
||||||
|
return SPENDING_TYPES.includes(type)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// InlineEditCell
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A table cell that displays a formatted currency value and switches to a
|
||||||
|
* number input on click. Commits on blur or Enter; reverts on Escape.
|
||||||
|
*/
|
||||||
|
interface InlineEditCellProps {
|
||||||
|
value: number
|
||||||
|
currency: string
|
||||||
|
onCommit: (next: number) => Promise<void>
|
||||||
|
"aria-label"?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function InlineEditCell({
|
||||||
|
value,
|
||||||
|
currency,
|
||||||
|
onCommit,
|
||||||
|
"aria-label": ariaLabel,
|
||||||
|
}: InlineEditCellProps) {
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [draft, setDraft] = useState(String(value))
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editing) {
|
||||||
|
inputRef.current?.select()
|
||||||
|
}
|
||||||
|
}, [editing])
|
||||||
|
|
||||||
|
function startEdit() {
|
||||||
|
setDraft(String(value))
|
||||||
|
setEditing(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commit() {
|
||||||
|
const parsed = parseFloat(draft)
|
||||||
|
if (!isNaN(parsed) && parsed !== value) {
|
||||||
|
await onCommit(parsed)
|
||||||
|
}
|
||||||
|
setEditing(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
setDraft(String(value))
|
||||||
|
setEditing(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
return (
|
||||||
|
<TableCell className="p-1">
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
className="h-7 w-28 text-right tabular-nums"
|
||||||
|
onBlur={() => void commit()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") void commit()
|
||||||
|
if (e.key === "Escape") cancel()
|
||||||
|
}}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableCell
|
||||||
|
className="cursor-pointer select-none text-right tabular-nums hover:bg-muted/40"
|
||||||
|
onClick={startEdit}
|
||||||
|
title="Click to edit"
|
||||||
|
>
|
||||||
|
{formatCurrency(value, currency)}
|
||||||
|
</TableCell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DifferenceCell
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function DifferenceCell({
|
||||||
|
budgeted,
|
||||||
|
actual,
|
||||||
|
currency,
|
||||||
|
type,
|
||||||
|
}: {
|
||||||
|
budgeted: number
|
||||||
|
actual: number
|
||||||
|
currency: string
|
||||||
|
type: CategoryType
|
||||||
|
}) {
|
||||||
|
const isOver = isSpendingType(type)
|
||||||
|
? actual > budgeted
|
||||||
|
: actual < budgeted
|
||||||
|
const diff = isSpendingType(type)
|
||||||
|
? budgeted - actual
|
||||||
|
: actual - budgeted
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableCell
|
||||||
|
className={cn(
|
||||||
|
"text-right tabular-nums",
|
||||||
|
isOver ? "text-over-budget" : diff !== 0 ? "text-on-budget" : "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatCurrency(Math.abs(diff), currency)}
|
||||||
|
{diff < 0 ? " over" : ""}
|
||||||
|
</TableCell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Add item dialog state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface AddItemState {
|
||||||
|
categoryId: string
|
||||||
|
amount: string
|
||||||
|
notes: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_ADD_STATE: AddItemState = {
|
||||||
|
categoryId: "",
|
||||||
|
amount: "",
|
||||||
|
notes: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// BudgetDetailPage
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function BudgetDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const { t, i18n } = useTranslation()
|
||||||
|
const budgetId = id ?? ""
|
||||||
|
|
||||||
|
const { budget, items, loading } = useBudgetDetail(budgetId)
|
||||||
|
const { updateItem, createItem, deleteItem } = useBudgets()
|
||||||
|
const { categories } = useCategories()
|
||||||
|
|
||||||
|
const [addDialogOpen, setAddDialogOpen] = useState(false)
|
||||||
|
const [addForm, setAddForm] = useState<AddItemState>(DEFAULT_ADD_STATE)
|
||||||
|
|
||||||
|
function openAddDialog() {
|
||||||
|
setAddForm(DEFAULT_ADD_STATE)
|
||||||
|
setAddDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddItem() {
|
||||||
|
const parsed = parseFloat(addForm.amount)
|
||||||
|
if (!addForm.categoryId || isNaN(parsed) || parsed < 0) return
|
||||||
|
try {
|
||||||
|
await createItem.mutateAsync({
|
||||||
|
budgetId,
|
||||||
|
category_id: addForm.categoryId,
|
||||||
|
budgeted_amount: parsed,
|
||||||
|
actual_amount: 0,
|
||||||
|
notes: addForm.notes.trim() || null,
|
||||||
|
})
|
||||||
|
setAddDialogOpen(false)
|
||||||
|
toast.success(t("budgets.addItem"))
|
||||||
|
} catch {
|
||||||
|
toast.error(t("common.error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdateActual(item: BudgetItem, actual_amount: number) {
|
||||||
|
try {
|
||||||
|
await updateItem.mutateAsync({
|
||||||
|
id: item.id,
|
||||||
|
budgetId,
|
||||||
|
actual_amount,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
toast.error(t("common.error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteItem(itemId: string) {
|
||||||
|
try {
|
||||||
|
await deleteItem.mutateAsync({ id: itemId, budgetId })
|
||||||
|
} catch {
|
||||||
|
toast.error(t("common.error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Budget heading: "Month YYYY" (locale-aware)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
function headingLabel(): string {
|
||||||
|
if (!budget) return ""
|
||||||
|
const [year, month] = budget.start_date.split("-").map(Number)
|
||||||
|
return new Intl.DateTimeFormat(i18n.language, { month: "long", year: "numeric" }).format(
|
||||||
|
new Date(year ?? 0, (month ?? 1) - 1, 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Derived: group items by category type
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
const grouped = CATEGORY_TYPES.map((type) => ({
|
||||||
|
type,
|
||||||
|
items: items.filter((item) => item.category?.type === type),
|
||||||
|
})).filter((g) => g.items.length > 0)
|
||||||
|
|
||||||
|
// Overall totals
|
||||||
|
const totalBudgeted = items.reduce((sum, i) => sum + i.budgeted_amount, 0)
|
||||||
|
const totalActual = items.reduce((sum, i) => sum + i.actual_amount, 0)
|
||||||
|
|
||||||
|
const currency = budget?.currency ?? "EUR"
|
||||||
|
|
||||||
|
if (loading) return (
|
||||||
|
<PageShell title="">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
{[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-32" />
|
||||||
|
<Skeleton className="ml-auto h-4 w-20" />
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
<Skeleton className="h-4 w-16" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Skeleton className="h-20 w-full rounded-md" />
|
||||||
|
</div>
|
||||||
|
</PageShell>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!budget) {
|
||||||
|
return (
|
||||||
|
<div className="py-20 text-center text-muted-foreground">
|
||||||
|
{t("common.error")}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell
|
||||||
|
title={headingLabel()}
|
||||||
|
action={
|
||||||
|
<Button onClick={openAddDialog} size="sm">
|
||||||
|
<Plus className="mr-1 size-4" />
|
||||||
|
{t("budgets.addItem")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
to="/budgets"
|
||||||
|
className="-mt-4 inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
{t("budgets.title")}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground">{t("budgets.empty")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{grouped.map(({ type, items: groupItems }) => {
|
||||||
|
const groupBudgeted = groupItems.reduce(
|
||||||
|
(sum, i) => sum + i.budgeted_amount,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
const groupActual = groupItems.reduce(
|
||||||
|
(sum, i) => sum + i.actual_amount,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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 className="text-right">
|
||||||
|
{t("budgets.budgeted")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right">
|
||||||
|
{t("budgets.actual")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right">
|
||||||
|
{t("budgets.difference")}
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>{t("budgets.notes")}</TableHead>
|
||||||
|
<TableHead className="w-10" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{groupItems.map((item) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{item.category?.name ?? item.category_id}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">
|
||||||
|
{formatCurrency(item.budgeted_amount, currency)}
|
||||||
|
</TableCell>
|
||||||
|
<InlineEditCell
|
||||||
|
value={item.actual_amount}
|
||||||
|
currency={currency}
|
||||||
|
onCommit={(val) => handleUpdateActual(item, val)}
|
||||||
|
aria-label={`${t("budgets.actual")} — ${item.category?.name ?? ""}`}
|
||||||
|
/>
|
||||||
|
<DifferenceCell
|
||||||
|
budgeted={item.budgeted_amount}
|
||||||
|
actual={item.actual_amount}
|
||||||
|
currency={currency}
|
||||||
|
type={type}
|
||||||
|
/>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{item.notes ?? ""}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label={t("common.delete")}
|
||||||
|
onClick={() => void handleDeleteItem(item.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
<TableFooter>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{t("budgets.total", { label: t(`categories.types.${type}`) })}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums font-medium">
|
||||||
|
{formatCurrency(groupBudgeted, currency)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums font-medium">
|
||||||
|
{formatCurrency(groupActual, currency)}
|
||||||
|
</TableCell>
|
||||||
|
<DifferenceCell
|
||||||
|
budgeted={groupBudgeted}
|
||||||
|
actual={groupActual}
|
||||||
|
currency={currency}
|
||||||
|
type={type}
|
||||||
|
/>
|
||||||
|
<TableCell colSpan={2} />
|
||||||
|
</TableRow>
|
||||||
|
</TableFooter>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Overall totals */}
|
||||||
|
<div className="rounded-md border p-4">
|
||||||
|
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">{t("budgets.budgeted")}</p>
|
||||||
|
<p className="text-lg font-semibold tabular-nums">
|
||||||
|
{formatCurrency(totalBudgeted, currency)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">{t("budgets.actual")}</p>
|
||||||
|
<p className="text-lg font-semibold tabular-nums">
|
||||||
|
{formatCurrency(totalActual, currency)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">{t("budgets.difference")}</p>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-semibold tabular-nums",
|
||||||
|
totalBudgeted - totalActual >= 0 ? "text-on-budget" : "text-over-budget"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatCurrency(Math.abs(totalBudgeted - totalActual), currency)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add one-off item dialog */}
|
||||||
|
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("budgets.addItem")}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Category picker */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("categories.name")}</Label>
|
||||||
|
<Select
|
||||||
|
value={addForm.categoryId}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setAddForm((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>
|
||||||
|
|
||||||
|
{/* Budgeted amount */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("budgets.budgeted")}</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={addForm.amount}
|
||||||
|
onChange={(e) =>
|
||||||
|
setAddForm((prev) => ({ ...prev, amount: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("budgets.notes")}</Label>
|
||||||
|
<Input
|
||||||
|
value={addForm.notes}
|
||||||
|
onChange={(e) =>
|
||||||
|
setAddForm((prev) => ({ ...prev, notes: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder={t("budgets.notes")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setAddDialogOpen(false)}
|
||||||
|
disabled={createItem.isPending}
|
||||||
|
>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => void handleAddItem()}
|
||||||
|
disabled={
|
||||||
|
!addForm.categoryId ||
|
||||||
|
!addForm.amount ||
|
||||||
|
isNaN(parseFloat(addForm.amount)) ||
|
||||||
|
createItem.isPending
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("common.save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</PageShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user