From 7dfd04f31b8c8a84db1082e3e43ce26df1c830c6 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 12 Mar 2026 13:08:46 +0100 Subject: [PATCH] feat(06-02): replace BudgetSetup with template-based month picker - Rewrite BudgetSetup to use month picker + currency + Generate button - Remove manual form fields (name, dates, carryover, copy-from select) - Handle 409 conflict gracefully by calling onCreated() to refresh list - Remove copyFrom method from budgets API (TMPL-06) - Update BudgetSetup test to reflect new month-picker UI - Remove copyFrom from DashboardPage test mock - Add budget.generate, budget.month, budget.generating i18n keys (EN/DE) - Remove budget.copyFrom and budget.setup i18n keys --- frontend/src/components/BudgetSetup.test.tsx | 42 ++++++++--- frontend/src/components/BudgetSetup.tsx | 78 +++++--------------- frontend/src/i18n/de.json | 5 +- frontend/src/i18n/en.json | 5 +- frontend/src/lib/api.ts | 2 - frontend/src/pages/DashboardPage.test.tsx | 3 +- 6 files changed, 60 insertions(+), 75 deletions(-) diff --git a/frontend/src/components/BudgetSetup.test.tsx b/frontend/src/components/BudgetSetup.test.tsx index 93973c5..7dad7c5 100644 --- a/frontend/src/components/BudgetSetup.test.tsx +++ b/frontend/src/components/BudgetSetup.test.tsx @@ -8,8 +8,15 @@ vi.mock('react-i18next', () => ({ vi.mock('@/lib/api', () => ({ budgets: { - create: vi.fn().mockResolvedValue({ id: 'b1', name: 'Test Budget' }), - copyFrom: vi.fn().mockResolvedValue({}), + generate: vi.fn().mockResolvedValue({ id: 'b1', name: 'April 2026' }), + }, + ApiError: class ApiError extends Error { + status: number + constructor(status: number, message: string) { + super(message) + this.name = 'ApiError' + this.status = status + } }, })) @@ -22,16 +29,33 @@ describe('BudgetSetup', () => { it('renders without crashing', () => { render() - expect(screen.getByText('budget.setup')).toBeInTheDocument() + const elements = screen.getAllByText('budget.generate') + expect(elements.length).toBeGreaterThanOrEqual(1) }) - it.skip('shows spinner in create button when saving', () => { - // IXTN-01: while saving is true, the create button renders a spinner (Loader2 icon) - // Steps: fill required fields, click create, assert spinner is visible before resolve + it('shows month input and currency input', () => { + render() + const monthInput = document.querySelector('input[type="month"]') + const currencyInput = document.querySelector('input:not([type="month"])') + expect(monthInput).toBeInTheDocument() + expect(currencyInput).toBeInTheDocument() }) - it.skip('disables create button when saving', () => { - // IXTN-01: while saving is true, the create button is disabled to prevent double-submit - // Steps: fill required fields, click create, assert button is disabled before resolve + it('shows Generate button (not Create)', () => { + render() + // The button text uses the i18n key which the mock returns as-is + const buttons = screen.getAllByText('budget.generate') + // One in header, one in button + expect(buttons.length).toBeGreaterThanOrEqual(1) + }) + + it('disables Generate button when month is empty', () => { + render() + const button = screen.getByRole('button', { name: 'budget.generate' }) + expect(button).toBeDisabled() + }) + + it.skip('shows spinner in Generate button when saving', () => { + // While saving is true, the Generate button renders a spinner }) }) diff --git a/frontend/src/components/BudgetSetup.tsx b/frontend/src/components/BudgetSetup.tsx index e53915e..9ee009b 100644 --- a/frontend/src/components/BudgetSetup.tsx +++ b/frontend/src/components/BudgetSetup.tsx @@ -3,9 +3,8 @@ import { useTranslation } from 'react-i18next' import { Card, CardContent, CardHeader, CardTitle, CardFooter } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Spinner } from '@/components/ui/spinner' -import { budgets as budgetsApi, type Budget } from '@/lib/api' +import { budgets as budgetsApi, type Budget, ApiError } from '@/lib/api' interface Props { existingBudgets: Budget[] @@ -13,30 +12,24 @@ interface Props { onCancel: () => void } -export function BudgetSetup({ existingBudgets, onCreated, onCancel }: Props) { +export function BudgetSetup({ existingBudgets: _existingBudgets, onCreated, onCancel }: Props) { const { t } = useTranslation() - const [name, setName] = useState('') - const [startDate, setStartDate] = useState('') - const [endDate, setEndDate] = useState('') + const [month, setMonth] = useState('') const [currency, setCurrency] = useState('EUR') - const [carryover, setCarryover] = useState('0') - const [copyFromId, setCopyFromId] = useState('') const [saving, setSaving] = useState(false) - const handleCreate = async () => { + const handleGenerate = async () => { setSaving(true) try { - const budget = await budgetsApi.create({ - name, - start_date: startDate, - end_date: endDate, - currency, - carryover_amount: parseFloat(carryover) || 0, - }) - if (copyFromId) { - await budgetsApi.copyFrom(budget.id, copyFromId) - } + await budgetsApi.generate({ month, currency }) onCreated() + } catch (err) { + if (err instanceof ApiError && err.status === 409) { + // Budget already exists for this month — navigate to it by refreshing the list + onCreated() + } else { + throw err + } } finally { setSaving(false) } @@ -45,53 +38,22 @@ export function BudgetSetup({ existingBudgets, onCreated, onCancel }: Props) { return ( - {t('budget.setup')} + {t('budget.generate')}
- - setName(e.target.value)} placeholder="Oktober 2025" /> + + setMonth(e.target.value)} />
-
-
- - setStartDate(e.target.value)} /> -
-
- - setEndDate(e.target.value)} /> -
+
+ + setCurrency(e.target.value)} />
-
-
- - setCurrency(e.target.value)} /> -
-
- - setCarryover(e.target.value)} /> -
-
- {existingBudgets.length > 0 && ( -
- - -
- )} - diff --git a/frontend/src/i18n/de.json b/frontend/src/i18n/de.json index 7df286d..f5440c9 100644 --- a/frontend/src/i18n/de.json +++ b/frontend/src/i18n/de.json @@ -49,9 +49,10 @@ "endDate": "Enddatum", "currency": "Waehrung", "carryover": "Uebertrag", - "copyFrom": "Vom vorherigen kopieren", "selectBudget": "Budget auswaehlen", - "setup": "Einrichtung" + "generate": "Aus Vorlage erstellen", + "month": "Monat", + "generating": "Wird erstellt..." }, "category": { "create": "Kategorie erstellen", diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 0b9b3af..2bb48be 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -49,9 +49,10 @@ "endDate": "End Date", "currency": "Currency", "carryover": "Carryover", - "copyFrom": "Copy from previous", "selectBudget": "Select Budget", - "setup": "Setup" + "generate": "Generate from Template", + "month": "Month", + "generating": "Generating..." }, "category": { "create": "Create Category", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index a30f2a4..be1ebc5 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -135,8 +135,6 @@ export const budgets = { update: (id: string, data: Partial) => request(`/budgets/${id}`, { method: 'PUT', body: JSON.stringify(data) }), delete: (id: string) => request(`/budgets/${id}`, { method: 'DELETE' }), - copyFrom: (id: string, srcId: string) => - request(`/budgets/${id}/copy-from/${srcId}`, { method: 'POST' }), generate: (data: { month: string; currency: string }) => request('/budgets/generate', { method: 'POST', body: JSON.stringify(data) }), } diff --git a/frontend/src/pages/DashboardPage.test.tsx b/frontend/src/pages/DashboardPage.test.tsx index f48f6f9..03a6ca6 100644 --- a/frontend/src/pages/DashboardPage.test.tsx +++ b/frontend/src/pages/DashboardPage.test.tsx @@ -11,8 +11,7 @@ vi.mock('@/lib/api', () => ({ budgets: { list: vi.fn().mockResolvedValue([]), get: vi.fn().mockResolvedValue(null), - create: vi.fn().mockResolvedValue({}), - copyFrom: vi.fn().mockResolvedValue({}), + generate: vi.fn().mockResolvedValue({}), }, budgetItems: { update: vi.fn().mockResolvedValue({}),