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);
|
||||
}
|
||||
Reference in New Issue
Block a user