import { describe, it, expect, beforeEach, vi } from 'vitest' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { BrowserRouter } from 'react-router-dom' import { Dashboard } from './Dashboard' import * as hooks from '@/hooks' import * as api from '@/services/api' import { useWorkspace, useAuth } from '@/context' import { makeAuthContextValue, makeWorkspaceContextValue, makeAnalyticsResult, makeParentalLeaveResult, makePlannedDaysResult, makeScraperFetchResult, makeWorkspaceMembersResult, makePlannedLeaveDay, makeOptimizationTipsResult, makeLeaveWishesResult, makePlanProposalResult, makeParentalLeaveFetch, makeTierData, makeTip, } from '../../tests/fixtures' const mockNavigate = vi.fn() vi.mock('@/hooks') vi.mock('@/context') vi.mock('@/services/api') vi.mock('react-router-dom', async () => { const actual = await vi.importActual('react-router-dom') return { ...actual, useNavigate: () => mockNavigate } }) // Layout pulls in useAuth/useNavigate, which need a full AuthProvider/Router tree // to work. Dashboard.tsx only cares that its content renders inside Layout, so // stub Layout out — same pattern used by Calendar.test.tsx / Settings.test.tsx. vi.mock('@/components/layout/Layout', () => ({ Layout: ({ children }: { children: React.ReactNode }) =>
{children}
, })) // Dashboard hands off to Onboarding entirely when there are no children yet — // stub it out so this file only asserts the hand-off happens, not // Onboarding's own behaviour (covered by Onboarding.test.tsx). vi.mock('@/pages/Onboarding', () => ({ Onboarding: () =>
Onboarding
, })) const renderWithRouter = (component: React.ReactElement) => { return render({component}) } describe('Dashboard', () => { beforeEach(() => { vi.clearAllMocks() vi.mocked(useAuth).mockReturnValue(makeAuthContextValue()) vi.mocked(useWorkspace).mockReturnValue( makeWorkspaceContextValue({ workspace: { id: 'ws-1', name: 'Test Workspace', createdAt: '2026-01-01' }, workspaceId: 'ws-1', children: [{ id: 'c-1', name: 'Child 1', birth_date: '2022-01-01' }], selectedChildId: 'c-1', personalDataUuid: 'pd-1', parentName: 'Test Parent', }) ) vi.mocked(hooks.useAnalytics).mockReturnValue( makeAnalyticsResult({ data: { workspaceId: 'ws-1', childId: 'c-1', parentProjections: [], combinedDaysUsed: 30, combinedDaysLeft: 270, combinedExpiringAtB4: 100, }, }) ) vi.mocked(hooks.useParentalLeave).mockReturnValue( makeParentalLeaveResult({ data: { id: 'pf-1', personalDataUuid: 'pd-1', workspaceId: 'ws-1', childId: 'c-1', source: 'scraped', fetchedAt: '2026-06-13T10:00:00Z', totalDays: 300, daysUsed: 30, daysLeft: 270, reservedDaysTotal: 90, reservedDaysLeft: 80, transferableDaysLeft: 50, dubbeldagarLeft: 60, tierData: { sjukpenningTotal: 100, sjukpenningUsed: 10, sjukpenningLeft: 90, lagstaNivaTotal: 200, lagstaNivaUsed: 20, lagstaNivaLeft: 180, }, createdAt: '2026-06-13T10:00:00Z', updatedAt: '2026-06-13T10:00:00Z', }, }) ) vi.mocked(hooks.usePlannedDays).mockReturnValue( makePlannedDaysResult({ data: [ makePlannedLeaveDay({ id: '1', date: '2026-06-15' }), makePlannedLeaveDay({ id: '2', date: '2026-06-16', dayType: 'sjukpenning' }), ], }) ) vi.mocked(hooks.useScraperFetch).mockReturnValue(makeScraperFetchResult()) vi.mocked(hooks.useWorkspaceMembers).mockReturnValue( makeWorkspaceMembersResult({ data: [ { uuid: 'pd-1', workspaceId: 'ws-1', name: 'Alex', salaryType: 'monthly', employmentStatus: 'employed', createdAt: '2026-01-01' }, { uuid: 'pd-2', workspaceId: 'ws-1', name: 'Sam', salaryType: 'monthly', employmentStatus: 'employed', createdAt: '2026-01-01' }, ], }) ) vi.mocked(hooks.useOptimizationTips).mockReturnValue(makeOptimizationTipsResult()) vi.mocked(hooks.useLeaveWishes).mockReturnValue(makeLeaveWishesResult()) vi.mocked(hooks.usePlanProposal).mockReturnValue(makePlanProposalResult()) // Default: nothing saved yet for whoever's balance form gets opened — // `useLatestBalance` (a real hook, imported directly by Dashboard.tsx to // bypass the `vi.mock('@/hooks')` above) falls back to the form's // preset. Tests that care about a saved balance override this. vi.mocked(api.getParentalLeave).mockResolvedValue(null) }) it('renders loading spinner initially', () => { vi.mocked(useWorkspace).mockReturnValue( makeWorkspaceContextValue({ workspace: { id: 'ws-1', name: 'Test Workspace', createdAt: '2026-01-01' }, workspaceId: 'ws-1', children: [{ id: 'c-1', name: 'Child 1', birth_date: '2022-01-01' }], selectedChildId: 'c-1', personalDataUuid: 'pd-1', parentName: 'Test Parent', isLoading: true, }) ) renderWithRouter() expect(screen.getByTestId('page-skeleton')).toBeInTheDocument() }) it('hands off to Onboarding when there are no children yet', () => { vi.mocked(useWorkspace).mockReturnValue( makeWorkspaceContextValue({ workspace: { id: 'ws-1', name: 'Test Workspace', createdAt: '2026-01-01' }, workspaceId: 'ws-1', children: [], selectedChildId: null, personalDataUuid: 'pd-1', parentName: 'Test Parent', }) ) renderWithRouter() expect(screen.getByTestId('onboarding-page')).toBeInTheDocument() expect(screen.queryByTestId('dashboard')).not.toBeInTheDocument() }) it('refetches workspace members once onboarding hands off (children goes from empty to non-empty)', async () => { // Regression: a couple's partner is added mid-onboarding via a direct // `api.addPartner` call, invisible to this component's own // `useWorkspaceMembers` instance (fetched once, on mount, back when // there was only one member). `Onboarding` only ever refreshes // `WorkspaceContext` — flipping `children` from empty to non-empty is // the only externally-observable signal this component gets that the // member list it already fetched might now be stale. const refreshMembers = vi.fn().mockResolvedValue(undefined) vi.mocked(hooks.useWorkspaceMembers).mockReturnValue( makeWorkspaceMembersResult({ refresh: refreshMembers, data: [] }), ) const soloWorkspace = makeWorkspaceContextValue({ workspace: { id: 'ws-1', name: 'Test Workspace', createdAt: '2026-01-01' }, workspaceId: 'ws-1', children: [], selectedChildId: null, personalDataUuid: 'pd-1', parentName: 'Test Parent', }) vi.mocked(useWorkspace).mockReturnValue(soloWorkspace) const { rerender } = renderWithRouter() expect(screen.getByTestId('onboarding-page')).toBeInTheDocument() expect(refreshMembers).not.toHaveBeenCalled() // Onboarding finishes: `WorkspaceContext.children` flips non-empty. vi.mocked(useWorkspace).mockReturnValue( makeWorkspaceContextValue({ ...soloWorkspace, children: [{ id: 'c-1', name: 'Child 1', birth_date: '2026-01-01' }], selectedChildId: 'c-1', }) ) rerender( , ) await waitFor(() => expect(refreshMembers).toHaveBeenCalledTimes(1)) }) describe('estimated-figures badge', () => { it('is hidden once real parental-leave balances have been fetched (the default mock)', () => { renderWithRouter() expect(screen.queryByTestId('dashboard-estimate-badge')).not.toBeInTheDocument() }) it('is shown when no parental-leave balance has ever been fetched, labelling the figures as estimates', () => { vi.mocked(hooks.useParentalLeave).mockReturnValue(makeParentalLeaveResult({ data: null })) renderWithRouter() expect(screen.getByTestId('dashboard-estimate-badge')).toHaveTextContent('Uppskattat') }) }) describe('Hero', () => { it('uses the forward-looking framing when there are no planned days yet', () => { vi.mocked(hooks.usePlannedDays).mockReturnValue(makePlannedDaysResult({ data: [] })) renderWithRouter() expect(screen.getByTestId('dashboard-hero-message')).toHaveTextContent( 'Du har 270 dagar att planera. Utan en plan försvinner 100 av dem när Child 1 fyller 4.', ) }) it('uses the warning framing once days are planned, with the 4th-birthday date', () => { // selectedChild birth_date is 2022-01-01 and there is no parent // projection in this test's default mock, so the 4th birthday is // derived client-side as plain date arithmetic (not a business rule). renderWithRouter() expect(screen.getByTestId('dashboard-hero-message')).toHaveTextContent( '100 dagar riskerar att försvinna när Child 1 fyller 4 (1 januari 2026).', ) }) it('uses the backend-provided 4th birthday when a parent projection is available', () => { vi.mocked(hooks.useAnalytics).mockReturnValue( makeAnalyticsResult({ data: { workspaceId: 'ws-1', childId: 'c-1', parentProjections: [ { personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2029-06-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60, savableCapAtB4: 96 }, ], combinedDaysUsed: 30, combinedDaysLeft: 210, combinedExpiringAtB4: 60, }, }) ) renderWithRouter() expect(screen.getByTestId('dashboard-hero-message')).toHaveTextContent('1 juni 2029') }) }) describe('Age4Bars', () => { it('renders each parent bar using the backend-resolved savableCapAtB4, never re-derived', () => { vi.mocked(hooks.useAnalytics).mockReturnValue( makeAnalyticsResult({ data: { workspaceId: 'ws-1', childId: 'c-1', parentProjections: [ { personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 40, savableAfterB4: 40, expiringAtB4: 0, savableCapAtB4: 48 }, { personalDataUuid: 'pd-2', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 1, plannedDaysBeforeB4: 0, projectedLeftAtB4: 239, savableAfterB4: 48, expiringAtB4: 191, savableCapAtB4: 48 }, ], combinedDaysUsed: 31, combinedDaysLeft: 279, combinedExpiringAtB4: 191, }, }) ) renderWithRouter() expect(screen.getByTestId('age4-bar-savable-pd-1')).toHaveTextContent('40 av 48 dagar kan sparas') expect(screen.getByTestId('age4-bar-pd-1')).toHaveTextContent('Alex') expect(screen.getByTestId('age4-bar-expiring-pd-2')).toHaveTextContent('191 dagar förfaller') expect(screen.getByTestId('age4-bar-pd-2')).toHaveTextContent('Sam') }) it('is not rendered when there are no parent projections yet', () => { renderWithRouter() expect(screen.queryByTestId('age4-bars')).not.toBeInTheDocument() }) }) describe('YearTimeline', () => { it('renders one row per year from the child\'s birth to the 4th birthday', () => { renderWithRouter() for (const year of [2022, 2023, 2024, 2025, 2026]) { expect(screen.getByTestId(`year-timeline-row-${year}`)).toBeInTheDocument() } }) it('navigates to the calendar at the clicked band\'s date', async () => { const user = userEvent.setup() vi.mocked(hooks.usePlannedDays).mockReturnValue( makePlannedDaysResult({ data: [makePlannedLeaveDay({ id: '1', date: '2024-06-15', dayType: 'vacation' })], }) ) renderWithRouter() await user.click(screen.getByTestId('year-timeline-band-2024-0')) expect(mockNavigate).toHaveBeenCalledWith('/calendar?date=2024-06-15') }) }) describe('Tips', () => { it('is hidden entirely when there are no tips', () => { renderWithRouter() expect(screen.queryByTestId('dashboard-tips')).not.toBeInTheDocument() }) it('renders tip cards when tips exist', () => { vi.mocked(hooks.useOptimizationTips).mockReturnValue( makeOptimizationTipsResult({ data: [makeTip({ id: 'tip-1', messageKey: 'tips.use_vacation_before_expiry' })] }) ) renderWithRouter() expect(screen.getByTestId('dashboard-tips')).toBeInTheDocument() expect(screen.getAllByTestId('tip-card')).toHaveLength(1) }) }) it('renders the folded plan-proposal section', () => { renderWithRouter() expect(screen.getByTestId('plan-fold')).toBeInTheDocument() }) it('shows overview metrics inside the collapsed "more details" section', () => { renderWithRouter() expect(screen.getByTestId('dashboard-more-details')).toBeInTheDocument() expect(screen.getByTestId('overview-metrics')).toBeInTheDocument() expect(screen.getByText(/Använda dagar/)).toBeInTheDocument() }) it('shows parental leave breakdown when data exists', () => { renderWithRouter() expect(screen.getByTestId('parental-leave-breakdown')).toBeInTheDocument() // ParentalLeaveBreakdown shows an explanatory note about reserved days // rather than a "Reserverade dagar" heading/row — see // ParentalLeaveBreakdown.test.tsx ("displays a reserved-days explanatory // note instead of a countdown"). expect(screen.getByTestId('reserved-days-note')).toHaveTextContent(/reserverade/i) }) it('renders FK fetch button', () => { renderWithRouter() expect(screen.getByRole('button', { name: /Hämta från Försäkringskassan/ })).toBeInTheDocument() }) it('disables FK fetch button when no child selected', () => { vi.mocked(useWorkspace).mockReturnValue( makeWorkspaceContextValue({ workspace: { id: 'ws-1', name: 'Test Workspace', createdAt: '2026-01-01' }, workspaceId: 'ws-1', children: [{ id: 'c-1', name: 'Child 1', birth_date: '2022-01-01' }], selectedChildId: null, personalDataUuid: 'pd-1', parentName: 'Test Parent', }) ) renderWithRouter() const button = screen.getByRole('button', { name: /Hämta från Försäkringskassan/ }) expect(button).toBeDisabled() }) it('enables FK fetch button when child selected', () => { renderWithRouter() const button = screen.getByRole('button', { name: /Hämta från Försäkringskassan/ }) expect(button).not.toBeDisabled() }) it('shows planned days breakdown', () => { renderWithRouter() expect(screen.getByTestId('days-breakdown')).toBeInTheDocument() }) it('shows Swedish section headings', () => { renderWithRouter() expect(screen.getByTestId('metrics-title')).toHaveTextContent(/Översikt/) expect(screen.getAllByText(/Planerade dagar/)[0]).toBeInTheDocument() }) // The combined expiring-at-4 figure reflects the currently saved plan, while // the Plan tab's own figure (PlanResults.tsx) reflects an unsaved proposal — // the label must say so, so the two screens are never confused for showing // the same thing. See PlanAge4Projection vs Age4Projection in types/entities.ts. it('labels the combined expiring-at-4 metric as reflecting the current saved plan', () => { renderWithRouter() expect(screen.getByTestId('metric-expiring-combined')).toHaveTextContent( 'Förfaller vid 4 år om nuvarande plan följs (alla föräldrar)', ) }) it('labels the per-parent expiring-at-4 metric as reflecting the current saved plan', () => { vi.mocked(hooks.useAnalytics).mockReturnValue( makeAnalyticsResult({ data: { workspaceId: 'ws-1', childId: 'c-1', parentProjections: [ { personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60, savableCapAtB4: 96 }, { personalDataUuid: 'pd-2', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44, savableCapAtB4: 96 }, ], combinedDaysUsed: 50, combinedDaysLeft: 190, combinedExpiringAtB4: 104, }, }) ) renderWithRouter() expect(screen.getByTestId('metric-expiring-parent-pd-1')).toHaveTextContent( 'Förfaller vid 4 år om nuvarande plan följs – Alex', ) }) it('displays error message if analytics fails', () => { vi.mocked(hooks.useAnalytics).mockReturnValue( makeAnalyticsResult({ data: null, error: 'Failed to load analytics', }) ) renderWithRouter() expect(screen.getByText(/Failed to load analytics/)).toBeInTheDocument() }) it('shows empty state for parental leave when data is null', () => { vi.mocked(hooks.useParentalLeave).mockReturnValue( makeParentalLeaveResult({ data: null, }) ) renderWithRouter() expect(screen.getByText(/Ingen data hämtad ännu/)).toBeInTheDocument() }) it('opens QR code modal on FK fetch button click', async () => { const user = userEvent.setup() const mockStart = vi.fn() vi.mocked(hooks.useScraperFetch).mockReturnValue( makeScraperFetchResult({ start: mockStart, }) ) renderWithRouter() const button = screen.getByRole('button', { name: /Hämta från Försäkringskassan/ }) await user.click(button) expect(mockStart).toHaveBeenCalledWith('c-1') }) it('shows FK button is not disabled by default with child selected', () => { renderWithRouter() const button = screen.getByRole('button', { name: /Hämta från Försäkringskassan/ }) expect(button).toBeInTheDocument() expect(button).not.toBeDisabled() }) it('has manual fetch button enabled when child selected', () => { renderWithRouter() const manualButton = screen.getByRole('button', { name: /Ange uppgifter manuellt/ }) expect(manualButton).toBeInTheDocument() expect(manualButton).not.toBeDisabled() }) it('disables manual fetch button when no child selected', () => { vi.mocked(useWorkspace).mockReturnValue( makeWorkspaceContextValue({ workspace: { id: 'ws-1', name: 'Test Workspace', createdAt: '2026-01-01' }, workspaceId: 'ws-1', children: [{ id: 'c-1', name: 'Child 1', birth_date: '2022-01-01' }], selectedChildId: null, personalDataUuid: 'pd-1', parentName: 'Test Parent', }) ) renderWithRouter() const manualButton = screen.getByRole('button', { name: /Ange uppgifter manuellt/ }) expect(manualButton).toBeDisabled() }) it('renders QRCodeModal when scraper is active', () => { vi.mocked(hooks.useScraperFetch).mockReturnValue( makeScraperFetchResult({ sessionId: 'session1', pollData: { status: 'authenticating' }, isRunning: true, }) ) renderWithRouter() // QRCodeModal is rendered inside Dashboard // Verify it was constructed with proper props expect(screen.getByText(/Hämta från Försäkringskassan/)).toBeInTheDocument() }) it('renders planned days breakdown with multiple day types', () => { vi.mocked(hooks.usePlannedDays).mockReturnValue( makePlannedDaysResult({ data: [ makePlannedLeaveDay({ id: '1', date: '2026-06-15' }), makePlannedLeaveDay({ id: '2', date: '2026-06-16', dayType: 'sjukpenning' }), makePlannedLeaveDay({ id: '3', date: '2026-06-17', dayType: 'lagsta' }), ], }) ) renderWithRouter() expect(screen.getByTestId('days-breakdown')).toBeInTheDocument() }) it('shows analytics loading state', () => { vi.mocked(hooks.useAnalytics).mockReturnValue( makeAnalyticsResult({ data: null, isLoading: true, }) ) renderWithRouter() expect(screen.getByTestId('page-skeleton')).toBeInTheDocument() }) it('shows planned days loading state', () => { vi.mocked(hooks.usePlannedDays).mockReturnValue( makePlannedDaysResult({ data: null, isLoading: true, }) ) renderWithRouter() expect(screen.getByTestId('page-skeleton')).toBeInTheDocument() }) it('renders with zero planned days', () => { vi.mocked(hooks.usePlannedDays).mockReturnValue( makePlannedDaysResult({ data: [], }) ) renderWithRouter() expect(screen.getByTestId('days-breakdown')).toBeInTheDocument() }) it('displays metrics with expiring days at B4', () => { vi.mocked(hooks.useAnalytics).mockReturnValue( makeAnalyticsResult({ data: { workspaceId: 'ws-1', childId: 'c-1', parentProjections: [], combinedDaysUsed: 50, combinedDaysLeft: 200, combinedExpiringAtB4: 104, }, }) ) renderWithRouter() expect(screen.getByTestId('overview-metrics')).toBeInTheDocument() }) it('shows only the combined expiring figure for a single-parent workspace', () => { vi.mocked(hooks.useAnalytics).mockReturnValue( makeAnalyticsResult({ data: { workspaceId: 'ws-1', childId: 'c-1', parentProjections: [ { personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 104, savableCapAtB4: 96 }, ], combinedDaysUsed: 30, combinedDaysLeft: 210, combinedExpiringAtB4: 104, }, }) ) renderWithRouter() expect(screen.getByTestId('metric-expiring-combined')).toBeInTheDocument() expect(screen.queryByTestId('metric-expiring-parent-pd-1')).not.toBeInTheDocument() }) it('breaks down expiring days per parent alongside the combined total', () => { vi.mocked(hooks.useAnalytics).mockReturnValue( makeAnalyticsResult({ data: { workspaceId: 'ws-1', childId: 'c-1', parentProjections: [ { personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60, savableCapAtB4: 96 }, { personalDataUuid: 'pd-2', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44, savableCapAtB4: 96 }, ], combinedDaysUsed: 50, combinedDaysLeft: 190, combinedExpiringAtB4: 104, }, }) ) renderWithRouter() const alexMetric = screen.getByTestId('metric-expiring-parent-pd-1') const samMetric = screen.getByTestId('metric-expiring-parent-pd-2') const combinedMetric = screen.getByTestId('metric-expiring-combined') expect(alexMetric).toHaveTextContent('Alex') expect(alexMetric).toHaveTextContent('60') expect(samMetric).toHaveTextContent('Sam') expect(samMetric).toHaveTextContent('44') expect(combinedMetric).toHaveTextContent('104') }) it('falls back to a numbered parent label while members are still loading', () => { vi.mocked(hooks.useWorkspaceMembers).mockReturnValue( makeWorkspaceMembersResult({ data: [], isLoading: true, }) ) vi.mocked(hooks.useAnalytics).mockReturnValue( makeAnalyticsResult({ data: { workspaceId: 'ws-1', childId: 'c-1', parentProjections: [ { personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60, savableCapAtB4: 96 }, { personalDataUuid: 'pd-2', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44, savableCapAtB4: 96 }, ], combinedDaysUsed: 50, combinedDaysLeft: 190, combinedExpiringAtB4: 104, }, }) ) renderWithRouter() expect(screen.getByTestId('metric-expiring-parent-pd-1')).toHaveTextContent('Förälder 1') expect(screen.getByTestId('metric-expiring-parent-pd-2')).toHaveTextContent('Förälder 2') }) it('falls back to a numbered parent label for a projection with no matching member', () => { vi.mocked(hooks.useAnalytics).mockReturnValue( makeAnalyticsResult({ data: { workspaceId: 'ws-1', childId: 'c-1', parentProjections: [ { personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60, savableCapAtB4: 96 }, { personalDataUuid: 'pd-unknown', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44, savableCapAtB4: 96 }, ], combinedDaysUsed: 50, combinedDaysLeft: 190, combinedExpiringAtB4: 104, }, }) ) renderWithRouter() expect(screen.getByTestId('metric-expiring-parent-pd-1')).toHaveTextContent('Alex') expect(screen.getByTestId('metric-expiring-parent-pd-unknown')).toHaveTextContent('Förälder 2') }) describe('manual balance entry — parent picker', () => { it('shows a parent picker before the form when the workspace has two members', async () => { const user = userEvent.setup() renderWithRouter() await user.click(screen.getByRole('button', { name: /Ange uppgifter manuellt/ })) expect(screen.getByTestId('dashboard-balance-parent-picker')).toBeInTheDocument() expect(screen.getByTestId('dashboard-balance-parent-pd-1')).toHaveTextContent('Alex') expect(screen.getByTestId('dashboard-balance-parent-pd-2')).toHaveTextContent('Sam') }) it('opens the form directly, with no picker, for a solo workspace', async () => { vi.mocked(hooks.useWorkspaceMembers).mockReturnValue( makeWorkspaceMembersResult({ data: [{ uuid: 'pd-1', workspaceId: 'ws-1', name: 'Alex', salaryType: 'monthly', employmentStatus: 'employed', createdAt: '2026-01-01' }], }) ) const user = userEvent.setup() renderWithRouter() await user.click(screen.getByRole('button', { name: /Ange uppgifter manuellt/ })) expect(screen.queryByTestId('dashboard-balance-parent-picker')).not.toBeInTheDocument() expect(await screen.findByText('Dina dagar kvar')).toBeInTheDocument() }) it('posts the chosen parent\'s uuid to saveManualFetch', async () => { const user = userEvent.setup() renderWithRouter() await user.click(screen.getByRole('button', { name: /Ange uppgifter manuellt/ })) await user.click(screen.getByTestId('dashboard-balance-parent-pd-2')) expect(screen.queryByTestId('dashboard-balance-parent-picker')).not.toBeInTheDocument() await user.click(await screen.findByRole('button', { name: 'Spara' })) await waitFor(() => expect(api.saveManualFetch).toHaveBeenCalledWith( expect.objectContaining({ personalDataUuid: 'pd-2', workspaceId: 'ws-1', childId: 'c-1', source: 'manual' }) ) ) }) it('shows an error when saving a manual balance fails', async () => { const user = userEvent.setup() vi.mocked(api.saveManualFetch).mockRejectedValue(new Error('save failed')) renderWithRouter() await user.click(screen.getByRole('button', { name: /Ange uppgifter manuellt/ })) await user.click(screen.getByTestId('dashboard-balance-parent-pd-1')) await user.click(await screen.findByRole('button', { name: 'Spara' })) expect(await screen.findByText('save failed')).toBeInTheDocument() }) it('prefills the manual-entry form with the parent\'s saved balance', async () => { vi.mocked(api.getParentalLeave).mockResolvedValue( makeParentalLeaveFetch({ personalDataUuid: 'pd-2', childId: 'c-1', dubbeldagarLeft: 7, tierData: makeTierData({ sjukpenningLeft: 65, lagstaNivaLeft: 8 }), }), ) const user = userEvent.setup() renderWithRouter() await user.click(screen.getByRole('button', { name: /Ange uppgifter manuellt/ })) await user.click(screen.getByTestId('dashboard-balance-parent-pd-2')) expect(await screen.findByText('Sams dagar kvar')).toBeInTheDocument() expect((screen.getByLabelText(/Sjukpenningnivå/) as HTMLInputElement).value).toBe('65') expect((screen.getByLabelText(/Lägstanivå/) as HTMLInputElement).value).toBe('8') expect((screen.getByLabelText(/Dubbeldagar/) as HTMLInputElement).value).toBe('7') }) it('falls back to the preset when the parent has no saved balance yet', async () => { vi.mocked(api.getParentalLeave).mockResolvedValue(null) const user = userEvent.setup() renderWithRouter() await user.click(screen.getByRole('button', { name: /Ange uppgifter manuellt/ })) await user.click(screen.getByTestId('dashboard-balance-parent-pd-1')) expect(await screen.findByText('Dina dagar kvar')).toBeInTheDocument() // Couple workspace -> 240-day preset, split 195:45. expect((screen.getByLabelText(/Sjukpenningnivå/) as HTMLInputElement).value).toBe('195') expect((screen.getByLabelText(/Lägstanivå/) as HTMLInputElement).value).toBe('45') expect((screen.getByLabelText(/Dubbeldagar/) as HTMLInputElement).value).toBe('60') }) }) })