Group settings into four panels and give balances one home
Settings becomes Barn & familj, Saldon, Kalender & språk and Undantag & röda dagar, each saving on its own. A BalanceFlow component owns fetching from Försäkringskassan and manual entry for every parent, used by the Saldon panel and by the onboarding wizard, which now runs without the app chrome. The red-days list and the day-override form merge into one exceptions view, the vacation table becomes wrapping rows that fit a phone, and the BankID modal speaks translated status lines instead of emoji. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XBRLomdLK9jF7wU7a8AA6H
This commit is contained in:
@@ -0,0 +1,430 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { BalanceFlow } from './BalanceFlow'
|
||||
import * as hooks from '@/hooks'
|
||||
import * as api from '@/services/api'
|
||||
import {
|
||||
makePersonalData,
|
||||
makeScraperFetchResult,
|
||||
makePollResponse,
|
||||
makePartnerDeduction,
|
||||
makeParentalLeaveFetch,
|
||||
makeTierData,
|
||||
makePartnerMemberResponse,
|
||||
} from '../../../tests/fixtures'
|
||||
|
||||
vi.mock('@/hooks')
|
||||
vi.mock('@/services/api')
|
||||
|
||||
const SELF = makePersonalData({ uuid: 'pd-1', name: 'Test Parent' })
|
||||
const PARTNER = makePersonalData({ uuid: 'pd-2', name: 'Sam' })
|
||||
|
||||
function getCard(uuid: string): HTMLElement {
|
||||
const cards = screen.getAllByTestId('onboarding-parent-card')
|
||||
const card = cards.find((el) => el.dataset.parent === uuid)
|
||||
if (!card) throw new Error(`No card for ${uuid}`)
|
||||
return card
|
||||
}
|
||||
|
||||
describe('BalanceFlow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(hooks.useScraperFetch).mockReturnValue(makeScraperFetchResult())
|
||||
// Default: nothing saved yet for anyone.
|
||||
vi.mocked(api.getParentalLeave).mockResolvedValue(null)
|
||||
// Default: saves succeed. `vi.clearAllMocks()` above only clears call
|
||||
// history, not a prior test's `mockRejectedValue`/`mockResolvedValue`
|
||||
// implementation — without a default here, a later test that never sets
|
||||
// its own `saveManualFetch` behaviour (e.g. the deduced-partner flow)
|
||||
// would silently inherit an earlier test's rejection.
|
||||
vi.mocked(api.saveManualFetch).mockResolvedValue(makeParentalLeaveFetch())
|
||||
})
|
||||
|
||||
describe('panel mode (Settings)', () => {
|
||||
it('renders one row per member, keyed by settings-balance-{uuid}', async () => {
|
||||
render(
|
||||
<BalanceFlow
|
||||
members={[SELF]}
|
||||
personalDataUuid="pd-1"
|
||||
workspaceId="ws-1"
|
||||
selectedChildId="c-1"
|
||||
mode="panel"
|
||||
/>,
|
||||
)
|
||||
expect(await screen.findByTestId('settings-balance-pd-1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows "no details yet" until a balance has been saved', async () => {
|
||||
render(
|
||||
<BalanceFlow members={[SELF]} personalDataUuid="pd-1" workspaceId="ws-1" selectedChildId="c-1" mode="panel" />,
|
||||
)
|
||||
expect(await screen.findByText('Inga uppgifter ännu')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the saved balance, its source and date once loaded', async () => {
|
||||
vi.mocked(api.getParentalLeave).mockResolvedValue(
|
||||
makeParentalLeaveFetch({
|
||||
source: 'scraped',
|
||||
fetchedAt: '2026-03-01T00:00:00',
|
||||
daysLeft: 380,
|
||||
totalDays: 480,
|
||||
}),
|
||||
)
|
||||
render(
|
||||
<BalanceFlow members={[SELF]} personalDataUuid="pd-1" workspaceId="ws-1" selectedChildId="c-1" mode="panel" />,
|
||||
)
|
||||
expect(await screen.findByText('380 av 480 dagar kvar')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Från Försäkringskassan/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows only "Ange själv" (no FK button) on a partner row', async () => {
|
||||
render(
|
||||
<BalanceFlow
|
||||
members={[SELF, PARTNER]}
|
||||
personalDataUuid="pd-1"
|
||||
workspaceId="ws-1"
|
||||
selectedChildId="c-1"
|
||||
mode="panel"
|
||||
/>,
|
||||
)
|
||||
const row = await screen.findByTestId('settings-balance-pd-2')
|
||||
expect(within(row).queryByRole('button', { name: 'Hämta från Försäkringskassan' })).not.toBeInTheDocument()
|
||||
expect(within(row).getByRole('button', { name: 'Ange själv' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the manual form for self with the 480-day solo preset', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<BalanceFlow members={[SELF]} personalDataUuid="pd-1" workspaceId="ws-1" selectedChildId="c-1" mode="panel" />,
|
||||
)
|
||||
await user.click(await screen.findByRole('button', { name: 'Ange själv' }))
|
||||
expect(await screen.findByText('Dina dagar kvar')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/Sjukpenningnivå/)).toHaveValue(390)
|
||||
})
|
||||
|
||||
it('opens the manual form for a partner with the 240-day couple preset and that name in the title', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<BalanceFlow
|
||||
members={[SELF, PARTNER]}
|
||||
personalDataUuid="pd-1"
|
||||
workspaceId="ws-1"
|
||||
selectedChildId="c-1"
|
||||
mode="panel"
|
||||
/>,
|
||||
)
|
||||
const row = await screen.findByTestId('settings-balance-pd-2')
|
||||
await user.click(within(row).getByRole('button', { name: 'Ange själv' }))
|
||||
expect(await screen.findByText('Sams dagar kvar')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/Sjukpenningnivå/)).toHaveValue(195)
|
||||
})
|
||||
|
||||
it('prefills the manual form from a saved balance instead of the preset', async () => {
|
||||
vi.mocked(api.getParentalLeave).mockResolvedValue(
|
||||
makeParentalLeaveFetch({
|
||||
dubbeldagarLeft: 12,
|
||||
reservedDaysLeft: 34,
|
||||
tierData: makeTierData({ sjukpenningLeft: 111, lagstaNivaLeft: 22 }),
|
||||
}),
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<BalanceFlow members={[SELF]} personalDataUuid="pd-1" workspaceId="ws-1" selectedChildId="c-1" mode="panel" />,
|
||||
)
|
||||
await screen.findByText(/dagar kvar/)
|
||||
await user.click(screen.getByRole('button', { name: 'Ange själv' }))
|
||||
expect(await screen.findByLabelText(/Sjukpenningnivå/)).toHaveValue(111)
|
||||
expect(screen.getByLabelText(/Lägstanivå/)).toHaveValue(22)
|
||||
})
|
||||
|
||||
it('shows the loading overlay instead of the form while the balance is still loading', async () => {
|
||||
let resolveFetch: (v: Awaited<ReturnType<typeof api.getParentalLeave>>) => void = () => {}
|
||||
vi.mocked(api.getParentalLeave).mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveFetch = resolve
|
||||
}),
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<BalanceFlow members={[SELF]} personalDataUuid="pd-1" workspaceId="ws-1" selectedChildId="c-1" mode="panel" />,
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'Ange själv' }))
|
||||
expect(screen.getByTestId('balance-loading')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Dina dagar kvar')).not.toBeInTheDocument()
|
||||
resolveFetch(null)
|
||||
expect(await screen.findByText('Dina dagar kvar')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('saves a manual balance and calls onSaved', async () => {
|
||||
vi.mocked(api.saveManualFetch).mockResolvedValue(
|
||||
makeParentalLeaveFetch({ personalDataUuid: 'pd-1', source: 'manual' }),
|
||||
)
|
||||
const onSaved = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<BalanceFlow
|
||||
members={[SELF]}
|
||||
personalDataUuid="pd-1"
|
||||
workspaceId="ws-1"
|
||||
selectedChildId="c-1"
|
||||
mode="panel"
|
||||
onSaved={onSaved}
|
||||
/>,
|
||||
)
|
||||
await user.click(await screen.findByRole('button', { name: 'Ange själv' }))
|
||||
await screen.findByText('Dina dagar kvar')
|
||||
await user.click(screen.getByRole('button', { name: 'Spara' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.saveManualFetch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ personalDataUuid: 'pd-1', workspaceId: 'ws-1', childId: 'c-1', source: 'manual' }),
|
||||
),
|
||||
)
|
||||
await waitFor(() => expect(onSaved).toHaveBeenCalled())
|
||||
expect(screen.queryByText('Dina dagar kvar')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not save and disables both actions when there is no child to attach the balance to', async () => {
|
||||
render(
|
||||
<BalanceFlow
|
||||
members={[SELF]}
|
||||
personalDataUuid="pd-1"
|
||||
workspaceId="ws-1"
|
||||
selectedChildId={null}
|
||||
mode="panel"
|
||||
/>,
|
||||
)
|
||||
expect(await screen.findByRole('button', { name: 'Hämta från Försäkringskassan' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Ange själv' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('surfaces a failed save as an alert instead of crashing', async () => {
|
||||
vi.mocked(api.saveManualFetch).mockRejectedValue(new Error('balance save failed'))
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<BalanceFlow members={[SELF]} personalDataUuid="pd-1" workspaceId="ws-1" selectedChildId="c-1" mode="panel" />,
|
||||
)
|
||||
await user.click(await screen.findByRole('button', { name: 'Ange själv' }))
|
||||
await screen.findByText('Dina dagar kvar')
|
||||
await user.click(screen.getByRole('button', { name: 'Spara' }))
|
||||
expect(await screen.findByText('balance save failed')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the deduced badge and value on a partner row once the FK fetch is done', async () => {
|
||||
vi.mocked(hooks.useScraperFetch).mockReturnValue(
|
||||
makeScraperFetchResult({
|
||||
isTerminal: true,
|
||||
pollData: makePollResponse({ status: 'done', result: {} }),
|
||||
partnerDeduction: makePartnerDeduction({ daysLeft: 140, totalDays: 240 }),
|
||||
}),
|
||||
)
|
||||
render(
|
||||
<BalanceFlow
|
||||
members={[SELF, PARTNER]}
|
||||
personalDataUuid="pd-1"
|
||||
workspaceId="ws-1"
|
||||
selectedChildId="c-1"
|
||||
mode="panel"
|
||||
/>,
|
||||
)
|
||||
const row = await screen.findByTestId('settings-balance-pd-2')
|
||||
expect(within(row).getByText('Härlett från din FK-hämtning')).toBeInTheDocument()
|
||||
expect(within(row).getByText('140 av 240 dagar kvar')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('FK fetch', () => {
|
||||
it('starts the scraper with the selected child id and opens the QR modal', async () => {
|
||||
const start = vi.fn().mockResolvedValue(undefined)
|
||||
vi.mocked(hooks.useScraperFetch).mockReturnValue(
|
||||
makeScraperFetchResult({ start, pollData: makePollResponse({ status: 'launching' }) }),
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<BalanceFlow members={[SELF]} personalDataUuid="pd-1" workspaceId="ws-1" selectedChildId="c-1" mode="panel" />,
|
||||
)
|
||||
await user.click(await screen.findByRole('button', { name: 'Hämta från Försäkringskassan' }))
|
||||
await waitFor(() => expect(start).toHaveBeenCalledWith('c-1'))
|
||||
expect(await screen.findByTestId('qrcode-modal-status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("refreshes self's row once the fetch completes", async () => {
|
||||
vi.mocked(api.getParentalLeave)
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(makeParentalLeaveFetch({ source: 'scraped', daysLeft: 380, totalDays: 480 }))
|
||||
const { rerender } = render(
|
||||
<BalanceFlow members={[SELF]} personalDataUuid="pd-1" workspaceId="ws-1" selectedChildId="c-1" mode="panel" />,
|
||||
)
|
||||
expect(await screen.findByText('Inga uppgifter ännu')).toBeInTheDocument()
|
||||
|
||||
vi.mocked(hooks.useScraperFetch).mockReturnValue(
|
||||
makeScraperFetchResult({ isTerminal: true, pollData: makePollResponse({ status: 'done', result: {} }) }),
|
||||
)
|
||||
rerender(
|
||||
<BalanceFlow members={[SELF]} personalDataUuid="pd-1" workspaceId="ws-1" selectedChildId="c-1" mode="panel" />,
|
||||
)
|
||||
|
||||
expect(await screen.findByText('380 av 480 dagar kvar')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('wizard mode (Onboarding)', () => {
|
||||
it('renders one card per member with the onboarding-parent-card testid', async () => {
|
||||
render(
|
||||
<BalanceFlow
|
||||
members={[SELF, PARTNER]}
|
||||
personalDataUuid="pd-1"
|
||||
workspaceId="ws-1"
|
||||
selectedChildId="c-1"
|
||||
mode="wizard"
|
||||
/>,
|
||||
)
|
||||
expect(await screen.findAllByTestId('onboarding-parent-card')).toHaveLength(2)
|
||||
expect(getCard('pd-1')).toHaveTextContent('(du)')
|
||||
})
|
||||
|
||||
it('shows the deduced-partner prompt for a solo family once the fetch completes', async () => {
|
||||
vi.mocked(hooks.useScraperFetch).mockReturnValue(
|
||||
makeScraperFetchResult({
|
||||
isTerminal: true,
|
||||
pollData: makePollResponse({ status: 'done', result: {} }),
|
||||
partnerDeduction: makePartnerDeduction({ daysLeft: 140, totalDays: 240 }),
|
||||
}),
|
||||
)
|
||||
render(
|
||||
<BalanceFlow members={[SELF]} personalDataUuid="pd-1" workspaceId="ws-1" selectedChildId="c-1" mode="wizard" />,
|
||||
)
|
||||
expect(await screen.findByTestId('onboarding-deduced-partner')).toHaveTextContent(
|
||||
'Försäkringskassan visar att den andra föräldern har 140 dagar kvar.',
|
||||
)
|
||||
})
|
||||
|
||||
it('does not show the deduced-partner prompt once a partner already exists', async () => {
|
||||
vi.mocked(hooks.useScraperFetch).mockReturnValue(
|
||||
makeScraperFetchResult({
|
||||
isTerminal: true,
|
||||
pollData: makePollResponse({ status: 'done', result: {} }),
|
||||
partnerDeduction: makePartnerDeduction({ daysLeft: 140, totalDays: 240 }),
|
||||
}),
|
||||
)
|
||||
render(
|
||||
<BalanceFlow
|
||||
members={[SELF, PARTNER]}
|
||||
personalDataUuid="pd-1"
|
||||
workspaceId="ws-1"
|
||||
selectedChildId="c-1"
|
||||
mode="wizard"
|
||||
/>,
|
||||
)
|
||||
await screen.findAllByTestId('onboarding-parent-card')
|
||||
expect(screen.queryByTestId('onboarding-deduced-partner')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('adds the deduced partner, saves their derived balance and calls onSaved', async () => {
|
||||
vi.mocked(hooks.useScraperFetch).mockReturnValue(
|
||||
makeScraperFetchResult({
|
||||
isTerminal: true,
|
||||
pollData: makePollResponse({ status: 'done', result: {} }),
|
||||
partnerDeduction: makePartnerDeduction({
|
||||
daysLeft: 140,
|
||||
totalDays: 240,
|
||||
sjukpenningLeft: 100,
|
||||
lagstaNivaLeft: 40,
|
||||
dubbeldagarLeft: 20,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
vi.mocked(api.addPartner).mockResolvedValue(makePartnerMemberResponse({ uuid: 'pd-2', name: 'Kim' }))
|
||||
const onSaved = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<BalanceFlow
|
||||
members={[SELF]}
|
||||
personalDataUuid="pd-1"
|
||||
workspaceId="ws-1"
|
||||
selectedChildId="c-1"
|
||||
mode="wizard"
|
||||
onSaved={onSaved}
|
||||
/>,
|
||||
)
|
||||
await user.type(await screen.findByLabelText(/Den andra förälderns namn/), 'Kim')
|
||||
await user.click(screen.getByRole('button', { name: 'Lägg till Kim' }))
|
||||
|
||||
await waitFor(() => expect(api.addPartner).toHaveBeenCalledWith('ws-1', 'Kim'))
|
||||
await waitFor(() =>
|
||||
expect(api.saveManualFetch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
personalDataUuid: 'pd-2',
|
||||
workspaceId: 'ws-1',
|
||||
childId: 'c-1',
|
||||
sjukpenningLeft: 100,
|
||||
lagstaNivaLeft: 40,
|
||||
dubbeldagarLeft: 20,
|
||||
totalDays: 240,
|
||||
source: 'deduced',
|
||||
}),
|
||||
),
|
||||
)
|
||||
await waitFor(() => expect(onSaved).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('shows a translated error when adding the deduced partner is rejected with a recognised message key', async () => {
|
||||
const { ApiRequestError } = await import('@/services/errors')
|
||||
vi.mocked(hooks.useScraperFetch).mockReturnValue(
|
||||
makeScraperFetchResult({
|
||||
isTerminal: true,
|
||||
pollData: makePollResponse({ status: 'done', result: {} }),
|
||||
partnerDeduction: makePartnerDeduction({ daysLeft: 140, totalDays: 240 }),
|
||||
}),
|
||||
)
|
||||
vi.mocked(api.addPartner).mockRejectedValue(
|
||||
new ApiRequestError(422, {
|
||||
code: 'workspace.parents.invalidName',
|
||||
message: 'invalid',
|
||||
details: { messageKey: 'workspace.parents.invalidName' },
|
||||
}),
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<BalanceFlow members={[SELF]} personalDataUuid="pd-1" workspaceId="ws-1" selectedChildId="c-1" mode="wizard" />,
|
||||
)
|
||||
await user.type(await screen.findByLabelText(/Den andra förälderns namn/), '!!!')
|
||||
await user.click(screen.getByRole('button', { name: 'Lägg till !!!' }))
|
||||
|
||||
expect(await screen.findByText('Partnerns namn är ogiltigt.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('dismisses the deduced-partner prompt without adding anyone', async () => {
|
||||
vi.mocked(hooks.useScraperFetch).mockReturnValue(
|
||||
makeScraperFetchResult({
|
||||
isTerminal: true,
|
||||
pollData: makePollResponse({ status: 'done', result: {} }),
|
||||
partnerDeduction: makePartnerDeduction({ daysLeft: 140, totalDays: 240 }),
|
||||
}),
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<BalanceFlow members={[SELF]} personalDataUuid="pd-1" workspaceId="ws-1" selectedChildId="c-1" mode="wizard" />,
|
||||
)
|
||||
await user.click(await screen.findByRole('button', { name: 'Nej, jag är ensam förälder' }))
|
||||
expect(screen.queryByTestId('onboarding-deduced-partner')).not.toBeInTheDocument()
|
||||
expect(api.addPartner).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not show the deduced-partner prompt when the deduction has zero days left', async () => {
|
||||
vi.mocked(hooks.useScraperFetch).mockReturnValue(
|
||||
makeScraperFetchResult({
|
||||
isTerminal: true,
|
||||
pollData: makePollResponse({ status: 'done', result: {} }),
|
||||
partnerDeduction: makePartnerDeduction({ daysLeft: 0, totalDays: 240 }),
|
||||
}),
|
||||
)
|
||||
render(
|
||||
<BalanceFlow members={[SELF]} personalDataUuid="pd-1" workspaceId="ws-1" selectedChildId="c-1" mode="wizard" />,
|
||||
)
|
||||
await screen.findAllByTestId('onboarding-parent-card')
|
||||
expect(screen.queryByTestId('onboarding-deduced-partner')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,364 @@
|
||||
import { ReactNode, useEffect, useState } from 'react'
|
||||
import * as api from '@/services/api'
|
||||
import { isApiError } from '@/services/errors'
|
||||
import { useScraperFetch } from '@/hooks'
|
||||
// Imported directly (not via the `@/hooks` barrel) so tests can drive it
|
||||
// through a mocked `api.getParentalLeave` while the rest of `@/hooks` stays
|
||||
// blanket-mocked — same escape hatch `Settings`/`Onboarding` used before this
|
||||
// component absorbed their balance UI.
|
||||
import { useLatestBalance } from '@/hooks/useLatestBalance'
|
||||
import { isSelf, orderSelfFirst } from '@/utils/members'
|
||||
import { formatDateForDisplay } from '@/utils/dates'
|
||||
import { useTranslation, type Locale, type TranslationKey, type TranslationParams } from '@/i18n'
|
||||
import type { PersonalData } from '@/types'
|
||||
import { QRCodeModal } from '@/components/modals/QRCodeModal'
|
||||
import { ParentBalanceForm, type ParentBalanceInitial } from '@/components/modals/ParentBalanceForm'
|
||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
|
||||
import './balance-flow.css'
|
||||
|
||||
export interface BalanceFlowProps {
|
||||
members: PersonalData[]
|
||||
personalDataUuid: string | null
|
||||
workspaceId: string
|
||||
/** The child the balance is scoped to. `null` disables every save action
|
||||
* (Settings can be reached before a child has been selected/created). */
|
||||
selectedChildId: string | null
|
||||
/** `'panel'` — Settings' "Saldon" section: a plain per-member row.
|
||||
* `'wizard'` — Onboarding's balances step: a `card` per member, plus the
|
||||
* "we noticed your partner has days left" prompt for a solo family. */
|
||||
mode: 'panel' | 'wizard'
|
||||
/** Called after any balance is saved (manual, deduced) or a deduced
|
||||
* partner is added — callers refetch whatever list of members/balances
|
||||
* they own. Optional since a bare read of the panel needs nothing. */
|
||||
onSaved?: () => void
|
||||
}
|
||||
|
||||
type BalanceValues = ParentBalanceInitial & { reservedDaysLeft: number | null }
|
||||
|
||||
/** Mirrors `Settings.tsx`'s `describePartnerError` — kept as its own small
|
||||
* copy rather than a shared import, matching the pattern already used
|
||||
* between `Settings.tsx` and `Onboarding.tsx` for the same reason: each
|
||||
* caller only needs the couple of message keys its own flow can hit. */
|
||||
const ADD_PARTNER_ERROR_KEYS: Record<string, TranslationKey> = {
|
||||
'workspace.notFound': 'settings.partnerActionErrorNotFound',
|
||||
'workspace.parents.invalidName': 'settings.partnerNameInvalid',
|
||||
}
|
||||
|
||||
type Translate = (key: TranslationKey, params?: TranslationParams) => string
|
||||
|
||||
function describeAddPartnerError(err: unknown, t: Translate, fallback: string): string {
|
||||
if (isApiError(err)) {
|
||||
const details = err.error.details as { messageKey?: string } | undefined
|
||||
const messageKey = details?.messageKey
|
||||
if (messageKey && ADD_PARTNER_ERROR_KEYS[messageKey]) {
|
||||
return t(ADD_PARTNER_ERROR_KEYS[messageKey])
|
||||
}
|
||||
if (messageKey) return fallback
|
||||
}
|
||||
return err instanceof Error ? err.message : fallback
|
||||
}
|
||||
|
||||
function sourceLabelKey(source: 'scraped' | 'manual' | 'deduced'): TranslationKey {
|
||||
if (source === 'scraped') return 'balance.sourceScraped'
|
||||
if (source === 'deduced') return 'balance.sourceDeduced'
|
||||
return 'balance.sourceManual'
|
||||
}
|
||||
|
||||
interface BalanceRowProps {
|
||||
member: PersonalData
|
||||
isSelfRow: boolean
|
||||
totalDays: number
|
||||
workspaceId: string
|
||||
selectedChildId: string | null
|
||||
mode: 'panel' | 'wizard'
|
||||
locale: Locale
|
||||
onSaved?: (() => void) | undefined
|
||||
onStartFkFetch: () => void
|
||||
isFkRunning: boolean
|
||||
/** True on self's row the moment an FK fetch this session finishes — the
|
||||
* backend has already persisted the scraped balance by then, so the row
|
||||
* refetches once instead of parsing the poll result itself. */
|
||||
fkJustCompleted: boolean
|
||||
/** Only ever set for a non-self row, once the self row's FK fetch
|
||||
* completed with a deduction for the other parent — purely informational
|
||||
* until the row's own "Ange själv" is used to actually save it. */
|
||||
deduction?: { daysLeft: number; totalDays: number; initial: BalanceValues } | undefined
|
||||
}
|
||||
|
||||
function BalanceRow({
|
||||
member,
|
||||
isSelfRow,
|
||||
totalDays,
|
||||
workspaceId,
|
||||
selectedChildId,
|
||||
mode,
|
||||
locale,
|
||||
onSaved,
|
||||
onStartFkFetch,
|
||||
isFkRunning,
|
||||
fkJustCompleted,
|
||||
deduction,
|
||||
}: BalanceRowProps): ReactNode {
|
||||
const { t } = useTranslation()
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const { initial, raw, isLoading, refresh } = useLatestBalance(member.uuid, selectedChildId)
|
||||
|
||||
// Self's row: once an FK fetch this session finishes, re-fetch to pick up
|
||||
// the newly persisted (source: 'scraped') balance without a remount.
|
||||
useEffect(() => {
|
||||
if (isSelfRow && fkJustCompleted) {
|
||||
void refresh()
|
||||
}
|
||||
}, [isSelfRow, fkJustCompleted, refresh])
|
||||
|
||||
const handleSaveManual = async (data: BalanceValues) => {
|
||||
if (!workspaceId || !selectedChildId) return
|
||||
// No try/catch — a rejection propagates to `ParentBalanceForm`'s own
|
||||
// `onSave`, which owns showing it (inline per-field for a mapped 422, a
|
||||
// top banner otherwise).
|
||||
try {
|
||||
setIsSaving(true)
|
||||
await api.saveManualFetch({
|
||||
personalDataUuid: member.uuid,
|
||||
workspaceId,
|
||||
childId: selectedChildId,
|
||||
sjukpenningLeft: data.sjukpenningLeft,
|
||||
lagstaNivaLeft: data.lagstaNivaLeft,
|
||||
dubbeldagarLeft: data.dubbeldagarLeft,
|
||||
reservedDaysLeft: data.reservedDaysLeft,
|
||||
source: 'manual',
|
||||
})
|
||||
await refresh()
|
||||
setShowForm(false)
|
||||
onSaved?.()
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveInitial = deduction?.initial ?? initial
|
||||
const testId = mode === 'wizard' ? 'onboarding-parent-card' : `settings-balance-${member.uuid}`
|
||||
const rowClass = mode === 'wizard' ? 'card balance-flow__card' : 'balance-flow__row'
|
||||
|
||||
return (
|
||||
<div className={rowClass} data-testid={testId} data-parent={member.uuid}>
|
||||
<div className="balance-flow__identity">
|
||||
<span className="balance-flow__name">
|
||||
{member.name}
|
||||
{isSelfRow && <span className="badge badge--muted balance-flow__you"> {t('balance.youBadge')}</span>}
|
||||
</span>
|
||||
{deduction && <span className="badge badge--accent">{t('balance.deducedBadge')}</span>}
|
||||
</div>
|
||||
|
||||
<p className="balance-flow__status">
|
||||
{deduction
|
||||
? t('balance.statusLine', { left: deduction.daysLeft, total: deduction.totalDays })
|
||||
: raw
|
||||
? t('balance.statusLine', { left: raw.daysLeft, total: raw.totalDays })
|
||||
: t('balance.statusNone')}
|
||||
</p>
|
||||
{!deduction && raw && (
|
||||
<p className="balance-flow__source">
|
||||
{t(sourceLabelKey(raw.source), { date: formatDateForDisplay(raw.fetchedAt.slice(0, 10), locale) })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="actions balance-flow__actions">
|
||||
{isSelfRow && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--primary"
|
||||
onClick={onStartFkFetch}
|
||||
disabled={isFkRunning || !selectedChildId}
|
||||
>
|
||||
{t('balance.fetchFromFk')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--secondary"
|
||||
onClick={() => setShowForm(true)}
|
||||
disabled={!selectedChildId}
|
||||
>
|
||||
{t('balance.enterManually')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm &&
|
||||
(isLoading ? (
|
||||
<div className="modal__overlay" data-testid="balance-loading">
|
||||
<LoadingSpinner message={t('balance.loading')} />
|
||||
</div>
|
||||
) : (
|
||||
<ParentBalanceForm
|
||||
parentName={member.name}
|
||||
isSelf={isSelfRow}
|
||||
totalDays={totalDays}
|
||||
initial={effectiveInitial}
|
||||
onSave={handleSaveManual}
|
||||
onClose={() => setShowForm(false)}
|
||||
isSaving={isSaving}
|
||||
error={null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function BalanceFlow({
|
||||
members,
|
||||
personalDataUuid,
|
||||
workspaceId,
|
||||
selectedChildId,
|
||||
mode,
|
||||
onSaved,
|
||||
}: BalanceFlowProps): ReactNode {
|
||||
const { t, locale } = useTranslation()
|
||||
const scraper = useScraperFetch()
|
||||
const [showScrapeModal, setShowScrapeModal] = useState(false)
|
||||
const [deducedPartnerName, setDeducedPartnerName] = useState('')
|
||||
const [isAddingDeducedPartner, setIsAddingDeducedPartner] = useState(false)
|
||||
const [deducedPartnerError, setDeducedPartnerError] = useState<string | null>(null)
|
||||
const [deducedDismissed, setDeducedDismissed] = useState(false)
|
||||
|
||||
const orderedMembers = orderSelfFirst(members, personalDataUuid)
|
||||
const totalDays = orderedMembers.length >= 2 ? 240 : 480
|
||||
const fkDone = scraper.isTerminal && scraper.pollData?.status === 'done'
|
||||
const partnerDeduction = scraper.partnerDeduction
|
||||
|
||||
const handleStartFkFetch = async () => {
|
||||
setShowScrapeModal(true)
|
||||
await scraper.start(selectedChildId ?? undefined)
|
||||
}
|
||||
|
||||
const handleScrapeModalClose = () => {
|
||||
if (scraper.isRunning) return
|
||||
setShowScrapeModal(false)
|
||||
}
|
||||
|
||||
const handleScrapeDone = () => {
|
||||
setShowScrapeModal(false)
|
||||
onSaved?.()
|
||||
}
|
||||
|
||||
const handleAddDeducedPartner = async () => {
|
||||
if (!workspaceId || !selectedChildId || !deducedPartnerName.trim() || !partnerDeduction) return
|
||||
setIsAddingDeducedPartner(true)
|
||||
setDeducedPartnerError(null)
|
||||
try {
|
||||
const added = await api.addPartner(workspaceId, deducedPartnerName.trim())
|
||||
await api.saveManualFetch({
|
||||
personalDataUuid: added.uuid,
|
||||
workspaceId,
|
||||
childId: selectedChildId,
|
||||
sjukpenningLeft: partnerDeduction.sjukpenningLeft,
|
||||
lagstaNivaLeft: partnerDeduction.lagstaNivaLeft,
|
||||
dubbeldagarLeft: partnerDeduction.dubbeldagarLeft,
|
||||
totalDays: partnerDeduction.totalDays,
|
||||
source: 'deduced',
|
||||
})
|
||||
setDeducedPartnerName('')
|
||||
onSaved?.()
|
||||
} catch (err) {
|
||||
setDeducedPartnerError(describeAddPartnerError(err, t, t('settings.addPartnerError')))
|
||||
} finally {
|
||||
setIsAddingDeducedPartner(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="balance-flow" data-testid="balance-flow">
|
||||
<div className={mode === 'wizard' ? 'balance-flow__cards' : 'balance-flow__rows'}>
|
||||
{orderedMembers.map((member) => {
|
||||
const selfRow = isSelf(member, personalDataUuid)
|
||||
const deduction =
|
||||
!selfRow && orderedMembers.length >= 2 && fkDone && partnerDeduction
|
||||
? {
|
||||
daysLeft: partnerDeduction.daysLeft,
|
||||
totalDays: partnerDeduction.totalDays,
|
||||
initial: {
|
||||
sjukpenningLeft: partnerDeduction.sjukpenningLeft,
|
||||
lagstaNivaLeft: partnerDeduction.lagstaNivaLeft,
|
||||
dubbeldagarLeft: partnerDeduction.dubbeldagarLeft,
|
||||
reservedDaysLeft: null,
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
return (
|
||||
<BalanceRow
|
||||
key={member.uuid}
|
||||
member={member}
|
||||
isSelfRow={selfRow}
|
||||
totalDays={totalDays}
|
||||
workspaceId={workspaceId}
|
||||
selectedChildId={selectedChildId}
|
||||
mode={mode}
|
||||
locale={locale}
|
||||
onSaved={onSaved}
|
||||
onStartFkFetch={() => void handleStartFkFetch()}
|
||||
isFkRunning={scraper.isRunning}
|
||||
fkJustCompleted={selfRow && fkDone}
|
||||
deduction={deduction}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{mode === 'wizard' &&
|
||||
orderedMembers.length === 1 &&
|
||||
fkDone &&
|
||||
partnerDeduction &&
|
||||
partnerDeduction.daysLeft > 0 &&
|
||||
!deducedDismissed && (
|
||||
<div className="card card--dashed balance-flow__deduced-prompt" data-testid="onboarding-deduced-partner">
|
||||
<p>{t('balance.deducedPartnerPrompt', { days: partnerDeduction.daysLeft })}</p>
|
||||
<div className="form-group">
|
||||
<label htmlFor="balance-deduced-partner-name">{t('onboarding.partnerNameLabel')}</label>
|
||||
<input
|
||||
id="balance-deduced-partner-name"
|
||||
type="text"
|
||||
className="field__control"
|
||||
value={deducedPartnerName}
|
||||
onChange={(e) => setDeducedPartnerName(e.target.value)}
|
||||
disabled={isAddingDeducedPartner}
|
||||
/>
|
||||
</div>
|
||||
{deducedPartnerError && (
|
||||
<div role="alert" className="alert alert--error">
|
||||
{deducedPartnerError}
|
||||
</div>
|
||||
)}
|
||||
<div className="actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--primary"
|
||||
onClick={() => void handleAddDeducedPartner()}
|
||||
disabled={isAddingDeducedPartner || !deducedPartnerName.trim()}
|
||||
>
|
||||
{isAddingDeducedPartner
|
||||
? t('settings.addingPartner')
|
||||
: t('balance.addDeducedPartnerButton', { name: deducedPartnerName })}
|
||||
</button>
|
||||
<button type="button" className="btn btn--link" onClick={() => setDeducedDismissed(true)}>
|
||||
{t('balance.dismissDeducedPartner')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<QRCodeModal
|
||||
isOpen={showScrapeModal}
|
||||
onClose={handleScrapeModalClose}
|
||||
onDone={handleScrapeDone}
|
||||
status={scraper.pollData?.status ?? null}
|
||||
qrData={scraper.pollData?.qr_data}
|
||||
result={scraper.pollData?.result}
|
||||
message={scraper.pollData?.message}
|
||||
error={scraper.error}
|
||||
isRunning={scraper.isRunning}
|
||||
onCancel={scraper.cancel}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/* BalanceFlow — shared by Settings' "Saldon" panel (`mode="panel"`, plain
|
||||
* unboxed rows separated by a hairline) and Onboarding's balances step
|
||||
* (`mode="wizard"`, one `.card` per member — matches the rest of the
|
||||
* wizard's choice-card language). */
|
||||
.balance-flow__rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.balance-flow__row {
|
||||
padding: var(--sp-4) 0;
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
}
|
||||
|
||||
.balance-flow__row:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.balance-flow__cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-4);
|
||||
margin-bottom: var(--sp-5);
|
||||
}
|
||||
|
||||
.balance-flow__identity {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
margin-bottom: var(--sp-1);
|
||||
}
|
||||
|
||||
.balance-flow__name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.balance-flow__you {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.balance-flow__status {
|
||||
margin: 0;
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
.balance-flow__source {
|
||||
margin: 0 0 var(--sp-3) 0;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
|
||||
.balance-flow__actions {
|
||||
justify-content: flex-start;
|
||||
margin-top: var(--sp-2);
|
||||
}
|
||||
|
||||
.balance-flow__deduced-prompt {
|
||||
margin-bottom: var(--sp-5);
|
||||
}
|
||||
|
||||
.balance-flow__deduced-prompt .btn--link {
|
||||
margin-left: var(--sp-3);
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
|
||||
import { CalendarExceptions } from './CalendarExceptions'
|
||||
import type { CalendarDayEntry, DayOverride } from '@/types'
|
||||
|
||||
// Ported from the deleted `RedDaysList.test.tsx` / `DayOverridesInput.test.tsx`
|
||||
// — `CalendarExceptions` merges both components (see its doc comment): the
|
||||
// year list + toggle behaviour from `RedDaysList`, and only the add-a-date
|
||||
// form from `DayOverridesInput` (its own separate override list/remove
|
||||
// button were redundant with the year list's toggle).
|
||||
|
||||
const baseDays: CalendarDayEntry[] = [
|
||||
{
|
||||
date: '2026-01-01',
|
||||
name: 'Nyårsdagen',
|
||||
dayType: 'red_day',
|
||||
isOverride: false,
|
||||
overrideIsWorkingDay: null,
|
||||
isFree: true,
|
||||
},
|
||||
{
|
||||
date: '2026-04-05',
|
||||
name: 'Påskdagen; Söndag',
|
||||
dayType: 'red_day',
|
||||
isOverride: false,
|
||||
overrideIsWorkingDay: null,
|
||||
isFree: true,
|
||||
},
|
||||
{
|
||||
date: '2026-12-24',
|
||||
name: null,
|
||||
dayType: 'working_day',
|
||||
isOverride: true,
|
||||
overrideIsWorkingDay: false,
|
||||
isFree: true,
|
||||
},
|
||||
{
|
||||
date: '2027-01-06',
|
||||
name: 'Trettondedag jul',
|
||||
dayType: 'red_day',
|
||||
isOverride: false,
|
||||
overrideIsWorkingDay: null,
|
||||
isFree: true,
|
||||
},
|
||||
{
|
||||
date: '2026-05-04',
|
||||
name: null,
|
||||
dayType: 'working_day',
|
||||
isOverride: false,
|
||||
overrideIsWorkingDay: null,
|
||||
isFree: true,
|
||||
},
|
||||
]
|
||||
|
||||
const baseOverrides: DayOverride[] = [
|
||||
{
|
||||
id: 'do-2026-12-24',
|
||||
workspaceId: 'ws-1',
|
||||
date: '2026-12-24',
|
||||
isWorkingDay: false,
|
||||
note: 'Ledig dagen efter jobbjul',
|
||||
createdAt: '2026-01-01T00:00:00',
|
||||
updatedAt: '2026-01-01T00:00:00',
|
||||
},
|
||||
]
|
||||
|
||||
describe('CalendarExceptions', () => {
|
||||
const defaultProps = {
|
||||
days: baseDays,
|
||||
startYear: 2026,
|
||||
years: 4,
|
||||
minYear: 2026,
|
||||
maxYear: 2035,
|
||||
onPrevWindow: vi.fn(),
|
||||
onNextWindow: vi.fn(),
|
||||
overrides: baseOverrides,
|
||||
workspaceId: 'ws-1',
|
||||
onUpsertOverride: vi.fn().mockResolvedValue(undefined),
|
||||
onRemoveOverride: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
|
||||
describe('year list (ported from RedDaysList)', () => {
|
||||
it('renders one year group per year in the window, including years with no matches', () => {
|
||||
render(<CalendarExceptions {...defaultProps} />)
|
||||
expect(screen.getByTestId('red-days-year-2026')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('red-days-year-2027')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('red-days-year-2028')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('red-days-year-2029')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('red-days-year-2030')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows statutory red days with a cleaned-up name', () => {
|
||||
render(<CalendarExceptions {...defaultProps} />)
|
||||
expect(screen.getByTestId('red-days-item-2026-01-01')).toHaveTextContent('Nyårsdagen')
|
||||
const easter = screen.getByTestId('red-days-item-2026-04-05')
|
||||
expect(easter).toHaveTextContent('Påskdagen')
|
||||
expect(easter).not.toHaveTextContent('Söndag')
|
||||
})
|
||||
|
||||
it('shows a workspace override even on a non-red day, with its note', () => {
|
||||
render(<CalendarExceptions {...defaultProps} />)
|
||||
const overrideItem = screen.getByTestId('red-days-item-2026-12-24')
|
||||
expect(overrideItem).toBeInTheDocument()
|
||||
const badge = screen.getByTestId('red-days-override-badge-2026-12-24')
|
||||
expect(badge).toBeInTheDocument()
|
||||
expect(badge).toHaveClass('red-days-list__override-badge--free')
|
||||
expect(screen.getByText('Anteckning: Ledig dagen efter jobbjul')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows an empty-year message for a year with no red days or overrides', () => {
|
||||
render(<CalendarExceptions {...defaultProps} />)
|
||||
expect(screen.getByTestId('red-days-year-2028')).toHaveTextContent(/Inga röda dagar eller undantag/)
|
||||
})
|
||||
|
||||
it('skips a plain working day that is neither a red day nor an override', () => {
|
||||
render(<CalendarExceptions {...defaultProps} />)
|
||||
expect(screen.queryByTestId('red-days-item-2026-05-04')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('labels a working-day override distinctly from a free-day override', () => {
|
||||
const workingOverrideDays: CalendarDayEntry[] = [
|
||||
{
|
||||
date: '2026-03-02',
|
||||
name: null,
|
||||
dayType: 'working_day',
|
||||
isOverride: true,
|
||||
overrideIsWorkingDay: true,
|
||||
isFree: false,
|
||||
},
|
||||
]
|
||||
render(<CalendarExceptions {...defaultProps} days={workingOverrideDays} overrides={[]} />)
|
||||
const badge = screen.getByTestId('red-days-override-badge-2026-03-02')
|
||||
expect(badge).toHaveClass('red-days-list__override-badge--working')
|
||||
expect(badge).toHaveTextContent('Undantag: arbetsdag')
|
||||
})
|
||||
|
||||
it('shows the effective-working label for a day that is not free', () => {
|
||||
const workingDays: CalendarDayEntry[] = [
|
||||
{
|
||||
date: '2026-03-02',
|
||||
name: null,
|
||||
dayType: 'working_day',
|
||||
isOverride: true,
|
||||
overrideIsWorkingDay: true,
|
||||
isFree: false,
|
||||
},
|
||||
]
|
||||
render(<CalendarExceptions {...defaultProps} days={workingDays} overrides={[]} />)
|
||||
const item = screen.getByTestId('red-days-item-2026-03-02')
|
||||
expect(item.querySelector('.red-days-list__effective--working')).toBeInTheDocument()
|
||||
expect(item.querySelector('.red-days-list__effective--free')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disables the previous button at minYear', () => {
|
||||
render(<CalendarExceptions {...defaultProps} startYear={2026} minYear={2026} />)
|
||||
expect(screen.getByTestId('red-days-prev')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('disables the next button when the window reaches maxYear', () => {
|
||||
render(<CalendarExceptions {...defaultProps} startYear={2032} maxYear={2035} />)
|
||||
expect(screen.getByTestId('red-days-next')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('enables both buttons in the middle of the range', () => {
|
||||
render(<CalendarExceptions {...defaultProps} startYear={2029} minYear={2026} maxYear={2035} />)
|
||||
expect(screen.getByTestId('red-days-prev')).not.toBeDisabled()
|
||||
expect(screen.getByTestId('red-days-next')).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('calls onPrevWindow / onNextWindow when the pagination buttons are clicked', () => {
|
||||
const onPrevWindow = vi.fn()
|
||||
const onNextWindow = vi.fn()
|
||||
render(
|
||||
<CalendarExceptions
|
||||
{...defaultProps}
|
||||
startYear={2029}
|
||||
minYear={2026}
|
||||
maxYear={2035}
|
||||
onPrevWindow={onPrevWindow}
|
||||
onNextWindow={onNextWindow}
|
||||
/>,
|
||||
)
|
||||
screen.getByTestId('red-days-prev').click()
|
||||
screen.getByTestId('red-days-next').click()
|
||||
expect(onPrevWindow).toHaveBeenCalledTimes(1)
|
||||
expect(onNextWindow).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('uses the actual endYear from the backend instead of startYear + years - 1 when the window was clamped', () => {
|
||||
render(<CalendarExceptions {...defaultProps} startYear={2034} endYear={2035} minYear={2026} maxYear={2035} />)
|
||||
expect(screen.getByTestId('red-days-range')).toHaveTextContent('2034–2035')
|
||||
expect(screen.queryByTestId('red-days-year-2036')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('red-days-year-2037')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a loading message and hides year groups while loading', () => {
|
||||
render(<CalendarExceptions {...defaultProps} isLoading={true} />)
|
||||
expect(screen.getByText(/Laddar röda dagar/)).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('red-days-year-2026')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows an error message and hides year groups on error', () => {
|
||||
render(<CalendarExceptions {...defaultProps} error="Something broke" />)
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Something broke')
|
||||
expect(screen.queryByTestId('red-days-year-2026')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('toggling a day (§Task8)', () => {
|
||||
it('clicking a statutory red day upserts an override making that exact date a working day', async () => {
|
||||
const onUpsertOverride = vi.fn().mockResolvedValue(undefined)
|
||||
render(<CalendarExceptions {...defaultProps} onUpsertOverride={onUpsertOverride} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('red-days-toggle-2026-01-01'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUpsertOverride).toHaveBeenCalledWith({
|
||||
workspaceId: 'ws-1',
|
||||
date: '2026-01-01',
|
||||
isWorkingDay: true,
|
||||
note: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('clicking an overridden day back to its statutory value removes the override by id', async () => {
|
||||
const onRemoveOverride = vi.fn().mockResolvedValue(undefined)
|
||||
render(<CalendarExceptions {...defaultProps} onRemoveOverride={onRemoveOverride} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('red-days-toggle-2026-12-24'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onRemoveOverride).toHaveBeenCalledWith('do-2026-12-24')
|
||||
})
|
||||
})
|
||||
|
||||
it('only touches the exact date clicked, not the same month/day in another year', async () => {
|
||||
const sameMonthDayAcrossYears: CalendarDayEntry[] = [
|
||||
{
|
||||
date: '2026-01-01',
|
||||
name: 'Nyårsdagen',
|
||||
dayType: 'red_day',
|
||||
isOverride: false,
|
||||
overrideIsWorkingDay: null,
|
||||
isFree: true,
|
||||
},
|
||||
{
|
||||
date: '2027-01-01',
|
||||
name: 'Nyårsdagen',
|
||||
dayType: 'red_day',
|
||||
isOverride: false,
|
||||
overrideIsWorkingDay: null,
|
||||
isFree: true,
|
||||
},
|
||||
]
|
||||
const onUpsertOverride = vi.fn().mockResolvedValue(undefined)
|
||||
render(
|
||||
<CalendarExceptions
|
||||
{...defaultProps}
|
||||
days={sameMonthDayAcrossYears}
|
||||
overrides={[]}
|
||||
onUpsertOverride={onUpsertOverride}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('red-days-toggle-2026-01-01'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUpsertOverride).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(onUpsertOverride).toHaveBeenCalledWith({
|
||||
workspaceId: 'ws-1',
|
||||
date: '2026-01-01',
|
||||
isWorkingDay: true,
|
||||
note: null,
|
||||
})
|
||||
expect(screen.getByTestId('red-days-toggle-2027-01-01')).toHaveTextContent('Ledig')
|
||||
})
|
||||
|
||||
it('disables the clicked button while the request is in flight', async () => {
|
||||
let resolveUpsert: () => void = () => {}
|
||||
const onUpsertOverride = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveUpsert = resolve
|
||||
}),
|
||||
)
|
||||
render(<CalendarExceptions {...defaultProps} onUpsertOverride={onUpsertOverride} />)
|
||||
|
||||
const button = screen.getByTestId('red-days-toggle-2026-01-01')
|
||||
fireEvent.click(button)
|
||||
expect(button).toBeDisabled()
|
||||
|
||||
resolveUpsert()
|
||||
await waitFor(() => expect(button).not.toBeDisabled())
|
||||
})
|
||||
|
||||
it('surfaces a failed toggle instead of swallowing it', async () => {
|
||||
const onUpsertOverride = vi.fn().mockRejectedValue(new Error('network blew up'))
|
||||
render(<CalendarExceptions {...defaultProps} onUpsertOverride={onUpsertOverride} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('red-days-toggle-2026-01-01'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('red-days-toggle-error')).toHaveTextContent('network blew up')
|
||||
})
|
||||
})
|
||||
|
||||
it('is keyboard accessible with an aria-label naming the date and action', () => {
|
||||
render(<CalendarExceptions {...defaultProps} />)
|
||||
const button = screen.getByTestId('red-days-toggle-2026-01-01')
|
||||
expect(button.tagName).toBe('BUTTON')
|
||||
expect(button).toHaveAccessibleName(/2026|januari/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('add-override form (ported from DayOverridesInput)', () => {
|
||||
it('renders the form with all fields', () => {
|
||||
render(<CalendarExceptions {...defaultProps} />)
|
||||
expect(screen.getByTestId('override-date-input')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('override-working-radio')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('override-off-radio')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('override-note-input')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('add-override-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onUpsertOverride with correct payload when working day is selected', async () => {
|
||||
const onUpsertOverride = vi.fn().mockResolvedValue(undefined)
|
||||
render(<CalendarExceptions {...defaultProps} onUpsertOverride={onUpsertOverride} />)
|
||||
|
||||
fireEvent.change(screen.getByTestId('override-date-input'), { target: { value: '2026-07-01' } })
|
||||
fireEvent.click(screen.getByTestId('override-working-radio'))
|
||||
fireEvent.change(screen.getByTestId('override-note-input'), { target: { value: 'Jobbar denna dag' } })
|
||||
fireEvent.click(screen.getByTestId('add-override-button'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUpsertOverride).toHaveBeenCalledWith({
|
||||
workspaceId: 'ws-1',
|
||||
date: '2026-07-01',
|
||||
isWorkingDay: true,
|
||||
note: 'Jobbar denna dag',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('calls onUpsertOverride with correct payload when off day is selected', async () => {
|
||||
const onUpsertOverride = vi.fn().mockResolvedValue(undefined)
|
||||
render(<CalendarExceptions {...defaultProps} onUpsertOverride={onUpsertOverride} />)
|
||||
|
||||
fireEvent.change(screen.getByTestId('override-date-input'), { target: { value: '2026-06-20' } })
|
||||
fireEvent.click(screen.getByTestId('override-off-radio'))
|
||||
fireEvent.click(screen.getByTestId('add-override-button'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUpsertOverride).toHaveBeenCalledWith({
|
||||
workspaceId: 'ws-1',
|
||||
date: '2026-06-20',
|
||||
isWorkingDay: false,
|
||||
note: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('clears the form after a successful add', async () => {
|
||||
render(<CalendarExceptions {...defaultProps} />)
|
||||
|
||||
const dateInput = screen.getByTestId('override-date-input') as HTMLInputElement
|
||||
const noteInput = screen.getByTestId('override-note-input') as HTMLInputElement
|
||||
|
||||
fireEvent.change(dateInput, { target: { value: '2026-07-01' } })
|
||||
fireEvent.change(noteInput, { target: { value: 'Test note' } })
|
||||
fireEvent.click(screen.getByTestId('add-override-button'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(dateInput.value).toBe('')
|
||||
expect(noteInput.value).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
it('disables submit when date is empty', () => {
|
||||
render(<CalendarExceptions {...defaultProps} />)
|
||||
expect(screen.getByTestId('add-override-button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('does not call onUpsertOverride when the form is submitted with no date entered', async () => {
|
||||
const onUpsertOverride = vi.fn().mockResolvedValue(undefined)
|
||||
render(<CalendarExceptions {...defaultProps} onUpsertOverride={onUpsertOverride} />)
|
||||
|
||||
// Submit the form directly (bypassing the disabled submit button) to
|
||||
// exercise the handler's own empty-date guard.
|
||||
fireEvent.submit(screen.getByTestId('add-override-form'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUpsertOverride).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('disables the form while the calendar window is loading', () => {
|
||||
render(<CalendarExceptions {...defaultProps} isLoading={true} />)
|
||||
expect((screen.getByTestId('override-date-input') as HTMLInputElement).disabled).toBe(true)
|
||||
expect((screen.getByTestId('add-override-button') as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* Settings' "Undantag & röda dagar" panel — merges what used to be two
|
||||
* separate sections/components:
|
||||
*
|
||||
* - `RedDaysList` (deleted): the per-year list of statutory red days *and*
|
||||
* workspace overrides (any date, not just red days — see the filter
|
||||
* below), four years at a time, each with a toggle that upserts/removes
|
||||
* an override for that exact date (§Task8/§3.8).
|
||||
* - `DayOverridesInput` (deleted): its own separate list of overrides (with
|
||||
* a delete button) was dropped as redundant — the year list above already
|
||||
* shows every override, red day or not, with a toggle that reverts it
|
||||
* (functionally the same as "delete"). Only its inline "add a new
|
||||
* exception on an arbitrary date" FORM survives, since the year list has
|
||||
* no date picker of its own.
|
||||
*/
|
||||
import { ReactNode, useMemo, useState } from 'react'
|
||||
import type { CalendarDayEntry, DayOverride, DayOverrideCreateRequest } from '@/types'
|
||||
import { formatDateForDisplay, formatHolidayName } from '@/utils/dates'
|
||||
import { useTranslation } from '@/i18n'
|
||||
import './calendar-exceptions.css'
|
||||
|
||||
export interface CalendarExceptionsProps {
|
||||
/** All days for the current window (any day_type); filtered to red days + overrides. */
|
||||
days: CalendarDayEntry[]
|
||||
startYear: number
|
||||
years?: number
|
||||
/** The window's ACTUAL end year (`CalendarWindow.endYear`) — see `RedDaysList`'s
|
||||
* original doc comment for why this can't be derived as `startYear + years - 1`. */
|
||||
endYear?: number | undefined
|
||||
minYear: number | null
|
||||
maxYear: number | null
|
||||
isLoading?: boolean
|
||||
error?: string | null
|
||||
onPrevWindow: () => void
|
||||
onNextWindow: () => void
|
||||
/** The workspace's own day overrides — not scoped to the visible window. */
|
||||
overrides: DayOverride[]
|
||||
workspaceId: string
|
||||
onUpsertOverride: (req: DayOverrideCreateRequest) => Promise<void>
|
||||
onRemoveOverride: (id: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function CalendarExceptions({
|
||||
days,
|
||||
startYear,
|
||||
years = 4,
|
||||
endYear: endYearProp,
|
||||
minYear,
|
||||
maxYear,
|
||||
isLoading = false,
|
||||
error = null,
|
||||
onPrevWindow,
|
||||
onNextWindow,
|
||||
overrides,
|
||||
workspaceId,
|
||||
onUpsertOverride,
|
||||
onRemoveOverride,
|
||||
}: CalendarExceptionsProps): ReactNode {
|
||||
const { t, locale } = useTranslation()
|
||||
const endYear = endYearProp ?? startYear + years - 1
|
||||
const [pendingDate, setPendingDate] = useState<string | null>(null)
|
||||
const [toggleError, setToggleError] = useState<string | null>(null)
|
||||
|
||||
// Form state (ported from `DayOverridesInput`).
|
||||
const [date, setDate] = useState('')
|
||||
const [isWorkingDay, setIsWorkingDay] = useState(true)
|
||||
const [note, setNote] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const overrideByDate = useMemo(() => {
|
||||
const map = new Map<string, DayOverride>()
|
||||
for (const override of overrides) {
|
||||
map.set(override.date, override)
|
||||
}
|
||||
return map
|
||||
}, [overrides])
|
||||
|
||||
const handleToggle = async (day: CalendarDayEntry) => {
|
||||
setToggleError(null)
|
||||
setPendingDate(day.date)
|
||||
try {
|
||||
if (day.isOverride) {
|
||||
// Any existing override, in either direction, reverts to the
|
||||
// statutory value on click — there is no third state.
|
||||
const existing = overrideByDate.get(day.date)
|
||||
if (!existing) {
|
||||
setToggleError(t('redDaysList.toggleError'))
|
||||
return
|
||||
}
|
||||
await onRemoveOverride(existing.id)
|
||||
} else {
|
||||
// Only statutory red days (always effectively free) reach this
|
||||
// branch — see the year-group filter below — so the flip is always
|
||||
// "make this one date a working day".
|
||||
await onUpsertOverride({ workspaceId, date: day.date, isWorkingDay: true, note: null })
|
||||
}
|
||||
} catch (err) {
|
||||
setToggleError(err instanceof Error ? err.message : t('redDaysList.toggleError'))
|
||||
} finally {
|
||||
setPendingDate(null)
|
||||
}
|
||||
}
|
||||
|
||||
const byYear = useMemo(() => {
|
||||
const map = new Map<number, CalendarDayEntry[]>()
|
||||
for (let y = startYear; y <= endYear; y++) {
|
||||
map.set(y, [])
|
||||
}
|
||||
for (const day of days) {
|
||||
if (day.dayType !== 'red_day' && !day.isOverride) continue
|
||||
const year = Number(day.date.slice(0, 4))
|
||||
const list = map.get(year)
|
||||
if (list) list.push(day)
|
||||
}
|
||||
for (const list of map.values()) {
|
||||
list.sort((a, b) => a.date.localeCompare(b.date))
|
||||
}
|
||||
return map
|
||||
}, [days, startYear, endYear])
|
||||
|
||||
const prevDisabled = minYear !== null && startYear <= minYear
|
||||
const nextDisabled = maxYear !== null && endYear >= maxYear
|
||||
|
||||
const handleAddOverride = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!date) return
|
||||
try {
|
||||
setIsSubmitting(true)
|
||||
await onUpsertOverride({ workspaceId, date, isWorkingDay, note: note || null })
|
||||
setDate('')
|
||||
setIsWorkingDay(true)
|
||||
setNote('')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="calendar-exceptions" data-testid="calendar-exceptions">
|
||||
<div className="red-days-list" data-testid="red-days-list">
|
||||
<p className="calendar-exceptions__help">{t('settings.redDaysHelp')}</p>
|
||||
|
||||
<div className="red-days-list__pagination">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPrevWindow}
|
||||
disabled={prevDisabled}
|
||||
data-testid="red-days-prev"
|
||||
className="btn btn--secondary btn--sm"
|
||||
>
|
||||
{t('redDaysList.prevWindow')}
|
||||
</button>
|
||||
<span className="red-days-list__range" data-testid="red-days-range">
|
||||
{startYear}–{endYear}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNextWindow}
|
||||
disabled={nextDisabled}
|
||||
data-testid="red-days-next"
|
||||
className="btn btn--secondary btn--sm"
|
||||
>
|
||||
{t('redDaysList.nextWindow')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading && <p className="red-days-list__loading">{t('redDaysList.loading')}</p>}
|
||||
{error && (
|
||||
<p className="red-days-list__error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{toggleError && (
|
||||
<p className="red-days-list__toggle-error" role="alert" data-testid="red-days-toggle-error">
|
||||
{toggleError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isLoading &&
|
||||
!error &&
|
||||
Array.from(byYear.entries()).map(([year, items]) => (
|
||||
<div key={year} className="red-days-list__year" data-testid={`red-days-year-${year}`}>
|
||||
<h3 className="red-days-list__year-title">{year}</h3>
|
||||
{items.length === 0 ? (
|
||||
<p className="red-days-list__empty">{t('redDaysList.emptyYear')}</p>
|
||||
) : (
|
||||
<ul className="red-days-list__items">
|
||||
{items.map((day) => {
|
||||
const override = overrideByDate.get(day.date)
|
||||
return (
|
||||
<li key={day.date} className="red-days-list__item" data-testid={`red-days-item-${day.date}`}>
|
||||
<span className="red-days-list__date">{formatDateForDisplay(day.date, locale)}</span>
|
||||
{day.dayType === 'red_day' && day.name && (
|
||||
<span className="red-days-list__name">{formatHolidayName(day.name)}</span>
|
||||
)}
|
||||
{day.isOverride && (
|
||||
<span
|
||||
className={`badge badge--${day.overrideIsWorkingDay ? 'danger' : 'accent'} red-days-list__override-badge red-days-list__override-badge--${day.overrideIsWorkingDay ? 'working' : 'free'}`}
|
||||
data-testid={`red-days-override-badge-${day.date}`}
|
||||
>
|
||||
{day.overrideIsWorkingDay ? t('redDaysList.overrideWorking') : t('redDaysList.overrideFree')}
|
||||
</span>
|
||||
)}
|
||||
{override?.note && (
|
||||
<span className="red-days-list__note">{t('dayOverridesInput.noteLabel', { note: override.note })}</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={`red-days-list__effective red-days-list__effective--${day.isFree ? 'free' : 'working'}`}
|
||||
onClick={() => handleToggle(day)}
|
||||
disabled={pendingDate === day.date}
|
||||
aria-label={
|
||||
day.isFree
|
||||
? t('redDaysList.toggleToWorkingAria', { date: formatDateForDisplay(day.date) })
|
||||
: t('redDaysList.toggleToFreeAria', { date: formatDateForDisplay(day.date) })
|
||||
}
|
||||
data-testid={`red-days-toggle-${day.date}`}
|
||||
>
|
||||
{day.isFree ? t('redDaysList.effectiveFree') : t('redDaysList.effectiveWorking')}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="day-overrides-input" data-testid="day-overrides-input">
|
||||
<p className="calendar-exceptions__help">{t('settings.dayOverridesHelp')}</p>
|
||||
<form onSubmit={(e) => void handleAddOverride(e)} className="day-overrides-input__form" data-testid="add-override-form">
|
||||
<h3>{t('dayOverridesInput.addSectionTitle')}</h3>
|
||||
|
||||
<div className="field day-overrides-input__form-group">
|
||||
<label htmlFor="override-date" className="field__label">
|
||||
{t('dayOverridesInput.dateLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="override-date"
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
required
|
||||
disabled={isSubmitting || isLoading}
|
||||
data-testid="override-date-input"
|
||||
className="field__control"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="day-overrides-input__form-group">
|
||||
<fieldset className="day-overrides-input__radio-group">
|
||||
<legend>{t('dayOverridesInput.statusLegend')}</legend>
|
||||
<label className="day-overrides-input__radio-label">
|
||||
<input
|
||||
type="radio"
|
||||
name="status"
|
||||
value="working"
|
||||
checked={isWorkingDay}
|
||||
onChange={() => setIsWorkingDay(true)}
|
||||
disabled={isSubmitting || isLoading}
|
||||
data-testid="override-working-radio"
|
||||
/>
|
||||
<span>{t('dayOverridesInput.workingStatus')}</span>
|
||||
</label>
|
||||
<label className="day-overrides-input__radio-label">
|
||||
<input
|
||||
type="radio"
|
||||
name="status"
|
||||
value="off"
|
||||
checked={!isWorkingDay}
|
||||
onChange={() => setIsWorkingDay(false)}
|
||||
disabled={isSubmitting || isLoading}
|
||||
data-testid="override-off-radio"
|
||||
/>
|
||||
<span>{t('dayOverridesInput.offStatus')}</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div className="field day-overrides-input__form-group">
|
||||
<label htmlFor="override-note" className="field__label">
|
||||
{t('dayOverridesInput.noteFieldLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="override-note"
|
||||
type="text"
|
||||
placeholder={t('dayOverridesInput.notePlaceholder')}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
maxLength={280}
|
||||
disabled={isSubmitting || isLoading}
|
||||
data-testid="override-note-input"
|
||||
className="field__control"
|
||||
/>
|
||||
<small className="day-overrides-input__char-count">{t('dayOverridesInput.charCount', { count: note.length })}</small>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || isLoading || !date}
|
||||
className="btn btn--primary"
|
||||
data-testid="add-override-button"
|
||||
>
|
||||
{isSubmitting ? t('dayOverridesInput.addingButton') : t('dayOverridesInput.addButton')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/* CalendarExceptions — merges the old RedDaysList (per-year red days +
|
||||
* overrides) with DayOverridesInput's add-a-date form. Most row/list/form
|
||||
* styling is already carried over unchanged from components.css under the
|
||||
* same `.red-days-list__*` / `.day-overrides-input__*` class names (both
|
||||
* components used them before being folded into this one); this file only
|
||||
* adds what's new here — spacing between the two merged blocks and the two
|
||||
* override-badge/effective-state variants the old components never needed
|
||||
* together in the same list. */
|
||||
.calendar-exceptions__help {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--c-text-muted);
|
||||
margin: 0 0 var(--sp-4) 0;
|
||||
max-width: 60ch;
|
||||
}
|
||||
|
||||
.calendar-exceptions .day-overrides-input {
|
||||
margin-top: var(--sp-6);
|
||||
padding-top: var(--sp-5);
|
||||
border-top: 1px solid var(--c-border);
|
||||
}
|
||||
|
||||
.red-days-list__note {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
|
||||
.red-days-list__override-badge--working {
|
||||
background-color: var(--c-danger-weak);
|
||||
color: var(--c-danger);
|
||||
}
|
||||
|
||||
.red-days-list__override-badge--free {
|
||||
background-color: var(--c-accent-weak);
|
||||
color: var(--c-accent);
|
||||
}
|
||||
|
||||
.red-days-list__effective--free {
|
||||
background-color: var(--c-accent-weak);
|
||||
color: var(--c-accent);
|
||||
border-color: var(--c-accent-weak);
|
||||
}
|
||||
@@ -65,7 +65,10 @@ describe('QRCodeModal', () => {
|
||||
onCancel={mockHandlers.onCancel}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText(/Autentiserar/)).toBeInTheDocument()
|
||||
// The status row above ("Status: Autentiserar…") and this loading
|
||||
// message happen to share the same translated word for this status —
|
||||
// assert at least one match rather than requiring exactly one.
|
||||
expect(screen.getAllByText(/Autentiserar/).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('displays loading text during fetching', () => {
|
||||
@@ -94,7 +97,9 @@ describe('QRCodeModal', () => {
|
||||
onCancel={mockHandlers.onCancel}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText(/Klar/)).toBeInTheDocument()
|
||||
// The status row ("Status: Klar") and the success message both contain
|
||||
// "Klar" — assert at least one match rather than requiring exactly one.
|
||||
expect(screen.getAllByText(/Klar/).length).toBeGreaterThan(0)
|
||||
expect(screen.getByRole('button', { name: 'Stäng' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -217,7 +222,33 @@ describe('QRCodeModal', () => {
|
||||
onCancel={mockHandlers.onCancel}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText(/fetching/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/In progress/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('translated statuses', () => {
|
||||
it.each([
|
||||
['launching', 'Startar…'],
|
||||
['awaiting_scan', 'Väntar på skanning'],
|
||||
['authenticating', 'Autentiserar…'],
|
||||
['fetching', 'Hämtar…'],
|
||||
['done', 'Klar'],
|
||||
['error', 'Fel'],
|
||||
['cancelled', 'Avbruten'],
|
||||
] as const)('renders the status enum %s via its translated label', (status, expected) => {
|
||||
render(
|
||||
<QRCodeModal
|
||||
isOpen={true}
|
||||
onClose={mockHandlers.onClose}
|
||||
onDone={mockHandlers.onDone}
|
||||
status={status}
|
||||
result={status === 'done' ? { data: 'fetched' } : undefined}
|
||||
isRunning={status !== 'done' && status !== 'error' && status !== 'cancelled'}
|
||||
onCancel={mockHandlers.onCancel}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByTestId('qrcode-modal-status')).toHaveTextContent(expected)
|
||||
// Never the raw, untranslated enum value.
|
||||
expect(screen.getByTestId('qrcode-modal-status')).not.toHaveTextContent(status)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
import { ReactNode } from 'react'
|
||||
import type { ScraperStatus } from '@/hooks/useScraperFetch'
|
||||
import { useTranslation } from '@/i18n'
|
||||
import { useTranslation, type TranslationKey } from '@/i18n'
|
||||
import { Icon } from '@/components/common/Icon'
|
||||
import './qrcode-modal.css'
|
||||
|
||||
// Every `ScraperStatus` maps to a translated `balance.status.*` label — the
|
||||
// raw enum value (`launching`, `awaiting_scan`, ...) used to be rendered
|
||||
// as-is, which is fine in English but reads as a bug in Swedish.
|
||||
const STATUS_LABEL_KEYS: Record<ScraperStatus, TranslationKey> = {
|
||||
idle: 'balance.status.idle',
|
||||
launching: 'balance.status.launching',
|
||||
awaiting_scan: 'balance.status.awaiting_scan',
|
||||
authenticating: 'balance.status.authenticating',
|
||||
fetching: 'balance.status.fetching',
|
||||
done: 'balance.status.done',
|
||||
error: 'balance.status.error',
|
||||
cancelled: 'balance.status.cancelled',
|
||||
}
|
||||
|
||||
interface QRCodeModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
@@ -60,8 +75,9 @@ export function QRCodeModal({
|
||||
className={`qrcode-modal__status-value qrcode-modal__status-value--${
|
||||
status === 'done' ? 'success' : status === 'error' ? 'error' : 'pending'
|
||||
}`}
|
||||
data-testid="qrcode-modal-status"
|
||||
>
|
||||
{status}
|
||||
{t(STATUS_LABEL_KEYS[status])}
|
||||
</span>
|
||||
{message && <span className="qrcode-modal__status-message">— {message}</span>}
|
||||
</div>
|
||||
@@ -79,12 +95,15 @@ export function QRCodeModal({
|
||||
|
||||
{(status === 'authenticating' || status === 'fetching') && (
|
||||
<p className="qrcode-modal__loading-message">
|
||||
⏳ {status === 'authenticating' ? t('qrModal.authenticating') : t('qrModal.fetching')}
|
||||
<Icon name="clock" />{' '}
|
||||
{status === 'authenticating' ? t('qrModal.authenticating') : t('qrModal.fetching')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status === 'done' && result && (
|
||||
<p className="qrcode-modal__success-message">✅ {t('qrModal.success')}</p>
|
||||
<p className="qrcode-modal__success-message">
|
||||
<Icon name="check" /> {t('qrModal.success')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status === 'cancelled' && (
|
||||
|
||||
@@ -1,15 +1,30 @@
|
||||
/**
|
||||
* Editable multi-year vacation days table.
|
||||
*
|
||||
* The planning engine budgets vacation time per calendar year, so this table
|
||||
* shows one editable row per year (current year through the year the selected
|
||||
* child turns 4, at minimum) rather than a single number. Each row also carries
|
||||
* `savedDaysIn` — days carried over from previous years (Swedish rules allow
|
||||
* saving days above 20 for up to 5 years).
|
||||
* The planning engine budgets vacation time per calendar year, so this shows
|
||||
* one editable row per year (current year through the year the selected
|
||||
* child turns 4, at minimum) rather than a single number. Each row also
|
||||
* carries `savedDaysIn` — days carried over from previous years (Swedish
|
||||
* rules allow saving days above 20 for up to 5 years).
|
||||
*
|
||||
* Rows are a wrapping flex group, not a `<table>` — a real table's columns
|
||||
* can't reflow at 390px without either a fixed-width horizontal scroll
|
||||
* region or a `@media` breakpoint (this file may use neither: breakpoints
|
||||
* live only in `responsive.css`, see `src/styles/styles.test.ts`). Wrapping
|
||||
* flex fields shrink and drop to a second line instead of forcing the page
|
||||
* to scroll sideways.
|
||||
*
|
||||
* Its CSS uses a `vacation-rows__*` prefix, not `vacation-days-table__*` —
|
||||
* the old `<table>`-based version's fixed-width rules of that name still
|
||||
* live in `src/styles/components.css` (owned by another workstream here;
|
||||
* not editable in this task) and would otherwise silently re-apply their
|
||||
* fixed 90px input width to this markup, since the class names would
|
||||
* collide even though the DOM shape changed.
|
||||
*/
|
||||
|
||||
import { ReactNode, useEffect, useState } from 'react'
|
||||
import { useTranslation } from '@/i18n'
|
||||
import './vacation-rows.css'
|
||||
|
||||
export interface VacationDaysTableRow {
|
||||
year: number
|
||||
@@ -52,75 +67,68 @@ export function VacationDaysTable({ rows, onSave, isSaving = false }: VacationDa
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return <p className="vacation-days-table__empty">{t('vacationDaysTable.empty')}</p>
|
||||
return <p className="vacation-rows__empty">{t('vacationDaysTable.empty')}</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<table className="vacation-days-table" data-testid="vacation-days-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('vacationDaysTable.yearHeader')}</th>
|
||||
<th>{t('vacationDaysTable.vacationDaysHeader')}</th>
|
||||
<th>{t('vacationDaysTable.savedDaysHeader')}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => {
|
||||
const draft = drafts[row.year] ?? { totalDays: row.totalDays, savedDaysIn: row.savedDaysIn }
|
||||
return (
|
||||
<tr key={row.year} data-testid={`vacation-year-row-${row.year}`}>
|
||||
<td className="vacation-days-table__year">
|
||||
{row.year}
|
||||
{row.isAssumed && (
|
||||
<span
|
||||
className="badge badge--warning vacation-days-table__assumed-badge"
|
||||
data-testid={`vacation-assumed-badge-${row.year}`}
|
||||
title={t('vacationDaysTable.assumedPrompt')}
|
||||
>
|
||||
{t('vacationDaysTable.assumedBadge')}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
max={365}
|
||||
value={draft.totalDays}
|
||||
onChange={(e) => updateDraft(row.year, 'totalDays', Number(e.target.value))}
|
||||
data-testid={`vacation-total-days-${row.year}`}
|
||||
className="vacation-days-table__input"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
max={365}
|
||||
value={draft.savedDaysIn}
|
||||
onChange={(e) => updateDraft(row.year, 'savedDaysIn', Number(e.target.value))}
|
||||
data-testid={`vacation-saved-days-${row.year}`}
|
||||
className="vacation-days-table__input"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSave(row.year, draft.totalDays, draft.savedDaysIn)}
|
||||
disabled={isSaving}
|
||||
data-testid={`vacation-save-${row.year}`}
|
||||
className="btn btn--secondary btn--sm vacation-days-table__save"
|
||||
<div className="vacation-rows" data-testid="vacation-days-table">
|
||||
{rows.map((row) => {
|
||||
const draft = drafts[row.year] ?? { totalDays: row.totalDays, savedDaysIn: row.savedDaysIn }
|
||||
return (
|
||||
<div key={row.year} className="vacation-rows__row" data-testid={`vacation-year-row-${row.year}`}>
|
||||
<div className="vacation-rows__year">
|
||||
{row.year}
|
||||
{row.isAssumed && (
|
||||
<span
|
||||
className="badge badge--warning vacation-rows__assumed-badge"
|
||||
data-testid={`vacation-assumed-badge-${row.year}`}
|
||||
title={t('vacationDaysTable.assumedPrompt')}
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{t('vacationDaysTable.assumedBadge')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="field vacation-rows__field">
|
||||
<span className="field__label">{t('vacationDaysTable.vacationDaysHeader')}</span>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
max={365}
|
||||
value={draft.totalDays}
|
||||
onChange={(e) => updateDraft(row.year, 'totalDays', Number(e.target.value))}
|
||||
data-testid={`vacation-total-days-${row.year}`}
|
||||
className="field__control vacation-rows__input"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="field vacation-rows__field">
|
||||
<span className="field__label">{t('vacationDaysTable.savedDaysHeader')}</span>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
max={365}
|
||||
value={draft.savedDaysIn}
|
||||
onChange={(e) => updateDraft(row.year, 'savedDaysIn', Number(e.target.value))}
|
||||
data-testid={`vacation-saved-days-${row.year}`}
|
||||
className="field__control vacation-rows__input"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSave(row.year, draft.totalDays, draft.savedDaysIn)}
|
||||
disabled={isSaving}
|
||||
data-testid={`vacation-save-${row.year}`}
|
||||
className="btn btn--secondary btn--sm vacation-rows__save"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/* VacationDaysTable — a wrapping flex group of rows, not a `<table>`, so a
|
||||
* row's fields drop to a second line at 390px instead of forcing the page
|
||||
* to scroll sideways (the old `<table>` version couldn't shrink its two
|
||||
* fixed-width number inputs + Save button below ~400px). One hairline
|
||||
* between rows, no boxed table chrome.
|
||||
*
|
||||
* Class names deliberately use a `vacation-rows__*` prefix rather than
|
||||
* `vacation-days-table__*` — the latter still has fixed-width rules for
|
||||
* this same component's old `<table>` markup in `src/styles/components.css`
|
||||
* (out of scope here) which would otherwise collide with these. */
|
||||
.vacation-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.vacation-rows__row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: var(--sp-2) var(--sp-4);
|
||||
padding: var(--sp-3) 0;
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
}
|
||||
|
||||
.vacation-rows__row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.vacation-rows__year {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
min-width: 56px;
|
||||
padding-bottom: var(--sp-2);
|
||||
font-weight: 600;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.vacation-rows__field {
|
||||
flex: 1 1 110px;
|
||||
min-width: 90px;
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
.vacation-rows__input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.vacation-rows__save {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.vacation-rows__assumed-badge {
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.vacation-rows__empty {
|
||||
color: var(--c-text-muted);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
@@ -14,19 +14,20 @@ describe('useLatestBalance', () => {
|
||||
it('initializes with no saved balance and does not fetch when personalDataUuid or childId is null', () => {
|
||||
const { result } = renderHook(() => useLatestBalance(null, null))
|
||||
expect(result.current.initial).toBeUndefined()
|
||||
expect(result.current.raw).toBeNull()
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
expect(result.current.error).toBeNull()
|
||||
expect(api.getParentalLeave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("maps the latest snapshot's tier data into the form's initial values", async () => {
|
||||
vi.mocked(api.getParentalLeave).mockResolvedValue(
|
||||
makeParentalLeaveFetch({
|
||||
dubbeldagarLeft: 12,
|
||||
reservedDaysLeft: 34,
|
||||
tierData: makeTierData({ sjukpenningLeft: 111, lagstaNivaLeft: 22 }),
|
||||
}),
|
||||
)
|
||||
it("maps the latest snapshot's tier data into the form's initial values, and exposes the full record as raw", async () => {
|
||||
const fetched = makeParentalLeaveFetch({
|
||||
dubbeldagarLeft: 12,
|
||||
reservedDaysLeft: 34,
|
||||
source: 'scraped',
|
||||
tierData: makeTierData({ sjukpenningLeft: 111, lagstaNivaLeft: 22 }),
|
||||
})
|
||||
vi.mocked(api.getParentalLeave).mockResolvedValue(fetched)
|
||||
|
||||
const { result } = renderHook(() => useLatestBalance('pd-1', 'child-1'))
|
||||
|
||||
@@ -41,6 +42,7 @@ describe('useLatestBalance', () => {
|
||||
dubbeldagarLeft: 12,
|
||||
reservedDaysLeft: 34,
|
||||
})
|
||||
expect(result.current.raw).toEqual(fetched)
|
||||
expect(result.current.error).toBeNull()
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
@@ -55,9 +57,28 @@ describe('useLatestBalance', () => {
|
||||
})
|
||||
|
||||
expect(result.current.initial).toBeUndefined()
|
||||
expect(result.current.raw).toBeNull()
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('refetches on demand via refresh, picking up a newly saved record', async () => {
|
||||
vi.mocked(api.getParentalLeave).mockResolvedValueOnce(null)
|
||||
const { result } = renderHook(() => useLatestBalance('pd-1', 'child-1'))
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
})
|
||||
expect(result.current.raw).toBeNull()
|
||||
|
||||
const fetched = makeParentalLeaveFetch({ tierData: makeTierData({ sjukpenningLeft: 200 }) })
|
||||
vi.mocked(api.getParentalLeave).mockResolvedValueOnce(fetched)
|
||||
await act(async () => {
|
||||
await result.current.refresh()
|
||||
})
|
||||
|
||||
expect(api.getParentalLeave).toHaveBeenCalledTimes(2)
|
||||
expect(result.current.raw).toEqual(fetched)
|
||||
})
|
||||
|
||||
it('sets an error and no initial value when the fetch fails', async () => {
|
||||
vi.mocked(api.getParentalLeave).mockRejectedValue(new Error('boom'))
|
||||
|
||||
|
||||
@@ -2,11 +2,21 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
import * as api from '@/services/api'
|
||||
import { useLatestRequest } from './useLatestRequest'
|
||||
import type { ParentBalanceInitial } from '@/components/modals/ParentBalanceForm'
|
||||
import type { ParentalLeaveFetch } from '@/types'
|
||||
|
||||
interface UseLatestBalanceResult {
|
||||
initial: ParentBalanceInitial | undefined
|
||||
/** The full saved record — `source`/`fetchedAt`/`daysLeft`/`totalDays`
|
||||
* that `initial` (deliberately scoped to just `ParentBalanceForm`'s
|
||||
* prefill fields) doesn't carry. `null` while loading or when nothing has
|
||||
* been saved yet; consumers that show a status line (`BalanceFlow`'s
|
||||
* per-member row) read this instead of `initial`. */
|
||||
raw: ParentalLeaveFetch | null
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
/** Re-runs the fetch — used after an FK scrape completes so a row already
|
||||
* on screen picks up the newly persisted balance without a remount. */
|
||||
refresh: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,6 +40,7 @@ export function useLatestBalance(
|
||||
childId: string | null,
|
||||
): UseLatestBalanceResult {
|
||||
const [initial, setInitial] = useState<ParentBalanceInitial | undefined>(undefined)
|
||||
const [raw, setRaw] = useState<ParentalLeaveFetch | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const { start, isCurrent } = useLatestRequest()
|
||||
@@ -38,6 +49,7 @@ export function useLatestBalance(
|
||||
const requestId = start()
|
||||
if (!personalDataUuid || !childId) {
|
||||
setInitial(undefined)
|
||||
setRaw(null)
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
@@ -59,10 +71,12 @@ export function useLatestBalance(
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
setRaw(response)
|
||||
} catch (err) {
|
||||
if (!isCurrent(requestId)) return
|
||||
setError(err instanceof Error ? err.message : 'Failed to load parental leave balance')
|
||||
setInitial(undefined)
|
||||
setRaw(null)
|
||||
} finally {
|
||||
if (isCurrent(requestId)) setIsLoading(false)
|
||||
}
|
||||
@@ -72,5 +86,5 @@ export function useLatestBalance(
|
||||
void fetch()
|
||||
}, [fetch])
|
||||
|
||||
return { initial, isLoading, error }
|
||||
return { initial, raw, isLoading, error, refresh: fetch }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
export const balance = {
|
||||
'balance.addDeducedPartnerButton': 'Add {name}',
|
||||
'balance.deducedBadge': 'Derived from your FK fetch',
|
||||
'balance.deducedPartnerPrompt': 'Försäkringskassan shows the other parent has {days} days left.',
|
||||
'balance.dismissDeducedPartner': "No, I'm a sole parent",
|
||||
'balance.enterManually': 'Enter myself',
|
||||
'balance.fetchFromFk': 'Fetch from Försäkringskassan',
|
||||
'balance.loading': 'Loading balance…',
|
||||
'balance.pickerPrompt': 'Whose balance is this?',
|
||||
'balance.sourceDeduced': 'Derived {date}',
|
||||
'balance.sourceManual': 'Entered manually {date}',
|
||||
'balance.sourceScraped': 'From Försäkringskassan {date}',
|
||||
'balance.status.authenticating': 'Authenticating…',
|
||||
'balance.status.awaiting_scan': 'Awaiting scan',
|
||||
'balance.status.cancelled': 'Cancelled',
|
||||
'balance.status.done': 'Done',
|
||||
'balance.status.error': 'Error',
|
||||
'balance.status.fetching': 'Fetching…',
|
||||
'balance.status.idle': 'Waiting',
|
||||
'balance.status.launching': 'Starting…',
|
||||
'balance.statusLine': '{left} of {total} days left',
|
||||
'balance.statusNone': 'No details yet',
|
||||
'balance.youBadge': '(you)',
|
||||
} as const
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { TranslationKey } from '../sv'
|
||||
import { common } from './common'
|
||||
import { auth } from './auth'
|
||||
import { balance } from './balance'
|
||||
import { calendar } from './calendar'
|
||||
import { dashboard } from './dashboard'
|
||||
import { onboarding } from './onboarding'
|
||||
@@ -25,6 +26,7 @@ import { tips } from './tips'
|
||||
export const en = {
|
||||
...common,
|
||||
...auth,
|
||||
...balance,
|
||||
...calendar,
|
||||
...dashboard,
|
||||
...onboarding,
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
export const onboarding = {
|
||||
'onboarding.addChildButton': 'Add child',
|
||||
'onboarding.addDeducedPartnerButton': 'Add {name}',
|
||||
'onboarding.addingChild': 'Adding...',
|
||||
'onboarding.balanceStatus': '{left} of {total} days left',
|
||||
'onboarding.balanceStatusNone': 'No details yet',
|
||||
'onboarding.birthdayLabel': 'Birthday',
|
||||
'onboarding.childBirthDateRequired': 'Enter the child\'s birth date',
|
||||
'onboarding.childNameLabel': 'Child\'s name',
|
||||
'onboarding.childNameRequired': 'Enter the child\'s name',
|
||||
'onboarding.continueButton': 'Continue',
|
||||
'onboarding.createChildError': 'Could not create child',
|
||||
'onboarding.deducedBadge': 'Derived from your FK fetch',
|
||||
'onboarding.deducedPartnerPrompt': 'Försäkringskassan shows the other parent has {days} days left.',
|
||||
'onboarding.dismissDeducedPartner': 'No, I\'m a sole parent',
|
||||
'onboarding.enterManuallyButton': 'Enter myself',
|
||||
'onboarding.familyCoupleDescription': 'The days are split 240/240 and you can each save 48 days after age 4. The partner doesn\'t need to create an account.',
|
||||
'onboarding.familyCoupleTitle': 'We are two parents',
|
||||
'onboarding.familySoloDescription': 'I have sole custody. All 480 days and 96 saved days after age 4 are mine.',
|
||||
@@ -24,7 +17,6 @@ export const onboarding = {
|
||||
'onboarding.finishError': 'Something went wrong. Please try again.',
|
||||
'onboarding.finishingButton': 'Saving...',
|
||||
'onboarding.namePlaceholder': 'Name',
|
||||
'onboarding.partnerAutoFillHint': 'Filled in automatically if you fetch from FK, or if {name} logs in and fetches.',
|
||||
'onboarding.partnerNameLabel': 'The other parent\'s name',
|
||||
'onboarding.partnerNameRequired': 'Enter the other parent\'s name',
|
||||
'onboarding.stepBalancesIntro': 'You can skip this and fill it in later — we\'ll then assume no days have been used.',
|
||||
@@ -32,5 +24,4 @@ export const onboarding = {
|
||||
'onboarding.stepChildIntro': 'Add your first child to start planning your parental leave.',
|
||||
'onboarding.stepChildTitle': 'Welcome! Let\'s add your child',
|
||||
'onboarding.stepIndicator': 'Step {step} of {total}',
|
||||
'onboarding.youBadge': 'you',
|
||||
} as const
|
||||
|
||||
@@ -7,7 +7,6 @@ export const settings = {
|
||||
'dayOverridesInput.addingButton': 'Adding...',
|
||||
'dayOverridesInput.charCount': '{count}/280',
|
||||
'dayOverridesInput.dateLabel': 'Date',
|
||||
'dayOverridesInput.empty': 'You have no calendar exceptions yet.',
|
||||
'dayOverridesInput.noteFieldLabel': 'Note (optional)',
|
||||
'dayOverridesInput.noteLabel': 'Note: {note}',
|
||||
'dayOverridesInput.notePlaceholder': 'E.g. Worked on Christmas Day',
|
||||
@@ -60,17 +59,16 @@ export const settings = {
|
||||
'settings.addPartnerError': 'Could not add partner',
|
||||
'settings.addPartnerSuccess': 'Partner added!',
|
||||
'settings.addingPartner': 'Adding...',
|
||||
'settings.balanceButton': 'Days left',
|
||||
'settings.calendarSectionTitle': 'Calendar',
|
||||
'settings.balanceSectionTitle': 'Balances',
|
||||
'settings.calendarLanguageSectionTitle': 'Calendar & language',
|
||||
'settings.cancelButton': 'Cancel',
|
||||
'settings.childBirthDateLabel': 'Child\'s birth date',
|
||||
'settings.childSectionTitle': 'Child',
|
||||
'settings.dayOverridesHelp': 'The planner is based on the Swedish calendar (public holidays, bridge days, Christmas Eve, Midsummer Eve and Whitsun Eve are normally days off). Here you can enter exceptions for your workplace — if you have to work on Christmas Day, or are off on days that are otherwise working days.',
|
||||
'settings.dayOverridesSectionTitle': 'Calendar exceptions',
|
||||
'settings.exceptionsSectionTitle': 'Exceptions & red days',
|
||||
'settings.familySectionTitle': 'Child & family',
|
||||
'settings.languageAutoOption': 'Automatic (browser language)',
|
||||
'settings.languageEnglishOption': 'English',
|
||||
'settings.languageLabel': 'Välj språk / Select language',
|
||||
'settings.languageSectionTitle': 'Språk / Language',
|
||||
'settings.languageSwedishOption': 'Svenska',
|
||||
'settings.loading': 'Loading settings...',
|
||||
'settings.membersLoading': 'Loading members…',
|
||||
@@ -87,7 +85,6 @@ export const settings = {
|
||||
'settings.profileHelp': 'Your salary type and employment status are used to estimate your SGI and plan your leave.',
|
||||
'settings.profileSectionTitle': 'Your profile',
|
||||
'settings.redDaysHelp': 'A list of public holidays under the Swedish calendar, together with your own exceptions — four years at a time.',
|
||||
'settings.redDaysSectionTitle': 'Public holidays',
|
||||
'settings.removeBlockerLeaveWishes': 'leave wishes',
|
||||
'settings.removeBlockerParentalLeave': 'recorded parental leave',
|
||||
'settings.removeBlockerPlannedLeave': 'planned leave days',
|
||||
@@ -101,12 +98,9 @@ export const settings = {
|
||||
'settings.removePartnerErrorSelf': 'You can\'t remove yourself from the workspace.',
|
||||
'settings.renameButton': 'Rename',
|
||||
'settings.renamePartnerError': 'Could not rename',
|
||||
'settings.saveChildButton': 'Save child\'s details',
|
||||
'settings.saveChildError': 'Could not save the child\'s details',
|
||||
'settings.saveChildSuccess': 'Child\'s details saved!',
|
||||
'settings.saveProfileButton': 'Save your profile',
|
||||
'settings.saveProfileError': 'Could not save your profile',
|
||||
'settings.saveProfileSuccess': 'Your profile was saved!',
|
||||
'settings.saveFamilyButton': 'Save',
|
||||
'settings.saveFamilyError': 'Could not save the details',
|
||||
'settings.saveFamilySuccess': 'Details saved!',
|
||||
'settings.saveRenameButton': 'Save name',
|
||||
'settings.saveVacationError': 'Could not save vacation days',
|
||||
'settings.saveVacationSuccess': 'Vacation days for {year} saved!',
|
||||
@@ -117,7 +111,6 @@ export const settings = {
|
||||
'settings.takePlOnRedDaysHelp': 'You get paid for Saturdays, Sundays and red days in the plan — but you spend days you would otherwise have kept, since vacation cannot be taken on these days.',
|
||||
'settings.title': 'Settings',
|
||||
'settings.vacationHelp': 'Enter vacation days per year. In the first year you are usually home more and have fewer days to spend on vacation, while later years have your full annual allotment. This controls how much time the planner has to work with.',
|
||||
'settings.vacationSectionTitle': 'Vacation days',
|
||||
'settings.youBadge': '(You)',
|
||||
'vacationDaysList.empty': 'No vacation days registered.',
|
||||
'vacationDaysList.itemDays': '{count} days',
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* The unified balance flow (`BalanceFlow.tsx`) — shared by Settings' "Saldon"
|
||||
* panel and Onboarding's balances step. These keys used to live scattered
|
||||
* across `dashboard.ts` (`fetchFromFk`/`enterManually`/`balancePickerPrompt`)
|
||||
* before Dashboard stopped owning any balance UI of its own (Phase 5 removes
|
||||
* the now-unused originals from `dashboard.ts`) and `onboarding.ts` (per-card
|
||||
* status text), plus the QR modal's status enum, previously rendered raw and
|
||||
* untranslated.
|
||||
*/
|
||||
export const balance = {
|
||||
'balance.addDeducedPartnerButton': 'Lägg till {name}',
|
||||
'balance.deducedBadge': 'Härlett från din FK-hämtning',
|
||||
'balance.deducedPartnerPrompt': 'Försäkringskassan visar att den andra föräldern har {days} dagar kvar.',
|
||||
'balance.dismissDeducedPartner': 'Nej, jag är ensam förälder',
|
||||
'balance.enterManually': 'Ange själv',
|
||||
'balance.fetchFromFk': 'Hämta från Försäkringskassan',
|
||||
'balance.loading': 'Laddar saldo…',
|
||||
'balance.pickerPrompt': 'Vem gäller det?',
|
||||
'balance.sourceDeduced': 'Härlett {date}',
|
||||
'balance.sourceManual': 'Angivet manuellt {date}',
|
||||
'balance.sourceScraped': 'Från Försäkringskassan {date}',
|
||||
'balance.status.authenticating': 'Autentiserar…',
|
||||
'balance.status.awaiting_scan': 'Väntar på skanning',
|
||||
'balance.status.cancelled': 'Avbruten',
|
||||
'balance.status.done': 'Klar',
|
||||
'balance.status.error': 'Fel',
|
||||
'balance.status.fetching': 'Hämtar…',
|
||||
'balance.status.idle': 'Väntar',
|
||||
'balance.status.launching': 'Startar…',
|
||||
'balance.statusLine': '{left} av {total} dagar kvar',
|
||||
'balance.statusNone': 'Inga uppgifter ännu',
|
||||
'balance.youBadge': '(du)',
|
||||
} as const
|
||||
@@ -1,5 +1,6 @@
|
||||
import { common } from './common'
|
||||
import { auth } from './auth'
|
||||
import { balance } from './balance'
|
||||
import { calendar } from './calendar'
|
||||
import { dashboard } from './dashboard'
|
||||
import { onboarding } from './onboarding'
|
||||
@@ -22,6 +23,7 @@ import { tips } from './tips'
|
||||
export const sv = {
|
||||
...common,
|
||||
...auth,
|
||||
...balance,
|
||||
...calendar,
|
||||
...dashboard,
|
||||
...onboarding,
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
export const onboarding = {
|
||||
'onboarding.addChildButton': 'Lägg till barn',
|
||||
'onboarding.addDeducedPartnerButton': 'Lägg till {name}',
|
||||
'onboarding.addingChild': 'Lägger till...',
|
||||
'onboarding.balanceStatus': '{left} av {total} dagar kvar',
|
||||
'onboarding.balanceStatusNone': 'Inga uppgifter ännu',
|
||||
'onboarding.birthdayLabel': 'Födelsedag',
|
||||
'onboarding.childBirthDateRequired': 'Ange barnets födelsedatum',
|
||||
'onboarding.childNameLabel': 'Barnets namn',
|
||||
'onboarding.childNameRequired': 'Ange barnets namn',
|
||||
'onboarding.continueButton': 'Fortsätt',
|
||||
'onboarding.createChildError': 'Kunde inte skapa barn',
|
||||
'onboarding.deducedBadge': 'Härlett från din FK-hämtning',
|
||||
'onboarding.deducedPartnerPrompt': 'Försäkringskassan visar att den andra föräldern har {days} dagar kvar.',
|
||||
'onboarding.dismissDeducedPartner': 'Nej, jag är ensam förälder',
|
||||
'onboarding.enterManuallyButton': 'Ange själv',
|
||||
'onboarding.familyCoupleDescription': 'Dagarna delas 240/240 och ni kan spara 48 dagar var efter 4 år. Partnern behöver inte skapa konto.',
|
||||
'onboarding.familyCoupleTitle': 'Vi är två föräldrar',
|
||||
'onboarding.familySoloDescription': 'Jag har ensam vårdnad. Alla 480 dagar och 96 sparade dagar efter 4 år är mina.',
|
||||
@@ -24,7 +17,6 @@ export const onboarding = {
|
||||
'onboarding.finishError': 'Något gick fel. Försök igen.',
|
||||
'onboarding.finishingButton': 'Sparar...',
|
||||
'onboarding.namePlaceholder': 'Namn',
|
||||
'onboarding.partnerAutoFillHint': 'Fylls i automatiskt om du hämtar från FK, eller om {name} loggar in och hämtar.',
|
||||
'onboarding.partnerNameLabel': 'Den andra förälderns namn',
|
||||
'onboarding.partnerNameRequired': 'Ange den andra förälderns namn',
|
||||
'onboarding.stepBalancesIntro': 'Du kan hoppa över och fylla i senare — vi antar då att inga dagar är uttagna.',
|
||||
@@ -32,5 +24,4 @@ export const onboarding = {
|
||||
'onboarding.stepChildIntro': 'Lägg till ditt första barn för att börja planera föräldraledigheten.',
|
||||
'onboarding.stepChildTitle': 'Välkommen! Låt oss lägga till ditt barn',
|
||||
'onboarding.stepIndicator': 'Steg {step} av {total}',
|
||||
'onboarding.youBadge': 'du',
|
||||
} as const
|
||||
|
||||
@@ -7,7 +7,6 @@ export const settings = {
|
||||
'dayOverridesInput.addingButton': 'Lägger till...',
|
||||
'dayOverridesInput.charCount': '{count}/280',
|
||||
'dayOverridesInput.dateLabel': 'Datum',
|
||||
'dayOverridesInput.empty': 'Du har inga undantag i kalendern ännu.',
|
||||
'dayOverridesInput.noteFieldLabel': 'Anteckning (valfritt)',
|
||||
'dayOverridesInput.noteLabel': 'Anteckning: {note}',
|
||||
'dayOverridesInput.notePlaceholder': 'T.ex. Jobbade på juldagen',
|
||||
@@ -60,17 +59,16 @@ export const settings = {
|
||||
'settings.addPartnerError': 'Kunde inte lägga till partner',
|
||||
'settings.addPartnerSuccess': 'Partner tillagd!',
|
||||
'settings.addingPartner': 'Lägger till...',
|
||||
'settings.balanceButton': 'Dagar kvar',
|
||||
'settings.calendarSectionTitle': 'Kalender',
|
||||
'settings.balanceSectionTitle': 'Saldon',
|
||||
'settings.calendarLanguageSectionTitle': 'Kalender & språk',
|
||||
'settings.cancelButton': 'Avbryt',
|
||||
'settings.childBirthDateLabel': 'Barnets födelsedatum',
|
||||
'settings.childSectionTitle': 'Barn',
|
||||
'settings.dayOverridesHelp': 'Planeraren utgår från den svenska kalendern (röda dagar, klämdagar, julafton, midsommarafton och pingstafton är normalt lediga). Här kan du ange undantag för din arbetsplats — om du måste jobba juldagen eller är ledig på annars arbetande dagar.',
|
||||
'settings.dayOverridesSectionTitle': 'Undantag i kalendern',
|
||||
'settings.exceptionsSectionTitle': 'Undantag & röda dagar',
|
||||
'settings.familySectionTitle': 'Barn & familj',
|
||||
'settings.languageAutoOption': 'Automatisk (webbläsarens språk)',
|
||||
'settings.languageEnglishOption': 'English',
|
||||
'settings.languageLabel': 'Välj språk / Select language',
|
||||
'settings.languageSectionTitle': 'Språk / Language',
|
||||
'settings.languageSwedishOption': 'Svenska',
|
||||
'settings.loading': 'Laddar inställningar...',
|
||||
'settings.membersLoading': 'Laddar medlemmar…',
|
||||
@@ -87,7 +85,6 @@ export const settings = {
|
||||
'settings.profileHelp': 'Din lönetyp och sysselsättning används för att uppskatta din SGI och planera din ledighet.',
|
||||
'settings.profileSectionTitle': 'Din profil',
|
||||
'settings.redDaysHelp': 'En lista över röda dagar enligt den svenska kalendern, tillsammans med dina egna undantag — fyra år i taget.',
|
||||
'settings.redDaysSectionTitle': 'Röda dagar',
|
||||
'settings.removeBlockerLeaveWishes': 'önskemål om ledighet',
|
||||
'settings.removeBlockerParentalLeave': 'registrerad föräldrapenning',
|
||||
'settings.removeBlockerPlannedLeave': 'planerade lediga dagar',
|
||||
@@ -101,12 +98,9 @@ export const settings = {
|
||||
'settings.removePartnerErrorSelf': 'Du kan inte ta bort dig själv från arbetsutrymmet.',
|
||||
'settings.renameButton': 'Byt namn',
|
||||
'settings.renamePartnerError': 'Kunde inte byta namn',
|
||||
'settings.saveChildButton': 'Spara barnuppgifter',
|
||||
'settings.saveChildError': 'Kunde inte spara barnets uppgifter',
|
||||
'settings.saveChildSuccess': 'Barnets uppgifter sparade!',
|
||||
'settings.saveProfileButton': 'Spara din profil',
|
||||
'settings.saveProfileError': 'Kunde inte spara din profil',
|
||||
'settings.saveProfileSuccess': 'Din profil sparades!',
|
||||
'settings.saveFamilyButton': 'Spara',
|
||||
'settings.saveFamilyError': 'Kunde inte spara uppgifterna',
|
||||
'settings.saveFamilySuccess': 'Uppgifterna sparades!',
|
||||
'settings.saveRenameButton': 'Spara namn',
|
||||
'settings.saveVacationError': 'Kunde inte spara semesterdagar',
|
||||
'settings.saveVacationSuccess': 'Semesterdagar för {year} sparade!',
|
||||
@@ -117,7 +111,6 @@ export const settings = {
|
||||
'settings.takePlOnRedDaysHelp': 'Du får betalt för lördagar, söndagar och röda dagar i planeringen — men du förbrukar dagar du annars hade sparat, eftersom semester inte kan tas ut på dessa dagar.',
|
||||
'settings.title': 'Inställningar',
|
||||
'settings.vacationHelp': 'Ange semesterdagar per år. Det första året är du oftast hemma mer och har färre dagar att lägga på semester, medan kommande år har din fulla årliga tilldelning. Detta styr hur mycket tid planeraren har att arbeta med.',
|
||||
'settings.vacationSectionTitle': 'Semesterdagar',
|
||||
'settings.youBadge': '(Du)',
|
||||
'vacationDaysList.empty': 'Inga semesterdagar registrerade.',
|
||||
'vacationDaysList.itemDays': '{count} dagar',
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { Onboarding } from './Onboarding'
|
||||
import * as hooks from '@/hooks'
|
||||
import { useWorkspace, useAuth } from '@/context'
|
||||
import { ApiRequestError } from '@/services/errors'
|
||||
import type { BalanceFlowProps } from '@/components/balance/BalanceFlow'
|
||||
import {
|
||||
makeAuthContextValue,
|
||||
makeWorkspaceContextValue,
|
||||
makeScraperFetchResult,
|
||||
makePollResponse,
|
||||
makePartnerMemberResponse,
|
||||
makeWorkspaceMembersResult,
|
||||
makePersonalData,
|
||||
makePartnerDeduction,
|
||||
makePartnerMemberResponse,
|
||||
} from '../../tests/fixtures'
|
||||
|
||||
vi.mock('@/hooks')
|
||||
@@ -27,24 +25,29 @@ vi.mock('@/components/layout/Layout', () => ({
|
||||
Layout: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
// Step 3 (balances) is `BalanceFlow` in `mode="wizard"` — its own extensive
|
||||
// suite (BalanceFlow.test.tsx) covers FK fetch, manual entry and the
|
||||
// deduced-partner prompt. Onboarding only needs to prove it reaches step 3
|
||||
// and wires the right props through; stub it out rather than duplicating
|
||||
// that coverage here.
|
||||
let lastBalanceFlowProps: BalanceFlowProps | null = null
|
||||
vi.mock('@/components/balance/BalanceFlow', () => ({
|
||||
BalanceFlow: (props: BalanceFlowProps) => {
|
||||
lastBalanceFlowProps = props
|
||||
return <div data-testid="balance-flow-stub" data-mode={props.mode} />
|
||||
},
|
||||
}))
|
||||
|
||||
const renderWithRouter = (component: React.ReactElement) => {
|
||||
return render(<BrowserRouter>{component}</BrowserRouter>)
|
||||
}
|
||||
|
||||
/** Finds the per-parent card rendered for `uuid` in step 3 — several cards
|
||||
* share the same button labels ("Ange själv"), so tests scope their
|
||||
* queries to the right card via `data-parent`. */
|
||||
function getParentCard(uuid: string): HTMLElement {
|
||||
const card = screen.getAllByTestId('onboarding-parent-card').find((el) => el.dataset.parent === uuid)
|
||||
if (!card) throw new Error(`No onboarding-parent-card for ${uuid}`)
|
||||
return card
|
||||
}
|
||||
|
||||
describe('Onboarding', () => {
|
||||
let refreshMembersMock: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
lastBalanceFlowProps = null
|
||||
refreshMembersMock = vi.fn().mockResolvedValue(undefined)
|
||||
vi.mocked(useAuth).mockReturnValue(makeAuthContextValue())
|
||||
vi.mocked(useWorkspace).mockReturnValue(
|
||||
@@ -57,7 +60,6 @@ describe('Onboarding', () => {
|
||||
parentName: 'Test Parent',
|
||||
})
|
||||
)
|
||||
vi.mocked(hooks.useScraperFetch).mockReturnValue(makeScraperFetchResult())
|
||||
vi.mocked(hooks.useWorkspaceMembers).mockReturnValue(
|
||||
makeWorkspaceMembersResult({
|
||||
data: [makePersonalData({ uuid: 'pd-1', name: 'Test Parent' })],
|
||||
@@ -228,7 +230,7 @@ describe('Onboarding', () => {
|
||||
it('adds the partner and continues to the balances step on the couple path', async () => {
|
||||
const user = await advanceToFamilyStep()
|
||||
const api = await import('@/services/api')
|
||||
vi.mocked(api.addPartner).mockResolvedValue(makePartnerMemberResponse({ name: 'Sam' }))
|
||||
vi.mocked(api.addPartner).mockResolvedValue(makePartnerMemberResponse({ uuid: 'pd-2', name: 'Sam' }))
|
||||
|
||||
await user.click(screen.getByTestId('onboarding-family-couple'))
|
||||
await user.type(screen.getByLabelText(/Den andra förälderns namn/), 'Sam')
|
||||
@@ -301,7 +303,7 @@ describe('Onboarding', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('step 3 — per-parent balances', () => {
|
||||
describe('step 3 — balances (BalanceFlow wiring)', () => {
|
||||
async function advanceToBalancesStep(): Promise<ReturnType<typeof userEvent.setup>> {
|
||||
const api = await import('@/services/api')
|
||||
vi.mocked(api.createChild).mockResolvedValue({ id: 'child-9', name: 'Barnet' })
|
||||
@@ -316,229 +318,23 @@ describe('Onboarding', () => {
|
||||
return user
|
||||
}
|
||||
|
||||
it('renders a single card for a solo workspace with a 480-day total', async () => {
|
||||
it('renders BalanceFlow in wizard mode with the newly created child id', async () => {
|
||||
await advanceToBalancesStep()
|
||||
const cards = screen.getAllByTestId('onboarding-parent-card')
|
||||
expect(cards).toHaveLength(1)
|
||||
expect(cards[0]).toHaveTextContent('Inga uppgifter ännu')
|
||||
})
|
||||
|
||||
it('renders two cards ordered self-first with a 240-day total each, once a partner exists', async () => {
|
||||
vi.mocked(hooks.useWorkspaceMembers).mockReturnValue(
|
||||
makeWorkspaceMembersResult({
|
||||
data: [
|
||||
makePersonalData({ uuid: 'pd-2', name: 'Sam' }),
|
||||
makePersonalData({ uuid: 'pd-1', name: 'Test Parent' }),
|
||||
],
|
||||
refresh: refreshMembersMock,
|
||||
})
|
||||
)
|
||||
await advanceToBalancesStep()
|
||||
const cards = screen.getAllByTestId('onboarding-parent-card')
|
||||
expect(cards).toHaveLength(2)
|
||||
expect(cards[0]).toHaveTextContent('Test Parent')
|
||||
expect(cards[0]).toHaveTextContent('du')
|
||||
expect(cards[1]).toHaveTextContent('Sam')
|
||||
})
|
||||
|
||||
it('starts the FK scraper with the newly created child id and opens the QR modal from the self card', async () => {
|
||||
const startMock = vi.fn().mockResolvedValue(undefined)
|
||||
vi.mocked(hooks.useScraperFetch).mockReturnValue(
|
||||
makeScraperFetchResult({ start: startMock, pollData: makePollResponse({ status: 'launching' }) })
|
||||
)
|
||||
const user = await advanceToBalancesStep()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Hämta från Försäkringskassan' }))
|
||||
|
||||
await waitFor(() => expect(startMock).toHaveBeenCalledWith('child-9'))
|
||||
expect(await screen.findByText('Status:')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('saves a manual balance for self and shows the updated status', async () => {
|
||||
const user = await advanceToBalancesStep()
|
||||
const api = await import('@/services/api')
|
||||
vi.mocked(api.saveManualFetch).mockResolvedValue({
|
||||
id: 'plf-1',
|
||||
expect(screen.getByTestId('balance-flow-stub')).toHaveAttribute('data-mode', 'wizard')
|
||||
expect(lastBalanceFlowProps).toMatchObject({
|
||||
mode: 'wizard',
|
||||
personalDataUuid: 'pd-1',
|
||||
workspaceId: 'ws-1',
|
||||
childId: 'child-9',
|
||||
source: 'manual',
|
||||
fetchedAt: '2026-01-01',
|
||||
totalDays: 480,
|
||||
daysUsed: 0,
|
||||
daysLeft: 480,
|
||||
reservedDaysTotal: 90,
|
||||
reservedDaysLeft: 90,
|
||||
transferableDaysLeft: 0,
|
||||
dubbeldagarLeft: 60,
|
||||
tierData: {
|
||||
sjukpenningTotal: 390,
|
||||
sjukpenningUsed: 0,
|
||||
sjukpenningLeft: 390,
|
||||
lagstaNivaTotal: 90,
|
||||
lagstaNivaUsed: 0,
|
||||
lagstaNivaLeft: 90,
|
||||
},
|
||||
createdAt: '2026-01-01',
|
||||
updatedAt: '2026-01-01',
|
||||
selectedChildId: 'child-9',
|
||||
})
|
||||
|
||||
const card = getParentCard('pd-1')
|
||||
await user.click(within(card).getByRole('button', { name: 'Ange själv' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Spara' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.saveManualFetch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ personalDataUuid: 'pd-1', workspaceId: 'ws-1', childId: 'child-9', source: 'manual' })
|
||||
)
|
||||
)
|
||||
expect(await screen.findByText('480 av 480 dagar kvar')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('saves a manual balance for the partner with that partner\'s uuid', async () => {
|
||||
vi.mocked(hooks.useWorkspaceMembers).mockReturnValue(
|
||||
makeWorkspaceMembersResult({
|
||||
data: [
|
||||
makePersonalData({ uuid: 'pd-1', name: 'Test Parent' }),
|
||||
makePersonalData({ uuid: 'pd-2', name: 'Sam' }),
|
||||
],
|
||||
refresh: refreshMembersMock,
|
||||
})
|
||||
)
|
||||
const user = await advanceToBalancesStep()
|
||||
const api = await import('@/services/api')
|
||||
vi.mocked(api.saveManualFetch).mockResolvedValue({
|
||||
id: 'plf-2',
|
||||
personalDataUuid: 'pd-2',
|
||||
workspaceId: 'ws-1',
|
||||
childId: 'child-9',
|
||||
source: 'manual',
|
||||
fetchedAt: '2026-01-01',
|
||||
totalDays: 240,
|
||||
daysUsed: 0,
|
||||
daysLeft: 240,
|
||||
reservedDaysTotal: 45,
|
||||
reservedDaysLeft: 45,
|
||||
transferableDaysLeft: 0,
|
||||
dubbeldagarLeft: 60,
|
||||
tierData: {
|
||||
sjukpenningTotal: 195,
|
||||
sjukpenningUsed: 0,
|
||||
sjukpenningLeft: 195,
|
||||
lagstaNivaTotal: 45,
|
||||
lagstaNivaUsed: 0,
|
||||
lagstaNivaLeft: 45,
|
||||
},
|
||||
createdAt: '2026-01-01',
|
||||
updatedAt: '2026-01-01',
|
||||
})
|
||||
|
||||
const card = getParentCard('pd-2')
|
||||
await user.click(within(card).getByRole('button', { name: 'Ange själv' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Spara' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.saveManualFetch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ personalDataUuid: 'pd-2', workspaceId: 'ws-1', childId: 'child-9', source: 'manual' })
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('shows the deduced badge on the partner card once the FK fetch is done for a two-member workspace', async () => {
|
||||
vi.mocked(hooks.useWorkspaceMembers).mockReturnValue(
|
||||
makeWorkspaceMembersResult({
|
||||
data: [
|
||||
makePersonalData({ uuid: 'pd-1', name: 'Test Parent' }),
|
||||
makePersonalData({ uuid: 'pd-2', name: 'Sam' }),
|
||||
],
|
||||
refresh: refreshMembersMock,
|
||||
})
|
||||
)
|
||||
vi.mocked(hooks.useScraperFetch).mockReturnValue(
|
||||
makeScraperFetchResult({
|
||||
isTerminal: true,
|
||||
pollData: makePollResponse({ status: 'done', result: {} }),
|
||||
partnerDeduction: makePartnerDeduction({ daysLeft: 140, totalDays: 240 }),
|
||||
})
|
||||
)
|
||||
it('refreshes members when BalanceFlow reports a save', async () => {
|
||||
await advanceToBalancesStep()
|
||||
|
||||
const card = getParentCard('pd-2')
|
||||
expect(within(card).getByText('Härlett från din FK-hämtning')).toBeInTheDocument()
|
||||
expect(within(card).getByText('140 av 240 dagar kvar')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the deduced-partner prompt when the FK fetch is done for a solo workspace', async () => {
|
||||
vi.mocked(hooks.useScraperFetch).mockReturnValue(
|
||||
makeScraperFetchResult({
|
||||
isTerminal: true,
|
||||
pollData: makePollResponse({ status: 'done', result: {} }),
|
||||
partnerDeduction: makePartnerDeduction({ daysLeft: 140, totalDays: 240 }),
|
||||
})
|
||||
)
|
||||
await advanceToBalancesStep()
|
||||
|
||||
expect(screen.getByTestId('onboarding-deduced-partner')).toHaveTextContent(
|
||||
'Försäkringskassan visar att den andra föräldern har 140 dagar kvar.'
|
||||
)
|
||||
})
|
||||
|
||||
it('adds the deduced partner and saves their derived balance', async () => {
|
||||
vi.mocked(hooks.useScraperFetch).mockReturnValue(
|
||||
makeScraperFetchResult({
|
||||
isTerminal: true,
|
||||
pollData: makePollResponse({ status: 'done', result: {} }),
|
||||
partnerDeduction: makePartnerDeduction({
|
||||
daysLeft: 140,
|
||||
totalDays: 240,
|
||||
sjukpenningLeft: 100,
|
||||
lagstaNivaLeft: 40,
|
||||
dubbeldagarLeft: 20,
|
||||
}),
|
||||
})
|
||||
)
|
||||
const user = await advanceToBalancesStep()
|
||||
const api = await import('@/services/api')
|
||||
vi.mocked(api.addPartner).mockResolvedValue(makePartnerMemberResponse({ uuid: 'pd-2', name: 'Kim' }))
|
||||
|
||||
await user.type(screen.getByLabelText(/Den andra förälderns namn/), 'Kim')
|
||||
await user.click(screen.getByRole('button', { name: 'Lägg till Kim' }))
|
||||
|
||||
await waitFor(() => expect(api.addPartner).toHaveBeenCalledWith('ws-1', 'Kim'))
|
||||
await waitFor(() =>
|
||||
expect(api.saveManualFetch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
personalDataUuid: 'pd-2',
|
||||
workspaceId: 'ws-1',
|
||||
childId: 'child-9',
|
||||
sjukpenningLeft: 100,
|
||||
lagstaNivaLeft: 40,
|
||||
dubbeldagarLeft: 20,
|
||||
totalDays: 240,
|
||||
source: 'deduced',
|
||||
})
|
||||
)
|
||||
)
|
||||
lastBalanceFlowProps?.onSaved?.()
|
||||
await waitFor(() => expect(refreshMembersMock).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('dismisses the deduced-partner prompt without adding anyone', async () => {
|
||||
vi.mocked(hooks.useScraperFetch).mockReturnValue(
|
||||
makeScraperFetchResult({
|
||||
isTerminal: true,
|
||||
pollData: makePollResponse({ status: 'done', result: {} }),
|
||||
partnerDeduction: makePartnerDeduction({ daysLeft: 140, totalDays: 240 }),
|
||||
})
|
||||
)
|
||||
const user = await advanceToBalancesStep()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Nej, jag är ensam förälder' }))
|
||||
|
||||
expect(screen.queryByTestId('onboarding-deduced-partner')).not.toBeInTheDocument()
|
||||
const api = await import('@/services/api')
|
||||
expect(api.addPartner).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('finishes onboarding (refreshing context) when "Klar" is pressed', async () => {
|
||||
const refreshContext = vi.fn().mockResolvedValue(undefined)
|
||||
vi.mocked(useWorkspace).mockReturnValue(
|
||||
@@ -589,18 +385,5 @@ describe('Onboarding', () => {
|
||||
|
||||
expect(await screen.findByText('Något gick fel. Försök igen.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("shows self's own status once the FK fetch completes with a real result", async () => {
|
||||
vi.mocked(hooks.useScraperFetch).mockReturnValue(
|
||||
makeScraperFetchResult({
|
||||
isTerminal: true,
|
||||
pollData: makePollResponse({ status: 'done', result: { days_left: 380, total_days: 480 } }),
|
||||
})
|
||||
)
|
||||
await advanceToBalancesStep()
|
||||
|
||||
const card = getParentCard('pd-1')
|
||||
expect(within(card).getByText('380 av 480 dagar kvar')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,13 +2,11 @@ import { FormEvent, ReactNode, useState } from 'react'
|
||||
import * as api from '@/services/api'
|
||||
import { isApiError } from '@/services/errors'
|
||||
import { useWorkspace } from '@/context'
|
||||
import { useScraperFetch, useWorkspaceMembers } from '@/hooks'
|
||||
import { isSelf, orderSelfFirst } from '@/utils/members'
|
||||
import { useWorkspaceMembers } from '@/hooks'
|
||||
import { useTranslation, type TranslationKey, type TranslationParams } from '@/i18n'
|
||||
import { Layout } from '@/components/layout/Layout'
|
||||
import { ChildBirthdayInput } from '@/components/inputs/ChildBirthdayInput'
|
||||
import { QRCodeModal } from '@/components/modals/QRCodeModal'
|
||||
import { ParentBalanceForm } from '@/components/modals/ParentBalanceForm'
|
||||
import { BalanceFlow } from '@/components/balance/BalanceFlow'
|
||||
import './onboarding.css'
|
||||
|
||||
type Step = 'child' | 'family' | 'balances'
|
||||
@@ -16,19 +14,6 @@ type FamilyChoice = 'solo' | 'couple'
|
||||
|
||||
type Translate = (key: TranslationKey, params?: TranslationParams) => string
|
||||
|
||||
interface BalanceValues {
|
||||
sjukpenningLeft: number
|
||||
lagstaNivaLeft: number
|
||||
dubbeldagarLeft: number
|
||||
reservedDaysLeft: number | null
|
||||
}
|
||||
|
||||
interface CardStatus {
|
||||
daysLeft: number
|
||||
totalDays: number
|
||||
source: 'manual' | 'deduced' | 'fk'
|
||||
}
|
||||
|
||||
/** Mirrors `Settings.tsx`'s `describePartnerError` (kept as its own small
|
||||
* copy rather than an import — that function is private to Settings.tsx,
|
||||
* and Settings.tsx is outside this task's owned files). Only the two error
|
||||
@@ -52,20 +37,6 @@ function describeAddPartnerError(err: unknown, t: Translate, fallback: string):
|
||||
return err instanceof Error ? err.message : fallback
|
||||
}
|
||||
|
||||
/** `pollData.result` is a loosely-typed `Record<string, unknown>` (see
|
||||
* `QRCodeModal`'s prop) — this pulls out the two fields the balances step
|
||||
* needs for self's status line without a cast. */
|
||||
function extractFkResultDays(
|
||||
result: Record<string, unknown> | undefined,
|
||||
): { daysLeft: number; totalDays: number } | null {
|
||||
const daysLeft = result?.days_left
|
||||
const totalDays = result?.total_days
|
||||
if (typeof daysLeft === 'number' && typeof totalDays === 'number') {
|
||||
return { daysLeft, totalDays }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* First-run wizard shown by `Dashboard.tsx` whenever `children.length === 0`.
|
||||
* Three steps: add the child, choose solo vs. two-parent (which decides the
|
||||
@@ -82,7 +53,6 @@ function extractFkResultDays(
|
||||
export function Onboarding(): ReactNode {
|
||||
const { t } = useTranslation()
|
||||
const { workspace, personalDataUuid, refreshContext } = useWorkspace()
|
||||
const scraper = useScraperFetch()
|
||||
const { data: members, refresh: refreshMembers } = useWorkspaceMembers(workspace?.id ?? null)
|
||||
|
||||
const [step, setStep] = useState<Step>('child')
|
||||
@@ -102,16 +72,8 @@ export function Onboarding(): ReactNode {
|
||||
const [isAddingPartner, setIsAddingPartner] = useState(false)
|
||||
const [familyError, setFamilyError] = useState<string | null>(null)
|
||||
|
||||
// Step 3 — per-parent balances
|
||||
const [showScrapeModal, setShowScrapeModal] = useState(false)
|
||||
const [balanceFormFor, setBalanceFormFor] = useState<string | null>(null)
|
||||
const [isSavingManual, setIsSavingManual] = useState(false)
|
||||
const [statusByUuid, setStatusByUuid] = useState<Record<string, { daysLeft: number; totalDays: number }>>({})
|
||||
const [initialByUuid, setInitialByUuid] = useState<Record<string, BalanceValues>>({})
|
||||
const [deducedPartnerName, setDeducedPartnerName] = useState('')
|
||||
const [isAddingDeducedPartner, setIsAddingDeducedPartner] = useState(false)
|
||||
const [deducedPartnerError, setDeducedPartnerError] = useState<string | null>(null)
|
||||
const [deducedDismissed, setDeducedDismissed] = useState(false)
|
||||
// Step 3 — per-parent balances (owned by `BalanceFlow` in `mode="wizard"`
|
||||
// below: FK fetch, manual entry, the deduced-partner prompt).
|
||||
const [isFinishing, setIsFinishing] = useState(false)
|
||||
const [finishError, setFinishError] = useState<string | null>(null)
|
||||
|
||||
@@ -190,110 +152,10 @@ export function Onboarding(): ReactNode {
|
||||
}
|
||||
}
|
||||
|
||||
const handleStartScrapeFetch = async () => {
|
||||
setShowScrapeModal(true)
|
||||
await scraper.start(createdChildId ?? undefined)
|
||||
}
|
||||
|
||||
const handleScrapeModalClose = () => {
|
||||
if (scraper.isRunning) return
|
||||
setShowScrapeModal(false)
|
||||
}
|
||||
|
||||
const handleScrapeDone = () => {
|
||||
setShowScrapeModal(false)
|
||||
}
|
||||
|
||||
const orderedMembers = orderSelfFirst(members, personalDataUuid)
|
||||
const totalDaysForMembers = orderedMembers.length >= 2 ? 240 : 480
|
||||
const fkDone = scraper.isTerminal && scraper.pollData?.status === 'done'
|
||||
const partnerDeduction = scraper.partnerDeduction
|
||||
|
||||
const getCardStatus = (uuid: string, isSelfCard: boolean): CardStatus | null => {
|
||||
const manual = statusByUuid[uuid]
|
||||
if (manual) return { ...manual, source: 'manual' }
|
||||
if (!isSelfCard && orderedMembers.length >= 2 && fkDone && partnerDeduction) {
|
||||
return { daysLeft: partnerDeduction.daysLeft, totalDays: partnerDeduction.totalDays, source: 'deduced' }
|
||||
}
|
||||
if (isSelfCard && fkDone) {
|
||||
const fromResult = extractFkResultDays(scraper.pollData?.result)
|
||||
if (fromResult) return { ...fromResult, source: 'fk' }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const getInitial = (uuid: string, isSelfCard: boolean): BalanceValues | undefined => {
|
||||
if (initialByUuid[uuid]) return initialByUuid[uuid]
|
||||
if (!isSelfCard && orderedMembers.length >= 2 && fkDone && partnerDeduction) {
|
||||
return {
|
||||
sjukpenningLeft: partnerDeduction.sjukpenningLeft,
|
||||
lagstaNivaLeft: partnerDeduction.lagstaNivaLeft,
|
||||
dubbeldagarLeft: partnerDeduction.dubbeldagarLeft,
|
||||
reservedDaysLeft: null,
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// No try/catch around the save call — a rejection propagates to
|
||||
// `ParentBalanceForm`'s own `onSave` handler, which owns showing it
|
||||
// (inline under the offending field for a mapped 422, or its own top
|
||||
// banner otherwise).
|
||||
const handleSaveManualFetch = async (uuid: string, data: BalanceValues) => {
|
||||
if (!workspace?.id || !createdChildId) return
|
||||
try {
|
||||
setIsSavingManual(true)
|
||||
await api.saveManualFetch({
|
||||
personalDataUuid: uuid,
|
||||
workspaceId: workspace.id,
|
||||
childId: createdChildId,
|
||||
sjukpenningLeft: data.sjukpenningLeft,
|
||||
lagstaNivaLeft: data.lagstaNivaLeft,
|
||||
dubbeldagarLeft: data.dubbeldagarLeft,
|
||||
reservedDaysLeft: data.reservedDaysLeft,
|
||||
source: 'manual',
|
||||
})
|
||||
setStatusByUuid((prev) => ({
|
||||
...prev,
|
||||
[uuid]: { daysLeft: data.sjukpenningLeft + data.lagstaNivaLeft, totalDays: totalDaysForMembers },
|
||||
}))
|
||||
setInitialByUuid((prev) => ({ ...prev, [uuid]: data }))
|
||||
setBalanceFormFor(null)
|
||||
} finally {
|
||||
setIsSavingManual(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddDeducedPartner = async () => {
|
||||
if (!workspace?.id || !createdChildId || !deducedPartnerName.trim() || !partnerDeduction) return
|
||||
setIsAddingDeducedPartner(true)
|
||||
setDeducedPartnerError(null)
|
||||
try {
|
||||
const added = await api.addPartner(workspace.id, deducedPartnerName.trim())
|
||||
await api.saveManualFetch({
|
||||
personalDataUuid: added.uuid,
|
||||
workspaceId: workspace.id,
|
||||
childId: createdChildId,
|
||||
sjukpenningLeft: partnerDeduction.sjukpenningLeft,
|
||||
lagstaNivaLeft: partnerDeduction.lagstaNivaLeft,
|
||||
dubbeldagarLeft: partnerDeduction.dubbeldagarLeft,
|
||||
totalDays: partnerDeduction.totalDays,
|
||||
source: 'deduced',
|
||||
})
|
||||
setDeducedPartnerName('')
|
||||
await refreshMembers()
|
||||
} catch (err) {
|
||||
setDeducedPartnerError(describeAddPartnerError(err, t, t('settings.addPartnerError')))
|
||||
} finally {
|
||||
setIsAddingDeducedPartner(false)
|
||||
}
|
||||
}
|
||||
|
||||
const stepNumber = step === 'child' ? 1 : step === 'family' ? 2 : 3
|
||||
const balanceFormMember = balanceFormFor ? orderedMembers.find((m) => m.uuid === balanceFormFor) : undefined
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Layout chrome="bare">
|
||||
<div data-testid="onboarding" className="onboarding">
|
||||
<p className="eyebrow" data-testid="onboarding-step-indicator">
|
||||
{t('onboarding.stepIndicator', { step: stepNumber, total: 3 })}
|
||||
@@ -434,96 +296,14 @@ export function Onboarding(): ReactNode {
|
||||
<h1>{t('onboarding.stepBalancesTitle')}</h1>
|
||||
<p>{t('onboarding.stepBalancesIntro')}</p>
|
||||
|
||||
<div className="onboarding__parent-cards">
|
||||
{orderedMembers.map((member) => {
|
||||
const selfCard = isSelf(member, personalDataUuid)
|
||||
const status = getCardStatus(member.uuid, selfCard)
|
||||
return (
|
||||
<div
|
||||
key={member.uuid}
|
||||
className="card onboarding__parent-card"
|
||||
data-testid="onboarding-parent-card"
|
||||
data-parent={member.uuid}
|
||||
>
|
||||
<h2>
|
||||
{member.name}
|
||||
{selfCard && <span className="badge badge--muted"> {t('onboarding.youBadge')}</span>}
|
||||
</h2>
|
||||
{status?.source === 'deduced' && (
|
||||
<p className="badge badge--accent">{t('onboarding.deducedBadge')}</p>
|
||||
)}
|
||||
<p className="onboarding__balance-status">
|
||||
{status
|
||||
? t('onboarding.balanceStatus', { left: status.daysLeft, total: status.totalDays })
|
||||
: t('onboarding.balanceStatusNone')}
|
||||
</p>
|
||||
<div className="actions onboarding__parent-card-actions">
|
||||
{selfCard && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--primary"
|
||||
onClick={() => void handleStartScrapeFetch()}
|
||||
disabled={isFinishing}
|
||||
>
|
||||
{t('dashboard.fetchFromFk')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBalanceFormFor(member.uuid)}
|
||||
disabled={isFinishing}
|
||||
className="btn btn--secondary"
|
||||
>
|
||||
{t('onboarding.enterManuallyButton')}
|
||||
</button>
|
||||
</div>
|
||||
{!selfCard && (
|
||||
<p className="onboarding__hint">
|
||||
{t('onboarding.partnerAutoFillHint', { name: member.name })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{orderedMembers.length === 1 && fkDone && partnerDeduction && partnerDeduction.daysLeft > 0 && !deducedDismissed && (
|
||||
<div className="card card--dashed onboarding__deduced-prompt" data-testid="onboarding-deduced-partner">
|
||||
<p>{t('onboarding.deducedPartnerPrompt', { days: partnerDeduction.daysLeft })}</p>
|
||||
<div className="form-group">
|
||||
<label htmlFor="onboarding-deduced-partner-name">{t('onboarding.partnerNameLabel')}</label>
|
||||
<input
|
||||
id="onboarding-deduced-partner-name"
|
||||
type="text"
|
||||
value={deducedPartnerName}
|
||||
onChange={(e) => setDeducedPartnerName(e.target.value)}
|
||||
disabled={isAddingDeducedPartner}
|
||||
/>
|
||||
</div>
|
||||
{deducedPartnerError && (
|
||||
<div role="alert" className="alert alert--error">
|
||||
{deducedPartnerError}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--primary"
|
||||
onClick={() => void handleAddDeducedPartner()}
|
||||
disabled={isAddingDeducedPartner || !deducedPartnerName.trim()}
|
||||
>
|
||||
{isAddingDeducedPartner
|
||||
? t('settings.addingPartner')
|
||||
: t('onboarding.addDeducedPartnerButton', { name: deducedPartnerName })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--link"
|
||||
onClick={() => setDeducedDismissed(true)}
|
||||
>
|
||||
{t('onboarding.dismissDeducedPartner')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<BalanceFlow
|
||||
members={members}
|
||||
personalDataUuid={personalDataUuid}
|
||||
workspaceId={workspace?.id ?? ''}
|
||||
selectedChildId={createdChildId}
|
||||
mode="wizard"
|
||||
onSaved={() => void refreshMembers()}
|
||||
/>
|
||||
|
||||
{finishError && (
|
||||
<div role="alert" className="alert alert--error">
|
||||
@@ -543,37 +323,6 @@ export function Onboarding(): ReactNode {
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<QRCodeModal
|
||||
isOpen={showScrapeModal}
|
||||
onClose={handleScrapeModalClose}
|
||||
onDone={handleScrapeDone}
|
||||
status={scraper.pollData?.status ?? null}
|
||||
qrData={scraper.pollData?.qr_data}
|
||||
result={scraper.pollData?.result}
|
||||
message={scraper.pollData?.message}
|
||||
error={scraper.error}
|
||||
isRunning={scraper.isRunning}
|
||||
onCancel={scraper.cancel}
|
||||
/>
|
||||
|
||||
{balanceFormMember &&
|
||||
(() => {
|
||||
const selfCard = isSelf(balanceFormMember, personalDataUuid)
|
||||
const initial = getInitial(balanceFormMember.uuid, selfCard)
|
||||
return (
|
||||
<ParentBalanceForm
|
||||
parentName={balanceFormMember.name}
|
||||
isSelf={selfCard}
|
||||
totalDays={totalDaysForMembers}
|
||||
{...(initial ? { initial } : {})}
|
||||
onSave={(data) => handleSaveManualFetch(balanceFormMember.uuid, data)}
|
||||
onClose={() => setBalanceFormFor(null)}
|
||||
isSaving={isSavingManual}
|
||||
error={null}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'
|
||||
import { Settings } from './Settings'
|
||||
import * as hooks from '@/hooks'
|
||||
import { useWorkspace } from '@/context'
|
||||
import * as api from '@/services/api'
|
||||
import { ApiRequestError } from '@/services/errors'
|
||||
import { LanguageProvider } from '@/i18n'
|
||||
import type { BalanceFlowProps } from '@/components/balance/BalanceFlow'
|
||||
import type { CalendarExceptionsProps } from '@/components/calendar/CalendarExceptions'
|
||||
import {
|
||||
makeWorkspaceContextValue,
|
||||
makeWorkspaceMembersResult,
|
||||
@@ -14,8 +16,6 @@ import {
|
||||
makeChildSettingsResult,
|
||||
makeDayOverridesResult,
|
||||
makeCalendarDaysResult,
|
||||
makeParentalLeaveFetch,
|
||||
makeTierData,
|
||||
} from '../../tests/fixtures'
|
||||
|
||||
vi.mock('@/hooks')
|
||||
@@ -29,6 +29,26 @@ vi.mock('@/components/layout/Layout', () => ({
|
||||
Layout: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
// `BalanceFlow` and `CalendarExceptions` each own an extensive test suite of
|
||||
// their own (BalanceFlow.test.tsx, CalendarExceptions.test.tsx) — Settings
|
||||
// only needs to prove it wires the right props through to each, not
|
||||
// re-exercise their internals.
|
||||
let lastBalanceFlowProps: BalanceFlowProps | null = null
|
||||
vi.mock('@/components/balance/BalanceFlow', () => ({
|
||||
BalanceFlow: (props: BalanceFlowProps) => {
|
||||
lastBalanceFlowProps = props
|
||||
return <div data-testid="balance-flow-stub" />
|
||||
},
|
||||
}))
|
||||
|
||||
let lastCalendarExceptionsProps: CalendarExceptionsProps | null = null
|
||||
vi.mock('@/components/calendar/CalendarExceptions', () => ({
|
||||
CalendarExceptions: (props: CalendarExceptionsProps) => {
|
||||
lastCalendarExceptionsProps = props
|
||||
return <div data-testid="calendar-exceptions-stub" />
|
||||
},
|
||||
}))
|
||||
|
||||
function renderSettings() {
|
||||
return render(<Settings />)
|
||||
}
|
||||
@@ -50,7 +70,6 @@ const CURRENT_YEAR = new Date().getFullYear()
|
||||
const CHILD_BIRTH_DATE = `${CURRENT_YEAR - 1}-01-01`
|
||||
|
||||
describe('Settings', () => {
|
||||
|
||||
const mockVacationDays = [
|
||||
{
|
||||
id: 'vd-1',
|
||||
@@ -108,6 +127,8 @@ describe('Settings', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
lastBalanceFlowProps = null
|
||||
lastCalendarExceptionsProps = null
|
||||
mockUpsertVacationDays = vi.fn().mockResolvedValue(undefined)
|
||||
mockUpdateChildDetails = vi.fn().mockResolvedValue(undefined)
|
||||
mockUpsertDayOverride = vi.fn().mockResolvedValue(undefined)
|
||||
@@ -175,11 +196,6 @@ describe('Settings', () => {
|
||||
})
|
||||
vi.mocked(api.renameWorkspaceParent).mockResolvedValue(partnerMember)
|
||||
vi.mocked(api.removeWorkspaceParent).mockResolvedValue(undefined)
|
||||
// Default: nothing saved yet for whoever's balance form gets opened —
|
||||
// `useLatestBalance` (a real hook, imported directly by Settings.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 while workspace loads', () => {
|
||||
@@ -202,28 +218,18 @@ describe('Settings', () => {
|
||||
expect(screen.getByTestId('page-skeleton')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders consistently Swedish section headings', () => {
|
||||
it('renders the four panels as h2 headings, in order, with the family panel\'s h3 subsections', () => {
|
||||
renderSettings()
|
||||
expect(screen.getByRole('heading', { name: 'Barn' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: 'Din profil' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: 'Partner' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: 'Semesterdagar' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: 'Kalender' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: 'Undantag i kalendern' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: 'Röda dagar' })).toBeInTheDocument()
|
||||
const h2s = screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent)
|
||||
expect(h2s).toEqual(['Barn & familj', 'Saldon', 'Kalender & språk', 'Undantag & röda dagar'])
|
||||
|
||||
const h3s = screen.getAllByRole('heading', { level: 3 }).map((h) => h.textContent)
|
||||
expect(h3s).toEqual(['Din profil', 'Partner'])
|
||||
})
|
||||
|
||||
it('renders the partner section where the removed planning-mode selector used to be, right after the profile section, and never renders a planning-mode selector', () => {
|
||||
it('keeps the settings-page root testid', () => {
|
||||
renderSettings()
|
||||
const headings = screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent)
|
||||
const profileIndex = headings.indexOf('Din profil')
|
||||
const partnerIndex = headings.indexOf('Partner')
|
||||
const vacationIndex = headings.indexOf('Semesterdagar')
|
||||
expect(profileIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(partnerIndex).toBe(profileIndex + 1)
|
||||
expect(vacationIndex).toBe(partnerIndex + 1)
|
||||
expect(screen.queryByTestId('planning-mode-selector')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('heading', { name: 'Planering' })).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('settings-page')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('vacation days table', () => {
|
||||
@@ -267,25 +273,17 @@ describe('Settings', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('saves saved-days-in alongside total days', async () => {
|
||||
it('does not disable the vacation table while the family panel is saving', async () => {
|
||||
renderSettings()
|
||||
const savedInput = screen.getByTestId(`vacation-saved-days-${CURRENT_YEAR}`)
|
||||
fireEvent.change(savedInput, { target: { value: '7' } })
|
||||
fireEvent.click(screen.getByTestId(`vacation-save-${CURRENT_YEAR}`))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockUpsertVacationDays).toHaveBeenCalledWith({
|
||||
personalDataUuid: 'pd-1',
|
||||
workspaceId: 'ws-1',
|
||||
year: CURRENT_YEAR,
|
||||
totalDays: 10,
|
||||
savedDaysIn: 7,
|
||||
}),
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('save-family-button'))
|
||||
// The vacation save button must stay enabled — it has its own
|
||||
// `isSavingVacation` flag, independent of the family panel's.
|
||||
expect(screen.getByTestId(`vacation-save-${CURRENT_YEAR}`)).not.toBeDisabled()
|
||||
await waitFor(() => expect(screen.getByTestId('save-family-button')).not.toBeDisabled())
|
||||
})
|
||||
})
|
||||
|
||||
describe('child settings', () => {
|
||||
describe('family panel — child + profile (one combined Save)', () => {
|
||||
it('prefills birth date and preschool planning days from the child', () => {
|
||||
renderSettings()
|
||||
const birthInput = screen.getByTestId('birth-date-input') as HTMLInputElement
|
||||
@@ -294,11 +292,13 @@ describe('Settings', () => {
|
||||
expect(preschoolInput.value).toBe('6')
|
||||
})
|
||||
|
||||
it('saves birth date and preschool planning days together', async () => {
|
||||
it('saves child details and the parent profile together with one Save button', async () => {
|
||||
renderSettings()
|
||||
const preschoolInput = screen.getByTestId('vacation-days-field')
|
||||
fireEvent.change(preschoolInput, { target: { value: '10' } })
|
||||
fireEvent.click(screen.getByTestId('save-child-button'))
|
||||
fireEvent.change(screen.getByTestId('salary-type'), { target: { value: 'monthly' } })
|
||||
fireEvent.change(screen.getByTestId('employment-status'), { target: { value: 'employed' } })
|
||||
fireEvent.click(screen.getByTestId('save-family-button'))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockUpdateChildDetails).toHaveBeenCalledWith({
|
||||
@@ -306,21 +306,20 @@ describe('Settings', () => {
|
||||
preschoolPlanningDaysPerYear: 10,
|
||||
}),
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(api.updateParentSettings).toHaveBeenCalledWith('pd-1', {
|
||||
salary_type: 'monthly',
|
||||
employment_status: 'employed',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('saves parent profile fields', async () => {
|
||||
renderSettings()
|
||||
fireEvent.change(screen.getByTestId('salary-type'), { target: { value: 'monthly' } })
|
||||
fireEvent.change(screen.getByTestId('employment-status'), { target: { value: 'employed' } })
|
||||
fireEvent.click(screen.getByTestId('save-parent-button'))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.updateParentSettings).toHaveBeenCalledWith('pd-1', {
|
||||
salary_type: 'monthly',
|
||||
employment_status: 'employed',
|
||||
}),
|
||||
)
|
||||
it('shows an error when the combined family save fails', async () => {
|
||||
vi.mocked(api.updateParentSettings).mockRejectedValue(new Error('save failed'))
|
||||
renderSettings()
|
||||
fireEvent.click(screen.getByTestId('save-family-button'))
|
||||
expect(await screen.findByTestId('save-message')).toHaveTextContent('save failed')
|
||||
})
|
||||
})
|
||||
|
||||
it('adds a partner', async () => {
|
||||
@@ -348,104 +347,6 @@ describe('Settings', () => {
|
||||
expect(message).toHaveTextContent('Partnerns namn är ogiltigt.')
|
||||
})
|
||||
|
||||
describe('parent balance ("Dagar kvar")', () => {
|
||||
it('opens the balance form for a solo workspace with a 480-day total', async () => {
|
||||
renderSettings()
|
||||
fireEvent.click(screen.getByTestId('settings-balance-pd-1'))
|
||||
expect(await screen.findByText('Dina dagar kvar')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('prefills the form with the parent\'s saved balance instead of the preset', async () => {
|
||||
vi.mocked(api.getParentalLeave).mockResolvedValue(
|
||||
makeParentalLeaveFetch({
|
||||
personalDataUuid: 'pd-1',
|
||||
childId: 'c-1',
|
||||
dubbeldagarLeft: 12,
|
||||
reservedDaysLeft: 34,
|
||||
tierData: makeTierData({ sjukpenningLeft: 111, lagstaNivaLeft: 22 }),
|
||||
}),
|
||||
)
|
||||
renderSettings()
|
||||
|
||||
fireEvent.click(screen.getByTestId('settings-balance-pd-1'))
|
||||
|
||||
expect(await screen.findByText('Dina dagar kvar')).toBeInTheDocument()
|
||||
expect((screen.getByLabelText(/Sjukpenningnivå/) as HTMLInputElement).value).toBe('111')
|
||||
expect((screen.getByLabelText(/Lägstanivå/) as HTMLInputElement).value).toBe('22')
|
||||
expect((screen.getByLabelText(/Dubbeldagar/) as HTMLInputElement).value).toBe('12')
|
||||
})
|
||||
|
||||
it('falls back to the preset when the parent has no saved balance yet', async () => {
|
||||
vi.mocked(api.getParentalLeave).mockResolvedValue(null)
|
||||
renderSettings()
|
||||
|
||||
fireEvent.click(screen.getByTestId('settings-balance-pd-1'))
|
||||
|
||||
expect(await screen.findByText('Dina dagar kvar')).toBeInTheDocument()
|
||||
// Solo workspace -> 480-day preset, split 390:90.
|
||||
expect((screen.getByLabelText(/Sjukpenningnivå/) as HTMLInputElement).value).toBe('390')
|
||||
expect((screen.getByLabelText(/Lägstanivå/) as HTMLInputElement).value).toBe('90')
|
||||
})
|
||||
|
||||
it("falls back to the workspace's first child when no child is selected yet", async () => {
|
||||
vi.mocked(useWorkspace).mockReturnValue(
|
||||
makeWorkspaceContextValue({
|
||||
workspace: { id: 'ws-1', name: 'Test Workspace', createdAt: '2026-01-01' },
|
||||
workspaceId: 'ws-1',
|
||||
children: [{ id: 'c-9', name: 'Child 9', birth_date: CHILD_BIRTH_DATE }],
|
||||
selectedChildId: null,
|
||||
personalDataUuid: 'pd-1',
|
||||
parentName: 'Test Parent',
|
||||
})
|
||||
)
|
||||
renderSettings()
|
||||
|
||||
fireEvent.click(screen.getByTestId('settings-balance-pd-1'))
|
||||
await screen.findByText('Dina dagar kvar')
|
||||
const saveButtons = screen.getAllByRole('button', { name: 'Spara' })
|
||||
fireEvent.click(saveButtons[saveButtons.length - 1]!)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.saveManualFetch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ personalDataUuid: 'pd-1', workspaceId: 'ws-1', childId: 'c-9', source: 'manual' })
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('does nothing when there is no child to attach the balance to', async () => {
|
||||
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',
|
||||
})
|
||||
)
|
||||
renderSettings()
|
||||
|
||||
fireEvent.click(screen.getByTestId('settings-balance-pd-1'))
|
||||
await screen.findByText('Dina dagar kvar')
|
||||
const saveButtons = screen.getAllByRole('button', { name: 'Spara' })
|
||||
fireEvent.click(saveButtons[saveButtons.length - 1]!)
|
||||
|
||||
expect(api.saveManualFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows an error when saving a balance fails', async () => {
|
||||
vi.mocked(api.saveManualFetch).mockRejectedValue(new Error('balance save failed'))
|
||||
renderSettings()
|
||||
|
||||
fireEvent.click(screen.getByTestId('settings-balance-pd-1'))
|
||||
await screen.findByText('Dina dagar kvar')
|
||||
const saveButtons = screen.getAllByRole('button', { name: 'Spara' })
|
||||
fireEvent.click(saveButtons[saveButtons.length - 1]!)
|
||||
|
||||
expect(await screen.findByText('balance save failed')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspace members', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(hooks.useWorkspaceMembers).mockReturnValue(
|
||||
@@ -587,55 +488,51 @@ describe('Settings', () => {
|
||||
const message = await screen.findByTestId('member-action-error-pd-2')
|
||||
expect(message).not.toHaveTextContent('workspace.parents.remove.self')
|
||||
})
|
||||
})
|
||||
|
||||
it('opens the balance form for the clicked member and saves with that member\'s uuid', async () => {
|
||||
vi.mocked(api.saveManualFetch).mockResolvedValue({
|
||||
id: 'plf-1',
|
||||
personalDataUuid: 'pd-2',
|
||||
workspaceId: 'ws-1',
|
||||
childId: 'c-1',
|
||||
source: 'manual',
|
||||
fetchedAt: '2026-01-01',
|
||||
totalDays: 240,
|
||||
daysUsed: 0,
|
||||
daysLeft: 240,
|
||||
reservedDaysTotal: 45,
|
||||
reservedDaysLeft: 45,
|
||||
transferableDaysLeft: 0,
|
||||
dubbeldagarLeft: 60,
|
||||
tierData: {
|
||||
sjukpenningTotal: 195,
|
||||
sjukpenningUsed: 0,
|
||||
sjukpenningLeft: 195,
|
||||
lagstaNivaTotal: 45,
|
||||
lagstaNivaUsed: 0,
|
||||
lagstaNivaLeft: 45,
|
||||
},
|
||||
createdAt: '2026-01-01',
|
||||
updatedAt: '2026-01-01',
|
||||
})
|
||||
describe('Saldon panel — BalanceFlow wiring', () => {
|
||||
it('renders BalanceFlow in panel mode with the workspace members and selected child', () => {
|
||||
renderSettings()
|
||||
|
||||
fireEvent.click(screen.getByTestId('settings-balance-pd-2'))
|
||||
expect(await screen.findByText('Sams dagar kvar')).toBeInTheDocument()
|
||||
// The vacation-days table above also has per-year "Spara" buttons —
|
||||
// the balance form's is the last one rendered (it's mounted at the
|
||||
// very end of the page).
|
||||
const saveButtons = screen.getAllByRole('button', { name: 'Spara' })
|
||||
fireEvent.click(saveButtons[saveButtons.length - 1]!)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.saveManualFetch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ personalDataUuid: 'pd-2', workspaceId: 'ws-1', childId: 'c-1', source: 'manual' })
|
||||
)
|
||||
)
|
||||
expect(screen.getByTestId('balance-flow-stub')).toBeInTheDocument()
|
||||
expect(lastBalanceFlowProps).toMatchObject({
|
||||
mode: 'panel',
|
||||
personalDataUuid: 'pd-1',
|
||||
workspaceId: 'ws-1',
|
||||
selectedChildId: 'c-1',
|
||||
})
|
||||
expect(lastBalanceFlowProps?.members).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('opens the balance form for the logged-in user\'s own row too', async () => {
|
||||
it("falls back to the workspace's first child when no child is selected yet", () => {
|
||||
vi.mocked(useWorkspace).mockReturnValue(
|
||||
makeWorkspaceContextValue({
|
||||
workspace: { id: 'ws-1', name: 'Test Workspace', createdAt: '2026-01-01' },
|
||||
workspaceId: 'ws-1',
|
||||
children: [{ id: 'c-9', name: 'Child 9', birth_date: CHILD_BIRTH_DATE }],
|
||||
selectedChildId: null,
|
||||
personalDataUuid: 'pd-1',
|
||||
parentName: 'Test Parent',
|
||||
})
|
||||
)
|
||||
renderSettings()
|
||||
expect(lastBalanceFlowProps?.selectedChildId).toBe('c-9')
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('settings-balance-pd-1'))
|
||||
expect(await screen.findByText('Dina dagar kvar')).toBeInTheDocument()
|
||||
it('refreshes members when BalanceFlow reports a save', () => {
|
||||
renderSettings()
|
||||
lastBalanceFlowProps?.onSaved?.()
|
||||
expect(mockRefreshMembers).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows a loading message instead of BalanceFlow while members are loading', () => {
|
||||
vi.mocked(hooks.useWorkspaceMembers).mockReturnValue(
|
||||
makeWorkspaceMembersResult({ data: [], isLoading: true, refresh: mockRefreshMembers })
|
||||
)
|
||||
renderSettings()
|
||||
// Both the partner list (family panel) and the Saldon panel gate on
|
||||
// the same `membersLoading` flag, so the message appears twice.
|
||||
expect(screen.getAllByText('Laddar medlemmar…')).toHaveLength(2)
|
||||
expect(screen.queryByTestId('balance-flow-stub')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -747,7 +644,6 @@ describe('Settings', () => {
|
||||
|
||||
it('is labelled understandably regardless of the active language', () => {
|
||||
renderSettingsWithLanguageProvider()
|
||||
expect(screen.getByText('Språk / Language')).toBeInTheDocument()
|
||||
expect(screen.getByText('Välj språk / Select language')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -788,89 +684,29 @@ describe('Settings', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('red days list (§3.8)', () => {
|
||||
it('passes the fetched calendar window through to the list, four years at a time', () => {
|
||||
vi.mocked(hooks.useCalendarDays).mockReturnValue(
|
||||
makeCalendarDaysResult({
|
||||
data: {
|
||||
startYear: CURRENT_YEAR,
|
||||
endYear: CURRENT_YEAR + 3,
|
||||
minYear: 2026,
|
||||
maxYear: 2035,
|
||||
days: [
|
||||
{
|
||||
date: `${CURRENT_YEAR}-01-01`,
|
||||
name: 'Nyårsdagen',
|
||||
dayType: 'red_day',
|
||||
isOverride: false,
|
||||
overrideIsWorkingDay: null,
|
||||
isFree: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
describe('Undantag & röda dagar panel — CalendarExceptions wiring', () => {
|
||||
it('passes the fetched calendar window and override handlers through', () => {
|
||||
renderSettings()
|
||||
|
||||
expect(screen.getByTestId('red-days-range')).toHaveTextContent(`${CURRENT_YEAR}–${CURRENT_YEAR + 3}`)
|
||||
expect(screen.getByTestId(`red-days-item-${CURRENT_YEAR}-01-01`)).toHaveTextContent('Nyårsdagen')
|
||||
expect(screen.getByTestId('calendar-exceptions-stub')).toBeInTheDocument()
|
||||
expect(lastCalendarExceptionsProps).toMatchObject({
|
||||
startYear: CURRENT_YEAR,
|
||||
years: 4,
|
||||
workspaceId: 'ws-1',
|
||||
})
|
||||
expect(lastCalendarExceptionsProps?.onUpsertOverride).toBe(mockUpsertDayOverride)
|
||||
expect(lastCalendarExceptionsProps?.onRemoveOverride).toBe(mockRemoveDayOverride)
|
||||
})
|
||||
|
||||
it('requests the next 4-year window when paginating forward', () => {
|
||||
renderSettings()
|
||||
|
||||
fireEvent.click(screen.getByTestId('red-days-next'))
|
||||
|
||||
act(() => lastCalendarExceptionsProps?.onNextWindow())
|
||||
expect(hooks.useCalendarDays).toHaveBeenLastCalledWith('ws-1', CURRENT_YEAR + 4, 4)
|
||||
})
|
||||
|
||||
it('requests the previous 4-year window when paginating backward', () => {
|
||||
// minYear well below the current window so the "previous" button isn't disabled.
|
||||
vi.mocked(hooks.useCalendarDays).mockReturnValue(
|
||||
makeCalendarDaysResult({
|
||||
data: { ...mockCalendarWindow, minYear: 2000 },
|
||||
})
|
||||
)
|
||||
|
||||
renderSettings()
|
||||
|
||||
fireEvent.click(screen.getByTestId('red-days-prev'))
|
||||
|
||||
act(() => lastCalendarExceptionsProps?.onPrevWindow())
|
||||
expect(hooks.useCalendarDays).toHaveBeenLastCalledWith('ws-1', CURRENT_YEAR - 4, 4)
|
||||
})
|
||||
|
||||
it('wires the toggle button through to the shared day-overrides hook', async () => {
|
||||
vi.mocked(hooks.useCalendarDays).mockReturnValue(
|
||||
makeCalendarDaysResult({
|
||||
data: {
|
||||
...mockCalendarWindow,
|
||||
days: [
|
||||
{
|
||||
date: `${CURRENT_YEAR}-01-01`,
|
||||
name: 'Nyårsdagen',
|
||||
dayType: 'red_day',
|
||||
isOverride: false,
|
||||
overrideIsWorkingDay: null,
|
||||
isFree: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
renderSettings()
|
||||
|
||||
fireEvent.click(screen.getByTestId(`red-days-toggle-${CURRENT_YEAR}-01-01`))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpsertDayOverride).toHaveBeenCalledWith({
|
||||
workspaceId: 'ws-1',
|
||||
date: `${CURRENT_YEAR}-01-01`,
|
||||
isWorkingDay: true,
|
||||
note: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+109
-196
@@ -9,22 +9,17 @@ import {
|
||||
useWorkspaceMembers,
|
||||
} from '@/hooks'
|
||||
import * as api from '@/services/api'
|
||||
// Imported directly (not via the `@/hooks` barrel) so this page's tests can
|
||||
// drive it through a mocked `api.getParentalLeave` — see the hook's doc
|
||||
// comment for why.
|
||||
import { useLatestBalance } from '@/hooks/useLatestBalance'
|
||||
import { isApiError } from '@/services/errors'
|
||||
import { useTranslation, type Locale, type TranslationKey } from '@/i18n'
|
||||
import { Layout } from '@/components/layout/Layout'
|
||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
|
||||
import { PageSkeleton } from '@/components/common/PageSkeleton'
|
||||
import { ChildBirthdayInput } from '@/components/inputs/ChildBirthdayInput'
|
||||
import { ParentProfileInput, type SalaryType, type EmploymentStatus } from '@/components/inputs/ParentProfileInput'
|
||||
import { VacationDaysInput } from '@/components/inputs/VacationDaysInput'
|
||||
import { VacationDaysTable, type VacationDaysTableRow } from '@/components/vacation-days/VacationDaysTable'
|
||||
import { DayOverridesInput } from '@/components/inputs/DayOverridesInput'
|
||||
import { RedDaysList } from '@/components/calendar/RedDaysList'
|
||||
import { ParentBalanceForm } from '@/components/modals/ParentBalanceForm'
|
||||
import { CalendarExceptions } from '@/components/calendar/CalendarExceptions'
|
||||
import { BalanceFlow } from '@/components/balance/BalanceFlow'
|
||||
import './settings.css'
|
||||
|
||||
const RED_DAYS_WINDOW_YEARS = 4
|
||||
|
||||
@@ -137,23 +132,23 @@ export function Settings(): ReactNode {
|
||||
const [preschoolPlanningDays, setPreschoolPlanningDays] = useState(6)
|
||||
const [parentData, setParentData] = useState<{ salaryType?: SalaryType; employmentStatus?: EmploymentStatus }>({})
|
||||
const [saveMessage, setSaveMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
// Each panel owns exactly one `isSaving` flag, scoped to its own actions —
|
||||
// previously a single page-wide `isSaving` meant saving a vacation year
|
||||
// (Saldon panel) also disabled the "Spara" button in the family panel,
|
||||
// and vice versa.
|
||||
const [isSavingFamily, setIsSavingFamily] = useState(false)
|
||||
const [isSavingVacation, setIsSavingVacation] = useState(false)
|
||||
const [isSavingRedDayPref, setIsSavingRedDayPref] = useState(false)
|
||||
const [newPartnerName, setNewPartnerName] = useState('')
|
||||
const [renamingUuid, setRenamingUuid] = useState<string | null>(null)
|
||||
const [renameValue, setRenameValue] = useState('')
|
||||
const [removeConfirmUuid, setRemoveConfirmUuid] = useState<string | null>(null)
|
||||
const [memberActionError, setMemberActionError] = useState<{ uuid: string; text: string } | null>(null)
|
||||
const [balanceFormUuid, setBalanceFormUuid] = useState<string | null>(null)
|
||||
const [isSavingBalance, setIsSavingBalance] = useState(false)
|
||||
const [isSavingRedDayPref, setIsSavingRedDayPref] = useState(false)
|
||||
|
||||
// Settings isn't scoped to a single child the way Onboarding/Dashboard are
|
||||
// — mirrors the fallback `handleSaveBalance` below uses when saving.
|
||||
// — the balance flow needs somewhere to attach a saved balance even before
|
||||
// a child is chosen/created elsewhere in the app.
|
||||
const balanceChildId = selectedChildId ?? children[0]?.id ?? null
|
||||
const { initial: balanceInitial, isLoading: balanceInitialLoading } = useLatestBalance(
|
||||
balanceFormUuid,
|
||||
balanceChildId,
|
||||
)
|
||||
|
||||
const isLoading = wsLoading || vacLoading || childDetailsLoading || dayOverridesLoading
|
||||
|
||||
@@ -164,6 +159,15 @@ export function Settings(): ReactNode {
|
||||
}
|
||||
}, [childDetails])
|
||||
|
||||
// `#saldon` in the URL scrolls that panel into view once the page's
|
||||
// content has actually mounted.
|
||||
useEffect(() => {
|
||||
if (isLoading) return
|
||||
if (window.location.hash === '#saldon') {
|
||||
document.getElementById('saldon')?.scrollIntoView()
|
||||
}
|
||||
}, [isLoading])
|
||||
|
||||
const selectedChild = children.find((c) => c.id === selectedChildId)
|
||||
const hasPartner = members.some((m) => m.uuid !== personalDataUuid)
|
||||
|
||||
@@ -206,20 +210,29 @@ export function Settings(): ReactNode {
|
||||
setTimeout(() => setSaveMessage(null), 3000)
|
||||
}
|
||||
|
||||
const handleSaveChild = async () => {
|
||||
// One combined save for the "Barn & familj" panel's editable fields (child
|
||||
// birth date/preschool days, own salary/employment) — one button, one
|
||||
// `isSavingFamily` flag, matching the rest of that panel's actions below.
|
||||
const handleSaveFamily = async () => {
|
||||
try {
|
||||
setIsSavingFamily(true)
|
||||
if (selectedChildId && childBirthDate) {
|
||||
setIsSaving(true)
|
||||
await updateChildDetails({
|
||||
birthDate: childBirthDate,
|
||||
preschoolPlanningDaysPerYear: preschoolPlanningDays,
|
||||
})
|
||||
showMessage('success', t('settings.saveChildSuccess'))
|
||||
}
|
||||
if (personalDataUuid) {
|
||||
await api.updateParentSettings(personalDataUuid, {
|
||||
salary_type: parentData.salaryType,
|
||||
employment_status: parentData.employmentStatus,
|
||||
})
|
||||
}
|
||||
showMessage('success', t('settings.saveFamilySuccess'))
|
||||
} catch (err) {
|
||||
showMessage('error', err instanceof Error ? err.message : t('settings.saveChildError'))
|
||||
showMessage('error', err instanceof Error ? err.message : t('settings.saveFamilyError'))
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
setIsSavingFamily(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,27 +256,10 @@ export function Settings(): ReactNode {
|
||||
})
|
||||
}
|
||||
|
||||
const handleSaveParentProfile = async () => {
|
||||
try {
|
||||
if (personalDataUuid) {
|
||||
setIsSaving(true)
|
||||
await api.updateParentSettings(personalDataUuid, {
|
||||
salary_type: parentData.salaryType,
|
||||
employment_status: parentData.employmentStatus,
|
||||
})
|
||||
showMessage('success', t('settings.saveProfileSuccess'))
|
||||
}
|
||||
} catch (err) {
|
||||
showMessage('error', err instanceof Error ? err.message : t('settings.saveProfileError'))
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveVacationYear = async (year: number, totalDays: number, savedDaysIn: number) => {
|
||||
try {
|
||||
if (workspace?.id && personalDataUuid) {
|
||||
setIsSaving(true)
|
||||
setIsSavingVacation(true)
|
||||
await upsertVacationDays({
|
||||
personalDataUuid,
|
||||
workspaceId: workspace.id,
|
||||
@@ -276,7 +272,7 @@ export function Settings(): ReactNode {
|
||||
} catch (err) {
|
||||
showMessage('error', err instanceof Error ? err.message : t('settings.saveVacationError'))
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
setIsSavingVacation(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,7 +291,7 @@ export function Settings(): ReactNode {
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSaving(true)
|
||||
setIsSavingFamily(true)
|
||||
setSaveMessage(null)
|
||||
await api.addPartner(workspace.id, newPartnerName)
|
||||
await refreshMembers()
|
||||
@@ -304,7 +300,7 @@ export function Settings(): ReactNode {
|
||||
} catch (err) {
|
||||
showMessage('error', describePartnerError(err, t, t('settings.addPartnerError')))
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
setIsSavingFamily(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,7 +322,7 @@ export function Settings(): ReactNode {
|
||||
return
|
||||
}
|
||||
try {
|
||||
setIsSaving(true)
|
||||
setIsSavingFamily(true)
|
||||
setMemberActionError(null)
|
||||
await api.renameWorkspaceParent(workspace.id, uuid, renameValue)
|
||||
await refreshMembers()
|
||||
@@ -338,7 +334,7 @@ export function Settings(): ReactNode {
|
||||
} catch (err) {
|
||||
setMemberActionError({ uuid, text: describePartnerError(err, t, t('settings.renamePartnerError')) })
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
setIsSavingFamily(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,7 +351,7 @@ export function Settings(): ReactNode {
|
||||
const handleConfirmRemove = async (uuid: string) => {
|
||||
if (!workspace?.id) return
|
||||
try {
|
||||
setIsSaving(true)
|
||||
setIsSavingFamily(true)
|
||||
setMemberActionError(null)
|
||||
await api.removeWorkspaceParent(workspace.id, uuid)
|
||||
setRemoveConfirmUuid(null)
|
||||
@@ -363,7 +359,7 @@ export function Settings(): ReactNode {
|
||||
} catch (err) {
|
||||
setMemberActionError({ uuid, text: describePartnerError(err, t, t('settings.removePartnerError')) })
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
setIsSavingFamily(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,44 +376,6 @@ export function Settings(): ReactNode {
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenBalanceForm = (uuid: string) => {
|
||||
setBalanceFormUuid(uuid)
|
||||
}
|
||||
|
||||
// No try/catch around the save call — a rejection propagates to
|
||||
// `ParentBalanceForm`'s own `onSave` handler, which owns showing it
|
||||
// (inline under the offending field for a mapped 422, or its own top
|
||||
// banner otherwise).
|
||||
const handleSaveBalance = async (data: {
|
||||
sjukpenningLeft: number
|
||||
lagstaNivaLeft: number
|
||||
dubbeldagarLeft: number
|
||||
reservedDaysLeft: number | null
|
||||
}) => {
|
||||
// Settings isn't scoped to a single child the way Onboarding/Dashboard
|
||||
// are guaranteed to be — `selectedChildId` can be unset before a child
|
||||
// is chosen elsewhere in the app. Fall back to the workspace's first
|
||||
// child so the balance still has somewhere to attach.
|
||||
const childId = selectedChildId ?? children[0]?.id
|
||||
if (!workspace?.id || !balanceFormUuid || !childId) return
|
||||
try {
|
||||
setIsSavingBalance(true)
|
||||
await api.saveManualFetch({
|
||||
personalDataUuid: balanceFormUuid,
|
||||
workspaceId: workspace.id,
|
||||
childId,
|
||||
sjukpenningLeft: data.sjukpenningLeft,
|
||||
lagstaNivaLeft: data.lagstaNivaLeft,
|
||||
dubbeldagarLeft: data.dubbeldagarLeft,
|
||||
reservedDaysLeft: data.reservedDaysLeft,
|
||||
source: 'manual',
|
||||
})
|
||||
setBalanceFormUuid(null)
|
||||
} finally {
|
||||
setIsSavingBalance(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div data-testid="settings-page" className="settings-page">
|
||||
@@ -433,10 +391,10 @@ export function Settings(): ReactNode {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="settings-page__section">
|
||||
<h2>{t('settings.childSectionTitle')}</h2>
|
||||
<section className="settings-panel">
|
||||
<h2>{t('settings.familySectionTitle')}</h2>
|
||||
{selectedChild ? (
|
||||
<>
|
||||
<div className="settings-panel__block">
|
||||
<p>
|
||||
{t('settings.selectedChildLabel')} <strong>{selectedChild.name}</strong>
|
||||
</p>
|
||||
@@ -445,8 +403,8 @@ export function Settings(): ReactNode {
|
||||
onBirthDateChange={setChildBirthDate}
|
||||
label={t('settings.childBirthDateLabel')}
|
||||
/>
|
||||
<div className="settings-page__field">
|
||||
<p className="settings-page__help">{t('settings.preschoolHelp')}</p>
|
||||
<div className="field settings-panel__field">
|
||||
<p className="settings-panel__help">{t('settings.preschoolHelp')}</p>
|
||||
<VacationDaysInput
|
||||
label={t('settings.preschoolPlanningDaysLabel')}
|
||||
value={preschoolPlanningDays}
|
||||
@@ -455,43 +413,34 @@ export function Settings(): ReactNode {
|
||||
max={30}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveChild}
|
||||
className="btn btn--primary"
|
||||
disabled={isSaving}
|
||||
data-testid="save-child-button"
|
||||
>
|
||||
{isSaving ? t('common.saving') : t('settings.saveChildButton')}
|
||||
</button>
|
||||
</>
|
||||
</div>
|
||||
) : (
|
||||
<p>{t('settings.noChildSelected')}</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="settings-page__section">
|
||||
<h2>{t('settings.profileSectionTitle')}</h2>
|
||||
<p className="settings-page__help">{t('settings.profileHelp')}</p>
|
||||
<h3>{t('settings.profileSectionTitle')}</h3>
|
||||
<p className="settings-panel__help">{t('settings.profileHelp')}</p>
|
||||
<ParentProfileInput parent={parentData} onChange={handleParentChange} />
|
||||
<button
|
||||
onClick={handleSaveParentProfile}
|
||||
className="btn btn--primary"
|
||||
disabled={isSaving}
|
||||
data-testid="save-parent-button"
|
||||
>
|
||||
{isSaving ? t('common.saving') : t('settings.saveProfileButton')}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="settings-page__section">
|
||||
<h2>{t('settings.partnerSectionTitle')}</h2>
|
||||
<div className="actions settings-panel__actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSaveFamily()}
|
||||
className="btn btn--primary"
|
||||
disabled={isSavingFamily}
|
||||
data-testid="save-family-button"
|
||||
>
|
||||
{isSavingFamily ? t('common.saving') : t('settings.saveFamilyButton')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3>{t('settings.partnerSectionTitle')}</h3>
|
||||
<p>{hasPartner ? t('settings.partnerLinkedIntro') : t('settings.partnerIntro')}</p>
|
||||
|
||||
{membersLoading ? (
|
||||
<p>{t('settings.membersLoading')}</p>
|
||||
) : (
|
||||
<ul className="settings-page__member-list" data-testid="member-list">
|
||||
<ul className="settings-panel__member-list" data-testid="member-list">
|
||||
{members.map((member) => {
|
||||
const isSelf = member.uuid === personalDataUuid
|
||||
const isRenaming = renamingUuid === member.uuid
|
||||
@@ -499,7 +448,7 @@ export function Settings(): ReactNode {
|
||||
return (
|
||||
<li
|
||||
key={member.uuid}
|
||||
className="settings-page__member-row"
|
||||
className="settings-panel__member-row"
|
||||
data-testid={`member-row-${member.uuid}`}
|
||||
>
|
||||
{isRenaming ? (
|
||||
@@ -508,14 +457,14 @@ export function Settings(): ReactNode {
|
||||
type="text"
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
disabled={isSaving}
|
||||
className="field__control settings-page__partner-input"
|
||||
disabled={isSavingFamily}
|
||||
className="field__control settings-panel__partner-input"
|
||||
data-testid={`rename-input-${member.uuid}`}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSaveRename(member.uuid)}
|
||||
className="btn btn--secondary btn--sm"
|
||||
disabled={isSaving}
|
||||
disabled={isSavingFamily}
|
||||
data-testid={`rename-save-${member.uuid}`}
|
||||
>
|
||||
{t('settings.saveRenameButton')}
|
||||
@@ -523,7 +472,7 @@ export function Settings(): ReactNode {
|
||||
<button
|
||||
onClick={handleCancelRename}
|
||||
className="btn btn--secondary btn--sm"
|
||||
disabled={isSaving}
|
||||
disabled={isSavingFamily}
|
||||
data-testid={`rename-cancel-${member.uuid}`}
|
||||
>
|
||||
{t('settings.cancelButton')}
|
||||
@@ -540,27 +489,19 @@ export function Settings(): ReactNode {
|
||||
<button
|
||||
onClick={() => handleStartRename(member)}
|
||||
className="btn btn--secondary btn--sm"
|
||||
disabled={isSaving}
|
||||
disabled={isSavingFamily}
|
||||
data-testid={`rename-button-${member.uuid}`}
|
||||
>
|
||||
{t('settings.renameButton')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleOpenBalanceForm(member.uuid)}
|
||||
className="btn btn--secondary btn--sm"
|
||||
disabled={isSaving}
|
||||
data-testid={`settings-balance-${member.uuid}`}
|
||||
>
|
||||
{t('settings.balanceButton')}
|
||||
</button>
|
||||
{!isSelf &&
|
||||
(isConfirmingRemove ? (
|
||||
<span className="settings-page__remove-confirm">
|
||||
<span className="settings-panel__remove-confirm">
|
||||
{t('settings.removeConfirmPrompt', { name: member.name })}
|
||||
<button
|
||||
onClick={() => handleConfirmRemove(member.uuid)}
|
||||
className="btn btn--danger btn--sm"
|
||||
disabled={isSaving}
|
||||
disabled={isSavingFamily}
|
||||
data-testid={`remove-confirm-${member.uuid}`}
|
||||
>
|
||||
{t('settings.removeConfirmButton')}
|
||||
@@ -568,7 +509,7 @@ export function Settings(): ReactNode {
|
||||
<button
|
||||
onClick={handleCancelRemove}
|
||||
className="btn btn--secondary btn--sm"
|
||||
disabled={isSaving}
|
||||
disabled={isSavingFamily}
|
||||
data-testid={`remove-cancel-${member.uuid}`}
|
||||
>
|
||||
{t('settings.cancelButton')}
|
||||
@@ -578,7 +519,7 @@ export function Settings(): ReactNode {
|
||||
<button
|
||||
onClick={() => handleRequestRemove(member.uuid)}
|
||||
className="btn btn--danger btn--sm"
|
||||
disabled={isSaving}
|
||||
disabled={isSavingFamily}
|
||||
data-testid={`remove-button-${member.uuid}`}
|
||||
>
|
||||
{t('settings.removePartnerButton')}
|
||||
@@ -602,36 +543,49 @@ export function Settings(): ReactNode {
|
||||
)}
|
||||
|
||||
{!hasPartner && (
|
||||
<div className="settings-page__partner-row">
|
||||
<div className="settings-panel__partner-row">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('settings.partnerNamePlaceholder')}
|
||||
value={newPartnerName}
|
||||
onChange={(e) => setNewPartnerName(e.target.value)}
|
||||
disabled={isSaving}
|
||||
className="field__control settings-page__partner-input"
|
||||
disabled={isSavingFamily}
|
||||
className="field__control settings-panel__partner-input"
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddPartner}
|
||||
onClick={() => void handleAddPartner()}
|
||||
className="btn btn--primary"
|
||||
disabled={isSaving}
|
||||
disabled={isSavingFamily}
|
||||
data-testid="add-partner-button"
|
||||
>
|
||||
{isSaving ? t('settings.addingPartner') : t('settings.addPartnerButton')}
|
||||
{isSavingFamily ? t('settings.addingPartner') : t('settings.addPartnerButton')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="settings-page__section">
|
||||
<h2>{t('settings.vacationSectionTitle')}</h2>
|
||||
<p className="settings-page__help">{t('settings.vacationHelp')}</p>
|
||||
<VacationDaysTable rows={vacationRows} onSave={handleSaveVacationYear} isSaving={isSaving} />
|
||||
<section id="saldon" className="settings-panel">
|
||||
<h2>{t('settings.balanceSectionTitle')}</h2>
|
||||
{membersLoading ? (
|
||||
<p>{t('settings.membersLoading')}</p>
|
||||
) : (
|
||||
<BalanceFlow
|
||||
members={members}
|
||||
personalDataUuid={personalDataUuid}
|
||||
workspaceId={workspace?.id ?? ''}
|
||||
selectedChildId={balanceChildId}
|
||||
mode="panel"
|
||||
onSaved={() => void refreshMembers()}
|
||||
/>
|
||||
)}
|
||||
|
||||
<p className="settings-panel__help">{t('settings.vacationHelp')}</p>
|
||||
<VacationDaysTable rows={vacationRows} onSave={handleSaveVacationYear} isSaving={isSavingVacation} />
|
||||
</section>
|
||||
|
||||
<section className="settings-page__section">
|
||||
<h2>{t('settings.calendarSectionTitle')}</h2>
|
||||
<label className="settings-page__checkbox-label">
|
||||
<section className="settings-panel">
|
||||
<h2>{t('settings.calendarLanguageSectionTitle')}</h2>
|
||||
<label className="settings-panel__checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showWeekNumbers}
|
||||
@@ -640,27 +594,24 @@ export function Settings(): ReactNode {
|
||||
/>
|
||||
<span>{t('settings.showWeekNumbers')}</span>
|
||||
</label>
|
||||
<label className="settings-page__checkbox-label">
|
||||
<label className="settings-panel__checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={workspace?.takePlOnRedDays ?? false}
|
||||
onChange={(e) => handleToggleTakePlOnRedDays(e.target.checked)}
|
||||
onChange={(e) => void handleToggleTakePlOnRedDays(e.target.checked)}
|
||||
disabled={isSavingRedDayPref}
|
||||
data-testid="take-pl-on-red-days-checkbox"
|
||||
/>
|
||||
<span>{t('settings.takePlOnRedDays')}</span>
|
||||
</label>
|
||||
<p className="settings-page__help">{t('settings.takePlOnRedDaysHelp')}</p>
|
||||
</section>
|
||||
<p className="settings-panel__help">{t('settings.takePlOnRedDaysHelp')}</p>
|
||||
|
||||
<section className="settings-page__section">
|
||||
<h2>{t('settings.languageSectionTitle')}</h2>
|
||||
<label htmlFor="language-select" className="settings-page__label">
|
||||
<label htmlFor="language-select" className="settings-panel__label">
|
||||
{t('settings.languageLabel')}
|
||||
</label>
|
||||
<select
|
||||
id="language-select"
|
||||
className="field__control settings-page__select"
|
||||
className="field__control settings-panel__select"
|
||||
value={explicitLocale ?? 'auto'}
|
||||
onChange={(e) => handleLanguageChange(e.target.value)}
|
||||
data-testid="language-select"
|
||||
@@ -671,22 +622,9 @@ export function Settings(): ReactNode {
|
||||
</select>
|
||||
</section>
|
||||
|
||||
<section className="settings-page__section">
|
||||
<h2>{t('settings.dayOverridesSectionTitle')}</h2>
|
||||
<p className="settings-page__help">{t('settings.dayOverridesHelp')}</p>
|
||||
<DayOverridesInput
|
||||
overrides={dayOverrides}
|
||||
isLoading={dayOverridesLoading || isSaving}
|
||||
onAdd={upsertDayOverride}
|
||||
onRemove={removeDayOverride}
|
||||
workspaceId={workspace?.id ?? ''}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="settings-page__section">
|
||||
<h2>{t('settings.redDaysSectionTitle')}</h2>
|
||||
<p className="settings-page__help">{t('settings.redDaysHelp')}</p>
|
||||
<RedDaysList
|
||||
<section className="settings-panel">
|
||||
<h2>{t('settings.exceptionsSectionTitle')}</h2>
|
||||
<CalendarExceptions
|
||||
days={calendarWindow?.days ?? []}
|
||||
startYear={redDaysStartYear}
|
||||
years={RED_DAYS_WINDOW_YEARS}
|
||||
@@ -703,31 +641,6 @@ export function Settings(): ReactNode {
|
||||
onRemoveOverride={removeDayOverride}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{balanceFormUuid &&
|
||||
(() => {
|
||||
const balanceFormMember = members.find((m) => m.uuid === balanceFormUuid)
|
||||
if (!balanceFormMember) return null
|
||||
if (balanceInitialLoading) {
|
||||
return (
|
||||
<div className="modal__overlay" data-testid="settings-balance-loading">
|
||||
<LoadingSpinner message={t('parentBalance.loadingBalance')} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<ParentBalanceForm
|
||||
parentName={balanceFormMember.name}
|
||||
isSelf={balanceFormMember.uuid === personalDataUuid}
|
||||
totalDays={members.length >= 2 ? 240 : 480}
|
||||
initial={balanceInitial}
|
||||
onSave={handleSaveBalance}
|
||||
onClose={() => setBalanceFormUuid(null)}
|
||||
isSaving={isSavingBalance}
|
||||
error={null}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
@@ -35,44 +35,13 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.onboarding__parent-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-4);
|
||||
/* Step 3 (balances) is `BalanceFlow` in `mode="wizard"` — its own
|
||||
* `balance-flow.css` owns the per-parent card/row styling; this file only
|
||||
* spaces the step's own intro text and footer around it. */
|
||||
.onboarding [data-testid='onboarding-step-balances'] .balance-flow {
|
||||
margin-bottom: var(--sp-5);
|
||||
}
|
||||
|
||||
.onboarding__parent-card h2 {
|
||||
margin: 0 0 var(--sp-1) 0;
|
||||
font-size: var(--fs-lg);
|
||||
}
|
||||
|
||||
.onboarding__balance-status {
|
||||
color: var(--c-text-muted);
|
||||
margin: 0 0 var(--sp-3) 0;
|
||||
}
|
||||
|
||||
/* `.actions` (primitives.css) right-aligns by default — these per-card
|
||||
* actions read better left-aligned, flush under the card's own status
|
||||
* text rather than pinned to the card's far edge. */
|
||||
.onboarding__parent-card-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.onboarding__hint {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--c-text-muted);
|
||||
margin-top: var(--sp-3);
|
||||
}
|
||||
|
||||
.onboarding__deduced-prompt {
|
||||
margin-bottom: var(--sp-5);
|
||||
}
|
||||
|
||||
.onboarding__deduced-prompt .btn--link {
|
||||
margin-left: var(--sp-3);
|
||||
}
|
||||
|
||||
.onboarding__footer {
|
||||
margin-top: var(--sp-5);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/* Settings — four unboxed panels (Barn & familj / Saldon / Kalender & språk /
|
||||
* Undantag & röda dagar). No cards, no boxes: a hairline between panels does
|
||||
* the separating, matching the almanac's "borders replaced by whitespace"
|
||||
* rule. Member/partner rows get their own thin hairline between rows only —
|
||||
* never a bordered box around the whole list. */
|
||||
.settings-page {
|
||||
max-width: var(--maxw);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* `.settings-page > .alert` (the save-message banner) is already styled in
|
||||
* components.css — not redefined here to avoid two files competing for the
|
||||
* same selector. */
|
||||
|
||||
.settings-panel {
|
||||
padding: var(--sp-7) 0;
|
||||
border-top: 1px solid var(--c-border);
|
||||
}
|
||||
|
||||
.settings-panel:first-of-type {
|
||||
border-top: none;
|
||||
padding-top: var(--sp-5);
|
||||
}
|
||||
|
||||
.settings-panel h2 {
|
||||
margin: 0 0 var(--sp-4) 0;
|
||||
}
|
||||
|
||||
.settings-panel h3 {
|
||||
margin: var(--sp-6) 0 var(--sp-2) 0;
|
||||
font-size: var(--fs-lg);
|
||||
}
|
||||
|
||||
.settings-panel__block {
|
||||
margin-bottom: var(--sp-4);
|
||||
}
|
||||
|
||||
.settings-panel__field {
|
||||
margin-top: var(--sp-4);
|
||||
}
|
||||
|
||||
.settings-panel__help {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--c-text-muted);
|
||||
margin: 0 0 var(--sp-3) 0;
|
||||
max-width: 60ch;
|
||||
}
|
||||
|
||||
.settings-panel__actions {
|
||||
justify-content: flex-start;
|
||||
margin-top: var(--sp-4);
|
||||
}
|
||||
|
||||
.settings-panel__label {
|
||||
display: block;
|
||||
margin: var(--sp-5) 0 var(--sp-2) 0;
|
||||
font-size: var(--fs-base);
|
||||
}
|
||||
|
||||
.settings-panel__select {
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.settings-panel__checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
cursor: pointer;
|
||||
padding: var(--sp-2) 0;
|
||||
}
|
||||
|
||||
.settings-panel__partner-row {
|
||||
display: flex;
|
||||
gap: var(--sp-3);
|
||||
margin-top: var(--sp-3);
|
||||
}
|
||||
|
||||
/* Box/border/focus chrome comes from `.field__control` in primitives.css —
|
||||
* this only adds the flex sizing that rule doesn't know about (it sits in
|
||||
* an inline row next to a button, not a stacked `.field`). */
|
||||
.settings-panel__partner-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.settings-panel__member-list {
|
||||
list-style: none;
|
||||
margin: 0 0 var(--sp-2) 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings-panel__member-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3) 0;
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
}
|
||||
|
||||
.settings-panel__remove-confirm {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
color: var(--c-danger);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
Reference in New Issue
Block a user