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
This commit is contained in:
21
frontend/src/components/EmptyState.tsx
Normal file
21
frontend/src/components/EmptyState.tsx
Normal file
@@ -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 (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<Icon className="size-12 text-muted-foreground mb-4" />
|
||||
<h2 className="font-semibold text-lg mb-1">{heading}</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">{subtext}</p>
|
||||
{action && (
|
||||
<Button onClick={action.onClick}>{action.label}</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<CategoryType, string> = {
|
||||
export function CategoriesPage() {
|
||||
const { t } = useTranslation()
|
||||
const [list, setList] = useState<Category[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<Category | null>(null)
|
||||
const [name, setName] = useState('')
|
||||
const [type, setType] = useState<CategoryType>('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<string | null>(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() {
|
||||
<Button onClick={openCreate}>{t('category.create')}</Button>
|
||||
</div>
|
||||
|
||||
{grouped.map((group) => (
|
||||
{!loading && list.length === 0 && (
|
||||
<EmptyState
|
||||
icon={FolderOpen}
|
||||
heading="No categories yet"
|
||||
subtext="Add a category to organize your budget."
|
||||
action={{ label: 'Add a category', onClick: openCreate }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{grouped.length > 0 && grouped.map((group) => (
|
||||
<Card key={group.type}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -106,7 +136,7 @@ export function CategoriesPage() {
|
||||
<Button variant="outline" size="sm" onClick={() => openEdit(cat)}>
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => handleDelete(cat.id)}>
|
||||
<Button variant="outline" size="sm" onClick={() => { setDeleteError(null); setPendingDelete({ id: cat.id, name: cat.name }) }}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</TableCell>
|
||||
@@ -152,6 +182,22 @@ export function CategoriesPage() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={!!pendingDelete} onOpenChange={(open) => { if (!open) { setPendingDelete(null); setDeleteError(null) } }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete {pendingDelete?.name}?</DialogTitle>
|
||||
<DialogDescription>This cannot be undone.</DialogDescription>
|
||||
</DialogHeader>
|
||||
{deleteError && <p className="text-sm text-destructive">{deleteError}</p>}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setPendingDelete(null)} disabled={deleting}>Cancel</Button>
|
||||
<Button variant="destructive" onClick={confirmDelete} disabled={deleting} className="min-w-[80px]">
|
||||
{deleting ? <Spinner /> : 'Delete'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button onClick={() => setShowCreate(true)}>{t('budget.create')}</Button>
|
||||
</div>
|
||||
{showCreate && (
|
||||
<BudgetSetup
|
||||
existingBudgets={list}
|
||||
onCreated={handleBudgetCreated}
|
||||
onCancel={() => setShowCreate(false)}
|
||||
/>
|
||||
)}
|
||||
<EmptyState
|
||||
icon={FolderOpen}
|
||||
heading="No budgets yet"
|
||||
subtext="Create your first budget to start tracking your finances."
|
||||
action={{ label: 'Create your first budget', onClick: () => setShowCreate(true) }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -91,11 +115,11 @@ export function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-muted-foreground">
|
||||
{t('dashboard.noBudgets')}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<EmptyState
|
||||
icon={FolderOpen}
|
||||
heading="Select a budget"
|
||||
subtext="Select a budget to view your dashboard."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user