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', () => ({
|
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
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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) }),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({}),
|
||||||
|
|||||||
Reference in New Issue
Block a user