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 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="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>
|
||||
|
||||
Reference in New Issue
Block a user