diff --git a/src/pages/BudgetDetailPage.tsx b/src/pages/BudgetDetailPage.tsx new file mode 100644 index 0000000..30f8ca2 --- /dev/null +++ b/src/pages/BudgetDetailPage.tsx @@ -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 + "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(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 ( + + 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} + /> + + ) + } + + return ( + + {formatCurrency(value, currency)} + + ) +} + +// --------------------------------------------------------------------------- +// 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 ( + + {formatCurrency(Math.abs(diff), currency)} + {diff < 0 ? " over" : ""} + + ) +} + +// --------------------------------------------------------------------------- +// 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(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 ( + +
+ + {[1, 2, 3].map((i) => ( +
+
+ +
+ {[1, 2].map((j) => ( +
+ + + + +
+ ))} +
+ ))} + +
+
+ ) + + if (!budget) { + return ( +
+ {t("common.error")} +
+ ) + } + + return ( + + + {t("budgets.addItem")} + + } + > + + + {t("budgets.title")} + + + {/* Empty state */} + {items.length === 0 ? ( +

{t("budgets.empty")}

+ ) : ( +
+ {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 ( +
+ {/* Group heading */} +
+ {t(`categories.types.${type}`)} +
+ + + + + {t("categories.name")} + + {t("budgets.budgeted")} + + + {t("budgets.actual")} + + + {t("budgets.difference")} + + {t("budgets.notes")} + + + + + {groupItems.map((item) => ( + + + {item.category?.name ?? item.category_id} + + + {formatCurrency(item.budgeted_amount, currency)} + + handleUpdateActual(item, val)} + aria-label={`${t("budgets.actual")} — ${item.category?.name ?? ""}`} + /> + + + {item.notes ?? ""} + + + + + + ))} + + + + + {t("budgets.total", { label: t(`categories.types.${type}`) })} + + + {formatCurrency(groupBudgeted, currency)} + + + {formatCurrency(groupActual, currency)} + + + + + +
+
+ ) + })} + + {/* Overall totals */} +
+
+
+

{t("budgets.budgeted")}

+

+ {formatCurrency(totalBudgeted, currency)} +

+
+
+

{t("budgets.actual")}

+

+ {formatCurrency(totalActual, currency)} +

+
+
+

{t("budgets.difference")}

+

= 0 ? "text-on-budget" : "text-over-budget" + )} + > + {formatCurrency(Math.abs(totalBudgeted - totalActual), currency)} +

+
+
+
+
+ )} + + {/* Add one-off item dialog */} + + + + {t("budgets.addItem")} + + +
+ {/* Category picker */} +
+ + +
+ + {/* Budgeted amount */} +
+ + + setAddForm((prev) => ({ ...prev, amount: e.target.value })) + } + placeholder="0.00" + /> +
+ + {/* Notes */} +
+ + + setAddForm((prev) => ({ ...prev, notes: e.target.value })) + } + placeholder={t("budgets.notes")} + /> +
+ +
+ + +
+
+
+
+
+ ) +}