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
This commit is contained in:
2026-03-12 13:08:46 +01:00
parent 14075850c3
commit 7dfd04f31b
6 changed files with 60 additions and 75 deletions

View File

@@ -8,8 +8,15 @@ vi.mock('react-i18next', () => ({
vi.mock('@/lib/api', () => ({ vi.mock('@/lib/api', () => ({
budgets: { budgets: {
create: vi.fn().mockResolvedValue({ id: 'b1', name: 'Test Budget' }), generate: vi.fn().mockResolvedValue({ id: 'b1', name: 'April 2026' }),
copyFrom: vi.fn().mockResolvedValue({}), },
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', () => { it('renders without crashing', () => {
render(<BudgetSetup {...defaultProps} />) render(<BudgetSetup {...defaultProps} />)
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', () => { it('shows month input and currency input', () => {
// IXTN-01: while saving is true, the create button renders a spinner (Loader2 icon) render(<BudgetSetup {...defaultProps} />)
// Steps: fill required fields, click create, assert spinner is visible before resolve 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', () => { it('shows Generate button (not Create)', () => {
// IXTN-01: while saving is true, the create button is disabled to prevent double-submit render(<BudgetSetup {...defaultProps} />)
// Steps: fill required fields, click create, assert button is disabled before resolve // 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(<BudgetSetup {...defaultProps} />)
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
}) })
}) })

View File

@@ -3,9 +3,8 @@ import { useTranslation } from 'react-i18next'
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle, CardFooter } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Spinner } from '@/components/ui/spinner' 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 { interface Props {
existingBudgets: Budget[] existingBudgets: Budget[]
@@ -13,30 +12,24 @@ interface Props {
onCancel: () => void onCancel: () => void
} }
export function BudgetSetup({ existingBudgets, onCreated, onCancel }: Props) { export function BudgetSetup({ existingBudgets: _existingBudgets, onCreated, onCancel }: Props) {
const { t } = useTranslation() const { t } = useTranslation()
const [name, setName] = useState('') const [month, setMonth] = useState('')
const [startDate, setStartDate] = useState('')
const [endDate, setEndDate] = useState('')
const [currency, setCurrency] = useState('EUR') const [currency, setCurrency] = useState('EUR')
const [carryover, setCarryover] = useState('0')
const [copyFromId, setCopyFromId] = useState('')
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const handleCreate = async () => { const handleGenerate = async () => {
setSaving(true) setSaving(true)
try { try {
const budget = await budgetsApi.create({ await budgetsApi.generate({ month, currency })
name,
start_date: startDate,
end_date: endDate,
currency,
carryover_amount: parseFloat(carryover) || 0,
})
if (copyFromId) {
await budgetsApi.copyFrom(budget.id, copyFromId)
}
onCreated() 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 { } finally {
setSaving(false) setSaving(false)
} }
@@ -45,53 +38,22 @@ export function BudgetSetup({ existingBudgets, onCreated, onCancel }: Props) {
return ( return (
<Card> <Card>
<CardHeader className="bg-gradient-to-r from-violet-50 to-purple-50"> <CardHeader className="bg-gradient-to-r from-violet-50 to-purple-50">
<CardTitle>{t('budget.setup')}</CardTitle> <CardTitle>{t('budget.generate')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-4 pt-4"> <CardContent className="flex flex-col gap-4 pt-4">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label className="text-sm font-medium">{t('budget.name')}</label> <label className="text-sm font-medium">{t('budget.month')}</label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Oktober 2025" /> <Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} />
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="flex flex-col gap-2">
<div className="flex flex-col gap-2"> <label className="text-sm font-medium">{t('budget.currency')}</label>
<label className="text-sm font-medium">{t('budget.startDate')}</label> <Input value={currency} onChange={(e) => setCurrency(e.target.value)} />
<Input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} />
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">{t('budget.endDate')}</label>
<Input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} />
</div>
</div> </div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">{t('budget.currency')}</label>
<Input value={currency} onChange={(e) => setCurrency(e.target.value)} />
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">{t('budget.carryover')}</label>
<Input type="number" step="0.01" value={carryover} onChange={(e) => setCarryover(e.target.value)} />
</div>
</div>
{existingBudgets.length > 0 && (
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">{t('budget.copyFrom')}</label>
<Select value={copyFromId} onValueChange={setCopyFromId}>
<SelectTrigger>
<SelectValue placeholder="—" />
</SelectTrigger>
<SelectContent>
{existingBudgets.map((b) => (
<SelectItem key={b.id} value={b.id}>{b.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</CardContent> </CardContent>
<CardFooter className="flex gap-2 justify-end"> <CardFooter className="flex gap-2 justify-end">
<Button variant="outline" onClick={onCancel}>{t('common.cancel')}</Button> <Button variant="outline" onClick={onCancel}>{t('common.cancel')}</Button>
<Button onClick={handleCreate} disabled={saving || !name || !startDate || !endDate} className="min-w-[120px]"> <Button onClick={handleGenerate} disabled={saving || !month} className="min-w-[160px]">
{saving ? <Spinner /> : t('common.create')} {saving ? <Spinner /> : t('budget.generate')}
</Button> </Button>
</CardFooter> </CardFooter>
</Card> </Card>

View File

@@ -49,9 +49,10 @@
"endDate": "Enddatum", "endDate": "Enddatum",
"currency": "Waehrung", "currency": "Waehrung",
"carryover": "Uebertrag", "carryover": "Uebertrag",
"copyFrom": "Vom vorherigen kopieren",
"selectBudget": "Budget auswaehlen", "selectBudget": "Budget auswaehlen",
"setup": "Einrichtung" "generate": "Aus Vorlage erstellen",
"month": "Monat",
"generating": "Wird erstellt..."
}, },
"category": { "category": {
"create": "Kategorie erstellen", "create": "Kategorie erstellen",

View File

@@ -49,9 +49,10 @@
"endDate": "End Date", "endDate": "End Date",
"currency": "Currency", "currency": "Currency",
"carryover": "Carryover", "carryover": "Carryover",
"copyFrom": "Copy from previous",
"selectBudget": "Select Budget", "selectBudget": "Select Budget",
"setup": "Setup" "generate": "Generate from Template",
"month": "Month",
"generating": "Generating..."
}, },
"category": { "category": {
"create": "Create Category", "create": "Create Category",

View File

@@ -135,8 +135,6 @@ export const budgets = {
update: (id: string, data: Partial<Budget>) => update: (id: string, data: Partial<Budget>) =>
request<Budget>(`/budgets/${id}`, { method: 'PUT', body: JSON.stringify(data) }), request<Budget>(`/budgets/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
delete: (id: string) => request<void>(`/budgets/${id}`, { method: 'DELETE' }), delete: (id: string) => request<void>(`/budgets/${id}`, { method: 'DELETE' }),
copyFrom: (id: string, srcId: string) =>
request<BudgetDetail>(`/budgets/${id}/copy-from/${srcId}`, { method: 'POST' }),
generate: (data: { month: string; currency: string }) => generate: (data: { month: string; currency: string }) =>
request<BudgetDetail>('/budgets/generate', { method: 'POST', body: JSON.stringify(data) }), request<BudgetDetail>('/budgets/generate', { method: 'POST', body: JSON.stringify(data) }),
} }

View File

@@ -11,8 +11,7 @@ vi.mock('@/lib/api', () => ({
budgets: { budgets: {
list: vi.fn().mockResolvedValue([]), list: vi.fn().mockResolvedValue([]),
get: vi.fn().mockResolvedValue(null), get: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue({}), generate: vi.fn().mockResolvedValue({}),
copyFrom: vi.fn().mockResolvedValue({}),
}, },
budgetItems: { budgetItems: {
update: vi.fn().mockResolvedValue({}), update: vi.fn().mockResolvedValue({}),