diff --git a/src/pages/CategoriesPage.tsx b/src/pages/CategoriesPage.tsx new file mode 100644 index 0000000..b4b6451 --- /dev/null +++ b/src/pages/CategoriesPage.tsx @@ -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(null) + const [name, setName] = useState("") + const [type, setType] = useState("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 ( + +
+ {[1, 2, 3].map((i) => ( +
+
+ +
+ {[1, 2].map((j) => ( +
+ + + +
+ ))} +
+ ))} +
+
+ ) + + return ( + + + {t("categories.add")} + + } + > + {categories.length === 0 ? ( +

{t("categories.empty")}

+ ) : ( +
+ {grouped.map(({ type, items }) => ( +
+
+ {t(`categories.types.${type}`)} +
+ + + + {t("categories.name")} + {t("categories.icon")} + + + + + {items.map((cat) => ( + + {cat.name} + + {cat.icon && {cat.icon}} + + +
+ + +
+
+
+ ))} +
+
+
+ ))} +
+ )} + + + + + + {editing ? t("categories.edit") : t("categories.add")} + + +
+
+ + setName(e.target.value)} /> +
+
+ + +
+
+ + setIcon(e.target.value)} + placeholder="e.g. emoji or icon name" + /> +
+
+ + +
+
+
+
+
+ ) +} diff --git a/src/pages/TemplatePage.tsx b/src/pages/TemplatePage.tsx new file mode 100644 index 0000000..4117b94 --- /dev/null +++ b/src/pages/TemplatePage.tsx @@ -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 +}) { + const [editing, setEditing] = useState(false) + const [draft, setDraft] = useState(name) + const inputRef = useRef(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 ( +
+ setDraft(e.target.value)} + className="h-8 text-xl font-semibold" + onKeyDown={(e) => { + if (e.key === "Enter") void commit() + if (e.key === "Escape") cancel() + }} + /> + + +
+ ) + } + + return ( + + ) +} + +// --------------------------------------------------------------------------- +// 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(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 ( +
+
+ + +
+
+ {[1, 2].map((i) => ( +
+
+ +
+ {[1, 2, 3].map((j) => ( +
+ + + + +
+ ))} +
+ ))} +
+
+ ) + + return ( +
+ {/* Header — mirrors PageShell's flex items-start justify-between gap-4 layout */} +
+ +
+ +
+
+ + {/* Empty state */} + {items.length === 0 ? ( +

{t("template.empty")}

+ ) : ( +
+ {grouped.map(({ type, items: groupItems }) => ( +
+ {/* Group heading */} +
+ {t(`categories.types.${type}`)} +
+ + + + + {t("categories.name")} + {t("categories.type")} + + {t("template.budgetedAmount")} + + + + + + {groupItems.map((item) => ( + + + {item.category?.name ?? item.category_id} + + + + + + {formatCurrency(item.budgeted_amount)} + + +
+ + +
+
+
+ ))} +
+
+
+ ))} +
+ )} + + {/* Add / Edit dialog */} + + + + + {dialog.mode === "edit" + ? t("common.edit") + : t("template.addItem")} + + + +
+ {/* Category picker — only shown when creating */} + {dialog.mode === "create" && ( +
+ + +
+ )} + + {/* Tier */} +
+ + +
+ + {/* Budgeted amount */} +
+ + + setDialog((prev) => ({ ...prev, amount: e.target.value })) + } + placeholder="0.00" + /> +
+ +
+ + +
+
+
+
+
+ ) +} + +// --------------------------------------------------------------------------- +// Small helper component +// --------------------------------------------------------------------------- + +function TierBadge({ + tier, + t, +}: { + tier: TemplateItem["item_tier"] + t: (key: string) => string +}) { + return ( + + {t(`template.${tier}`)} + + ) +}