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:
@@ -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(<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', () => {
|
||||
// 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(<BudgetSetup {...defaultProps} />)
|
||||
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(<BudgetSetup {...defaultProps} />)
|
||||
// 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
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 (
|
||||
<Card>
|
||||
<CardHeader className="bg-gradient-to-r from-violet-50 to-purple-50">
|
||||
<CardTitle>{t('budget.setup')}</CardTitle>
|
||||
<CardTitle>{t('budget.generate')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 pt-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium">{t('budget.name')}</label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Oktober 2025" />
|
||||
<label className="text-sm font-medium">{t('budget.month')}</label>
|
||||
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium">{t('budget.startDate')}</label>
|
||||
<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 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>
|
||||
<CardFooter className="flex gap-2 justify-end">
|
||||
<Button variant="outline" onClick={onCancel}>{t('common.cancel')}</Button>
|
||||
<Button onClick={handleCreate} disabled={saving || !name || !startDate || !endDate} className="min-w-[120px]">
|
||||
{saving ? <Spinner /> : t('common.create')}
|
||||
<Button onClick={handleGenerate} disabled={saving || !month} className="min-w-[160px]">
|
||||
{saving ? <Spinner /> : t('budget.generate')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -135,8 +135,6 @@ export const budgets = {
|
||||
update: (id: string, data: Partial<Budget>) =>
|
||||
request<Budget>(`/budgets/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
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 }) =>
|
||||
request<BudgetDetail>('/budgets/generate', { method: 'POST', body: JSON.stringify(data) }),
|
||||
}
|
||||
|
||||
@@ -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({}),
|
||||
|
||||
Reference in New Issue
Block a user