From 4fc63893b8d5c26d92e5b0cede3b15890eda8350 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 11 Mar 2026 22:32:34 +0100 Subject: [PATCH] feat(03-02): add EmptyState component and wire into Dashboard and Categories pages - Create shared EmptyState component with icon, heading, subtext, and optional CTA button - DashboardPage: show EmptyState when no budgets exist with Create CTA; replace plain Card fallback with EmptyState for no-current-budget case - CategoriesPage: add loading state to prevent empty-state flash on initial load; show EmptyState when no categories exist --- frontend/src/components/EmptyState.tsx | 21 +++++++++ frontend/src/pages/CategoriesPage.tsx | 62 ++++++++++++++++++++++---- frontend/src/pages/DashboardPage.tsx | 36 ++++++++++++--- 3 files changed, 105 insertions(+), 14 deletions(-) create mode 100644 frontend/src/components/EmptyState.tsx diff --git a/frontend/src/components/EmptyState.tsx b/frontend/src/components/EmptyState.tsx new file mode 100644 index 0000000..2d36a29 --- /dev/null +++ b/frontend/src/components/EmptyState.tsx @@ -0,0 +1,21 @@ +import { Button } from '@/components/ui/button' + +interface EmptyStateProps { + icon: React.ElementType + heading: string + subtext: string + action?: { label: string; onClick: () => void } +} + +export function EmptyState({ icon: Icon, heading, subtext, action }: EmptyStateProps) { + return ( +
+ +

{heading}

+

{subtext}

+ {action && ( + + )} +
+ ) +} diff --git a/frontend/src/pages/CategoriesPage.tsx b/frontend/src/pages/CategoriesPage.tsx index b26926b..1408eb8 100644 --- a/frontend/src/pages/CategoriesPage.tsx +++ b/frontend/src/pages/CategoriesPage.tsx @@ -6,8 +6,11 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@ import { Badge } from '@/components/ui/badge' import { Input } from '@/components/ui/input' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog' import { categories as categoriesApi, type Category, type CategoryType } from '@/lib/api' +import { EmptyState } from '@/components/EmptyState' +import { Spinner } from '@/components/ui/spinner' +import { FolderOpen } from 'lucide-react' const CATEGORY_TYPES: CategoryType[] = ['income', 'bill', 'variable_expense', 'debt', 'saving', 'investment'] @@ -23,15 +26,23 @@ const TYPE_COLORS: Record = { export function CategoriesPage() { const { t } = useTranslation() const [list, setList] = useState([]) + const [loading, setLoading] = useState(true) const [dialogOpen, setDialogOpen] = useState(false) const [editing, setEditing] = useState(null) const [name, setName] = useState('') const [type, setType] = useState('bill') const [sortOrder, setSortOrder] = useState(0) + const [pendingDelete, setPendingDelete] = useState<{ id: string; name: string } | null>(null) + const [deleting, setDeleting] = useState(false) + const [deleteError, setDeleteError] = useState(null) const fetchCategories = async () => { - const data = await categoriesApi.list() - setList(data) + try { + const data = await categoriesApi.list() + setList(data) + } finally { + setLoading(false) + } } useEffect(() => { @@ -64,9 +75,19 @@ export function CategoriesPage() { fetchCategories() } - const handleDelete = async (id: string) => { - await categoriesApi.delete(id) - fetchCategories() + const confirmDelete = async () => { + if (!pendingDelete) return + setDeleting(true) + setDeleteError(null) + try { + await categoriesApi.delete(pendingDelete.id) + setPendingDelete(null) + fetchCategories() + } catch (err) { + setDeleteError(err instanceof Error ? err.message : 'Failed to delete category') + } finally { + setDeleting(false) + } } const grouped = CATEGORY_TYPES.map((ct) => ({ @@ -81,7 +102,16 @@ export function CategoriesPage() { - {grouped.map((group) => ( + {!loading && list.length === 0 && ( + + )} + + {grouped.length > 0 && grouped.map((group) => ( @@ -106,7 +136,7 @@ export function CategoriesPage() { - @@ -152,6 +182,22 @@ export function CategoriesPage() { + + { if (!open) { setPendingDelete(null); setDeleteError(null) } }}> + + + Delete {pendingDelete?.name}? + This cannot be undone. + + {deleteError &&

{deleteError}

} + + + + +
+
) } diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 59a497d..1748514 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -1,6 +1,5 @@ import { useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' -import { Card, CardContent } from '@/components/ui/card' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' @@ -11,8 +10,10 @@ import { VariableExpenses } from '@/components/VariableExpenses' import { ExpenseBreakdown } from '@/components/ExpenseBreakdown' import { DebtTracker } from '@/components/DebtTracker' import { AvailableBalance } from '@/components/AvailableBalance' +import { EmptyState } from '@/components/EmptyState' import { useBudgets } from '@/hooks/useBudgets' import { budgetItems as budgetItemsApi } from '@/lib/api' +import { FolderOpen } from 'lucide-react' export function DashboardPage() { const { t } = useTranslation() @@ -46,6 +47,29 @@ export function DashboardPage() { ) } + if (list.length === 0 && !loading) { + return ( +
+
+ +
+ {showCreate && ( + setShowCreate(false)} + /> + )} + setShowCreate(true) }} + /> +
+ ) + } + return (
@@ -91,11 +115,11 @@ export function DashboardPage() {
) : ( - - - {t('dashboard.noBudgets')} - - + )} )