---
phase: 03-interaction-quality-and-completeness
plan: 02
type: execute
wave: 1
depends_on: ["03-00"]
files_modified:
- frontend/src/pages/CategoriesPage.tsx
- frontend/src/pages/DashboardPage.tsx
- frontend/src/components/EmptyState.tsx
autonomous: true
requirements: [IXTN-05, STATE-01, STATE-02]
must_haves:
truths:
- "Clicking delete on a category opens a confirmation dialog, not an immediate delete"
- "Confirming delete executes the API call with spinner; cancelling closes the dialog"
- "If delete fails (ON DELETE RESTRICT), error message shows inline in the dialog"
- "Dashboard with no budgets shows an empty state with icon, heading, subtext, and Create CTA"
- "Categories page with no categories shows an empty state with Add CTA"
artifacts:
- path: "frontend/src/pages/CategoriesPage.tsx"
provides: "Delete confirmation dialog with spinner and error handling"
contains: "pendingDelete"
- path: "frontend/src/pages/DashboardPage.tsx"
provides: "Empty state when list is empty and not loading"
contains: "EmptyState"
- path: "frontend/src/components/EmptyState.tsx"
provides: "Shared empty state component with icon + heading + subtext + CTA"
exports: ["EmptyState"]
key_links:
- from: "frontend/src/pages/CategoriesPage.tsx"
to: "categories API delete endpoint"
via: "categoriesApi.delete in confirmDelete handler"
pattern: "categoriesApi\\.delete"
- from: "frontend/src/pages/DashboardPage.tsx"
to: "frontend/src/components/EmptyState.tsx"
via: "EmptyState import"
pattern: "import.*EmptyState"
---
Add delete confirmation dialog to CategoriesPage and designed empty states to Dashboard and Categories pages.
Purpose: Prevent accidental category deletion with a confirmation step that handles backend constraints gracefully. Replace bare fallback content with designed empty states that guide users toward first actions.
Output: CategoriesPage with delete dialog, EmptyState shared component, empty states on Dashboard and Categories pages.
@/home/jean-luc-makiola/.claude/get-shit-done/workflows/execute-plan.md
@/home/jean-luc-makiola/.claude/get-shit-done/templates/summary.md
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/03-interaction-quality-and-completeness/03-CONTEXT.md
@.planning/phases/03-interaction-quality-and-completeness/03-RESEARCH.md
From frontend/src/pages/CategoriesPage.tsx:
```typescript
const handleDelete = async (id: string) => {
await categoriesApi.delete(id)
fetchCategories()
}
```
From frontend/src/pages/CategoriesPage.tsx:
```typescript
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
```
From frontend/src/pages/DashboardPage.tsx:
```typescript
// Line 93-99: plain Card with text — replace with EmptyState
{t('dashboard.noBudgets')}
```
From frontend/src/lib/api.ts:
```typescript
export type CategoryType = 'income' | 'bill' | 'variable_expense' | 'debt' | 'saving' | 'investment'
export interface Category { id: string; name: string; type: CategoryType; sort_order: number }
```
From frontend/src/components/ui/spinner.tsx:
```typescript
export { Spinner }
```
Task 1: Create shared EmptyState component and wire into Dashboard and Categories pagesfrontend/src/components/EmptyState.tsx, frontend/src/pages/DashboardPage.tsx, frontend/src/pages/CategoriesPage.tsx
**Step 0 — Check shadcn registry first (per project skill rules):**
Run `bunx --bun shadcn@latest search -q empty` in the frontend directory. If shadcn provides an EmptyState or similar component, use it instead of creating a custom one. If nothing relevant is found (expected), proceed with custom component below.
**Create `frontend/src/components/EmptyState.tsx`:**
```typescript
interface EmptyStateProps {
icon: React.ElementType // lucide-react icon component
heading: string
subtext: string
action?: { label: string; onClick: () => void }
}
```
Render: centered flex column with `py-16 text-center`, icon at `size-12 text-muted-foreground`, heading as `font-semibold`, subtext as `text-sm text-muted-foreground`, optional Button with action.label/onClick.
**DashboardPage.tsx:**
- Import `EmptyState` and `FolderOpen` from lucide-react
- Add a new condition: after the loading skeleton block (line 39-47), before the main return, check `list.length === 0 && !loading`. If true, render the budget selector area + an ` setShowCreate(true) }} />` inside the page layout. Keep the existing Create Budget button in the header area as well.
- Replace the existing plain Card fallback (the `!current` branch, lines 93-99) with an `` as well — this handles the "budgets exist but none selected" edge case. Use a simpler message: "Select a budget to view your dashboard."
**CategoriesPage.tsx:**
- Import `EmptyState` and `FolderOpen` from lucide-react
- Add `loading` state: `const [loading, setLoading] = useState(true)` — set to `true` initially, set to `false` after `fetchCategories` completes (wrap existing fetch in try/finally with `setLoading(false)`)
- After the header div and before the `grouped.map(...)`, add: `{!loading && list.length === 0 && }`
- Guard the grouped cards render with `{grouped.length > 0 && grouped.map(...)}` so both empty state and cards don't show simultaneously.
cd /home/jean-luc-makiola/Development/projects/SimpleFinanceDash/frontend && bun vitest run src/pages/DashboardPage.test.tsx src/pages/CategoriesPage.test.tsx && bun run buildEmptyState component exists and is used in DashboardPage (no-budgets case) and CategoriesPage (no-categories case). CategoriesPage has loading state to prevent empty-state flash. Build passes.Task 2: Add delete confirmation dialog with spinner and error handling to CategoriesPagefrontend/src/pages/CategoriesPage.tsx
Add new state variables:
```typescript
const [pendingDelete, setPendingDelete] = useState<{ id: string; name: string } | null>(null)
const [deleting, setDeleting] = useState(false)
const [deleteError, setDeleteError] = useState(null)
```
Import `Spinner` from `@/components/ui/spinner` and `DialogDescription` from `@/components/ui/dialog`.
Replace `handleDelete`:
```typescript
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)
}
}
```
Change the delete button in each category row from `onClick={() => handleDelete(cat.id)}` to `onClick={() => { setDeleteError(null); setPendingDelete({ id: cat.id, name: cat.name }) }}`.
Add a second Dialog (the delete confirmation) after the existing create/edit dialog:
```tsx
```
Remove the old `handleDelete` function entirely. The delete button in the category row now only sets state — no direct API call.
**CRITICAL:** The ON DELETE RESTRICT constraint means deleting a category with budget items returns 500. The catch block handles this — the error message displays inline in the dialog. The dialog does NOT auto-close on error, letting the user read the message and dismiss manually.
cd /home/jean-luc-makiola/Development/projects/SimpleFinanceDash/frontend && bun vitest run src/pages/CategoriesPage.test.tsx && bun run buildDelete button opens confirmation dialog. Confirm executes delete with spinner. Error from ON DELETE RESTRICT shows inline. Cancel closes dialog. Build passes with zero errors.
- `cd frontend && bun run build` — production build succeeds
- `cd frontend && bun vitest run` — full test suite passes
- CategoriesPage delete button opens dialog, not immediate delete
- DashboardPage shows EmptyState when no budgets exist
- CategoriesPage shows EmptyState when no categories exist
- Delete confirmation dialog prevents accidental deletion
- ON DELETE RESTRICT errors display inline in dialog (not silent failure)
- EmptyState component renders icon + heading + subtext + optional CTA
- Dashboard empty state shows "Create your first budget" CTA
- Categories empty state shows "Add a category" CTA
- No empty-state flash on initial page load (loading guard in CategoriesPage)