Make the overview answer the year-by-year question
The dashboard now leads with a typographic headline, three key figures, per-parent age-4 bars and one row per year until the fourth birthday. Each year row shows the timeline bands beside each parent's parental-leave days left at year end and vacation days left, carried over or expiring, and opens into a per-parent table with that year's wish outcomes. Tips become a plain list and absorb the planner's surplus advice; "Detaljer" keeps the metrics and a balance snapshot for every parent. The separate plan-proposal fold and the type-count table are gone. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XBRLomdLK9jF7wU7a8AA6H
This commit is contained in:
@@ -1,328 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
|
||||
import { RedDaysList } from './RedDaysList'
|
||||
import type { CalendarDayEntry, DayOverride } from '@/types'
|
||||
|
||||
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: null,
|
||||
createdAt: '2026-01-01T00:00:00',
|
||||
updatedAt: '2026-01-01T00:00:00',
|
||||
},
|
||||
]
|
||||
|
||||
describe('RedDaysList', () => {
|
||||
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),
|
||||
}
|
||||
|
||||
it('renders one year group per year in the window, including years with no matches', () => {
|
||||
render(<RedDaysList {...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(<RedDaysList {...defaultProps} />)
|
||||
expect(screen.getByTestId('red-days-item-2026-01-01')).toHaveTextContent('Nyårsdagen')
|
||||
// "; Söndag" is stripped, not dumped raw
|
||||
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', () => {
|
||||
render(<RedDaysList {...defaultProps} />)
|
||||
const overrideItem = screen.getByTestId('red-days-item-2026-12-24')
|
||||
expect(overrideItem).toBeInTheDocument()
|
||||
expect(screen.getByTestId('red-days-override-badge-2026-12-24')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows an empty-year message for a year with no red days or overrides', () => {
|
||||
render(<RedDaysList {...defaultProps} />)
|
||||
const year2028 = screen.getByTestId('red-days-year-2028')
|
||||
expect(year2028).toHaveTextContent(/Inga röda dagar eller undantag/)
|
||||
})
|
||||
|
||||
it('skips a plain working day that is neither a red day nor an override', () => {
|
||||
render(<RedDaysList {...defaultProps} />)
|
||||
// 2026-05-04 is a plain working_day, non-override entry in baseDays — it
|
||||
// must not show up as a list item under its year.
|
||||
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 = [
|
||||
{
|
||||
date: '2026-03-02',
|
||||
name: null,
|
||||
dayType: 'working_day' as const,
|
||||
isOverride: true,
|
||||
overrideIsWorkingDay: true,
|
||||
isFree: false,
|
||||
},
|
||||
]
|
||||
render(<RedDaysList {...defaultProps} days={workingOverrideDays} />)
|
||||
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 = [
|
||||
{
|
||||
date: '2026-03-02',
|
||||
name: null,
|
||||
dayType: 'working_day' as const,
|
||||
isOverride: true,
|
||||
overrideIsWorkingDay: true,
|
||||
isFree: false,
|
||||
},
|
||||
]
|
||||
render(<RedDaysList {...defaultProps} days={workingDays} />)
|
||||
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(<RedDaysList {...defaultProps} startYear={2026} minYear={2026} />)
|
||||
expect(screen.getByTestId('red-days-prev')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('disables the next button when the window reaches maxYear', () => {
|
||||
render(<RedDaysList {...defaultProps} startYear={2032} maxYear={2035} />)
|
||||
expect(screen.getByTestId('red-days-next')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('enables both buttons in the middle of the range', () => {
|
||||
render(<RedDaysList {...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()
|
||||
// Neither button disabled: startYear sits strictly between minYear and maxYear.
|
||||
render(
|
||||
<RedDaysList
|
||||
{...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', () => {
|
||||
// Regression test: the backend clamps a window whose requested end
|
||||
// exceeds the cached range (e.g. paging to the last page) and reports
|
||||
// the ACTUAL end year via `endYear`. Without honoring it, this component
|
||||
// would render extra year headers (2036, 2037) the backend never
|
||||
// returned data for, showing a false "no red days" for years that are
|
||||
// simply outside the cache rather than genuinely holiday-free.
|
||||
render(
|
||||
<RedDaysList
|
||||
{...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(<RedDaysList {...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(<RedDaysList {...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(<RedDaysList {...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(<RedDaysList {...defaultProps} onRemoveOverride={onRemoveOverride} />)
|
||||
|
||||
// 2026-12-24 carries the override from `baseOverrides` (id: do-2026-12-24).
|
||||
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(
|
||||
<RedDaysList
|
||||
{...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,
|
||||
})
|
||||
// The 2027 New Year's Day button was never touched by the 2026 click.
|
||||
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(<RedDaysList {...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(<RedDaysList {...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(<RedDaysList {...defaultProps} />)
|
||||
const button = screen.getByTestId('red-days-toggle-2026-01-01')
|
||||
expect(button.tagName).toBe('BUTTON')
|
||||
expect(button).toHaveAccessibleName(/2026|januari/i)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,204 +0,0 @@
|
||||
/**
|
||||
* §3.8 — a per-year list of röda dagar (statutory red days) plus the
|
||||
* workspace's own day overrides, four years at a time, paginated across the
|
||||
* full cached range using `minYear`/`maxYear` from `GET /api/calendar/days`.
|
||||
*
|
||||
* Product-owner decision: show BOTH the statutory red days AND the user's
|
||||
* day overrides (worked/off) — not just the statutory calendar — so the
|
||||
* list matches what the app actually treats as free.
|
||||
*/
|
||||
import { ReactNode, useMemo, useState } from 'react'
|
||||
import type { CalendarDayEntry, DayOverride, DayOverrideCreateRequest } from '@/types'
|
||||
import { formatDateForDisplay, formatHolidayName } from '@/utils/dates'
|
||||
import { useTranslation } from '@/i18n'
|
||||
|
||||
export interface RedDaysListProps {
|
||||
/** All days for the current window (any day_type); the component filters to red days + overrides. */
|
||||
days: CalendarDayEntry[]
|
||||
startYear: number
|
||||
years?: number
|
||||
/**
|
||||
* The window's ACTUAL end year, as reported by `GET /api/calendar/days`
|
||||
* (`CalendarWindow.endYear`). The backend clamps its window to the cached
|
||||
* range, so the last page can span fewer than `years` years — pass this
|
||||
* through so the header/year-groups never claim years the backend didn't
|
||||
* return (which would otherwise render as false "no red days" for years
|
||||
* past the cache, e.g. once paging reaches `maxYear`). Falls back to the
|
||||
* naive `startYear + years - 1` when omitted, e.g. before the first fetch
|
||||
* resolves and there's no response to read it from yet.
|
||||
*/
|
||||
endYear?: number | undefined
|
||||
minYear: number | null
|
||||
maxYear: number | null
|
||||
isLoading?: boolean
|
||||
error?: string | null
|
||||
onPrevWindow: () => void
|
||||
onNextWindow: () => void
|
||||
/** The workspace's own day overrides — used to find an existing override's id so a click back to the statutory value can `remove` it rather than leave a redundant row. Not scoped to the visible window: dates are absolute, so this can be the full workspace list. */
|
||||
overrides: DayOverride[]
|
||||
workspaceId: string
|
||||
/** Creates/replaces the override for one absolute date — never affects the same month/day in another year. */
|
||||
onUpsertOverride: (req: DayOverrideCreateRequest) => Promise<void>
|
||||
/** Deletes an existing override by id, reverting that one date to its statutory value. */
|
||||
onRemoveOverride: (id: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function RedDaysList({
|
||||
days,
|
||||
startYear,
|
||||
years = 4,
|
||||
endYear: endYearProp,
|
||||
minYear,
|
||||
maxYear,
|
||||
isLoading = false,
|
||||
error = null,
|
||||
onPrevWindow,
|
||||
onNextWindow,
|
||||
overrides,
|
||||
workspaceId,
|
||||
onUpsertOverride,
|
||||
onRemoveOverride,
|
||||
}: RedDaysListProps): ReactNode {
|
||||
const { t } = useTranslation()
|
||||
const endYear = endYearProp ?? startYear + years - 1
|
||||
const [pendingDate, setPendingDate] = useState<string | null>(null)
|
||||
const [toggleError, setToggleError] = useState<string | null>(null)
|
||||
|
||||
const overrideIdByDate = useMemo(() => {
|
||||
const map = new Map<string, string>()
|
||||
for (const override of overrides) {
|
||||
map.set(override.date, override.id)
|
||||
}
|
||||
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 overrideId = overrideIdByDate.get(day.date)
|
||||
if (!overrideId) {
|
||||
setToggleError(t('redDaysList.toggleError'))
|
||||
return
|
||||
}
|
||||
await onRemoveOverride(overrideId)
|
||||
} 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
|
||||
|
||||
return (
|
||||
<div className="red-days-list" data-testid="red-days-list">
|
||||
<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) => (
|
||||
<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)}</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>
|
||||
)}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render } from '@testing-library/react'
|
||||
import { DaysBreakdown, type DaysBreakdownProps } from './DaysBreakdown'
|
||||
|
||||
describe('DaysBreakdown', () => {
|
||||
it('renders empty state when no breakdown provided', () => {
|
||||
const { getByTestId } = render(<DaysBreakdown breakdown={[]} />)
|
||||
expect(getByTestId('days-breakdown').textContent).toContain('Inga dagar registrerade än')
|
||||
})
|
||||
|
||||
it('renders empty state when breakdown itself is missing (defensive null/undefined guard)', () => {
|
||||
const { getByTestId } = render(
|
||||
<DaysBreakdown breakdown={undefined as unknown as DaysBreakdownProps['breakdown']} />
|
||||
)
|
||||
expect(getByTestId('days-breakdown').textContent).toContain('Inga dagar registrerade än')
|
||||
})
|
||||
|
||||
it('renders table with breakdown data', () => {
|
||||
const breakdown = [
|
||||
{ type: 'vacation', count: 10 },
|
||||
{ type: 'sick leave', count: 3 },
|
||||
{ type: 'parental', count: 50 },
|
||||
]
|
||||
const { getByTestId } = render(<DaysBreakdown breakdown={breakdown} />)
|
||||
const table = getByTestId('days-breakdown')
|
||||
expect(table.querySelector('table')).toBeInTheDocument()
|
||||
expect(table.textContent).toContain('vacation')
|
||||
expect(table.textContent).toContain('10')
|
||||
})
|
||||
|
||||
it('renders correct number of rows', () => {
|
||||
const breakdown = [
|
||||
{ type: 'vacation', count: 10 },
|
||||
{ type: 'sick leave', count: 3 },
|
||||
{ type: 'parental', count: 50 },
|
||||
]
|
||||
const { getByTestId } = render(<DaysBreakdown breakdown={breakdown} />)
|
||||
expect(getByTestId('breakdown-row-vacation')).toBeInTheDocument()
|
||||
expect(getByTestId('breakdown-row-sick leave')).toBeInTheDocument()
|
||||
expect(getByTestId('breakdown-row-parental')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays correct values in table', () => {
|
||||
const breakdown = [{ type: 'vacation', count: 25 }]
|
||||
const { getByTestId } = render(<DaysBreakdown breakdown={breakdown} />)
|
||||
const row = getByTestId('breakdown-row-vacation')
|
||||
expect(row.textContent).toContain('vacation')
|
||||
expect(row.textContent).toContain('25')
|
||||
})
|
||||
|
||||
it('renders table headers', () => {
|
||||
const breakdown = [{ type: 'vacation', count: 10 }]
|
||||
const { getByTestId } = render(<DaysBreakdown breakdown={breakdown} />)
|
||||
const table = getByTestId('days-breakdown').querySelector('table')
|
||||
expect(table?.querySelector('thead')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,39 +0,0 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { useTranslation } from '@/i18n'
|
||||
|
||||
export interface DaysBreakdownProps {
|
||||
breakdown: Array<{ type: string; count: number }>
|
||||
}
|
||||
|
||||
export function DaysBreakdown({ breakdown }: DaysBreakdownProps): ReactNode {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!breakdown || breakdown.length === 0) {
|
||||
return (
|
||||
<div data-testid="days-breakdown" className="days-breakdown">
|
||||
<p className="days-breakdown__empty">{t('daysBreakdown.empty')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="days-breakdown" className="days-breakdown">
|
||||
<table className="days-breakdown__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('daysBreakdown.typeHeader')}</th>
|
||||
<th>{t('daysBreakdown.daysHeader')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{breakdown.map((item, idx) => (
|
||||
<tr key={idx} data-testid={`breakdown-row-${item.type}`}>
|
||||
<td>{item.type}</td>
|
||||
<td>{item.count}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { KeyFigures } from './KeyFigures'
|
||||
|
||||
describe('KeyFigures', () => {
|
||||
it('renders the three combined figures with their labels', () => {
|
||||
render(<KeyFigures daysLeft={270} daysUsed={30} expiringAtB4={100} />)
|
||||
expect(screen.getByTestId('dashboard-figure-left')).toHaveTextContent('270')
|
||||
expect(screen.getByTestId('dashboard-figure-left')).toHaveTextContent('Dagar kvar')
|
||||
expect(screen.getByTestId('dashboard-figure-used')).toHaveTextContent('30')
|
||||
expect(screen.getByTestId('dashboard-figure-used')).toHaveTextContent('Använda')
|
||||
expect(screen.getByTestId('dashboard-figure-expiring')).toHaveTextContent('100')
|
||||
expect(screen.getByTestId('dashboard-figure-expiring')).toHaveTextContent('Förfaller vid 4 år')
|
||||
})
|
||||
|
||||
it('wears danger ink on the expiring figure only when it is non-zero', () => {
|
||||
const { rerender } = render(<KeyFigures daysLeft={270} daysUsed={30} expiringAtB4={100} />)
|
||||
expect(screen.getByTestId('dashboard-figure-expiring').querySelector('dd')).toHaveClass(
|
||||
'key-figures__value--danger',
|
||||
)
|
||||
rerender(<KeyFigures daysLeft={270} daysUsed={30} expiringAtB4={0} />)
|
||||
expect(screen.getByTestId('dashboard-figure-expiring').querySelector('dd')).not.toHaveClass(
|
||||
'key-figures__value--danger',
|
||||
)
|
||||
expect(screen.getByTestId('dashboard-figure-left').querySelector('dd')).not.toHaveClass(
|
||||
'key-figures__value--danger',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { useTranslation } from '@/i18n'
|
||||
|
||||
export interface KeyFiguresProps {
|
||||
/** `combinedDaysLeft` — days still available to plan, all parents. */
|
||||
daysLeft: number
|
||||
/** `combinedDaysUsed`. */
|
||||
daysUsed: number
|
||||
/** `combinedExpiringAtB4` — projected to be lost at the 4th birthday. */
|
||||
expiringAtB4: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The three headline figures under the hero — large tabular numerals with
|
||||
* a 14px label each, no tiles. The expiring figure wears danger ink only
|
||||
* when it is non-zero: a zero is good news and should read as quiet.
|
||||
*/
|
||||
export function KeyFigures({ daysLeft, daysUsed, expiringAtB4 }: KeyFiguresProps): ReactNode {
|
||||
const { t } = useTranslation()
|
||||
const figures = [
|
||||
{ key: 'left', label: t('dashboard.figureLeft'), value: daysLeft, danger: false },
|
||||
{ key: 'used', label: t('dashboard.figureUsed'), value: daysUsed, danger: false },
|
||||
{ key: 'expiring', label: t('dashboard.figureExpiring'), value: expiringAtB4, danger: expiringAtB4 > 0 },
|
||||
]
|
||||
return (
|
||||
<dl className="key-figures" data-testid="key-figures">
|
||||
{figures.map((figure) => (
|
||||
<div key={figure.key} className="key-figures__item" data-testid={`dashboard-figure-${figure.key}`}>
|
||||
<dt className="key-figures__label">{figure.label}</dt>
|
||||
<dd className={figure.danger ? 'key-figures__value key-figures__value--danger' : 'key-figures__value'}>
|
||||
{figure.value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
@@ -4,103 +4,67 @@ import { ParentalLeaveBreakdown } from './ParentalLeaveBreakdown'
|
||||
import { LEAVE_RULES } from '@/utils/constants'
|
||||
import { LanguageProvider } from '@/i18n'
|
||||
import type { ParentalLeaveFetch } from '@/types'
|
||||
import { makeParentalLeaveFetch, makeTierData } from '../../../tests/fixtures'
|
||||
|
||||
const alexData = makeParentalLeaveFetch({
|
||||
personalDataUuid: 'pd-a',
|
||||
source: 'scraped',
|
||||
fetchedAt: '2026-06-13T10:00:00Z',
|
||||
transferableDaysLeft: 50,
|
||||
dubbeldagarLeft: 60,
|
||||
tierData: makeTierData({ sjukpenningLeft: 80, sjukpenningTotal: 100, lagstaNivaLeft: 170, lagstaNivaTotal: 200 }),
|
||||
})
|
||||
|
||||
const alex = { personalDataUuid: 'pd-a', name: 'Alex', data: alexData }
|
||||
const samWithout = { personalDataUuid: 'pd-b', name: 'Sam', data: null }
|
||||
|
||||
describe('ParentalLeaveBreakdown', () => {
|
||||
const mockData: ParentalLeaveFetch = {
|
||||
id: 'pf1',
|
||||
personalDataUuid: 'pd1',
|
||||
workspaceId: 'ws1',
|
||||
childId: 'c1',
|
||||
source: 'scraped',
|
||||
fetchedAt: '2026-06-13T10:00:00Z',
|
||||
totalDays: 300,
|
||||
daysUsed: 50,
|
||||
daysLeft: 250,
|
||||
reservedDaysTotal: 90,
|
||||
reservedDaysLeft: 80,
|
||||
transferableDaysLeft: 50,
|
||||
dubbeldagarLeft: 60,
|
||||
tierData: {
|
||||
sjukpenningTotal: 100,
|
||||
sjukpenningUsed: 20,
|
||||
sjukpenningLeft: 80,
|
||||
lagstaNivaTotal: 200,
|
||||
lagstaNivaUsed: 30,
|
||||
lagstaNivaLeft: 170,
|
||||
},
|
||||
createdAt: '2026-06-13T10:00:00Z',
|
||||
updatedAt: '2026-06-13T10:00:00Z',
|
||||
}
|
||||
|
||||
it('renders empty state when data is null', () => {
|
||||
render(<ParentalLeaveBreakdown data={null} />)
|
||||
expect(screen.getByText(/Ingen data hämtad ännu/)).toBeInTheDocument()
|
||||
it('renders an empty state when there are no parents', () => {
|
||||
render(<ParentalLeaveBreakdown parents={[]} />)
|
||||
expect(screen.getByTestId('parental-leave-breakdown')).toHaveTextContent('Inga saldon hämtade än.')
|
||||
})
|
||||
|
||||
it('omits the tier rows when tierData is absent', () => {
|
||||
const dataWithoutTiers = { ...mockData, tierData: undefined } as unknown as ParentalLeaveFetch
|
||||
render(<ParentalLeaveBreakdown data={dataWithoutTiers} />)
|
||||
expect(screen.queryByTestId('breakdown-sjukpenning')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('breakdown-lagsta-niva')).not.toBeInTheDocument()
|
||||
// The rows that don't depend on tierData still render.
|
||||
expect(screen.getByTestId('breakdown-transferable')).toBeInTheDocument()
|
||||
it('renders one column per parent with each tier as left-of-total', () => {
|
||||
render(<ParentalLeaveBreakdown parents={[alex, samWithout]} />)
|
||||
expect(screen.getByRole('columnheader', { name: 'Alex' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('columnheader', { name: 'Sam' })).toBeInTheDocument()
|
||||
expect(screen.getByTestId('breakdown-sjukpenning-pd-a')).toHaveTextContent('80 av 100')
|
||||
expect(screen.getByTestId('breakdown-lagsta-niva-pd-a')).toHaveTextContent('170 av 200')
|
||||
expect(screen.getByTestId('breakdown-transferable-pd-a')).toHaveTextContent('50')
|
||||
expect(screen.getByTestId('breakdown-dubbeldagar-pd-a')).toHaveTextContent('60')
|
||||
})
|
||||
|
||||
it('renders breakdown table with data', () => {
|
||||
render(<ParentalLeaveBreakdown data={mockData} />)
|
||||
expect(screen.getByTestId('parental-leave-breakdown')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('breakdown-transferable')).toBeInTheDocument()
|
||||
it('renders dashes and a "no balance yet" note for a parent without a snapshot', () => {
|
||||
render(<ParentalLeaveBreakdown parents={[alex, samWithout]} />)
|
||||
expect(screen.getByTestId('breakdown-sjukpenning-pd-b')).toHaveTextContent('–')
|
||||
expect(screen.getByTestId('breakdown-dubbeldagar-pd-b')).toHaveTextContent('–')
|
||||
expect(screen.getByTestId('breakdown-provenance-pd-b')).toHaveTextContent('Sam Inget saldo än')
|
||||
})
|
||||
|
||||
it('does not render a reserved-days countdown row', () => {
|
||||
render(<ParentalLeaveBreakdown data={mockData} />)
|
||||
expect(screen.queryByTestId('breakdown-reserved')).not.toBeInTheDocument()
|
||||
it('renders dashes for the tier rows when tierData is absent', () => {
|
||||
const noTiers = { ...alexData, tierData: undefined } as unknown as ParentalLeaveFetch // deliberately malformed wire data
|
||||
render(<ParentalLeaveBreakdown parents={[{ ...alex, data: noTiers }]} />)
|
||||
expect(screen.getByTestId('breakdown-sjukpenning-pd-a')).toHaveTextContent('–')
|
||||
expect(screen.getByTestId('breakdown-lagsta-niva-pd-a')).toHaveTextContent('–')
|
||||
expect(screen.getByTestId('breakdown-transferable-pd-a')).toHaveTextContent('50')
|
||||
})
|
||||
|
||||
it('displays a reserved-days explanatory note instead of a countdown', () => {
|
||||
render(<ParentalLeaveBreakdown data={mockData} />)
|
||||
const note = screen.getByTestId('reserved-days-note')
|
||||
expect(note).toHaveTextContent(`${LEAVE_RULES.reservedDaysPerParent} av dina dagar är reserverade`)
|
||||
expect(note).toHaveTextContent(`max ${LEAVE_RULES.transferableDaysPerParent} dagar kan överlåtas`)
|
||||
it('displays the reserved-days note once, not per parent', () => {
|
||||
render(<ParentalLeaveBreakdown parents={[alex, samWithout]} />)
|
||||
const notes = screen.getAllByTestId('reserved-days-note')
|
||||
expect(notes).toHaveLength(1)
|
||||
expect(notes[0]).toHaveTextContent(`${LEAVE_RULES.reservedDaysPerParent} av varje förälders dagar är reserverade`)
|
||||
expect(notes[0]).toHaveTextContent(`max ${LEAVE_RULES.transferableDaysPerParent} dagar kan överlåtas`)
|
||||
})
|
||||
|
||||
it('displays transferable days', () => {
|
||||
render(<ParentalLeaveBreakdown data={mockData} />)
|
||||
const row = screen.getByTestId('breakdown-transferable')
|
||||
expect(row).toHaveTextContent('50')
|
||||
it('shows the scraped source and date per parent', () => {
|
||||
render(<ParentalLeaveBreakdown parents={[alex]} />)
|
||||
expect(screen.getByTestId('breakdown-provenance-pd-a')).toHaveTextContent('Alex Hämtad från Försäkringskassan 2026-06-13')
|
||||
})
|
||||
|
||||
it('displays dubbeldagar left', () => {
|
||||
render(<ParentalLeaveBreakdown data={mockData} />)
|
||||
const row = screen.getByTestId('breakdown-dubbeldagar')
|
||||
expect(row).toHaveTextContent('60')
|
||||
})
|
||||
|
||||
it('displays tier data', () => {
|
||||
render(<ParentalLeaveBreakdown data={mockData} />)
|
||||
const sjukRow = screen.getByTestId('breakdown-sjukpenning')
|
||||
expect(sjukRow).toHaveTextContent('80')
|
||||
expect(sjukRow).toHaveTextContent('100')
|
||||
|
||||
const lagstaRow = screen.getByTestId('breakdown-lagsta-niva')
|
||||
expect(lagstaRow).toHaveTextContent('170')
|
||||
expect(lagstaRow).toHaveTextContent('200')
|
||||
})
|
||||
|
||||
it('displays scraped source label', () => {
|
||||
render(<ParentalLeaveBreakdown data={mockData} />)
|
||||
expect(screen.getByText(/Hämtad från Försäkringskassan/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays manual source label', () => {
|
||||
const manualData = { ...mockData, source: 'manual' as const }
|
||||
render(<ParentalLeaveBreakdown data={manualData} />)
|
||||
expect(screen.getByText(/Manuellt angiven/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays formatted date', () => {
|
||||
render(<ParentalLeaveBreakdown data={mockData} />)
|
||||
expect(screen.getByText(/2026-06-13/)).toBeInTheDocument()
|
||||
it('shows the manual source label', () => {
|
||||
render(<ParentalLeaveBreakdown parents={[{ ...alex, data: { ...alexData, source: 'manual' } }]} />)
|
||||
expect(screen.getByTestId('breakdown-provenance-pd-a')).toHaveTextContent('Manuellt angiven')
|
||||
})
|
||||
|
||||
it('formats the provenance date using the en-US locale when the language is English', () => {
|
||||
@@ -114,11 +78,12 @@ describe('ParentalLeaveBreakdown', () => {
|
||||
try {
|
||||
render(
|
||||
<LanguageProvider>
|
||||
<ParentalLeaveBreakdown data={mockData} />
|
||||
<ParentalLeaveBreakdown parents={[alex]} />
|
||||
</LanguageProvider>,
|
||||
)
|
||||
// en-US formats as M/D/YYYY, distinct from the sv-SE "2026-06-13" form.
|
||||
expect(screen.getByText(/6\/13\/2026/)).toBeInTheDocument()
|
||||
expect(screen.getByTestId('breakdown-provenance-pd-a')).toHaveTextContent('6/13/2026')
|
||||
expect(screen.getByTestId('breakdown-sjukpenning-pd-a')).toHaveTextContent('80 of 100')
|
||||
} finally {
|
||||
Object.defineProperty(window.navigator, 'language', { value: 'sv-SE', configurable: true })
|
||||
vi.unstubAllGlobals()
|
||||
|
||||
@@ -3,14 +3,27 @@ import type { ParentalLeaveFetch } from '@/types'
|
||||
import { LEAVE_RULES } from '@/utils/constants'
|
||||
import { useTranslation } from '@/i18n'
|
||||
|
||||
export interface ParentalLeaveBreakdownProps {
|
||||
/** One parent's balance snapshot — `data` is `null` when nothing has been
|
||||
* fetched or entered for them yet. */
|
||||
export interface ParentalLeaveBreakdownParent {
|
||||
personalDataUuid: string
|
||||
name: string
|
||||
data: ParentalLeaveFetch | null
|
||||
}
|
||||
|
||||
export function ParentalLeaveBreakdown({ data }: ParentalLeaveBreakdownProps): ReactNode {
|
||||
export interface ParentalLeaveBreakdownProps {
|
||||
parents: ParentalLeaveBreakdownParent[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The raw balance snapshot per parent (one column each): what is left of
|
||||
* each tier, transferable and double days, and where the figures came from.
|
||||
* Lives inside the dashboard's "Detaljer" disclosure.
|
||||
*/
|
||||
export function ParentalLeaveBreakdown({ parents }: ParentalLeaveBreakdownProps): ReactNode {
|
||||
const { t, locale } = useTranslation()
|
||||
|
||||
if (!data) {
|
||||
if (parents.length === 0) {
|
||||
return (
|
||||
<div data-testid="parental-leave-breakdown" className="parental-leave-breakdown">
|
||||
<p className="parental-leave-breakdown__empty">{t('parentalLeaveBreakdown.empty')}</p>
|
||||
@@ -18,54 +31,72 @@ export function ParentalLeaveBreakdown({ data }: ParentalLeaveBreakdownProps): R
|
||||
)
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString(locale === 'sv' ? 'sv-SE' : 'en-US')
|
||||
}
|
||||
const formatDate = (dateString: string) =>
|
||||
new Date(dateString).toLocaleDateString(locale === 'sv' ? 'sv-SE' : 'en-US')
|
||||
|
||||
const sourceLabel =
|
||||
data.source === 'scraped'
|
||||
? t('parentalLeaveBreakdown.sourceScraped')
|
||||
: t('parentalLeaveBreakdown.sourceManual')
|
||||
const cell = (parent: ParentalLeaveBreakdownParent, testId: string, render: (data: ParentalLeaveFetch) => ReactNode) => (
|
||||
<td key={parent.personalDataUuid} className="parental-leave-breakdown__num tnum" data-testid={`${testId}-${parent.personalDataUuid}`}>
|
||||
{parent.data ? render(parent.data) : '–'}
|
||||
</td>
|
||||
)
|
||||
|
||||
const leftOfTotal = (left: number, total: number) => t('parentalLeaveBreakdown.leftOfTotal', { left, total })
|
||||
|
||||
return (
|
||||
<div data-testid="parental-leave-breakdown" className="parental-leave-breakdown">
|
||||
<div className="parental-leave-breakdown__section">
|
||||
<table className="parental-leave-breakdown__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('parentalLeaveBreakdown.typeHeader')}</th>
|
||||
<th>{t('parentalLeaveBreakdown.remainingHeader')}</th>
|
||||
<th>{t('parentalLeaveBreakdown.totalHeader')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr data-testid="breakdown-transferable">
|
||||
<td>{t('parentalLeaveBreakdown.transferableDays')}</td>
|
||||
<td>{data.transferableDaysLeft}</td>
|
||||
<td>—</td>
|
||||
</tr>
|
||||
<tr data-testid="breakdown-dubbeldagar">
|
||||
<td>{t('parentalLeaveBreakdown.dubbeldagarLeft')}</td>
|
||||
<td>{data.dubbeldagarLeft}</td>
|
||||
<td>—</td>
|
||||
</tr>
|
||||
{data.tierData && (
|
||||
<>
|
||||
<tr data-testid="breakdown-sjukpenning">
|
||||
<td>{t('parentalLeaveBreakdown.sjukpenning')}</td>
|
||||
<td>{data.tierData.sjukpenningLeft}</td>
|
||||
<td>{data.tierData.sjukpenningTotal}</td>
|
||||
</tr>
|
||||
<tr data-testid="breakdown-lagsta-niva">
|
||||
<td>{t('parentalLeaveBreakdown.lagstaNiva')}</td>
|
||||
<td>{data.tierData.lagstaNivaLeft}</td>
|
||||
<td>{data.tierData.lagstaNivaTotal}</td>
|
||||
</tr>
|
||||
</>
|
||||
<table className="parental-leave-breakdown__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" />
|
||||
{parents.map((parent) => (
|
||||
<th key={parent.personalDataUuid} scope="col" className="parental-leave-breakdown__num">
|
||||
{parent.name}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr data-testid="breakdown-sjukpenning">
|
||||
<th scope="row">{t('parentalLeaveBreakdown.sjukpenning')}</th>
|
||||
{parents.map((parent) =>
|
||||
cell(parent, 'breakdown-sjukpenning', (d) =>
|
||||
d.tierData ? leftOfTotal(d.tierData.sjukpenningLeft, d.tierData.sjukpenningTotal) : '–',
|
||||
),
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</tr>
|
||||
<tr data-testid="breakdown-lagsta-niva">
|
||||
<th scope="row">{t('parentalLeaveBreakdown.lagstaNiva')}</th>
|
||||
{parents.map((parent) =>
|
||||
cell(parent, 'breakdown-lagsta-niva', (d) =>
|
||||
d.tierData ? leftOfTotal(d.tierData.lagstaNivaLeft, d.tierData.lagstaNivaTotal) : '–',
|
||||
),
|
||||
)}
|
||||
</tr>
|
||||
<tr data-testid="breakdown-transferable">
|
||||
<th scope="row">{t('parentalLeaveBreakdown.transferableDays')}</th>
|
||||
{parents.map((parent) => cell(parent, 'breakdown-transferable', (d) => d.transferableDaysLeft))}
|
||||
</tr>
|
||||
<tr data-testid="breakdown-dubbeldagar">
|
||||
<th scope="row">{t('parentalLeaveBreakdown.dubbeldagarLeft')}</th>
|
||||
{parents.map((parent) => cell(parent, 'breakdown-dubbeldagar', (d) => d.dubbeldagarLeft))}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<ul className="parental-leave-breakdown__provenance">
|
||||
{parents.map((parent) => (
|
||||
<li key={parent.personalDataUuid} data-testid={`breakdown-provenance-${parent.personalDataUuid}`}>
|
||||
<span className="parental-leave-breakdown__provenance-name">{parent.name}</span>{' '}
|
||||
{parent.data
|
||||
? `${
|
||||
parent.data.source === 'scraped'
|
||||
? t('parentalLeaveBreakdown.sourceScraped')
|
||||
: t('parentalLeaveBreakdown.sourceManual')
|
||||
} ${formatDate(parent.data.fetchedAt)}`
|
||||
: t('parentalLeaveBreakdown.noBalance')}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<p className="parental-leave-breakdown__reserved-note" data-testid="reserved-days-note">
|
||||
{t('parentalLeaveBreakdown.reservedNote', {
|
||||
@@ -73,12 +104,6 @@ export function ParentalLeaveBreakdown({ data }: ParentalLeaveBreakdownProps): R
|
||||
transferable: LEAVE_RULES.transferableDaysPerParent,
|
||||
})}
|
||||
</p>
|
||||
|
||||
<div className="parental-leave-breakdown__provenance">
|
||||
<small>
|
||||
{sourceLabel} {formatDate(data.fetchedAt)}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,344 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { PlanFold } from './PlanFold'
|
||||
import * as hooks from '@/hooks'
|
||||
import {
|
||||
makeLeaveWishesResult,
|
||||
makePlanProposalResult,
|
||||
makePersonalData,
|
||||
makeLeaveWish,
|
||||
makePlanProposal,
|
||||
makeWindowOutcome,
|
||||
makeSurplusAdvice,
|
||||
makeShortfallRemedy,
|
||||
makePoolBalance,
|
||||
makeVacationBalance,
|
||||
} from '../../../tests/fixtures'
|
||||
|
||||
vi.mock('@/hooks')
|
||||
|
||||
const members = [
|
||||
makePersonalData({ uuid: 'pd-a', name: 'Alex' }),
|
||||
makePersonalData({ uuid: 'pd-b', name: 'Sam' }),
|
||||
]
|
||||
|
||||
describe('PlanFold', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(hooks.useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [makeLeaveWish()] }))
|
||||
vi.mocked(hooks.usePlanProposal).mockReturnValue(makePlanProposalResult())
|
||||
})
|
||||
|
||||
it('is collapsed by default (a native <details> element)', () => {
|
||||
render(<PlanFold workspaceId="ws-1" members={members} />)
|
||||
const details = screen.getByTestId('plan-fold')
|
||||
expect(details.tagName).toBe('DETAILS')
|
||||
expect(details).not.toHaveAttribute('open')
|
||||
})
|
||||
|
||||
it('shows a hint instead of a proposal when there are no wishes', () => {
|
||||
vi.mocked(hooks.useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [] }))
|
||||
render(<PlanFold workspaceId="ws-1" members={members} />)
|
||||
expect(screen.getByTestId('plan-fold-no-wishes')).toHaveTextContent(
|
||||
'Lägg till önskemål under Kalender för att se ett förslag här.',
|
||||
)
|
||||
})
|
||||
|
||||
it('shows the proposal error when the read-only propose call fails', () => {
|
||||
vi.mocked(hooks.usePlanProposal).mockReturnValue(
|
||||
makePlanProposalResult({ error: 'Kunde inte beräkna förslag' }),
|
||||
)
|
||||
render(<PlanFold workspaceId="ws-1" members={members} />)
|
||||
expect(screen.getByTestId('plan-fold-proposal-error')).toHaveTextContent('Kunde inte beräkna förslag')
|
||||
})
|
||||
|
||||
it('auto-proposes once wishes are loaded, without a manual button press', () => {
|
||||
const propose = vi.fn()
|
||||
vi.mocked(hooks.usePlanProposal).mockReturnValue(makePlanProposalResult({ propose }))
|
||||
render(<PlanFold workspaceId="ws-1" members={members} />)
|
||||
expect(propose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not propose again once a proposal is already loaded', () => {
|
||||
const propose = vi.fn()
|
||||
vi.mocked(hooks.usePlanProposal).mockReturnValue(
|
||||
makePlanProposalResult({ propose, data: makePlanProposal() }),
|
||||
)
|
||||
render(<PlanFold workspaceId="ws-1" members={members} />)
|
||||
expect(propose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not propose while there are no wishes yet', () => {
|
||||
const propose = vi.fn()
|
||||
vi.mocked(hooks.useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [] }))
|
||||
vi.mocked(hooks.usePlanProposal).mockReturnValue(makePlanProposalResult({ propose }))
|
||||
render(<PlanFold workspaceId="ws-1" members={members} />)
|
||||
expect(propose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not propose while wishes are still loading', () => {
|
||||
const propose = vi.fn()
|
||||
vi.mocked(hooks.useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ isLoading: true, data: [] }))
|
||||
vi.mocked(hooks.usePlanProposal).mockReturnValue(makePlanProposalResult({ propose }))
|
||||
render(<PlanFold workspaceId="ws-1" members={members} />)
|
||||
expect(propose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not propose again while a propose call is already in flight', () => {
|
||||
const propose = vi.fn()
|
||||
vi.mocked(hooks.usePlanProposal).mockReturnValue(makePlanProposalResult({ propose, isLoading: true }))
|
||||
render(<PlanFold workspaceId="ws-1" members={members} />)
|
||||
expect(propose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders per-period wished-vs-funded from the proposal', () => {
|
||||
vi.mocked(hooks.usePlanProposal).mockReturnValue(
|
||||
makePlanProposalResult({
|
||||
data: makePlanProposal({
|
||||
outcomes: [makeWindowOutcome({ wishId: 'lw-1', wishName: 'Sommar', year: 2026, weeksWished: 6, weeksFunded: 4 })],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
render(<PlanFold workspaceId="ws-1" members={members} />)
|
||||
expect(screen.getByTestId('plan-fold-overview-year-2026')).toHaveTextContent('Sommar: 4/6 veckor')
|
||||
})
|
||||
|
||||
it('shows the empty-horizon message when the proposal has no outcomes', () => {
|
||||
vi.mocked(hooks.usePlanProposal).mockReturnValue(
|
||||
makePlanProposalResult({ data: makePlanProposal({ outcomes: [] }) }),
|
||||
)
|
||||
render(<PlanFold workspaceId="ws-1" members={members} />)
|
||||
expect(screen.getByTestId('plan-fold-overview')).toHaveTextContent('Inga fönster inom planeringshorisonten.')
|
||||
})
|
||||
|
||||
it('renders surplus advice with a resolved parent name, never a raw uuid', () => {
|
||||
vi.mocked(hooks.usePlanProposal).mockReturnValue(
|
||||
makePlanProposalResult({
|
||||
data: makePlanProposal({
|
||||
surplusAdvice: [
|
||||
makeSurplusAdvice({
|
||||
kind: 'wish_more_leave',
|
||||
messageKey: 'planSurplusAdvice.wish_more_leave',
|
||||
daysInvolved: 12,
|
||||
data: { parent_personal_data_uuid: 'pd-b', child_id: 'child-1', weeks: 2 },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
render(<PlanFold workspaceId="ws-1" members={members} />)
|
||||
const advice = screen.getByTestId('plan-fold-surplus-advice-0')
|
||||
expect(advice).toHaveTextContent('Sam')
|
||||
expect(advice).not.toHaveTextContent('pd-b')
|
||||
})
|
||||
|
||||
it('falls back to a generic label for surplus advice when no member matches', () => {
|
||||
vi.mocked(hooks.usePlanProposal).mockReturnValue(
|
||||
makePlanProposalResult({
|
||||
data: makePlanProposal({
|
||||
surplusAdvice: [
|
||||
makeSurplusAdvice({
|
||||
kind: 'wish_more_leave',
|
||||
messageKey: 'planSurplusAdvice.wish_more_leave',
|
||||
daysInvolved: 12,
|
||||
data: { parent_personal_data_uuid: 'pd-unknown', child_id: 'child-1', weeks: 2 },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
render(<PlanFold workspaceId="ws-1" members={members} />)
|
||||
const advice = screen.getByTestId('plan-fold-surplus-advice-0')
|
||||
expect(advice).not.toHaveTextContent('pd-unknown')
|
||||
})
|
||||
|
||||
it('renders an unrecognised surplus advice kind/messageKey by falling back to the raw value', () => {
|
||||
vi.mocked(hooks.usePlanProposal).mockReturnValue(
|
||||
makePlanProposalResult({
|
||||
data: makePlanProposal({
|
||||
surplusAdvice: [
|
||||
makeSurplusAdvice({
|
||||
kind: 'brand_new_kind',
|
||||
messageKey: 'planSurplusAdvice.brand_new_kind',
|
||||
data: {},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
render(<PlanFold workspaceId="ws-1" members={members} />)
|
||||
const advice = screen.getByTestId('plan-fold-surplus-advice-0')
|
||||
expect(advice).toHaveTextContent('brand_new_kind')
|
||||
})
|
||||
|
||||
it('does not render a surplus-advice section when there is none', () => {
|
||||
vi.mocked(hooks.usePlanProposal).mockReturnValue(
|
||||
makePlanProposalResult({ data: makePlanProposal({ surplusAdvice: [] }) }),
|
||||
)
|
||||
render(<PlanFold workspaceId="ws-1" members={members} />)
|
||||
expect(screen.queryByTestId('plan-fold-surplus-advice')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders pool and vacation balance tables', () => {
|
||||
vi.mocked(hooks.usePlanProposal).mockReturnValue(
|
||||
makePlanProposalResult({
|
||||
data: makePlanProposal({
|
||||
poolBalances: [makePoolBalance({ parentPersonalDataUuid: 'pd-a', sjukpenningLeft: 300, lagstaLeft: 80, dubbeldagarLeft: 50 })],
|
||||
vacationBalances: [makeVacationBalance({ parentPersonalDataUuid: 'pd-a', year: 2026, alreadyUsed: 10, totalDays: 25, savedDaysIn: 0 })],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
render(<PlanFold workspaceId="ws-1" members={members} />)
|
||||
expect(screen.getByTestId('plan-fold-pool-balances')).toHaveTextContent('Alex')
|
||||
expect(screen.getByTestId('plan-fold-pool-balances')).toHaveTextContent('300')
|
||||
expect(screen.getByTestId('plan-fold-vacation-balances')).toHaveTextContent('25')
|
||||
})
|
||||
|
||||
it('does not render a shortfalls section when no window has a shortfall', () => {
|
||||
vi.mocked(hooks.usePlanProposal).mockReturnValue(
|
||||
makePlanProposalResult({
|
||||
data: makePlanProposal({ outcomes: [makeWindowOutcome({ shortfallDays: 0, remedies: [] })] }),
|
||||
}),
|
||||
)
|
||||
render(<PlanFold workspaceId="ws-1" members={members} />)
|
||||
expect(screen.queryByTestId('plan-fold-shortfalls')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the shortfall line and its remedies for a window that ran short', () => {
|
||||
vi.mocked(hooks.usePlanProposal).mockReturnValue(
|
||||
makePlanProposalResult({
|
||||
data: makePlanProposal({
|
||||
outcomes: [
|
||||
makeWindowOutcome({
|
||||
wishId: 'lw-1',
|
||||
wishName: 'Sportlov',
|
||||
year: 2030,
|
||||
shortfallDays: 2,
|
||||
remedies: [
|
||||
makeShortfallRemedy({
|
||||
kind: 'carry_vacation_forward',
|
||||
messageKey: 'planRemedies.carry_vacation_forward',
|
||||
data: { spare_total: 5 },
|
||||
daysFreed: 5,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
render(<PlanFold workspaceId="ws-1" members={members} />)
|
||||
const shortfall = screen.getByTestId('plan-fold-shortfall-lw-1-2030')
|
||||
expect(shortfall).toHaveTextContent('Sportlov 2030')
|
||||
expect(shortfall).toHaveTextContent('2 dag(ar) kunde inte finansieras')
|
||||
expect(screen.getByTestId('plan-fold-remedy-lw-1-2030-0')).toHaveTextContent(
|
||||
'5 semesterdagar från tidigare år kan sparas framåt',
|
||||
)
|
||||
})
|
||||
|
||||
it('renders the widened shift-window remedy with its concrete dates', () => {
|
||||
vi.mocked(hooks.usePlanProposal).mockReturnValue(
|
||||
makePlanProposalResult({
|
||||
data: makePlanProposal({
|
||||
outcomes: [
|
||||
makeWindowOutcome({
|
||||
wishId: 'lw-2',
|
||||
wishName: 'Vårvecka',
|
||||
year: 2028,
|
||||
shortfallDays: 5,
|
||||
remedies: [
|
||||
makeShortfallRemedy({
|
||||
kind: 'shift_window_into_free_days',
|
||||
messageKey: 'planRemedies.shift_window_into_free_days',
|
||||
data: {
|
||||
wish_name: 'Vårvecka',
|
||||
new_start_date: '2028-04-11',
|
||||
new_end_date: '2028-04-17',
|
||||
shift_gain: 2,
|
||||
},
|
||||
daysFreed: 2,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
render(<PlanFold workspaceId="ws-1" members={members} />)
|
||||
expect(screen.getByTestId('plan-fold-remedy-lw-2-2028-0')).toHaveTextContent(
|
||||
'2028-04-11 – 2028-04-17',
|
||||
)
|
||||
})
|
||||
|
||||
it('renders a window with multiple remedies as one line each', () => {
|
||||
vi.mocked(hooks.usePlanProposal).mockReturnValue(
|
||||
makePlanProposalResult({
|
||||
data: makePlanProposal({
|
||||
outcomes: [
|
||||
makeWindowOutcome({
|
||||
wishId: 'lw-3',
|
||||
year: 2027,
|
||||
shortfallDays: 4,
|
||||
remedies: [
|
||||
makeShortfallRemedy({
|
||||
kind: 'reduce_preschool_reserve',
|
||||
messageKey: 'planRemedies.reduce_preschool_reserve',
|
||||
data: { reserved: 3 },
|
||||
daysFreed: 3,
|
||||
}),
|
||||
makeShortfallRemedy({
|
||||
kind: 'lower_priority_wish',
|
||||
messageKey: 'planRemedies.lower_priority_wish',
|
||||
data: { wish_name: 'Höstlov', from_weeks: 2, to_weeks: 1 },
|
||||
daysFreed: 4,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
render(<PlanFold workspaceId="ws-1" members={members} />)
|
||||
expect(screen.getByTestId('plan-fold-remedy-lw-3-2027-0')).toHaveTextContent(
|
||||
'Minska förskolans planeringsdagar (3 reserverade) frigör 3 dagar',
|
||||
)
|
||||
expect(screen.getByTestId('plan-fold-remedy-lw-3-2027-1')).toHaveTextContent(
|
||||
'Sänk Höstlov från 2 till 1 veckor frigör 4 dagar',
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the raw messageKey for an unrecognised remedy kind', () => {
|
||||
vi.mocked(hooks.usePlanProposal).mockReturnValue(
|
||||
makePlanProposalResult({
|
||||
data: makePlanProposal({
|
||||
outcomes: [
|
||||
makeWindowOutcome({
|
||||
wishId: 'lw-4',
|
||||
year: 2027,
|
||||
shortfallDays: 1,
|
||||
remedies: [
|
||||
makeShortfallRemedy({
|
||||
kind: 'brand_new_kind',
|
||||
messageKey: 'planRemedies.brand_new_kind',
|
||||
data: {},
|
||||
daysFreed: 1,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
render(<PlanFold workspaceId="ws-1" members={members} />)
|
||||
expect(screen.getByTestId('plan-fold-remedy-lw-4-2027-0')).toHaveTextContent(
|
||||
'planRemedies.brand_new_kind',
|
||||
)
|
||||
})
|
||||
|
||||
it('renders nothing beyond the summary/hint when there is no proposal yet', () => {
|
||||
vi.mocked(hooks.usePlanProposal).mockReturnValue(makePlanProposalResult({ data: null }))
|
||||
render(<PlanFold workspaceId="ws-1" members={members} />)
|
||||
expect(screen.queryByTestId('plan-fold-overview')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('plan-fold-balances')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,274 +0,0 @@
|
||||
/**
|
||||
* Folds the wished-vs-funded/surplus/balance content that used to live only
|
||||
* on the Plan tab's `PlanResults.tsx` (since deleted) into Översikt,
|
||||
* collapsed below the fold. This is now the sole implementation of that
|
||||
* content — a trimmed-down component, not a shared import.
|
||||
*
|
||||
* Read-only. The calendar's `POST /api/plan/sync` (see `Calendar.tsx`) is
|
||||
* now the only way a plan is written — it proposes and persists in one
|
||||
* step, so there is no separate "propose, review, then apply" flow left
|
||||
* anywhere in the app, and this fold never mutates anything. It still calls
|
||||
* `usePlanProposal` (`POST /api/plan/propose`) to get the wished-vs-funded
|
||||
* figures, surplus advice and balance tables below, because that data has
|
||||
* no other source: there is no GET endpoint for "the current plan" shaped
|
||||
* like a proposal, and `/api/plan/propose` is documented read-only —
|
||||
* "persists nothing, safe to spam" (`backend/app/routers/plan.py`).
|
||||
*
|
||||
* Dropped relative to the old `PlanResults.tsx` (content not duplicated here
|
||||
* since the Hero and `Age4Bars` already cover it): the warnings section and
|
||||
* the age4Projections table. The per-window shortfall + remedies section
|
||||
* (§todo item 13) is NOT dropped — see the "plan-fold-shortfalls" section
|
||||
* below, a from-scratch render (not a port of the old detail cards).
|
||||
* Also dropped, as of the move to `/api/plan/sync`: the apply button,
|
||||
* collision-confirmation UI, and rejection rendering — applying is no
|
||||
* longer a thing this fold does (see `frontend/src/utils/rejectionMessages.ts`
|
||||
* for where rejection rendering now lives, for the sync toast in
|
||||
* `Calendar.tsx`).
|
||||
*/
|
||||
import { ReactNode, useEffect } from 'react'
|
||||
import type { PersonalData, ShortfallRemedy, SurplusAdvice, WindowOutcome } from '@/types'
|
||||
import { useLeaveWishes, usePlanProposal } from '@/hooks'
|
||||
import { useTranslation, toTranslationParams, type TranslationKey, type TranslationParams } from '@/i18n'
|
||||
import { nameResolver } from '@/utils/parentNames'
|
||||
import { resolveWishName } from '@/utils/wishName'
|
||||
|
||||
type Translate = (key: TranslationKey, params?: TranslationParams) => string
|
||||
|
||||
// §todo item 13, part 3 — the shortfall remedies `_build_remedies`
|
||||
// (allocation_service.py) already computes and puts on the wire were never
|
||||
// rendered anywhere (ideas.md §3's "the engine can't explain itself"
|
||||
// complaint). Mirrors `SURPLUS_ADVICE_BODY_KEYS` below: `messageKey` ->
|
||||
// the body template that interpolates the remedy's own `data`.
|
||||
const REMEDY_BODY_KEYS: Record<string, TranslationKey> = {
|
||||
'planRemedies.reduce_preschool_reserve': 'planResults.remedyBodyReducePreschoolReserve',
|
||||
'planRemedies.carry_vacation_forward': 'planResults.remedyBodyCarryVacationForward',
|
||||
'planRemedies.lower_priority_wish': 'planResults.remedyBodyLowerPriorityWish',
|
||||
'planRemedies.shift_window_into_free_days': 'planResults.remedyBodyShiftWindowIntoFreeDays',
|
||||
}
|
||||
|
||||
function renderRemedyLine(remedy: ShortfallRemedy, t: Translate): string {
|
||||
const key = REMEDY_BODY_KEYS[remedy.messageKey]
|
||||
if (!key) return remedy.messageKey
|
||||
const params = toTranslationParams(remedy.data)
|
||||
params.days_freed = remedy.daysFreed
|
||||
return t(key, params)
|
||||
}
|
||||
|
||||
// §9.2 surplus-advice kinds — originally mirrored the deleted `PlanResults.tsx`'s
|
||||
// `SURPLUS_ADVICE_KEYS`/`SURPLUS_ADVICE_BODY_KEYS`; this is now the sole copy
|
||||
// (see the file banner above).
|
||||
const SURPLUS_ADVICE_KEYS: Record<string, TranslationKey> = {
|
||||
wish_more_leave: 'planResults.surplusAdviceWishMoreLeave',
|
||||
shift_mix_to_fp: 'planResults.surplusAdviceShiftMixToFp',
|
||||
front_load_early_years: 'planResults.surplusAdviceFrontLoadEarlyYears',
|
||||
use_less_vacation: 'planResults.surplusAdviceUseLessVacation',
|
||||
}
|
||||
|
||||
const SURPLUS_ADVICE_BODY_KEYS: Record<string, TranslationKey> = {
|
||||
'planSurplusAdvice.wish_more_leave': 'planResults.surplusAdviceBodyWishMoreLeave',
|
||||
'planSurplusAdvice.shift_mix_to_fp': 'planResults.surplusAdviceBodyShiftMixToFp',
|
||||
'planSurplusAdvice.front_load_early_years': 'planResults.surplusAdviceBodyFrontLoadEarlyYears',
|
||||
'planSurplusAdvice.use_less_vacation': 'planResults.surplusAdviceBodyUseLessVacation',
|
||||
}
|
||||
|
||||
function nameForSafe(members: PersonalData[], t: Translate): (uuid: string) => string {
|
||||
const resolve = nameResolver(members)
|
||||
return (uuid: string) => {
|
||||
const name = resolve(uuid)
|
||||
return name === uuid ? t('dashboard.planFold.unknownParent') : name
|
||||
}
|
||||
}
|
||||
|
||||
function renderSurplusAdviceBody(advice: SurplusAdvice, t: Translate, nameFor: (uuid: string) => string): string {
|
||||
const key = SURPLUS_ADVICE_BODY_KEYS[advice.messageKey]
|
||||
if (!key) return advice.messageKey
|
||||
const params = toTranslationParams(advice.data)
|
||||
const uuid = advice.data.parent_personal_data_uuid
|
||||
if (typeof uuid === 'string') {
|
||||
params.parent_name = nameFor(uuid)
|
||||
}
|
||||
params.days_involved = advice.daysInvolved
|
||||
return t(key, params)
|
||||
}
|
||||
|
||||
function groupByYear(outcomes: WindowOutcome[]): Array<{ year: number; outcomes: WindowOutcome[] }> {
|
||||
const years = Array.from(new Set(outcomes.map((o) => o.year))).sort((a, b) => a - b)
|
||||
return years.map((year) => ({
|
||||
year,
|
||||
outcomes: outcomes.filter((o) => o.year === year).sort((a, b) => a.startDate.localeCompare(b.startDate)),
|
||||
}))
|
||||
}
|
||||
|
||||
export interface PlanFoldProps {
|
||||
workspaceId: string
|
||||
members: PersonalData[]
|
||||
}
|
||||
|
||||
export function PlanFold({ workspaceId, members }: PlanFoldProps): ReactNode {
|
||||
const { t } = useTranslation()
|
||||
const { data: wishes, isLoading: wishesLoading } = useLeaveWishes(workspaceId)
|
||||
const { data: proposal, isLoading: isProposing, error: proposalError, propose } = usePlanProposal(workspaceId)
|
||||
const nameFor = nameForSafe(members, t)
|
||||
|
||||
// Auto-propose once wishes are available — this section is read-only-by-
|
||||
// default (collapsed), so nothing should require the user to first find
|
||||
// and press a separate "suggest" button just to see it. `propose` calls
|
||||
// the read-only `/api/plan/propose` endpoint — it persists nothing.
|
||||
useEffect(() => {
|
||||
if (!wishesLoading && wishes.length > 0 && proposal === null && !isProposing) {
|
||||
void propose()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [wishesLoading, wishes.length, proposal])
|
||||
|
||||
return (
|
||||
<details className="plan-fold" data-testid="plan-fold">
|
||||
<summary className="plan-fold__summary" data-testid="plan-fold-summary">
|
||||
{t('dashboard.planFold.summary')}
|
||||
</summary>
|
||||
|
||||
<div className="plan-fold__content">
|
||||
{!wishesLoading && wishes.length === 0 && (
|
||||
<p className="plan-fold__hint" data-testid="plan-fold-no-wishes">
|
||||
{t('dashboard.planFold.noWishesHint')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{proposalError && (
|
||||
<div className="alert alert--error" role="alert" data-testid="plan-fold-proposal-error">
|
||||
{proposalError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{proposal && (
|
||||
<>
|
||||
<section className="plan-fold__section" data-testid="plan-fold-overview">
|
||||
<h4>{t('planResults.yearOverviewTitle')}</h4>
|
||||
{proposal.outcomes.length === 0 ? (
|
||||
<p>{t('planResults.noWindowsInHorizon')}</p>
|
||||
) : (
|
||||
<table className="plan-overview-table" data-testid="plan-fold-overview-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('planResults.yearHeader')}</th>
|
||||
<th>{t('planResults.leavesHeader')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groupByYear(proposal.outcomes).map(({ year, outcomes }) => (
|
||||
<tr key={year} data-testid={`plan-fold-overview-year-${year}`}>
|
||||
<td>{year}</td>
|
||||
<td>
|
||||
{outcomes.map((outcome) => (
|
||||
<span key={outcome.wishId} className="plan-overview-badge">
|
||||
{resolveWishName({ name: outcome.wishName, nameMessageKey: outcome.wishNameMessageKey }, t)}
|
||||
: {outcome.weeksFunded}/{outcome.weeksWished} {t('planResults.weeksUnit')}
|
||||
</span>
|
||||
))}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{proposal.outcomes.some((o) => o.shortfallDays > 0) && (
|
||||
<section className="plan-fold__section" data-testid="plan-fold-shortfalls">
|
||||
<h4>{t('planResults.shortfallsTitle')}</h4>
|
||||
<ul>
|
||||
{proposal.outcomes
|
||||
.filter((outcome) => outcome.shortfallDays > 0)
|
||||
.map((outcome) => (
|
||||
<li
|
||||
key={`${outcome.wishId}-${outcome.year}`}
|
||||
data-testid={`plan-fold-shortfall-${outcome.wishId}-${outcome.year}`}
|
||||
>
|
||||
<strong>
|
||||
{resolveWishName({ name: outcome.wishName, nameMessageKey: outcome.wishNameMessageKey }, t)}{' '}
|
||||
{outcome.year}
|
||||
</strong>
|
||||
<p>{t('planResults.shortfallLine', { shortfall_days: outcome.shortfallDays })}</p>
|
||||
{outcome.remedies.length > 0 && (
|
||||
<ul data-testid={`plan-fold-remedies-${outcome.wishId}-${outcome.year}`}>
|
||||
{outcome.remedies.map((remedy, idx) => (
|
||||
<li key={`${remedy.kind}-${idx}`} data-testid={`plan-fold-remedy-${outcome.wishId}-${outcome.year}-${idx}`}>
|
||||
{renderRemedyLine(remedy, t)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{proposal.surplusAdvice.length > 0 && (
|
||||
<section className="plan-fold__section" data-testid="plan-fold-surplus-advice">
|
||||
<h4>{t('planResults.surplusAdviceTitle')}</h4>
|
||||
<p>{t('planResults.surplusAdviceIntro')}</p>
|
||||
<ul>
|
||||
{proposal.surplusAdvice.map((advice, idx) => {
|
||||
const titleKey = SURPLUS_ADVICE_KEYS[advice.kind]
|
||||
return (
|
||||
<li key={`${advice.kind}-${idx}`} data-testid={`plan-fold-surplus-advice-${idx}`}>
|
||||
<strong>{titleKey ? t(titleKey) : advice.kind}</strong>
|
||||
<p>{renderSurplusAdviceBody(advice, t, nameFor)}</p>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="plan-fold__section" data-testid="plan-fold-balances">
|
||||
<h4>{t('planResults.balancesTitle')}</h4>
|
||||
<table className="plan-balances-table" data-testid="plan-fold-pool-balances">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('planResults.parentHeader')}</th>
|
||||
<th>{t('planResults.sjukpenningLeftHeader')}</th>
|
||||
<th>{t('planResults.lagstaLeftHeader')}</th>
|
||||
<th>{t('planResults.dubbeldagarLeftHeader')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{proposal.poolBalances.map((pool) => (
|
||||
<tr key={`${pool.parentPersonalDataUuid}-${pool.childId}`}>
|
||||
<td>{nameFor(pool.parentPersonalDataUuid)}</td>
|
||||
<td>{pool.sjukpenningLeft}</td>
|
||||
<td>{pool.lagstaLeft}</td>
|
||||
<td>{pool.dubbeldagarLeft}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<table className="plan-balances-table" data-testid="plan-fold-vacation-balances">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('planResults.parentHeader')}</th>
|
||||
<th>{t('planResults.yearHeader')}</th>
|
||||
<th>{t('planResults.usedHeader')}</th>
|
||||
<th>{t('planResults.totalAvailableHeader')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{proposal.vacationBalances.map((budget) => (
|
||||
<tr key={`${budget.parentPersonalDataUuid}-${budget.year}`}>
|
||||
<td>{nameFor(budget.parentPersonalDataUuid)}</td>
|
||||
<td>{budget.year}</td>
|
||||
<td>{budget.alreadyUsed}</td>
|
||||
<td>{budget.totalDays + budget.savedDaysIn}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { SurplusAdviceTip } from './SurplusAdviceTip'
|
||||
import { makeSurplusAdvice } from '../../../tests/fixtures'
|
||||
|
||||
const nameFor = (uuid: string) => (uuid === 'pd-b' ? 'Sam' : 'Okänd förälder')
|
||||
|
||||
describe('SurplusAdviceTip', () => {
|
||||
it('renders a known kind with its translated title and body, resolving the parent name', () => {
|
||||
render(
|
||||
<SurplusAdviceTip
|
||||
index={0}
|
||||
nameFor={nameFor}
|
||||
advice={makeSurplusAdvice({
|
||||
kind: 'wish_more_leave',
|
||||
messageKey: 'planSurplusAdvice.wish_more_leave',
|
||||
daysInvolved: 12,
|
||||
data: { parent_personal_data_uuid: 'pd-b', weeks: 2 },
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
const tip = screen.getByTestId('dashboard-surplus-advice-0')
|
||||
expect(tip).toHaveClass('tip-card', 'tip-card--action')
|
||||
expect(tip).toHaveTextContent('Önska mer ledighet')
|
||||
expect(tip).toHaveTextContent('Sam har råd med ytterligare 2 veckor ledigt (12 FP-dagar')
|
||||
expect(tip).not.toHaveTextContent('pd-b')
|
||||
})
|
||||
|
||||
it('leaves the parent name unresolved when the advice carries no uuid', () => {
|
||||
render(
|
||||
<SurplusAdviceTip
|
||||
index={1}
|
||||
nameFor={nameFor}
|
||||
advice={makeSurplusAdvice({
|
||||
kind: 'use_less_vacation',
|
||||
messageKey: 'planSurplusAdvice.use_less_vacation',
|
||||
daysInvolved: 3,
|
||||
data: { years_text: '2027', min_vacation_days_per_year: 20 },
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
const tip = screen.getByTestId('dashboard-surplus-advice-1')
|
||||
expect(tip).toHaveTextContent('Spara färre semesterdagar')
|
||||
expect(tip).toHaveTextContent('{parent_name} kan ta ut 3 färre semesterdagar (2027)')
|
||||
})
|
||||
|
||||
it('falls back to the raw kind and messageKey for an unrecognised advice', () => {
|
||||
render(
|
||||
<SurplusAdviceTip
|
||||
index={2}
|
||||
nameFor={nameFor}
|
||||
advice={makeSurplusAdvice({ kind: 'brand_new_kind', messageKey: 'planSurplusAdvice.brand_new_kind', data: {} })}
|
||||
/>,
|
||||
)
|
||||
const tip = screen.getByTestId('dashboard-surplus-advice-2')
|
||||
expect(tip).toHaveTextContent('brand_new_kind')
|
||||
expect(tip).toHaveTextContent('planSurplusAdvice.brand_new_kind')
|
||||
})
|
||||
|
||||
it('has no dismiss control', () => {
|
||||
render(<SurplusAdviceTip index={0} nameFor={nameFor} advice={makeSurplusAdvice()} />)
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import { ReactNode } from 'react'
|
||||
import type { SurplusAdvice } from '@/types'
|
||||
import { useTranslation, toTranslationParams, type TranslationKey } from '@/i18n'
|
||||
|
||||
// §9.2 surplus-advice kinds — `kind` -> title, `messageKey` -> the body
|
||||
// template that interpolates the advice's own `data`. An unrecognised kind
|
||||
// (a newer backend rule) falls back to the raw value rather than crashing.
|
||||
const SURPLUS_ADVICE_TITLE_KEYS: Record<string, TranslationKey> = {
|
||||
wish_more_leave: 'planResults.surplusAdviceWishMoreLeave',
|
||||
shift_mix_to_fp: 'planResults.surplusAdviceShiftMixToFp',
|
||||
front_load_early_years: 'planResults.surplusAdviceFrontLoadEarlyYears',
|
||||
use_less_vacation: 'planResults.surplusAdviceUseLessVacation',
|
||||
}
|
||||
|
||||
const SURPLUS_ADVICE_BODY_KEYS: Record<string, TranslationKey> = {
|
||||
'planSurplusAdvice.wish_more_leave': 'planResults.surplusAdviceBodyWishMoreLeave',
|
||||
'planSurplusAdvice.shift_mix_to_fp': 'planResults.surplusAdviceBodyShiftMixToFp',
|
||||
'planSurplusAdvice.front_load_early_years': 'planResults.surplusAdviceBodyFrontLoadEarlyYears',
|
||||
'planSurplusAdvice.use_less_vacation': 'planResults.surplusAdviceBodyUseLessVacation',
|
||||
}
|
||||
|
||||
export interface SurplusAdviceTipProps {
|
||||
advice: SurplusAdvice
|
||||
/** Resolves a parent uuid to a display name (never a raw uuid). */
|
||||
nameFor: (uuid: string) => string
|
||||
/** Position in the advice list — only used for the test id. */
|
||||
index: number
|
||||
}
|
||||
|
||||
/**
|
||||
* One plan-proposal surplus advice rendered in the same visual as a
|
||||
* backend `TipCard` (same `tip-card` classes) so the dashboard's tips read
|
||||
* as one list. Advice has no dismiss — it is derived from the plan and goes
|
||||
* away when the plan changes.
|
||||
*/
|
||||
export function SurplusAdviceTip({ advice, nameFor, index }: SurplusAdviceTipProps): ReactNode {
|
||||
const { t } = useTranslation()
|
||||
const titleKey = SURPLUS_ADVICE_TITLE_KEYS[advice.kind]
|
||||
const bodyKey = SURPLUS_ADVICE_BODY_KEYS[advice.messageKey]
|
||||
const title = titleKey ? t(titleKey) : advice.kind
|
||||
|
||||
let body = advice.messageKey
|
||||
if (bodyKey) {
|
||||
const params = toTranslationParams(advice.data)
|
||||
const uuid = advice.data.parent_personal_data_uuid
|
||||
if (typeof uuid === 'string') {
|
||||
params.parent_name = nameFor(uuid)
|
||||
}
|
||||
params.days_involved = advice.daysInvolved
|
||||
body = t(bodyKey, params)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="tip-card tip-card--action"
|
||||
role="article"
|
||||
aria-label={t('tip.aria', { title })}
|
||||
data-testid={`dashboard-surplus-advice-${index}`}
|
||||
>
|
||||
<div className="tip-card__header">
|
||||
<h4 className="tip-card__title">{title}</h4>
|
||||
</div>
|
||||
<p className="tip-card__body">{body}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { YearRows, type YearRowsParent } from './YearRows'
|
||||
import {
|
||||
makeParentYearBalance,
|
||||
makeParentalLeaveYearBalance,
|
||||
makePlannedLeaveDay,
|
||||
makeShortfallRemedy,
|
||||
makeVacationYearBalance,
|
||||
makeWindowOutcome,
|
||||
} from '../../../tests/fixtures'
|
||||
|
||||
const HORIZON = { startDate: '2025-06-01', endDate: '2029-06-01' }
|
||||
|
||||
function balancesFor(uuid: string, years: number[], overrides: Partial<ReturnType<typeof makeParentYearBalance>> = {}) {
|
||||
return years.map((year) =>
|
||||
makeParentYearBalance({
|
||||
personalDataUuid: uuid,
|
||||
year,
|
||||
vacation: makeVacationYearBalance({ year, ...(overrides.vacation ?? {}) }),
|
||||
parentalLeave: makeParentalLeaveYearBalance({ year, ...(overrides.parentalLeave ?? {}) }),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const YEARS = [2025, 2026, 2027, 2028, 2029]
|
||||
|
||||
const alex: YearRowsParent = {
|
||||
personalDataUuid: 'pd-a',
|
||||
name: 'Alex',
|
||||
yearBalances: balancesFor('pd-a', YEARS, {
|
||||
vacation: makeVacationYearBalance({ left: 12, carriedOut: 5, expiring: 0 }),
|
||||
parentalLeave: makeParentalLeaveYearBalance({ sjukpenningLeft: 300, lagstaLeft: 12, sjukpenningUsed: 40, lagstaUsed: 3 }),
|
||||
}),
|
||||
}
|
||||
|
||||
const sam: YearRowsParent = {
|
||||
personalDataUuid: 'pd-b',
|
||||
name: 'Sam',
|
||||
yearBalances: balancesFor('pd-b', YEARS, {
|
||||
vacation: makeVacationYearBalance({ left: 20, carriedOut: 0, expiring: 3, isAssumed: false, earned: 30 }),
|
||||
parentalLeave: makeParentalLeaveYearBalance({ sjukpenningLeft: 200, lagstaLeft: 40 }),
|
||||
}),
|
||||
}
|
||||
|
||||
function renderRows(props: Partial<Parameters<typeof YearRows>[0]> = {}) {
|
||||
const onBandClick = vi.fn()
|
||||
const utils = render(
|
||||
<YearRows
|
||||
parents={[alex, sam]}
|
||||
plannedDays={[]}
|
||||
outcomes={[]}
|
||||
onBandClick={onBandClick}
|
||||
{...HORIZON}
|
||||
{...props}
|
||||
/>,
|
||||
)
|
||||
return { ...utils, onBandClick }
|
||||
}
|
||||
|
||||
describe('YearRows', () => {
|
||||
describe('rows', () => {
|
||||
it('renders one collapsed <details> row per year across the horizon', () => {
|
||||
renderRows()
|
||||
for (const year of YEARS) {
|
||||
const row = screen.getByTestId(`year-row-${year}`)
|
||||
expect(row.tagName).toBe('DETAILS')
|
||||
expect(row).not.toHaveAttribute('open')
|
||||
expect(row).toHaveClass('disclosure')
|
||||
}
|
||||
})
|
||||
|
||||
it('renders no rows when the horizon is inverted', () => {
|
||||
renderRows({ startDate: '2029-06-01', endDate: '2025-06-01' })
|
||||
expect(screen.queryByTestId(/^year-row-\d{4}$/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders a legend with one entry per day type', () => {
|
||||
renderRows()
|
||||
const legend = screen.getByTestId('year-rows-legend')
|
||||
expect(legend).toHaveTextContent('Sjukpenningnivå')
|
||||
expect(legend).toHaveTextContent('Lägstanivå')
|
||||
expect(legend).toHaveTextContent('Semester')
|
||||
})
|
||||
})
|
||||
|
||||
describe('per-parent figures (couple)', () => {
|
||||
it('shows each parent\'s FP left (sjukpenning + lägsta) and vacation left · saved for the year', () => {
|
||||
renderRows()
|
||||
expect(screen.getByTestId('year-row-2026-fp-pd-a')).toHaveTextContent('FP kvar 312')
|
||||
expect(screen.getByTestId('year-row-2026-vacation-pd-a')).toHaveTextContent('Semester kvar 12 · sparas 5')
|
||||
expect(screen.getByTestId('year-row-2026-fp-pd-b')).toHaveTextContent('FP kvar 240')
|
||||
expect(screen.getByTestId('year-row-2026-vacation-pd-b')).toHaveTextContent('Semester kvar 20 · sparas 0')
|
||||
})
|
||||
|
||||
it('names each parent in the summary and the open table for a couple', () => {
|
||||
renderRows()
|
||||
const row = screen.getByTestId('year-row-2026')
|
||||
expect(within(row).getByTestId('year-row-2026-parent-pd-a')).toHaveTextContent('Alex')
|
||||
expect(within(row).getByTestId('year-row-2026-parent-pd-b')).toHaveTextContent('Sam')
|
||||
const table = within(row).getByTestId('year-row-2026-table')
|
||||
expect(within(table).getByRole('columnheader', { name: 'Alex' })).toBeInTheDocument()
|
||||
expect(within(table).getByRole('columnheader', { name: 'Sam' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows an expiring figure in the summary only when days actually expire', () => {
|
||||
renderRows()
|
||||
expect(screen.getByTestId('year-row-2026-expiring-pd-b')).toHaveTextContent('förfaller 3')
|
||||
expect(screen.queryByTestId('year-row-2026-expiring-pd-a')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders a dash for a year the parent has no balance entry for', () => {
|
||||
const partial: YearRowsParent = { ...alex, yearBalances: balancesFor('pd-a', [2025]) }
|
||||
renderRows({ parents: [partial, sam] })
|
||||
expect(screen.getByTestId('year-row-2027-fp-pd-a')).toHaveTextContent('FP kvar –')
|
||||
expect(screen.getByTestId('year-row-2027-vacation-pd-a')).toHaveTextContent('Semester kvar –')
|
||||
expect(screen.getByTestId('year-row-2027-vacation-pd-a')).not.toHaveTextContent('sparas')
|
||||
const table = screen.getByTestId('year-row-2027-table')
|
||||
expect(within(table).getAllByText('–').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('gives the lanes self/partner caps and one lane per parent, self first', () => {
|
||||
renderRows()
|
||||
const laneA = screen.getByTestId('year-row-2026-lane-pd-a')
|
||||
const laneB = screen.getByTestId('year-row-2026-lane-pd-b')
|
||||
expect(laneA).toHaveClass('year-row__lane--self')
|
||||
expect(laneB).toHaveClass('year-row__lane--partner')
|
||||
expect(laneA.compareDocumentPosition(laneB) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('solo parent', () => {
|
||||
it('omits the parent name and the table header, and gives the single lane no cap', () => {
|
||||
renderRows({ parents: [alex] })
|
||||
const row = screen.getByTestId('year-row-2026')
|
||||
expect(within(row).getByTestId('year-row-2026-parent-pd-a')).not.toHaveTextContent('Alex')
|
||||
expect(within(row).queryByRole('columnheader')).not.toBeInTheDocument()
|
||||
const lane = screen.getByTestId('year-row-2026-lane-pd-a')
|
||||
expect(lane).not.toHaveClass('year-row__lane--self')
|
||||
expect(lane).not.toHaveClass('year-row__lane--partner')
|
||||
})
|
||||
})
|
||||
|
||||
describe('no parent projections yet', () => {
|
||||
it('still draws every planned day in one anonymous lane, without figures or a table', () => {
|
||||
renderRows({
|
||||
parents: [],
|
||||
plannedDays: [makePlannedLeaveDay({ id: '1', date: '2026-06-15', parentPersonalDataUuid: 'pd-a' })],
|
||||
})
|
||||
expect(screen.getByTestId('year-row-2026-lane-all')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('year-timeline-band-2026-0')).toHaveAttribute(
|
||||
'title',
|
||||
'Semester, 15 juni 2026 – 15 juni 2026 (1 dag). Klicka för att öppna i kalendern.',
|
||||
)
|
||||
expect(screen.queryByTestId('year-row-2026-fp-pd-a')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('year-row-2026-table')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('open body table', () => {
|
||||
it('lists the parental-leave and vacation balances per parent', () => {
|
||||
renderRows()
|
||||
const table = screen.getByTestId('year-row-2026-table')
|
||||
const rowFor = (label: string) => within(table).getByRole('row', { name: new RegExp(label) })
|
||||
expect(rowFor('Använda i år · Sjukpenningnivå')).toHaveTextContent('40')
|
||||
expect(rowFor('Använda i år · Lägstanivå')).toHaveTextContent('3')
|
||||
expect(rowFor('Kvar vid årets slut · Sjukpenningnivå')).toHaveTextContent('300')
|
||||
expect(rowFor('Kvar vid årets slut · Lägstanivå')).toHaveTextContent('12')
|
||||
expect(rowFor('^Intjänade')).toHaveTextContent('25')
|
||||
expect(rowFor('^Intjänade')).toHaveTextContent('30')
|
||||
expect(rowFor('Sparade in')).toBeInTheDocument()
|
||||
expect(rowFor('Planerade')).toBeInTheDocument()
|
||||
expect(rowFor('^Kvar \\d')).toHaveTextContent('12')
|
||||
expect(rowFor('Sparas till nästa år')).toHaveTextContent('5')
|
||||
expect(rowFor('^Förfaller')).toHaveTextContent('3')
|
||||
})
|
||||
|
||||
it('badges an assumed vacation entitlement and colours a non-zero expiring value', () => {
|
||||
renderRows()
|
||||
const table = screen.getByTestId('year-row-2026-table')
|
||||
const earned = within(table).getByRole('row', { name: /^Intjänade/ })
|
||||
// Alex's entitlement is the statutory default (assumed); Sam's was entered.
|
||||
expect(within(earned).getAllByText('antaget')).toHaveLength(1)
|
||||
const expiring = within(table).getByRole('row', { name: /^Förfaller/ })
|
||||
const cells = within(expiring).getAllByRole('cell')
|
||||
expect(cells[0]).not.toHaveClass('year-row__cell--danger')
|
||||
expect(cells[1]).toHaveClass('year-row__cell--danger')
|
||||
})
|
||||
|
||||
it('opens and closes as a native disclosure', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderRows()
|
||||
const row = screen.getByTestId('year-row-2026')
|
||||
await user.click(row.querySelector('summary') as HTMLElement)
|
||||
expect(row).toHaveAttribute('open')
|
||||
await user.click(row.querySelector('summary') as HTMLElement)
|
||||
expect(row).not.toHaveAttribute('open')
|
||||
})
|
||||
})
|
||||
|
||||
describe('bands', () => {
|
||||
it('groups a contiguous run of one parent\'s planned days into a single band in that parent\'s lane', () => {
|
||||
renderRows({
|
||||
plannedDays: [
|
||||
makePlannedLeaveDay({ id: '1', date: '2026-06-15', dayType: 'vacation', parentPersonalDataUuid: 'pd-a' }),
|
||||
makePlannedLeaveDay({ id: '2', date: '2026-06-16', dayType: 'vacation', parentPersonalDataUuid: 'pd-a' }),
|
||||
makePlannedLeaveDay({ id: '3', date: '2026-06-17', dayType: 'sjukpenning', parentPersonalDataUuid: 'pd-a' }),
|
||||
],
|
||||
})
|
||||
expect(screen.getAllByTestId(/^year-timeline-band-2026-/)).toHaveLength(1)
|
||||
const band = screen.getByTestId('year-timeline-band-2026-0')
|
||||
expect(band).toHaveAttribute(
|
||||
'title',
|
||||
'Alex · Semester, 15 juni 2026 – 17 juni 2026 (3 dagar). Klicka för att öppna i kalendern.',
|
||||
)
|
||||
expect(band).toHaveClass('year-row__band--vacation')
|
||||
expect(screen.getByTestId('year-row-2026-lane-pd-a')).toContainElement(band)
|
||||
})
|
||||
|
||||
it('numbers bands across lanes in lane order (self first)', () => {
|
||||
renderRows({
|
||||
plannedDays: [
|
||||
makePlannedLeaveDay({ id: '1', date: '2026-08-01', dayType: 'lagsta', parentPersonalDataUuid: 'pd-b' }),
|
||||
makePlannedLeaveDay({ id: '2', date: '2026-01-05', dayType: 'sjukpenning', parentPersonalDataUuid: 'pd-a' }),
|
||||
],
|
||||
})
|
||||
expect(screen.getByTestId('year-row-2026-lane-pd-a')).toContainElement(screen.getByTestId('year-timeline-band-2026-0'))
|
||||
expect(screen.getByTestId('year-row-2026-lane-pd-b')).toContainElement(screen.getByTestId('year-timeline-band-2026-1'))
|
||||
expect(screen.getByTestId('year-timeline-band-2026-1')).toHaveClass('year-row__band--lagsta')
|
||||
})
|
||||
|
||||
it('splits into separate bands across a gap bigger than a bridged weekend', () => {
|
||||
renderRows({
|
||||
plannedDays: [
|
||||
makePlannedLeaveDay({ id: '1', date: '2026-01-05', parentPersonalDataUuid: 'pd-a' }),
|
||||
makePlannedLeaveDay({ id: '2', date: '2026-06-15', parentPersonalDataUuid: 'pd-a' }),
|
||||
],
|
||||
})
|
||||
expect(screen.getAllByTestId(/^year-timeline-band-2026-/)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('never lets a band span a calendar-year boundary', () => {
|
||||
renderRows({
|
||||
plannedDays: [
|
||||
makePlannedLeaveDay({ id: '1', date: '2026-12-30', parentPersonalDataUuid: 'pd-a' }),
|
||||
makePlannedLeaveDay({ id: '2', date: '2027-01-02', parentPersonalDataUuid: 'pd-a' }),
|
||||
],
|
||||
})
|
||||
expect(screen.getByTestId('year-timeline-band-2026-0')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('year-timeline-band-2027-0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('excludes planned days outside the horizon and days of an unknown parent', () => {
|
||||
renderRows({
|
||||
plannedDays: [
|
||||
makePlannedLeaveDay({ id: '1', date: '2030-01-01', parentPersonalDataUuid: 'pd-a' }),
|
||||
makePlannedLeaveDay({ id: '2', date: '2026-01-01', parentPersonalDataUuid: 'pd-zzz' }),
|
||||
],
|
||||
})
|
||||
expect(screen.queryByTestId(/^year-timeline-band-/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('breaks a tie between types in sjukpenning > lägsta > semester order', () => {
|
||||
renderRows({
|
||||
plannedDays: [
|
||||
makePlannedLeaveDay({ id: '1', date: '2026-06-15', dayType: 'vacation', parentPersonalDataUuid: 'pd-a' }),
|
||||
makePlannedLeaveDay({ id: '2', date: '2026-06-16', dayType: 'lagsta', parentPersonalDataUuid: 'pd-a' }),
|
||||
],
|
||||
})
|
||||
expect(screen.getByTestId('year-timeline-band-2026-0')).toHaveClass('year-row__band--lagsta')
|
||||
})
|
||||
|
||||
it('calls onBandClick with the band start date and does not toggle the row', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onBandClick } = renderRows({
|
||||
plannedDays: [makePlannedLeaveDay({ id: '1', date: '2026-06-15', parentPersonalDataUuid: 'pd-a' })],
|
||||
})
|
||||
await user.click(screen.getByTestId('year-timeline-band-2026-0'))
|
||||
expect(onBandClick).toHaveBeenCalledWith('2026-06-15')
|
||||
expect(screen.getByTestId('year-row-2026')).not.toHaveAttribute('open')
|
||||
})
|
||||
})
|
||||
|
||||
describe('wish outcomes', () => {
|
||||
it('renders nothing about wishes when there are no outcomes', () => {
|
||||
renderRows()
|
||||
expect(screen.queryByTestId('year-row-2026-wishes')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('lists the year\'s outcomes in start-date order under the table, fully funded ones without a shortfall', () => {
|
||||
renderRows({
|
||||
outcomes: [
|
||||
makeWindowOutcome({ wishId: 'lw-2', wishName: 'Höstlov', year: 2026, startDate: '2026-10-26', weeksWished: 1, weeksFunded: 1 }),
|
||||
makeWindowOutcome({ wishId: 'lw-1', wishName: 'Sommar', year: 2026, weeksWished: 6, weeksFunded: 6 }),
|
||||
makeWindowOutcome({ wishId: 'lw-3', wishName: 'Sommar', year: 2027, weeksWished: 6, weeksFunded: 4, shortfallDays: 10 }),
|
||||
],
|
||||
})
|
||||
const wishes = screen.getByTestId('year-row-2026-wishes')
|
||||
const items = within(wishes).getAllByRole('listitem')
|
||||
expect(items[0]).toHaveTextContent('Sommar')
|
||||
expect(items[0]).toHaveTextContent('6 av 6 veckor')
|
||||
expect(items[1]).toHaveTextContent('Höstlov')
|
||||
expect(screen.queryByTestId('year-row-shortfall-lw-1-2026')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('year-row-wish-lw-3-2026')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('year-row-wish-lw-3-2027')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('rounds fractional funded weeks to one decimal in the locale notation', () => {
|
||||
renderRows({
|
||||
outcomes: [makeWindowOutcome({ wishId: 'lw-1', year: 2026, weeksWished: 6, weeksFunded: 4.2857 })],
|
||||
})
|
||||
expect(screen.getByTestId('year-row-wish-lw-1-2026')).toHaveTextContent('4,3 av 6 veckor')
|
||||
})
|
||||
|
||||
it('resolves a preset wish name through its message key', () => {
|
||||
renderRows({
|
||||
outcomes: [makeWindowOutcome({ wishId: 'lw-1', wishName: 'stale', wishNameMessageKey: 'wishEditor.seasonSommar', year: 2026 })],
|
||||
})
|
||||
expect(screen.getByTestId('year-row-wish-lw-1-2026')).not.toHaveTextContent('stale')
|
||||
expect(screen.getByTestId('year-row-wish-lw-1-2026')).toHaveTextContent('Sommar')
|
||||
})
|
||||
|
||||
it('renders the shortfall line and each remedy for a window that ran short', () => {
|
||||
renderRows({
|
||||
outcomes: [
|
||||
makeWindowOutcome({
|
||||
wishId: 'lw-1',
|
||||
wishName: 'Sportlov',
|
||||
year: 2027,
|
||||
weeksWished: 2,
|
||||
weeksFunded: 1,
|
||||
shortfallDays: 2,
|
||||
remedies: [
|
||||
makeShortfallRemedy({
|
||||
kind: 'carry_vacation_forward',
|
||||
messageKey: 'planRemedies.carry_vacation_forward',
|
||||
data: { spare_total: 5 },
|
||||
daysFreed: 5,
|
||||
}),
|
||||
makeShortfallRemedy({
|
||||
kind: 'lower_priority_wish',
|
||||
messageKey: 'planRemedies.lower_priority_wish',
|
||||
data: { wish_name: 'Höstlov', from_weeks: 2, to_weeks: 1 },
|
||||
daysFreed: 4,
|
||||
}),
|
||||
makeShortfallRemedy({ kind: 'novel', messageKey: 'planRemedies.novel', data: {}, daysFreed: 1 }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
})
|
||||
const wish = screen.getByTestId('year-row-wish-lw-1-2027')
|
||||
expect(wish).toHaveTextContent('1 av 2 veckor')
|
||||
expect(screen.getByTestId('year-row-shortfall-lw-1-2027')).toHaveTextContent('2 dag(ar) kunde inte finansieras')
|
||||
expect(screen.getByTestId('year-row-remedy-lw-1-2027-0')).toHaveTextContent(
|
||||
'5 semesterdagar från tidigare år kan sparas framåt',
|
||||
)
|
||||
expect(screen.getByTestId('year-row-remedy-lw-1-2027-1')).toHaveTextContent(
|
||||
'Sänk Höstlov från 2 till 1 veckor frigör 4 dagar',
|
||||
)
|
||||
expect(screen.getByTestId('year-row-remedy-lw-1-2027-2')).toHaveTextContent('planRemedies.novel')
|
||||
})
|
||||
|
||||
it('shows the shortfall line without a remedy list when the engine has none', () => {
|
||||
renderRows({
|
||||
outcomes: [makeWindowOutcome({ wishId: 'lw-1', year: 2026, shortfallDays: 3, remedies: [] })],
|
||||
})
|
||||
expect(screen.getByTestId('year-row-shortfall-lw-1-2026')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('year-row-remedy-lw-1-2026-0')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,463 @@
|
||||
import { MouseEvent, ReactNode } from 'react'
|
||||
import type { ParentYearBalance, PlannedLeaveDay, ShortfallRemedy, WindowOutcome } from '@/types'
|
||||
import { useTranslation, toTranslationParams, type TranslationKey, type TranslationParams } from '@/i18n'
|
||||
import { getLabelForType, getTypeKey } from '@/utils/leaveTypes'
|
||||
import { formatDateForDisplay } from '@/utils/dates'
|
||||
import { resolveWishName } from '@/utils/wishName'
|
||||
|
||||
type Translate = (key: TranslationKey, params?: TranslationParams) => string
|
||||
|
||||
/** One parent's lane in the rows — already resolved to a display name
|
||||
* (never a raw uuid) and ordered self-first by the caller. */
|
||||
export interface YearRowsParent {
|
||||
personalDataUuid: string
|
||||
name: string
|
||||
/** `Age4Projection.yearBalances` for this parent. */
|
||||
yearBalances: ParentYearBalance[]
|
||||
}
|
||||
|
||||
export interface YearRowsProps {
|
||||
parents: YearRowsParent[]
|
||||
plannedDays: PlannedLeaveDay[]
|
||||
/** ISO-8601 date — horizon start (the child's birth date). */
|
||||
startDate: string
|
||||
/** ISO-8601 date — horizon end (the child's 4th birthday). */
|
||||
endDate: string
|
||||
/** The plan proposal's per-wish outcomes (empty when there are no wishes). */
|
||||
outcomes: WindowOutcome[]
|
||||
/** Called with a band's ISO-8601 start date when clicked. */
|
||||
onBandClick: (date: string) => void
|
||||
}
|
||||
|
||||
interface Band {
|
||||
startDate: string
|
||||
endDate: string
|
||||
dominantType: PlannedLeaveDay['dayType']
|
||||
/** Planned days actually in the band — a bridged weekend inside it is not one. */
|
||||
dayCount: number
|
||||
}
|
||||
|
||||
interface Lane {
|
||||
personalDataUuid: string | null
|
||||
name: string | null
|
||||
bands: Band[]
|
||||
}
|
||||
|
||||
// A run of planned days separates into a new band once the gap between
|
||||
// consecutive dates exceeds this many days — bridges a weekend (2 days) or
|
||||
// a weekend plus a single röd dag (3 days) without splitting one leave
|
||||
// period into several bands.
|
||||
const GAP_DAYS = 3
|
||||
|
||||
// Legend order, and the tie-break order when a band's days split evenly.
|
||||
const TYPE_ORDER: PlannedLeaveDay['dayType'][] = ['sjukpenning', 'lagsta', 'vacation']
|
||||
|
||||
// The shortfall remedies `_build_remedies` (allocation_service.py) puts on
|
||||
// the wire: `messageKey` -> the body template interpolating the remedy's
|
||||
// own `data`.
|
||||
const REMEDY_BODY_KEYS: Record<string, TranslationKey> = {
|
||||
'planRemedies.reduce_preschool_reserve': 'planResults.remedyBodyReducePreschoolReserve',
|
||||
'planRemedies.carry_vacation_forward': 'planResults.remedyBodyCarryVacationForward',
|
||||
'planRemedies.lower_priority_wish': 'planResults.remedyBodyLowerPriorityWish',
|
||||
'planRemedies.shift_window_into_free_days': 'planResults.remedyBodyShiftWindowIntoFreeDays',
|
||||
}
|
||||
|
||||
function toUtcDate(iso: string): Date {
|
||||
return new Date(iso + 'T00:00:00Z')
|
||||
}
|
||||
|
||||
function dayOfYearUtc(d: Date): number {
|
||||
const yearStart = Date.UTC(d.getUTCFullYear(), 0, 1)
|
||||
return Math.floor((d.getTime() - yearStart) / 86_400_000) + 1
|
||||
}
|
||||
|
||||
function daysInYear(year: number): number {
|
||||
return Math.floor((Date.UTC(year + 1, 0, 1) - Date.UTC(year, 0, 1)) / 86_400_000)
|
||||
}
|
||||
|
||||
function dominantType(days: PlannedLeaveDay[]): PlannedLeaveDay['dayType'] {
|
||||
const counts: Record<string, number> = {}
|
||||
for (const d of days) {
|
||||
counts[d.dayType] = (counts[d.dayType] ?? 0) + 1
|
||||
}
|
||||
let best = TYPE_ORDER[0]!
|
||||
let bestCount = -1
|
||||
for (const type of TYPE_ORDER) {
|
||||
const count = counts[type] ?? 0
|
||||
if (count > bestCount) {
|
||||
bestCount = count
|
||||
best = type
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/** Groups sorted planned days into contiguous bands (one leave period each),
|
||||
* never spanning a calendar-year boundary — each year gets its own row. */
|
||||
function buildBands(plannedDays: PlannedLeaveDay[], startDate: string, endDate: string): Band[] {
|
||||
const start = toUtcDate(startDate)
|
||||
const end = toUtcDate(endDate)
|
||||
const inHorizon = plannedDays
|
||||
.filter((d) => {
|
||||
const date = toUtcDate(d.date)
|
||||
return date >= start && date <= end
|
||||
})
|
||||
.sort((a, b) => a.date.localeCompare(b.date))
|
||||
|
||||
const bands: Band[] = []
|
||||
let current: PlannedLeaveDay[] = []
|
||||
|
||||
const closeCurrent = () => {
|
||||
if (current.length === 0) return
|
||||
bands.push({
|
||||
startDate: current[0]!.date,
|
||||
endDate: current[current.length - 1]!.date,
|
||||
dominantType: dominantType(current),
|
||||
dayCount: current.length,
|
||||
})
|
||||
current = []
|
||||
}
|
||||
|
||||
for (const day of inHorizon) {
|
||||
if (current.length === 0) {
|
||||
current.push(day)
|
||||
continue
|
||||
}
|
||||
const prev = toUtcDate(current[current.length - 1]!.date)
|
||||
const next = toUtcDate(day.date)
|
||||
const gapDays = Math.round((next.getTime() - prev.getTime()) / 86_400_000)
|
||||
const crossesYear = prev.getUTCFullYear() !== next.getUTCFullYear()
|
||||
if (gapDays > GAP_DAYS || crossesYear) {
|
||||
closeCurrent()
|
||||
}
|
||||
current.push(day)
|
||||
}
|
||||
closeCurrent()
|
||||
|
||||
return bands
|
||||
}
|
||||
|
||||
/** One lane per parent (self first, as given); with no parent projections
|
||||
* yet, a single anonymous lane carries every planned day so the timeline
|
||||
* still shows the plan. */
|
||||
function buildLanes(parents: YearRowsParent[], plannedDays: PlannedLeaveDay[], startDate: string, endDate: string): Lane[] {
|
||||
if (parents.length === 0) {
|
||||
return [{ personalDataUuid: null, name: null, bands: buildBands(plannedDays, startDate, endDate) }]
|
||||
}
|
||||
return parents.map((parent) => ({
|
||||
personalDataUuid: parent.personalDataUuid,
|
||||
name: parent.name,
|
||||
bands: buildBands(
|
||||
plannedDays.filter((d) => d.parentPersonalDataUuid === parent.personalDataUuid),
|
||||
startDate,
|
||||
endDate,
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
function renderRemedyLine(remedy: ShortfallRemedy, t: Translate): string {
|
||||
const key = REMEDY_BODY_KEYS[remedy.messageKey]
|
||||
if (!key) return remedy.messageKey
|
||||
const params = toTranslationParams(remedy.data)
|
||||
params.days_freed = remedy.daysFreed
|
||||
return t(key, params)
|
||||
}
|
||||
|
||||
/**
|
||||
* One disclosure row per calendar year from the child's birth to their 4th
|
||||
* birthday: the year, a thin per-parent timeline of planned leave (click a
|
||||
* band to open the calendar there), and each parent's "what is left at the
|
||||
* end of this year" figures for both parental-leave days and vacation.
|
||||
* Opening a row shows the full per-parent balance table for that year plus
|
||||
* the plan proposal's wish outcomes.
|
||||
*/
|
||||
export function YearRows({ parents, plannedDays, startDate, endDate, outcomes, onBandClick }: YearRowsProps): ReactNode {
|
||||
const { t, locale } = useTranslation()
|
||||
const startYear = toUtcDate(startDate).getUTCFullYear()
|
||||
const endYear = toUtcDate(endDate).getUTCFullYear()
|
||||
const years = startYear <= endYear ? Array.from({ length: endYear - startYear + 1 }, (_, i) => startYear + i) : []
|
||||
const lanes = buildLanes(parents, plannedDays, startDate, endDate)
|
||||
const couple = parents.length > 1
|
||||
|
||||
const balanceFor = (parent: YearRowsParent, year: number): ParentYearBalance | undefined =>
|
||||
parent.yearBalances.find((b) => b.year === year)
|
||||
|
||||
// The engine reports funded weeks as a fraction of the wish (4.29 of 6);
|
||||
// one decimal in the locale's own notation is all a glance needs.
|
||||
const formatWeeks = (weeks: number): string =>
|
||||
weeks.toLocaleString(locale === 'sv' ? 'sv-SE' : 'en-GB', { maximumFractionDigits: 1 })
|
||||
|
||||
const handleBandClick = (event: MouseEvent<HTMLButtonElement>, date: string) => {
|
||||
// The band sits inside the row's <summary>; cancelling the click keeps
|
||||
// the row from toggling on top of the navigation.
|
||||
event.preventDefault()
|
||||
onBandClick(date)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="year-rows" data-testid="year-rows">
|
||||
<ul className="year-rows__legend" data-testid="year-rows-legend">
|
||||
{TYPE_ORDER.map((type) => (
|
||||
<li key={type} className="year-rows__legend-item">
|
||||
<span className={`year-rows__swatch year-rows__swatch--${getTypeKey(type)}`} aria-hidden="true" />
|
||||
{getLabelForType(type, t)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{years.map((year) => {
|
||||
const yearLength = daysInYear(year)
|
||||
const outcomesForYear = outcomes
|
||||
.filter((o) => o.year === year)
|
||||
.sort((a, b) => a.startDate.localeCompare(b.startDate))
|
||||
let bandIndex = 0
|
||||
return (
|
||||
<details key={year} className="disclosure year-row" data-testid={`year-row-${year}`}>
|
||||
<summary className="disclosure__summary year-row__summary">
|
||||
<span className="year-row__year tnum">{year}</span>
|
||||
|
||||
<span className={couple ? 'year-row__track year-row__track--couple' : 'year-row__track'}>
|
||||
{lanes.map((lane, laneIndex) => (
|
||||
<span
|
||||
key={lane.personalDataUuid ?? 'all'}
|
||||
className={`year-row__lane${couple ? (laneIndex === 0 ? ' year-row__lane--self' : ' year-row__lane--partner') : ''}`}
|
||||
data-testid={`year-row-${year}-lane-${lane.personalDataUuid ?? 'all'}`}
|
||||
>
|
||||
{lane.bands
|
||||
.filter((band) => toUtcDate(band.startDate).getUTCFullYear() === year)
|
||||
.map((band) => {
|
||||
const idx = bandIndex++
|
||||
const left = ((dayOfYearUtc(toUtcDate(band.startDate)) - 1) / yearLength) * 100
|
||||
const width =
|
||||
((dayOfYearUtc(toUtcDate(band.endDate)) - dayOfYearUtc(toUtcDate(band.startDate)) + 1) / yearLength) * 100
|
||||
const text = t(band.dayCount === 1 ? 'yearRow.bandLabelOneDay' : 'yearRow.bandLabel', {
|
||||
dayType: getLabelForType(band.dominantType, t),
|
||||
startDate: formatDateForDisplay(band.startDate, locale),
|
||||
endDate: formatDateForDisplay(band.endDate, locale),
|
||||
dayCount: band.dayCount,
|
||||
})
|
||||
const label = lane.name ? `${lane.name} · ${text}` : text
|
||||
return (
|
||||
<button
|
||||
key={`${year}-${idx}`}
|
||||
type="button"
|
||||
className={`year-row__band year-row__band--${getTypeKey(band.dominantType)}`}
|
||||
style={{ left: `${left}%`, width: `${width}%` }}
|
||||
onClick={(event) => handleBandClick(event, band.startDate)}
|
||||
data-testid={`year-timeline-band-${year}-${idx}`}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
|
||||
{parents.length > 0 && (
|
||||
<span className="year-row__figures">
|
||||
{parents.map((parent) => {
|
||||
const balance = balanceFor(parent, year)
|
||||
return (
|
||||
<span
|
||||
key={parent.personalDataUuid}
|
||||
className="year-row__parent"
|
||||
data-testid={`year-row-${year}-parent-${parent.personalDataUuid}`}
|
||||
>
|
||||
{couple && <span className="year-row__name">{parent.name}</span>}
|
||||
<span className="year-row__figure" data-testid={`year-row-${year}-fp-${parent.personalDataUuid}`}>
|
||||
<span className="year-row__figure-label">{t('yearRow.fpLeft')}</span>{' '}
|
||||
<span className="year-row__figure-value tnum">
|
||||
{balance ? balance.parentalLeave.sjukpenningLeft + balance.parentalLeave.lagstaLeft : '–'}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className="year-row__figure"
|
||||
data-testid={`year-row-${year}-vacation-${parent.personalDataUuid}`}
|
||||
>
|
||||
<span className="year-row__figure-label">{t('yearRow.vacationLeft')}</span>{' '}
|
||||
<span className="year-row__figure-value tnum">{balance ? balance.vacation.left : '–'}</span>
|
||||
{balance && (
|
||||
<span className="year-row__figure-sub tnum">
|
||||
{' · '}
|
||||
{t('yearRow.carriedOut', { days: balance.vacation.carriedOut })}
|
||||
</span>
|
||||
)}
|
||||
{balance && balance.vacation.expiring > 0 && (
|
||||
<span
|
||||
className="year-row__figure-danger tnum"
|
||||
data-testid={`year-row-${year}-expiring-${parent.personalDataUuid}`}
|
||||
>
|
||||
{' · '}
|
||||
{t('yearRow.expiring', { days: balance.vacation.expiring })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</summary>
|
||||
|
||||
<div className="disclosure__body year-row__body">
|
||||
{parents.length > 0 && (
|
||||
<table className="year-row__table" data-testid={`year-row-${year}-table`}>
|
||||
{couple && (
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" />
|
||||
{parents.map((parent) => (
|
||||
<th key={parent.personalDataUuid} scope="col" className="year-row__cell--num">
|
||||
{parent.name}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
)}
|
||||
<tbody>
|
||||
<tr className="year-row__group">
|
||||
<th scope="rowgroup" colSpan={parents.length + 1}>
|
||||
{t('yearRow.parentalLeaveTitle')}
|
||||
</th>
|
||||
</tr>
|
||||
<BalanceRow
|
||||
label={`${t('yearRow.usedThisYear')} · ${getLabelForType('sjukpenning', t)}`}
|
||||
parents={parents}
|
||||
year={year}
|
||||
pick={(b) => b.parentalLeave.sjukpenningUsed}
|
||||
/>
|
||||
<BalanceRow
|
||||
label={`${t('yearRow.usedThisYear')} · ${getLabelForType('lagsta', t)}`}
|
||||
parents={parents}
|
||||
year={year}
|
||||
pick={(b) => b.parentalLeave.lagstaUsed}
|
||||
/>
|
||||
<BalanceRow
|
||||
label={`${t('yearRow.leftAtYearEnd')} · ${getLabelForType('sjukpenning', t)}`}
|
||||
parents={parents}
|
||||
year={year}
|
||||
pick={(b) => b.parentalLeave.sjukpenningLeft}
|
||||
emphasis
|
||||
/>
|
||||
<BalanceRow
|
||||
label={`${t('yearRow.leftAtYearEnd')} · ${getLabelForType('lagsta', t)}`}
|
||||
parents={parents}
|
||||
year={year}
|
||||
pick={(b) => b.parentalLeave.lagstaLeft}
|
||||
emphasis
|
||||
/>
|
||||
<tr className="year-row__group">
|
||||
<th scope="rowgroup" colSpan={parents.length + 1}>
|
||||
{t('yearRow.vacationTitle')}
|
||||
</th>
|
||||
</tr>
|
||||
<BalanceRow
|
||||
label={t('yearRow.earned')}
|
||||
parents={parents}
|
||||
year={year}
|
||||
pick={(b) => b.vacation.earned}
|
||||
badge={(b) => (b.vacation.isAssumed ? t('yearRow.assumed') : null)}
|
||||
/>
|
||||
<BalanceRow label={t('yearRow.carriedIn')} parents={parents} year={year} pick={(b) => b.vacation.carriedIn} />
|
||||
<BalanceRow label={t('yearRow.plannedUse')} parents={parents} year={year} pick={(b) => b.vacation.plannedUse} />
|
||||
<BalanceRow label={t('yearRow.left')} parents={parents} year={year} pick={(b) => b.vacation.left} emphasis />
|
||||
<BalanceRow
|
||||
label={t('yearRow.carriedOutRow')}
|
||||
parents={parents}
|
||||
year={year}
|
||||
pick={(b) => b.vacation.carriedOut}
|
||||
/>
|
||||
<BalanceRow
|
||||
label={t('yearRow.expiringRow')}
|
||||
parents={parents}
|
||||
year={year}
|
||||
pick={(b) => b.vacation.expiring}
|
||||
danger={(b) => b.vacation.expiring > 0}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{outcomesForYear.length > 0 && (
|
||||
<div className="year-row__wishes" data-testid={`year-row-${year}-wishes`}>
|
||||
<h4 className="year-row__wishes-title">{t('yearRow.wishesTitle')}</h4>
|
||||
<ul className="year-row__wish-list">
|
||||
{outcomesForYear.map((outcome) => (
|
||||
<li
|
||||
key={outcome.wishId}
|
||||
className="year-row__wish"
|
||||
data-testid={`year-row-wish-${outcome.wishId}-${year}`}
|
||||
>
|
||||
<span className="year-row__wish-name">
|
||||
{resolveWishName({ name: outcome.wishName, nameMessageKey: outcome.wishNameMessageKey }, t)}
|
||||
</span>
|
||||
<span className={outcome.shortfallDays > 0 ? 'year-row__wish-weeks year-row__wish-weeks--short' : 'year-row__wish-weeks'}>
|
||||
{t('yearRow.weeksFunded', {
|
||||
funded: formatWeeks(outcome.weeksFunded),
|
||||
wished: formatWeeks(outcome.weeksWished),
|
||||
})}
|
||||
</span>
|
||||
{outcome.shortfallDays > 0 && (
|
||||
<span className="year-row__shortfall" data-testid={`year-row-shortfall-${outcome.wishId}-${year}`}>
|
||||
{t('planResults.shortfallLine', { shortfall_days: outcome.shortfallDays })}
|
||||
</span>
|
||||
)}
|
||||
{outcome.shortfallDays > 0 && outcome.remedies.length > 0 && (
|
||||
<ul className="year-row__remedies">
|
||||
{outcome.remedies.map((remedy, idx) => (
|
||||
<li key={`${remedy.kind}-${idx}`} data-testid={`year-row-remedy-${outcome.wishId}-${year}-${idx}`}>
|
||||
{renderRemedyLine(remedy, t)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface BalanceRowProps {
|
||||
label: string
|
||||
parents: YearRowsParent[]
|
||||
year: number
|
||||
pick: (balance: ParentYearBalance) => number
|
||||
/** Optional small badge text after the value (e.g. "antaget"). */
|
||||
badge?: (balance: ParentYearBalance) => string | null
|
||||
/** Danger ink on the value when true for that parent. */
|
||||
danger?: (balance: ParentYearBalance) => boolean
|
||||
/** A "what is left" row — heavier value weight. */
|
||||
emphasis?: boolean
|
||||
}
|
||||
|
||||
function BalanceRow({ label, parents, year, pick, badge, danger, emphasis }: BalanceRowProps): ReactNode {
|
||||
return (
|
||||
<tr className={emphasis ? 'year-row__row year-row__row--emphasis' : 'year-row__row'}>
|
||||
<th scope="row" className="year-row__cell-label">
|
||||
{label}
|
||||
</th>
|
||||
{parents.map((parent) => {
|
||||
const balance = parent.yearBalances.find((b) => b.year === year)
|
||||
const badgeText = balance && badge ? badge(balance) : null
|
||||
const isDanger = balance && danger ? danger(balance) : false
|
||||
return (
|
||||
<td
|
||||
key={parent.personalDataUuid}
|
||||
className={isDanger ? 'year-row__cell--num year-row__cell--danger tnum' : 'year-row__cell--num tnum'}
|
||||
>
|
||||
{badgeText && <span className="badge badge--muted year-row__badge">{badgeText}</span>}
|
||||
{balance ? pick(balance) : '–'}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { YearTimeline } from './YearTimeline'
|
||||
import { makePlannedLeaveDay } from '../../../tests/fixtures'
|
||||
|
||||
describe('YearTimeline', () => {
|
||||
it('renders one row per year across the horizon', () => {
|
||||
render(
|
||||
<YearTimeline
|
||||
plannedDays={[]}
|
||||
startDate="2025-06-01"
|
||||
endDate="2029-06-01"
|
||||
onBandClick={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
for (const year of [2025, 2026, 2027, 2028, 2029]) {
|
||||
expect(screen.getByTestId(`year-timeline-row-${year}`)).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
it('groups a contiguous run of planned days into a single band', () => {
|
||||
render(
|
||||
<YearTimeline
|
||||
plannedDays={[
|
||||
makePlannedLeaveDay({ id: '1', date: '2026-06-15', dayType: 'vacation' }),
|
||||
makePlannedLeaveDay({ id: '2', date: '2026-06-16', dayType: 'vacation' }),
|
||||
makePlannedLeaveDay({ id: '3', date: '2026-06-17', dayType: 'sjukpenning' }),
|
||||
]}
|
||||
startDate="2025-06-01"
|
||||
endDate="2029-06-01"
|
||||
onBandClick={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
// Three consecutive days -> exactly one band for 2026, dominant type
|
||||
// vacation (2 of 3 days).
|
||||
expect(screen.getAllByTestId(/^year-timeline-band-2026-/)).toHaveLength(1)
|
||||
expect(screen.getByTestId('year-timeline-band-2026-0')).toHaveAttribute(
|
||||
'title',
|
||||
'Semester, 15 juni 2026 – 17 juni 2026 (3 dagar). Klicka för att öppna denna månad i kalendern.',
|
||||
)
|
||||
})
|
||||
|
||||
it('splits into separate bands across a gap bigger than a bridged weekend', () => {
|
||||
render(
|
||||
<YearTimeline
|
||||
plannedDays={[
|
||||
makePlannedLeaveDay({ id: '1', date: '2026-01-05', dayType: 'vacation' }),
|
||||
makePlannedLeaveDay({ id: '2', date: '2026-06-15', dayType: 'vacation' }),
|
||||
]}
|
||||
startDate="2025-06-01"
|
||||
endDate="2029-06-01"
|
||||
onBandClick={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getAllByTestId(/^year-timeline-band-2026-/)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('never lets a band span a calendar-year boundary', () => {
|
||||
render(
|
||||
<YearTimeline
|
||||
plannedDays={[
|
||||
makePlannedLeaveDay({ id: '1', date: '2026-12-30', dayType: 'vacation' }),
|
||||
makePlannedLeaveDay({ id: '2', date: '2027-01-02', dayType: 'vacation' }),
|
||||
]}
|
||||
startDate="2025-06-01"
|
||||
endDate="2029-06-01"
|
||||
onBandClick={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('year-timeline-band-2026-0')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('year-timeline-band-2027-0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('excludes planned days outside the horizon', () => {
|
||||
render(
|
||||
<YearTimeline
|
||||
plannedDays={[makePlannedLeaveDay({ id: '1', date: '2030-01-01', dayType: 'vacation' })]}
|
||||
startDate="2025-06-01"
|
||||
endDate="2029-06-01"
|
||||
onBandClick={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByTestId(/^year-timeline-band-/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onBandClick with the band start date when clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onBandClick = vi.fn()
|
||||
render(
|
||||
<YearTimeline
|
||||
plannedDays={[makePlannedLeaveDay({ id: '1', date: '2026-06-15', dayType: 'sjukpenning' })]}
|
||||
startDate="2025-06-01"
|
||||
endDate="2029-06-01"
|
||||
onBandClick={onBandClick}
|
||||
/>,
|
||||
)
|
||||
await user.click(screen.getByTestId('year-timeline-band-2026-0'))
|
||||
expect(onBandClick).toHaveBeenCalledWith('2026-06-15')
|
||||
})
|
||||
|
||||
it('renders an explanatory caption below the title', () => {
|
||||
render(
|
||||
<YearTimeline plannedDays={[]} startDate="2025-06-01" endDate="2029-06-01" onBandClick={vi.fn()} />,
|
||||
)
|
||||
expect(screen.getByText(/Varje rad är ett år/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders a colour legend with one entry per day type', () => {
|
||||
render(
|
||||
<YearTimeline plannedDays={[]} startDate="2025-06-01" endDate="2029-06-01" onBandClick={vi.fn()} />,
|
||||
)
|
||||
const legend = screen.getByTestId('year-timeline-legend')
|
||||
expect(legend).toHaveTextContent('Sjukpenningnivå')
|
||||
expect(legend).toHaveTextContent('Lägstanivå')
|
||||
expect(legend).toHaveTextContent('Semester')
|
||||
})
|
||||
|
||||
it('includes the day count in a band\'s label', () => {
|
||||
render(
|
||||
<YearTimeline
|
||||
plannedDays={[makePlannedLeaveDay({ id: '1', date: '2026-06-15', dayType: 'vacation' })]}
|
||||
startDate="2025-06-01"
|
||||
endDate="2029-06-01"
|
||||
onBandClick={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByTestId('year-timeline-band-2026-0')).toHaveAttribute(
|
||||
'title',
|
||||
'Semester, 15 juni 2026 – 15 juni 2026 (1 dag). Klicka för att öppna denna månad i kalendern.',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,183 +0,0 @@
|
||||
import { ReactNode } from 'react'
|
||||
import type { PlannedLeaveDay } from '@/types'
|
||||
import { useTranslation } from '@/i18n'
|
||||
import { getColorForType, getLabelForType, getTypeModifier } from '@/utils/leaveTypes'
|
||||
import { formatDateForDisplay } from '@/utils/dates'
|
||||
|
||||
export interface YearTimelineProps {
|
||||
plannedDays: PlannedLeaveDay[]
|
||||
/** ISO-8601 date — horizon start (the child's birth date). */
|
||||
startDate: string
|
||||
/** ISO-8601 date — horizon end (the child's 4th birthday). */
|
||||
endDate: string
|
||||
/** Called with a band's ISO-8601 start date when clicked. */
|
||||
onBandClick: (date: string) => void
|
||||
}
|
||||
|
||||
interface Band {
|
||||
startDate: string
|
||||
endDate: string
|
||||
dominantType: PlannedLeaveDay['dayType']
|
||||
/** Number of planned days actually in this band (not the calendar span —
|
||||
* a bridged weekend/röd dag inside the band isn't a planned day). */
|
||||
dayCount: number
|
||||
}
|
||||
|
||||
// A run of planned days separates into a new band once the gap between
|
||||
// consecutive dates exceeds this many days — bridges a weekend (2 days) or
|
||||
// a weekend plus a single röd dag (3 days) without splitting one leave
|
||||
// period into several bands.
|
||||
const GAP_DAYS = 3
|
||||
|
||||
// Tie-break order when a band's days split evenly across types.
|
||||
const TYPE_PRIORITY: PlannedLeaveDay['dayType'][] = ['sjukpenning', 'lagsta', 'vacation']
|
||||
|
||||
function toUtcDate(iso: string): Date {
|
||||
return new Date(iso + 'T00:00:00Z')
|
||||
}
|
||||
|
||||
function dayOfYearUtc(d: Date): number {
|
||||
const yearStart = Date.UTC(d.getUTCFullYear(), 0, 1)
|
||||
return Math.floor((d.getTime() - yearStart) / 86_400_000) + 1
|
||||
}
|
||||
|
||||
function daysInYear(year: number): number {
|
||||
return Math.floor((Date.UTC(year + 1, 0, 1) - Date.UTC(year, 0, 1)) / 86_400_000)
|
||||
}
|
||||
|
||||
function dominantType(days: PlannedLeaveDay[]): PlannedLeaveDay['dayType'] {
|
||||
const counts: Record<string, number> = {}
|
||||
for (const d of days) {
|
||||
counts[d.dayType] = (counts[d.dayType] ?? 0) + 1
|
||||
}
|
||||
let best = TYPE_PRIORITY[0]!
|
||||
let bestCount = -1
|
||||
for (const type of TYPE_PRIORITY) {
|
||||
const count = counts[type] ?? 0
|
||||
if (count > bestCount) {
|
||||
bestCount = count
|
||||
best = type
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/** Groups sorted planned days into contiguous bands (one leave period each),
|
||||
* never spanning a calendar-year boundary — each year gets its own row. */
|
||||
function buildBands(plannedDays: PlannedLeaveDay[], startDate: string, endDate: string): Band[] {
|
||||
const start = toUtcDate(startDate)
|
||||
const end = toUtcDate(endDate)
|
||||
const inHorizon = plannedDays
|
||||
.filter((d) => {
|
||||
const date = toUtcDate(d.date)
|
||||
return date >= start && date <= end
|
||||
})
|
||||
.sort((a, b) => a.date.localeCompare(b.date))
|
||||
|
||||
const bands: Band[] = []
|
||||
let current: PlannedLeaveDay[] = []
|
||||
|
||||
const closeCurrent = () => {
|
||||
if (current.length === 0) return
|
||||
bands.push({
|
||||
startDate: current[0]!.date,
|
||||
endDate: current[current.length - 1]!.date,
|
||||
dominantType: dominantType(current),
|
||||
dayCount: current.length,
|
||||
})
|
||||
current = []
|
||||
}
|
||||
|
||||
for (const day of inHorizon) {
|
||||
if (current.length === 0) {
|
||||
current.push(day)
|
||||
continue
|
||||
}
|
||||
const prev = toUtcDate(current[current.length - 1]!.date)
|
||||
const next = toUtcDate(day.date)
|
||||
const gapDays = Math.round((next.getTime() - prev.getTime()) / 86_400_000)
|
||||
const crossesYear = prev.getUTCFullYear() !== next.getUTCFullYear()
|
||||
if (gapDays > GAP_DAYS || crossesYear) {
|
||||
closeCurrent()
|
||||
}
|
||||
current.push(day)
|
||||
}
|
||||
closeCurrent()
|
||||
|
||||
return bands
|
||||
}
|
||||
|
||||
/**
|
||||
* One row per calendar year from the child's birth to their 4th birthday,
|
||||
* with a colored band per contiguous planned-leave period ("what do the
|
||||
* next four years look like"). Clicking a band navigates to the calendar at
|
||||
* that date — the reason this view exists alongside (not instead of) the
|
||||
* per-parent day bars above it.
|
||||
*/
|
||||
export function YearTimeline({ plannedDays, startDate, endDate, onBandClick }: YearTimelineProps): ReactNode {
|
||||
const { t, locale } = useTranslation()
|
||||
const start = toUtcDate(startDate)
|
||||
const end = toUtcDate(endDate)
|
||||
const startYear = start.getUTCFullYear()
|
||||
const endYear = end.getUTCFullYear()
|
||||
const years = startYear <= endYear
|
||||
? Array.from({ length: endYear - startYear + 1 }, (_, i) => startYear + i)
|
||||
: []
|
||||
|
||||
const bands = buildBands(plannedDays, startDate, endDate)
|
||||
|
||||
return (
|
||||
<div data-testid="year-timeline" className="year-timeline">
|
||||
<h3 className="year-timeline__title">{t('yearTimeline.title')}</h3>
|
||||
<p className="year-timeline__caption">{t('yearTimeline.caption')}</p>
|
||||
<ul className="year-timeline__legend" data-testid="year-timeline-legend">
|
||||
{TYPE_PRIORITY.map((type) => (
|
||||
<li key={type} className="year-timeline__legend-item">
|
||||
<span
|
||||
className={`year-timeline__legend-swatch year-timeline__legend-swatch--${getTypeModifier(type)}`}
|
||||
style={{ backgroundColor: getColorForType(type) }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{getLabelForType(type, t)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{years.map((year) => {
|
||||
const yearLength = daysInYear(year)
|
||||
const bandsForYear = bands.filter((b) => toUtcDate(b.startDate).getUTCFullYear() === year)
|
||||
return (
|
||||
<div key={year} className="year-timeline__row" data-testid={`year-timeline-row-${year}`}>
|
||||
<span className="year-timeline__year-label">{year}</span>
|
||||
<div className="year-timeline__track">
|
||||
{bandsForYear.map((band, idx) => {
|
||||
const left = ((dayOfYearUtc(toUtcDate(band.startDate)) - 1) / yearLength) * 100
|
||||
const width = ((dayOfYearUtc(toUtcDate(band.endDate)) - dayOfYearUtc(toUtcDate(band.startDate)) + 1) / yearLength) * 100
|
||||
const label = t(
|
||||
band.dayCount === 1 ? 'yearTimeline.bandLabelOneDay' : 'yearTimeline.bandLabel',
|
||||
{
|
||||
dayType: getLabelForType(band.dominantType, t),
|
||||
startDate: formatDateForDisplay(band.startDate, locale),
|
||||
endDate: formatDateForDisplay(band.endDate, locale),
|
||||
dayCount: band.dayCount,
|
||||
},
|
||||
)
|
||||
return (
|
||||
<button
|
||||
key={`${year}-${idx}`}
|
||||
type="button"
|
||||
className={`year-timeline__band year-timeline__band--${getTypeModifier(band.dominantType)}`}
|
||||
style={{ left: `${left}%`, width: `${width}%`, backgroundColor: getColorForType(band.dominantType) }}
|
||||
onClick={() => onBandClick(band.startDate)}
|
||||
data-testid={`year-timeline-band-${year}-${idx}`}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { DayOverridesInput } from './DayOverridesInput'
|
||||
|
||||
describe('DayOverridesInput', () => {
|
||||
const mockOverrides = [
|
||||
{
|
||||
id: 'do-1',
|
||||
workspaceId: 'ws-1',
|
||||
date: '2026-12-24',
|
||||
isWorkingDay: true,
|
||||
note: 'Jobbade på juldagen',
|
||||
createdAt: '2026-01-01T00:00:00',
|
||||
updatedAt: '2026-01-01T00:00:00',
|
||||
},
|
||||
{
|
||||
id: 'do-2',
|
||||
workspaceId: 'ws-1',
|
||||
date: '2026-06-20',
|
||||
isWorkingDay: false,
|
||||
note: null,
|
||||
createdAt: '2026-01-01T00:00:00',
|
||||
updatedAt: '2026-01-01T00:00:00',
|
||||
},
|
||||
]
|
||||
|
||||
let mockOnAdd: ReturnType<typeof vi.fn>
|
||||
let mockOnRemove: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnAdd = vi.fn().mockResolvedValue(undefined)
|
||||
mockOnRemove = vi.fn().mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders list of existing overrides', () => {
|
||||
render(
|
||||
<DayOverridesInput
|
||||
overrides={mockOverrides}
|
||||
onAdd={mockOnAdd}
|
||||
onRemove={mockOnRemove}
|
||||
workspaceId="ws-1"
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('24 december 2026')).toBeInTheDocument()
|
||||
expect(screen.getByText('20 juni 2026')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders working day override with correct status', () => {
|
||||
render(
|
||||
<DayOverridesInput
|
||||
overrides={mockOverrides}
|
||||
onAdd={mockOnAdd}
|
||||
onRemove={mockOnRemove}
|
||||
workspaceId="ws-1"
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('override-item-do-1').textContent).toContain('Jag jobbar den här dagen')
|
||||
})
|
||||
|
||||
it('renders off day override with correct status', () => {
|
||||
render(
|
||||
<DayOverridesInput
|
||||
overrides={mockOverrides}
|
||||
onAdd={mockOnAdd}
|
||||
onRemove={mockOnRemove}
|
||||
workspaceId="ws-1"
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('override-item-do-2').textContent).toContain('Jag är ledig den här dagen')
|
||||
})
|
||||
|
||||
it('renders note when present', () => {
|
||||
render(
|
||||
<DayOverridesInput
|
||||
overrides={mockOverrides}
|
||||
onAdd={mockOnAdd}
|
||||
onRemove={mockOnRemove}
|
||||
workspaceId="ws-1"
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Anteckning: Jobbade på juldagen')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders empty state when no overrides', () => {
|
||||
render(
|
||||
<DayOverridesInput
|
||||
overrides={[]}
|
||||
onAdd={mockOnAdd}
|
||||
onRemove={mockOnRemove}
|
||||
workspaceId="ws-1"
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Du har inga undantag i kalendern ännu.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders add form with all fields', () => {
|
||||
render(
|
||||
<DayOverridesInput
|
||||
overrides={[]}
|
||||
onAdd={mockOnAdd}
|
||||
onRemove={mockOnRemove}
|
||||
workspaceId="ws-1"
|
||||
/>
|
||||
)
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
describe('adding override', () => {
|
||||
it('calls onAdd with correct payload when working day is selected', async () => {
|
||||
render(
|
||||
<DayOverridesInput
|
||||
overrides={[]}
|
||||
onAdd={mockOnAdd}
|
||||
onRemove={mockOnRemove}
|
||||
workspaceId="ws-1"
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByTestId('override-date-input'), { target: { value: '2026-12-24' } })
|
||||
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(mockOnAdd).toHaveBeenCalledWith({
|
||||
workspaceId: 'ws-1',
|
||||
date: '2026-12-24',
|
||||
isWorkingDay: true,
|
||||
note: 'Jobbar denna dag',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('calls onAdd with correct payload when off day is selected', async () => {
|
||||
render(
|
||||
<DayOverridesInput
|
||||
overrides={[]}
|
||||
onAdd={mockOnAdd}
|
||||
onRemove={mockOnRemove}
|
||||
workspaceId="ws-1"
|
||||
/>
|
||||
)
|
||||
|
||||
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(mockOnAdd).toHaveBeenCalledWith({
|
||||
workspaceId: 'ws-1',
|
||||
date: '2026-06-20',
|
||||
isWorkingDay: false,
|
||||
note: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('clears form after successful add', async () => {
|
||||
render(
|
||||
<DayOverridesInput
|
||||
overrides={[]}
|
||||
onAdd={mockOnAdd}
|
||||
onRemove={mockOnRemove}
|
||||
workspaceId="ws-1"
|
||||
/>
|
||||
)
|
||||
|
||||
const dateInput = screen.getByTestId('override-date-input') as HTMLInputElement
|
||||
const noteInput = screen.getByTestId('override-note-input') as HTMLInputElement
|
||||
|
||||
fireEvent.change(dateInput, { target: { value: '2026-12-24' } })
|
||||
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 button when date is empty', () => {
|
||||
render(
|
||||
<DayOverridesInput
|
||||
overrides={[]}
|
||||
onAdd={mockOnAdd}
|
||||
onRemove={mockOnRemove}
|
||||
workspaceId="ws-1"
|
||||
/>
|
||||
)
|
||||
|
||||
const submitButton = screen.getByTestId('add-override-button') as HTMLButtonElement
|
||||
expect(submitButton.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('does not call onAdd when the form is submitted with no date entered', async () => {
|
||||
render(
|
||||
<DayOverridesInput
|
||||
overrides={[]}
|
||||
onAdd={mockOnAdd}
|
||||
onRemove={mockOnRemove}
|
||||
workspaceId="ws-1"
|
||||
/>
|
||||
)
|
||||
|
||||
// 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(mockOnAdd).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('removing override', () => {
|
||||
it('calls onRemove with correct id', async () => {
|
||||
render(
|
||||
<DayOverridesInput
|
||||
overrides={mockOverrides}
|
||||
onAdd={mockOnAdd}
|
||||
onRemove={mockOnRemove}
|
||||
workspaceId="ws-1"
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('remove-override-do-1'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnRemove).toHaveBeenCalledWith('do-1')
|
||||
})
|
||||
})
|
||||
|
||||
it('swallows an onRemove failure instead of throwing (handled by the parent)', async () => {
|
||||
mockOnRemove.mockRejectedValueOnce(new Error('delete failed'))
|
||||
render(
|
||||
<DayOverridesInput
|
||||
overrides={mockOverrides}
|
||||
onAdd={mockOnAdd}
|
||||
onRemove={mockOnRemove}
|
||||
workspaceId="ws-1"
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('remove-override-do-1'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnRemove).toHaveBeenCalledWith('do-1')
|
||||
})
|
||||
// The item is still rendered — the component didn't crash on the rejection.
|
||||
expect(screen.getByTestId('override-item-do-1')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('loading state', () => {
|
||||
it('disables form during loading', () => {
|
||||
render(
|
||||
<DayOverridesInput
|
||||
overrides={[]}
|
||||
isLoading={true}
|
||||
onAdd={mockOnAdd}
|
||||
onRemove={mockOnRemove}
|
||||
workspaceId="ws-1"
|
||||
/>
|
||||
)
|
||||
|
||||
expect((screen.getByTestId('override-date-input') as HTMLInputElement).disabled).toBe(true)
|
||||
expect((screen.getByTestId('add-override-button') as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,178 +0,0 @@
|
||||
import { ReactNode, useState } from 'react'
|
||||
import { formatDateForDisplay } from '@/utils/dates'
|
||||
import { useTranslation } from '@/i18n'
|
||||
import type { DayOverride, DayOverrideCreateRequest } from '@/types'
|
||||
|
||||
export interface DayOverridesInputProps {
|
||||
overrides: DayOverride[]
|
||||
isLoading?: boolean
|
||||
onAdd: (req: DayOverrideCreateRequest) => Promise<void>
|
||||
onRemove: (id: string) => Promise<void>
|
||||
workspaceId: string
|
||||
}
|
||||
|
||||
export function DayOverridesInput({
|
||||
overrides,
|
||||
isLoading = false,
|
||||
onAdd,
|
||||
onRemove,
|
||||
workspaceId,
|
||||
}: DayOverridesInputProps): ReactNode {
|
||||
const { t, locale } = useTranslation()
|
||||
const [date, setDate] = useState('')
|
||||
const [isWorkingDay, setIsWorkingDay] = useState(true)
|
||||
const [note, setNote] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const handleAddOverride = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!date) return
|
||||
|
||||
try {
|
||||
setIsSubmitting(true)
|
||||
await onAdd({
|
||||
workspaceId,
|
||||
date,
|
||||
isWorkingDay,
|
||||
note: note || null,
|
||||
})
|
||||
setDate('')
|
||||
setIsWorkingDay(true)
|
||||
setNote('')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = async (id: string) => {
|
||||
try {
|
||||
await onRemove(id)
|
||||
} catch {
|
||||
// Error handling is done in parent
|
||||
}
|
||||
}
|
||||
|
||||
const sortedOverrides = [...overrides].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
|
||||
|
||||
return (
|
||||
<div data-testid="day-overrides-input" className="day-overrides-input">
|
||||
{overrides.length === 0 ? (
|
||||
<div className="day-overrides-input__empty">
|
||||
<p>{t('dayOverridesInput.empty')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="day-overrides-input__list">
|
||||
<ul className="day-overrides-input__items">
|
||||
{sortedOverrides.map((override) => (
|
||||
<li key={override.id} className="day-overrides-input__item" data-testid={`override-item-${override.id}`}>
|
||||
<div className="day-overrides-input__item-header">
|
||||
<div className="day-overrides-input__item-date">
|
||||
<strong>{formatDateForDisplay(override.date, locale)}</strong>
|
||||
</div>
|
||||
<div className="day-overrides-input__item-status">
|
||||
<span className={`day-overrides-input__status day-overrides-input__status--${override.isWorkingDay ? 'working' : 'off'}`}>
|
||||
{override.isWorkingDay ? t('dayOverridesInput.workingStatus') : t('dayOverridesInput.offStatus')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{override.note && (
|
||||
<div className="day-overrides-input__item-note">
|
||||
{t('dayOverridesInput.noteLabel', { note: override.note })}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemove(override.id)}
|
||||
disabled={isSubmitting || isLoading}
|
||||
className="btn btn--danger btn--sm btn--block"
|
||||
data-testid={`remove-override-${override.id}`}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleAddOverride} 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 btn--block"
|
||||
data-testid="add-override-button"
|
||||
>
|
||||
{isSubmitting ? t('dayOverridesInput.addingButton') : t('dayOverridesInput.addButton')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useParentalLeaves } from './useParentalLeaves'
|
||||
import * as api from '@/services/api'
|
||||
import { makeParentalLeaveFetch } from '../../tests/fixtures'
|
||||
|
||||
vi.mock('@/services/api')
|
||||
|
||||
const flush = () =>
|
||||
act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
describe('useParentalLeaves', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('starts empty and does not fetch without a child or members', () => {
|
||||
const { result } = renderHook(() => useParentalLeaves([], null))
|
||||
expect(result.current.data).toEqual({})
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
expect(api.getParentalLeave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not fetch when there are members but no child', () => {
|
||||
renderHook(() => useParentalLeaves(['pd-a'], null))
|
||||
expect(api.getParentalLeave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fetches one snapshot per member and keys the result by uuid, keeping null for a parent with none', async () => {
|
||||
const alex = makeParentalLeaveFetch({ personalDataUuid: 'pd-a' })
|
||||
vi.mocked(api.getParentalLeave).mockImplementation(async (uuid) => (uuid === 'pd-a' ? alex : null))
|
||||
|
||||
const { result } = renderHook(() => useParentalLeaves(['pd-a', 'pd-b'], 'child-1'))
|
||||
await flush()
|
||||
|
||||
expect(api.getParentalLeave).toHaveBeenCalledWith('pd-a', 'child-1')
|
||||
expect(api.getParentalLeave).toHaveBeenCalledWith('pd-b', 'child-1')
|
||||
expect(result.current.data).toEqual({ 'pd-a': alex, 'pd-b': null })
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('surfaces a fetch error and clears the map', async () => {
|
||||
vi.mocked(api.getParentalLeave).mockRejectedValue(new Error('Fetch failed'))
|
||||
const { result } = renderHook(() => useParentalLeaves(['pd-a'], 'child-1'))
|
||||
await flush()
|
||||
expect(result.current.error).toBe('Fetch failed')
|
||||
expect(result.current.data).toEqual({})
|
||||
})
|
||||
|
||||
it('falls back to a generic message when a non-Error value is thrown', async () => {
|
||||
vi.mocked(api.getParentalLeave).mockRejectedValue('boom')
|
||||
const { result } = renderHook(() => useParentalLeaves(['pd-a'], 'child-1'))
|
||||
await flush()
|
||||
expect(result.current.error).toBe('Failed to fetch parental leave data')
|
||||
})
|
||||
|
||||
it('refetches when the membership changes, not on a mere array identity change', async () => {
|
||||
vi.mocked(api.getParentalLeave).mockResolvedValue(null)
|
||||
const { rerender } = renderHook(({ uuids }: { uuids: string[] }) => useParentalLeaves(uuids, 'child-1'), {
|
||||
initialProps: { uuids: ['pd-a'] },
|
||||
})
|
||||
await flush()
|
||||
expect(api.getParentalLeave).toHaveBeenCalledTimes(1)
|
||||
|
||||
rerender({ uuids: ['pd-a'] })
|
||||
await flush()
|
||||
expect(api.getParentalLeave).toHaveBeenCalledTimes(1)
|
||||
|
||||
rerender({ uuids: ['pd-a', 'pd-b'] })
|
||||
await flush()
|
||||
expect(api.getParentalLeave).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('clears the map when the child identity is lost', async () => {
|
||||
vi.mocked(api.getParentalLeave).mockResolvedValue(makeParentalLeaveFetch())
|
||||
const { result, rerender } = renderHook(
|
||||
({ childId }: { childId: string | null }) => useParentalLeaves(['pd-a'], childId),
|
||||
{ initialProps: { childId: 'child-1' as string | null } },
|
||||
)
|
||||
await flush()
|
||||
expect(Object.keys(result.current.data)).toEqual(['pd-a'])
|
||||
|
||||
rerender({ childId: null })
|
||||
await flush()
|
||||
expect(result.current.data).toEqual({})
|
||||
})
|
||||
|
||||
it('exposes refresh, which fetches again', async () => {
|
||||
vi.mocked(api.getParentalLeave).mockResolvedValue(null)
|
||||
const { result } = renderHook(() => useParentalLeaves(['pd-a'], 'child-1'))
|
||||
await flush()
|
||||
await act(async () => {
|
||||
await result.current.refresh()
|
||||
})
|
||||
expect(api.getParentalLeave).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('ignores a stale response that lands after a newer request started', async () => {
|
||||
let resolveFirst: (value: null) => void = () => undefined
|
||||
vi.mocked(api.getParentalLeave)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<null>((resolve) => {
|
||||
resolveFirst = resolve
|
||||
}),
|
||||
)
|
||||
.mockResolvedValue(makeParentalLeaveFetch({ personalDataUuid: 'pd-b' }))
|
||||
|
||||
const { result, rerender } = renderHook(({ uuids }: { uuids: string[] }) => useParentalLeaves(uuids, 'child-1'), {
|
||||
initialProps: { uuids: ['pd-a'] },
|
||||
})
|
||||
rerender({ uuids: ['pd-b'] })
|
||||
await flush()
|
||||
expect(Object.keys(result.current.data)).toEqual(['pd-b'])
|
||||
|
||||
await act(async () => {
|
||||
resolveFirst(null)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
})
|
||||
expect(Object.keys(result.current.data)).toEqual(['pd-b'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import * as api from '@/services/api'
|
||||
import { useLatestRequest } from './useLatestRequest'
|
||||
import type { ParentalLeaveFetch } from '@/types'
|
||||
|
||||
/** uuid → that parent's latest balance snapshot, or `null` when nothing has
|
||||
* been saved for them yet. A uuid that is absent (not `null`) has not been
|
||||
* fetched yet — callers that gate an "estimated" hint must treat the two
|
||||
* differently. */
|
||||
export type ParentalLeaveByMember = Record<string, ParentalLeaveFetch | null>
|
||||
|
||||
interface UseParentalLeavesResult {
|
||||
data: ParentalLeaveByMember
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
refresh: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* `useParentalLeave` for every member of a workspace at once — one
|
||||
* `GET /api/parental-leave/{uuid}/{childId}` per member, in parallel. Feeds
|
||||
* the dashboard's per-parent balance snapshot, where a single-parent fetch
|
||||
* left the partner's figures invisible.
|
||||
*
|
||||
* Deliberately not exported from the `@/hooks` barrel: `Dashboard.tsx`
|
||||
* imports it directly so its page test can mock this one module without
|
||||
* touching the barrel's automock.
|
||||
*/
|
||||
export function useParentalLeaves(memberUuids: string[], childId: string | null): UseParentalLeavesResult {
|
||||
const [data, setData] = useState<ParentalLeaveByMember>({})
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const { start, isCurrent } = useLatestRequest()
|
||||
|
||||
// A fresh array identity every render would re-run the effect below on
|
||||
// every render; the joined key only changes when the membership does.
|
||||
const memberKey = memberUuids.join('\n')
|
||||
|
||||
const fetch = useCallback(async () => {
|
||||
const requestId = start()
|
||||
const uuids = memberKey === '' ? [] : memberKey.split('\n')
|
||||
if (!childId || uuids.length === 0) {
|
||||
// Identity gone (logout, no child yet) — clear rather than keep the
|
||||
// previous account's figures around until new ids arrive.
|
||||
setData({})
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const results = await Promise.all(uuids.map((uuid) => api.getParentalLeave(uuid, childId)))
|
||||
if (!isCurrent(requestId)) return
|
||||
const next: ParentalLeaveByMember = {}
|
||||
uuids.forEach((uuid, index) => {
|
||||
next[uuid] = results[index] ?? null
|
||||
})
|
||||
setData(next)
|
||||
} catch (err) {
|
||||
if (!isCurrent(requestId)) return
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch parental leave data')
|
||||
setData({})
|
||||
} finally {
|
||||
if (isCurrent(requestId)) setIsLoading(false)
|
||||
}
|
||||
}, [memberKey, childId, start, isCurrent])
|
||||
|
||||
useEffect(() => {
|
||||
void fetch()
|
||||
}, [fetch])
|
||||
|
||||
return { data, isLoading, error, refresh: fetch }
|
||||
}
|
||||
@@ -109,7 +109,7 @@ describe('LanguageContext', () => {
|
||||
expect(
|
||||
translate('en', 'parentalLeaveBreakdown.reservedNote', { reserved: 90, transferable: 45 }),
|
||||
).toBe(
|
||||
'90 of your days are reserved and cannot be transferred to the other parent (up to 45 days can be transferred).',
|
||||
'90 of each parent\'s days are reserved and cannot be transferred to the other parent (up to 45 days can be transferred).',
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -2,44 +2,55 @@ export const dashboard = {
|
||||
'age4Bars.expiringStat': '{expiring} days expiring',
|
||||
'age4Bars.savableStat': '{left} of {cap} days can be saved',
|
||||
'age4Bars.title': 'Days left at age 4, per parent',
|
||||
'dashboard.balancePickerPrompt': 'Whose balance is this?',
|
||||
'dashboard.balanceLink': 'Update balances',
|
||||
'dashboard.daysLeft': 'Days left',
|
||||
'dashboard.enterManually': 'Enter details manually',
|
||||
'dashboard.estimatedBadge': 'Estimated',
|
||||
'dashboard.estimatedBadgeCaption': 'No real balances have been fetched yet — these figures use the statutory assumption (480 days, none used). Fetch from Försäkringskassan or enter details manually for exact figures.',
|
||||
'dashboard.estimatedBadgeCaption': 'No real balances have been fetched yet — these figures assume 480 days, none used. Update balances under Settings.',
|
||||
'dashboard.expiringAtB4': 'Expiring at age 4 if the current plan is followed (all parents)',
|
||||
'dashboard.expiringAtB4ForParent': 'Expiring at age 4 if the current plan is followed – {name}',
|
||||
'dashboard.expiringAtB4ParentFallback': 'Parent {number}',
|
||||
// Still referenced by `Onboarding.tsx`'s balances step; the balance flow
|
||||
// is moving to a `balance.*` namespace — drop this key once nothing else
|
||||
// reads it.
|
||||
'dashboard.fetchFromFk': 'Fetch from Försäkringskassan',
|
||||
'dashboard.figureExpiring': 'Expiring at age 4',
|
||||
'dashboard.figureLeft': 'Days left',
|
||||
'dashboard.figureUsed': 'Used',
|
||||
'dashboard.hero.hasPlanMessage': '{atRisk} days are at risk of disappearing when {childName} turns 4 ({date}).',
|
||||
'dashboard.hero.noneAtRiskMessage': 'No days will be lost — your plan uses them all before {childName} turns 4 ({date}).',
|
||||
'dashboard.hero.noPlanMessage': 'You have {days} days to plan. Without a plan, {atRisk} of them will disappear when {childName} turns 4.',
|
||||
'dashboard.loading': 'Loading...',
|
||||
'dashboard.moreDetailsSummary': 'More details',
|
||||
'dashboard.leaveOverviewTitle': 'Until age 4',
|
||||
'dashboard.moreDetailsSummary': 'Details',
|
||||
'dashboard.overviewTitle': 'Overview',
|
||||
'dashboard.planFold.noWishesHint': 'Add wishes under Calendar to see a proposal here.',
|
||||
'dashboard.planFold.summary': 'Plan proposal for the years ahead',
|
||||
'dashboard.planFold.unknownParent': 'Unknown parent',
|
||||
'dashboard.plannedDaysTitle': 'Planned days',
|
||||
'dashboard.saveManualFetchError': 'Could not save the details',
|
||||
'dashboard.usedDays': 'Used days',
|
||||
'daysBreakdown.daysHeader': 'Days',
|
||||
'daysBreakdown.empty': 'No days registered yet',
|
||||
'daysBreakdown.typeHeader': 'Leave type',
|
||||
'overviewMetrics.defaultTitle': 'Overview',
|
||||
'parentalLeaveBreakdown.dubbeldagarLeft': 'Double days left',
|
||||
'parentalLeaveBreakdown.empty': 'No data fetched yet — press Fetch from Försäkringskassan.',
|
||||
'parentalLeaveBreakdown.empty': 'No balances fetched yet.',
|
||||
'parentalLeaveBreakdown.lagstaNiva': 'Minimum rate',
|
||||
'parentalLeaveBreakdown.remainingHeader': 'Remaining',
|
||||
'parentalLeaveBreakdown.reservedNote': '{reserved} of your days are reserved and cannot be transferred to the other parent (up to {transferable} days can be transferred).',
|
||||
'parentalLeaveBreakdown.leftOfTotal': '{left} of {total}',
|
||||
'parentalLeaveBreakdown.noBalance': 'No balance yet',
|
||||
'parentalLeaveBreakdown.reservedNote': '{reserved} of each parent\'s days are reserved and cannot be transferred to the other parent (up to {transferable} days can be transferred).',
|
||||
'parentalLeaveBreakdown.sjukpenning': 'Sjukpenningnivå (income-based)',
|
||||
'parentalLeaveBreakdown.sourceManual': 'Manually entered',
|
||||
'parentalLeaveBreakdown.sourceScraped': 'Fetched from Försäkringskassan',
|
||||
'parentalLeaveBreakdown.totalHeader': 'Total',
|
||||
'parentalLeaveBreakdown.transferableDays': 'Transferable days',
|
||||
'parentalLeaveBreakdown.typeHeader': 'Type',
|
||||
'yearTimeline.bandLabel': '{dayType}, {startDate} – {endDate} ({dayCount} days). Click to open this month in the calendar.',
|
||||
'yearTimeline.bandLabelOneDay': '{dayType}, {startDate} – {endDate} (1 day). Click to open this month in the calendar.',
|
||||
'yearTimeline.caption': 'Each row is one year; each colored band is a planned leave period. Click a band to open that month in the calendar.',
|
||||
'yearTimeline.title': 'The years ahead',
|
||||
'yearRow.assumed': 'assumed',
|
||||
'yearRow.bandLabel': '{dayType}, {startDate} – {endDate} ({dayCount} days). Click to open in the calendar.',
|
||||
'yearRow.bandLabelOneDay': '{dayType}, {startDate} – {endDate} (1 day). Click to open in the calendar.',
|
||||
'yearRow.carriedIn': 'Carried in',
|
||||
'yearRow.carriedOut': 'saved {days}',
|
||||
'yearRow.carriedOutRow': 'Saved for next year',
|
||||
'yearRow.earned': 'Earned',
|
||||
'yearRow.expiring': 'expiring {days}',
|
||||
'yearRow.expiringRow': 'Expiring',
|
||||
'yearRow.fpLeft': 'PL left',
|
||||
'yearRow.left': 'Left',
|
||||
'yearRow.leftAtYearEnd': 'Left at year end',
|
||||
'yearRow.parentalLeaveTitle': 'Parental leave',
|
||||
'yearRow.plannedUse': 'Planned',
|
||||
'yearRow.usedThisYear': 'Used this year',
|
||||
'yearRow.vacationLeft': 'Vacation left',
|
||||
'yearRow.vacationTitle': 'Vacation',
|
||||
'yearRow.weeksFunded': '{funded} of {wished} weeks',
|
||||
'yearRow.wishesTitle': 'Leave periods',
|
||||
} as const
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
// `plan.*` (the standalone Plan tab) and most `planResults.*` keys were
|
||||
// retired with the Plan/Tips tabs (leave is now authored on the calendar and
|
||||
// Översikt carries the headline figures — see PLANNING_ENGINE_SPEC.md's
|
||||
// superseding note). What's left here is the subset `planResults.*`
|
||||
// `PlanFold.tsx` (the read-only plan-proposal fold on Översikt) and
|
||||
// `frontend/src/utils/rejectionMessages.ts` still render.
|
||||
// superseding note). What's left here is the subset the dashboard's year
|
||||
// rows (`YearRows.tsx`: shortfall + remedy lines), its surplus-advice tips
|
||||
// (`SurplusAdviceTip.tsx`) and `frontend/src/utils/rejectionMessages.ts`
|
||||
// still render.
|
||||
export const plan = {
|
||||
'planResults.balancesTitle': 'Remaining balances after the plan',
|
||||
'planResults.dubbeldagarLeftHeader': 'Double days left',
|
||||
'planResults.lagstaLeftHeader': 'Lägstanivå days left',
|
||||
'planResults.leavesHeader': 'Leave periods',
|
||||
'planResults.noWindowsInHorizon': 'No windows within the planning horizon.',
|
||||
'planResults.parentHeader': 'Parent',
|
||||
'planResults.remedyBodyCarryVacationForward': '{spare_total} vacation day(s) from earlier years can be carried forward',
|
||||
'planResults.remedyBodyLowerPriorityWish': 'Lowering {wish_name} from {from_weeks} to {to_weeks} week(s) frees {days_freed} day(s)',
|
||||
'planResults.remedyBodyReducePreschoolReserve': 'Reducing the preschool planning-day reserve ({reserved} reserved) frees {days_freed} day(s)',
|
||||
@@ -26,22 +21,13 @@ export const plan = {
|
||||
'planResults.rejectionInvalidDayType': 'Day {index} ({date}): invalid day type "{day_type}"',
|
||||
'planResults.rejectionOutsideEligibleWindow': '{date}: outside the child\'s eligible window ({birth_date} – {hard_expiry})',
|
||||
'planResults.shortfallLine': '{shortfall_days} day(s) could not be funded',
|
||||
'planResults.shortfallsTitle': 'Periods with a shortfall',
|
||||
'planResults.sjukpenningLeftHeader': 'Sjukpenningnivå days left',
|
||||
'planResults.surplusAdviceBodyFrontLoadEarlyYears': '{parent_name} has a large surplus ({days_involved} parental-benefit days) — take more days now while the child is still small and you are home anyway, rather than spreading them thinly until the 4th birthday.',
|
||||
'planResults.surplusAdviceBodyShiftMixToFp': '{parent_name} could swap {days_involved} vacation day(s) for parental-benefit days in this plan — the parental-benefit days would otherwise be forfeited at the 4th birthday, while the vacation days would not.',
|
||||
'planResults.surplusAdviceBodyUseLessVacation': '{parent_name} could take {days_involved} fewer vacation day(s) ({years_text}) without going below the statutory minimum ({min_vacation_days_per_year} days/year) — save them instead to cover parental-benefit days that would otherwise be forfeited.',
|
||||
'planResults.surplusAdviceBodyWishMoreLeave': '{parent_name} can afford {weeks} more week(s) of leave ({days_involved} parental-benefit days would otherwise be forfeited at the child\'s 4th birthday) — add a week in the summer or an autumn break.',
|
||||
'planResults.surplusAdviceFrontLoadEarlyYears': 'Take more leave while the child is small',
|
||||
'planResults.surplusAdviceIntro': 'This plan leaves days unused that would otherwise be lost at the child\'s 4th birthday. A few suggestions:',
|
||||
'planResults.surplusAdviceShiftMixToFp': 'Spend parental-benefit days instead of vacation',
|
||||
'planResults.surplusAdviceTitle': 'You have unused days',
|
||||
'planResults.surplusAdviceUseLessVacation': 'Save fewer vacation days',
|
||||
'planResults.surplusAdviceWishMoreLeave': 'Wish for more leave',
|
||||
'planResults.totalAvailableHeader': 'Total available',
|
||||
'planResults.unknownParent': 'Unknown parent',
|
||||
'planResults.usedHeader': 'Used',
|
||||
'planResults.weeksUnit': 'weeks',
|
||||
'planResults.yearHeader': 'Year',
|
||||
'planResults.yearOverviewTitle': 'Overview by year',
|
||||
} as const
|
||||
|
||||
@@ -2,44 +2,55 @@ export const dashboard = {
|
||||
'age4Bars.expiringStat': '{expiring} dagar förfaller',
|
||||
'age4Bars.savableStat': '{left} av {cap} dagar kan sparas',
|
||||
'age4Bars.title': 'Dagar kvar vid 4 års ålder, per förälder',
|
||||
'dashboard.balancePickerPrompt': 'Vem gäller det?',
|
||||
'dashboard.balanceLink': 'Uppdatera saldon',
|
||||
'dashboard.daysLeft': 'Kvarvarande dagar',
|
||||
'dashboard.enterManually': 'Ange uppgifter manuellt',
|
||||
'dashboard.estimatedBadge': 'Uppskattat',
|
||||
'dashboard.estimatedBadgeCaption': 'Inga verkliga saldon är hämtade än — siffrorna bygger på det lagstadgade antagandet (480 dagar, inget uttaget). Hämta från Försäkringskassan eller ange uppgifter manuellt för exakta siffror.',
|
||||
'dashboard.estimatedBadgeCaption': 'Inga verkliga saldon är hämtade än — siffrorna utgår från 480 dagar, inget uttaget. Uppdatera saldon under Inställningar.',
|
||||
'dashboard.expiringAtB4': 'Förfaller vid 4 år om nuvarande plan följs (alla föräldrar)',
|
||||
'dashboard.expiringAtB4ForParent': 'Förfaller vid 4 år om nuvarande plan följs – {name}',
|
||||
'dashboard.expiringAtB4ParentFallback': 'Förälder {number}',
|
||||
// Still referenced by `Onboarding.tsx`'s balances step; the balance flow
|
||||
// is moving to a `balance.*` namespace — drop this key once nothing else
|
||||
// reads it.
|
||||
'dashboard.fetchFromFk': 'Hämta från Försäkringskassan',
|
||||
'dashboard.figureExpiring': 'Förfaller vid 4 år',
|
||||
'dashboard.figureLeft': 'Dagar kvar',
|
||||
'dashboard.figureUsed': 'Använda',
|
||||
'dashboard.hero.hasPlanMessage': '{atRisk} dagar riskerar att försvinna när {childName} fyller 4 ({date}).',
|
||||
'dashboard.hero.noneAtRiskMessage': 'Inga dagar går förlorade — er plan hinner använda alla innan {childName} fyller 4 ({date}).',
|
||||
'dashboard.hero.noPlanMessage': 'Du har {days} dagar att planera. Utan en plan försvinner {atRisk} av dem när {childName} fyller 4.',
|
||||
'dashboard.loading': 'Laddar...',
|
||||
'dashboard.moreDetailsSummary': 'Fler detaljer',
|
||||
'dashboard.leaveOverviewTitle': 'Fram till 4 år',
|
||||
'dashboard.moreDetailsSummary': 'Detaljer',
|
||||
'dashboard.overviewTitle': 'Översikt',
|
||||
'dashboard.planFold.noWishesHint': 'Lägg till önskemål under Kalender för att se ett förslag här.',
|
||||
'dashboard.planFold.summary': 'Planförslag för de kommande åren',
|
||||
'dashboard.planFold.unknownParent': 'Okänd förälder',
|
||||
'dashboard.plannedDaysTitle': 'Planerade dagar',
|
||||
'dashboard.saveManualFetchError': 'Kunde inte spara uppgifterna',
|
||||
'dashboard.usedDays': 'Använda dagar',
|
||||
'daysBreakdown.daysHeader': 'Dagar',
|
||||
'daysBreakdown.empty': 'Inga dagar registrerade än',
|
||||
'daysBreakdown.typeHeader': 'Typ av ledighet',
|
||||
'overviewMetrics.defaultTitle': 'Översikt',
|
||||
'parentalLeaveBreakdown.dubbeldagarLeft': 'Dubbeldagar kvar',
|
||||
'parentalLeaveBreakdown.empty': 'Ingen data hämtad ännu — tryck på Hämta från Försäkringskassan.',
|
||||
'parentalLeaveBreakdown.lagstaNiva': 'Lägsta nivå',
|
||||
'parentalLeaveBreakdown.remainingHeader': 'Återstående',
|
||||
'parentalLeaveBreakdown.reservedNote': '{reserved} av dina dagar är reserverade och kan inte överlåtas till den andra föräldern (max {transferable} dagar kan överlåtas).',
|
||||
'parentalLeaveBreakdown.sjukpenning': 'Sjukpenning',
|
||||
'parentalLeaveBreakdown.empty': 'Inga saldon hämtade än.',
|
||||
'parentalLeaveBreakdown.lagstaNiva': 'Lägstanivå',
|
||||
'parentalLeaveBreakdown.leftOfTotal': '{left} av {total}',
|
||||
'parentalLeaveBreakdown.noBalance': 'Inget saldo än',
|
||||
'parentalLeaveBreakdown.reservedNote': '{reserved} av varje förälders dagar är reserverade och kan inte överlåtas till den andra föräldern (max {transferable} dagar kan överlåtas).',
|
||||
'parentalLeaveBreakdown.sjukpenning': 'Sjukpenningnivå',
|
||||
'parentalLeaveBreakdown.sourceManual': 'Manuellt angiven',
|
||||
'parentalLeaveBreakdown.sourceScraped': 'Hämtad från Försäkringskassan',
|
||||
'parentalLeaveBreakdown.totalHeader': 'Totalt',
|
||||
'parentalLeaveBreakdown.transferableDays': 'Överlåtbara dagar',
|
||||
'parentalLeaveBreakdown.typeHeader': 'Typ',
|
||||
'yearTimeline.bandLabel': '{dayType}, {startDate} – {endDate} ({dayCount} dagar). Klicka för att öppna denna månad i kalendern.',
|
||||
'yearTimeline.bandLabelOneDay': '{dayType}, {startDate} – {endDate} (1 dag). Klicka för att öppna denna månad i kalendern.',
|
||||
'yearTimeline.caption': 'Varje rad är ett år och varje färgad stapel en planerad ledighetsperiod. Klicka på en stapel för att öppna den månaden i kalendern.',
|
||||
'yearTimeline.title': 'De kommande åren',
|
||||
} as const
|
||||
'yearRow.assumed': 'antaget',
|
||||
'yearRow.bandLabel': '{dayType}, {startDate} – {endDate} ({dayCount} dagar). Klicka för att öppna i kalendern.',
|
||||
'yearRow.bandLabelOneDay': '{dayType}, {startDate} – {endDate} (1 dag). Klicka för att öppna i kalendern.',
|
||||
'yearRow.carriedIn': 'Sparade in',
|
||||
'yearRow.carriedOut': 'sparas {days}',
|
||||
'yearRow.carriedOutRow': 'Sparas till nästa år',
|
||||
'yearRow.earned': 'Intjänade',
|
||||
'yearRow.expiring': 'förfaller {days}',
|
||||
'yearRow.expiringRow': 'Förfaller',
|
||||
'yearRow.fpLeft': 'FP kvar',
|
||||
'yearRow.left': 'Kvar',
|
||||
'yearRow.leftAtYearEnd': 'Kvar vid årets slut',
|
||||
'yearRow.parentalLeaveTitle': 'Föräldrapenning',
|
||||
'yearRow.plannedUse': 'Planerade',
|
||||
'yearRow.usedThisYear': 'Använda i år',
|
||||
'yearRow.vacationLeft': 'Semester kvar',
|
||||
'yearRow.vacationTitle': 'Semester',
|
||||
'yearRow.weeksFunded': '{funded} av {wished} veckor',
|
||||
'yearRow.wishesTitle': 'Ledigheter',
|
||||
} as const
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
// `plan.*` (the standalone Plan tab) and most `planResults.*` keys were
|
||||
// retired with the Plan/Tips tabs (leave is now authored on the calendar and
|
||||
// Översikt carries the headline figures — see PLANNING_ENGINE_SPEC.md's
|
||||
// superseding note). What's left here is the subset `planResults.*`
|
||||
// `PlanFold.tsx` (the read-only plan-proposal fold on Översikt) and
|
||||
// `frontend/src/utils/rejectionMessages.ts` still render.
|
||||
// superseding note). What's left here is the subset the dashboard's year
|
||||
// rows (`YearRows.tsx`: shortfall + remedy lines), its surplus-advice tips
|
||||
// (`SurplusAdviceTip.tsx`) and `frontend/src/utils/rejectionMessages.ts`
|
||||
// still render.
|
||||
export const plan = {
|
||||
'planResults.balancesTitle': 'Kvarvarande saldon efter planen',
|
||||
'planResults.dubbeldagarLeftHeader': 'Dubbeldagar kvar',
|
||||
'planResults.lagstaLeftHeader': 'Lägstanivådagar kvar',
|
||||
'planResults.leavesHeader': 'Ledigheter',
|
||||
'planResults.noWindowsInHorizon': 'Inga fönster inom planeringshorisonten.',
|
||||
'planResults.parentHeader': 'Förälder',
|
||||
'planResults.remedyBodyCarryVacationForward': '{spare_total} semesterdagar från tidigare år kan sparas framåt',
|
||||
'planResults.remedyBodyLowerPriorityWish': 'Sänk {wish_name} från {from_weeks} till {to_weeks} veckor frigör {days_freed} dagar',
|
||||
'planResults.remedyBodyReducePreschoolReserve': 'Minska förskolans planeringsdagar ({reserved} reserverade) frigör {days_freed} dagar',
|
||||
@@ -26,22 +21,13 @@ export const plan = {
|
||||
'planResults.rejectionInvalidDayType': 'Dag {index} ({date}): ogiltig dagtyp "{day_type}"',
|
||||
'planResults.rejectionOutsideEligibleWindow': '{date}: utanför barnets tillåtna period ({birth_date} – {hard_expiry})',
|
||||
'planResults.shortfallLine': '{shortfall_days} dag(ar) kunde inte finansieras',
|
||||
'planResults.shortfallsTitle': 'Perioder med underskott',
|
||||
'planResults.sjukpenningLeftHeader': 'Sjukpenningdagar kvar',
|
||||
'planResults.surplusAdviceBodyFrontLoadEarlyYears': '{parent_name} har ett stort överskott ({days_involved} FP-dagar) — ta ut fler dagar nu medan barnet är litet och ni ändå är hemma, istället för att sprida ut dem tunt fram till 4-årsdagen.',
|
||||
'planResults.surplusAdviceBodyShiftMixToFp': '{parent_name} kan byta {days_involved} semesterdagar mot föräldrapenningdagar under planen — FP-dagarna går annars förlorade vid 4-årsdagen, medan semestern inte gör det.',
|
||||
'planResults.surplusAdviceBodyUseLessVacation': '{parent_name} kan ta ut {days_involved} färre semesterdagar ({years_text}) utan att gå under lägsta uttaget ({min_vacation_days_per_year} dagar/år) — spara dem istället för att täcka FP-dagar som annars går förlorade.',
|
||||
'planResults.surplusAdviceBodyWishMoreLeave': '{parent_name} har råd med ytterligare {weeks} veckor ledigt ({days_involved} FP-dagar går annars förlorade vid barnets 4-årsdag) — lägg till en vecka på sommaren eller ett höstlov.',
|
||||
'planResults.surplusAdviceFrontLoadEarlyYears': 'Ta ut mer ledighet medan barnet är litet',
|
||||
'planResults.surplusAdviceIntro': 'Planen lämnar dagar outnyttjade som annars går förlorade vid barnets 4-årsdag. Några förslag:',
|
||||
'planResults.surplusAdviceShiftMixToFp': 'Använd föräldradagar istället för semester',
|
||||
'planResults.surplusAdviceTitle': 'Ni har outnyttjade dagar',
|
||||
'planResults.surplusAdviceUseLessVacation': 'Spara färre semesterdagar',
|
||||
'planResults.surplusAdviceWishMoreLeave': 'Önska mer ledighet',
|
||||
'planResults.totalAvailableHeader': 'Totalt tillgängliga',
|
||||
'planResults.unknownParent': 'Okänd förälder',
|
||||
'planResults.usedHeader': 'Använda',
|
||||
'planResults.weeksUnit': 'veckor',
|
||||
'planResults.yearHeader': 'År',
|
||||
'planResults.yearOverviewTitle': 'Översikt över åren',
|
||||
} as const
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+112
-227
@@ -1,37 +1,30 @@
|
||||
import { ReactNode, useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { ReactNode, useEffect, useRef } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useWorkspace } from '@/context'
|
||||
import {
|
||||
useAnalytics,
|
||||
useLeaveWishes,
|
||||
usePlanProposal,
|
||||
usePlannedDays,
|
||||
useScraperFetch,
|
||||
useParentalLeave,
|
||||
useWorkspaceMembers,
|
||||
useOptimizationTips,
|
||||
} from '@/hooks'
|
||||
// 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 type { PlannedLeaveDay } from '@/types'
|
||||
import * as api from '@/services/api'
|
||||
import { getLabelForType } from '@/utils/leaveTypes'
|
||||
// Imported directly (not via the `@/hooks` barrel) so this page's test can
|
||||
// mock this one module while the barrel stays automocked.
|
||||
import { useParentalLeaves } from '@/hooks/useParentalLeaves'
|
||||
import { nameResolver } from '@/utils/parentNames'
|
||||
import { isSelf, orderSelfFirst } from '@/utils/members'
|
||||
import { orderSelfFirst } from '@/utils/members'
|
||||
import { useTranslation } from '@/i18n'
|
||||
import { Layout } from '@/components/layout/Layout'
|
||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
|
||||
import { PageSkeleton } from '@/components/common/PageSkeleton'
|
||||
import { OverviewMetrics } from '@/components/dashboard/OverviewMetrics'
|
||||
import { DaysBreakdown } from '@/components/dashboard/DaysBreakdown'
|
||||
import { Hero } from '@/components/dashboard/Hero'
|
||||
import { KeyFigures } from '@/components/dashboard/KeyFigures'
|
||||
import { Age4Bars } from '@/components/dashboard/Age4Bars'
|
||||
import { YearTimeline } from '@/components/dashboard/YearTimeline'
|
||||
import { PlanFold } from '@/components/dashboard/PlanFold'
|
||||
import { YearRows } from '@/components/dashboard/YearRows'
|
||||
import { SurplusAdviceTip } from '@/components/dashboard/SurplusAdviceTip'
|
||||
import { ParentalLeaveBreakdown } from '@/components/dashboard/ParentalLeaveBreakdown'
|
||||
import { TipCard } from '@/components/inputs/TipCard'
|
||||
import { QRCodeModal } from '@/components/modals/QRCodeModal'
|
||||
import { ParentBalanceForm } from '@/components/modals/ParentBalanceForm'
|
||||
import { Onboarding } from '@/pages/Onboarding'
|
||||
|
||||
/** Fallback horizon end when the analytics projection isn't available yet
|
||||
@@ -47,30 +40,21 @@ export function Dashboard(): ReactNode {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { workspace, isLoading: wsLoading, selectedChildId, children, personalDataUuid } = useWorkspace()
|
||||
const { data: analytics, isLoading: analyticsLoading, error: analyticsError, refresh: refreshAnalytics } = useAnalytics(
|
||||
const { data: analytics, isLoading: analyticsLoading, error: analyticsError } = useAnalytics(
|
||||
workspace?.id ?? null,
|
||||
selectedChildId ?? ''
|
||||
)
|
||||
const { data: plannedDays, isLoading: pdLoading } = usePlannedDays(selectedChildId ?? undefined)
|
||||
const { data: parentalLeave, refresh: refreshParentalLeave } = useParentalLeave(personalDataUuid, selectedChildId ?? null)
|
||||
const { data: members, isLoading: membersLoading, refresh: refreshMembers } = useWorkspaceMembers(workspace?.id ?? null)
|
||||
const { data: tips, dismiss: dismissTip } = useOptimizationTips(workspace?.id ?? null)
|
||||
const scraper = useScraperFetch()
|
||||
const { data: wishes, isLoading: wishesLoading } = useLeaveWishes(workspace?.id ?? null)
|
||||
const { data: proposal, isLoading: isProposing, error: proposalError, propose } = usePlanProposal(workspace?.id ?? null)
|
||||
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [showBalancePicker, setShowBalancePicker] = useState(false)
|
||||
const [showManualFetchModal, setShowManualFetchModal] = useState(false)
|
||||
const [manualFetchTargetUuid, setManualFetchTargetUuid] = useState<string | null>(null)
|
||||
const [isManualFetchSaving, setIsManualFetchSaving] = useState(false)
|
||||
|
||||
// Reopening the balance form for a parent should show what was actually
|
||||
// saved for them, not always the 195/45/60 preset — `manualFetchTargetUuid`
|
||||
// is null except while the form is open/opening, so this no-ops the rest
|
||||
// of the time.
|
||||
const {
|
||||
initial: manualFetchInitial,
|
||||
isLoading: manualFetchInitialLoading,
|
||||
} = useLatestBalance(manualFetchTargetUuid, selectedChildId ?? null)
|
||||
const orderedMembers = orderSelfFirst(members, personalDataUuid)
|
||||
const { data: leaves, isLoading: leavesLoading } = useParentalLeaves(
|
||||
orderedMembers.map((m) => m.uuid),
|
||||
selectedChildId ?? null,
|
||||
)
|
||||
|
||||
// `useWorkspaceMembers` fetches once per `workspace.id`, which never
|
||||
// changes across onboarding — so a couple's partner, added mid-wizard via
|
||||
@@ -89,6 +73,16 @@ export function Dashboard(): ReactNode {
|
||||
wasAwaitingFirstChildRef.current = children.length === 0
|
||||
}, [children.length, refreshMembers])
|
||||
|
||||
// The plan proposal (`POST /api/plan/propose`, read-only — persists
|
||||
// nothing) is the only source for wished-vs-funded per year and the
|
||||
// surplus advice, so it is requested automatically once wishes exist.
|
||||
// Stops after one failure rather than retrying on every render.
|
||||
useEffect(() => {
|
||||
if (!wishesLoading && wishes.length > 0 && proposal === null && !isProposing && proposalError === null) {
|
||||
void propose()
|
||||
}
|
||||
}, [wishesLoading, wishes.length, proposal, isProposing, proposalError, propose])
|
||||
|
||||
const isLoading = wsLoading || analyticsLoading || pdLoading
|
||||
|
||||
if (isLoading) {
|
||||
@@ -99,96 +93,15 @@ export function Dashboard(): ReactNode {
|
||||
)
|
||||
}
|
||||
|
||||
const handleFetchFK = async () => {
|
||||
setShowModal(true)
|
||||
await scraper.start(selectedChildId ?? undefined)
|
||||
}
|
||||
|
||||
const handleSaveManualFetch = async (data: {
|
||||
sjukpenningLeft: number
|
||||
lagstaNivaLeft: number
|
||||
dubbeldagarLeft: number
|
||||
reservedDaysLeft: number | null
|
||||
}) => {
|
||||
// `manualFetchTargetUuid` is always set to a real uuid by
|
||||
// `handleOpenManualFetch`/`handlePickManualFetchTarget` before the form
|
||||
// is ever shown — there is no path that calls this with it unset.
|
||||
const targetUuid = manualFetchTargetUuid
|
||||
if (!workspace?.id || !selectedChildId || !targetUuid) return
|
||||
|
||||
// No try/catch here on purpose — a rejection propagates back 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). See the audit note on `ParentBalanceForm.tsx`.
|
||||
try {
|
||||
setIsManualFetchSaving(true)
|
||||
|
||||
await api.saveManualFetch({
|
||||
personalDataUuid: targetUuid,
|
||||
workspaceId: workspace.id,
|
||||
childId: selectedChildId,
|
||||
sjukpenningLeft: data.sjukpenningLeft,
|
||||
lagstaNivaLeft: data.lagstaNivaLeft,
|
||||
dubbeldagarLeft: data.dubbeldagarLeft,
|
||||
reservedDaysLeft: data.reservedDaysLeft,
|
||||
source: 'manual',
|
||||
})
|
||||
|
||||
await refreshParentalLeave()
|
||||
await refreshAnalytics()
|
||||
setShowManualFetchModal(false)
|
||||
// Reset so the next open of this same target refetches instead of
|
||||
// reusing the (now stale) balance `useLatestBalance` already cached.
|
||||
setManualFetchTargetUuid(null)
|
||||
} finally {
|
||||
setIsManualFetchSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** "Ange manuellt" — for a solo workspace this opens the form straight for
|
||||
* self; for a couple it shows a small parent picker first (see
|
||||
* `dashboard-balance-parent-picker` below) since either parent's balance
|
||||
* can be entered from here. */
|
||||
const handleOpenManualFetch = () => {
|
||||
if (members.length >= 2) {
|
||||
setShowBalancePicker(true)
|
||||
return
|
||||
}
|
||||
setManualFetchTargetUuid(personalDataUuid)
|
||||
setShowManualFetchModal(true)
|
||||
}
|
||||
|
||||
const handlePickManualFetchTarget = (uuid: string) => {
|
||||
setManualFetchTargetUuid(uuid)
|
||||
setShowBalancePicker(false)
|
||||
setShowManualFetchModal(true)
|
||||
}
|
||||
|
||||
const handleModalClose = () => {
|
||||
if (scraper.isRunning) return
|
||||
setShowModal(false)
|
||||
}
|
||||
|
||||
const handleFetchDone = async () => {
|
||||
setShowModal(false)
|
||||
await Promise.all([refreshAnalytics(), refreshParentalLeave()])
|
||||
}
|
||||
|
||||
const handleBandClick = (date: string) => {
|
||||
navigate(`/calendar?date=${date}`)
|
||||
}
|
||||
|
||||
const breakdown = [
|
||||
{ type: getLabelForType('vacation', t), count: plannedDays?.filter((d: PlannedLeaveDay) => d.dayType === 'vacation').length ?? 0 },
|
||||
{ type: getLabelForType('sjukpenning', t), count: plannedDays?.filter((d: PlannedLeaveDay) => d.dayType === 'sjukpenning').length ?? 0 },
|
||||
{ type: getLabelForType('lagsta', t), count: plannedDays?.filter((d: PlannedLeaveDay) => d.dayType === 'lagsta').length ?? 0 },
|
||||
]
|
||||
|
||||
// Per-parent expiring-day figures are only informative once there's more
|
||||
// than one parent to compare — with a single parent they'd just repeat
|
||||
// the combined total below, so skip the breakdown in that case.
|
||||
const nameFor = nameResolver(members)
|
||||
const parentProjections = analytics?.parentProjections ?? []
|
||||
// Per-parent expiring-day figures are only informative once there's more
|
||||
// than one parent to compare — with a single parent they'd just repeat
|
||||
// the combined total, so skip the breakdown in that case.
|
||||
const showPerParentExpiring = parentProjections.length > 1
|
||||
|
||||
const parentDisplayName = (uuid: string, index: number): string => {
|
||||
@@ -197,6 +110,10 @@ export function Dashboard(): ReactNode {
|
||||
? t('dashboard.expiringAtB4ParentFallback', { number: index + 1 })
|
||||
: resolved
|
||||
}
|
||||
const nameForAdvice = (uuid: string): string => {
|
||||
const resolved = nameFor(uuid)
|
||||
return resolved === uuid ? t('planResults.unknownParent') : resolved
|
||||
}
|
||||
|
||||
const overviewMetrics = analytics
|
||||
? [
|
||||
@@ -216,9 +133,7 @@ export function Dashboard(): ReactNode {
|
||||
value: analytics.combinedExpiringAtB4,
|
||||
testId: 'metric-expiring-combined',
|
||||
// Only actually a distinct "all parents" summary when there was
|
||||
// a per-parent breakdown above it to summarize — for a single
|
||||
// parent it's just this workspace's one figure, so it renders as
|
||||
// an ordinary grid cell instead of a footer row.
|
||||
// a per-parent breakdown above it to summarize.
|
||||
isSummary: showPerParentExpiring,
|
||||
},
|
||||
]
|
||||
@@ -237,13 +152,43 @@ export function Dashboard(): ReactNode {
|
||||
const hasPlan = (plannedDays?.length ?? 0) > 0
|
||||
const fourthBirthday =
|
||||
parentProjections[0]?.fourthBirthday ?? (selectedChild ? computeFourthBirthday(selectedChild.birth_date) : '')
|
||||
const age4Bars = parentProjections.map((projection, index) => ({
|
||||
|
||||
// Self first, matching the order of every per-parent list on the page.
|
||||
const orderedProjections = [
|
||||
...parentProjections.filter((p) => p.personalDataUuid === personalDataUuid),
|
||||
...parentProjections.filter((p) => p.personalDataUuid !== personalDataUuid),
|
||||
]
|
||||
const projectionName = (uuid: string) =>
|
||||
parentDisplayName(uuid, parentProjections.findIndex((p) => p.personalDataUuid === uuid))
|
||||
|
||||
const age4Bars = orderedProjections.map((projection) => ({
|
||||
personalDataUuid: projection.personalDataUuid,
|
||||
name: parentDisplayName(projection.personalDataUuid, index),
|
||||
name: projectionName(projection.personalDataUuid),
|
||||
projectedLeftAtB4: projection.projectedLeftAtB4,
|
||||
savableCapAtB4: projection.savableCapAtB4,
|
||||
expiringAtB4: projection.expiringAtB4,
|
||||
}))
|
||||
const yearRowParents = orderedProjections.map((projection) => ({
|
||||
personalDataUuid: projection.personalDataUuid,
|
||||
name: projectionName(projection.personalDataUuid),
|
||||
yearBalances: projection.yearBalances,
|
||||
}))
|
||||
|
||||
// "Estimated" once the members are known and at least one of them has no
|
||||
// saved balance — the headline figures then rest on the statutory
|
||||
// assumption for that parent. Absent from the map = not fetched yet, so
|
||||
// the line never flashes before the first response.
|
||||
const showEstimate =
|
||||
!leavesLoading && orderedMembers.length > 0 && orderedMembers.some((m) => leaves[m.uuid] === null)
|
||||
|
||||
const breakdownParents = orderedMembers.map((member) => ({
|
||||
personalDataUuid: member.uuid,
|
||||
name: member.name,
|
||||
data: leaves[member.uuid] ?? null,
|
||||
}))
|
||||
|
||||
const surplusAdvice = proposal?.surplusAdvice ?? []
|
||||
const hasTips = (tips?.length ?? 0) > 0 || surplusAdvice.length > 0
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
@@ -264,127 +209,67 @@ export function Dashboard(): ReactNode {
|
||||
/>
|
||||
)}
|
||||
|
||||
{!parentalLeave && (
|
||||
<p
|
||||
className="badge badge--warning dashboard__estimate-badge"
|
||||
data-testid="dashboard-estimate-badge"
|
||||
title={t('dashboard.estimatedBadgeCaption')}
|
||||
>
|
||||
{t('dashboard.estimatedBadge')}
|
||||
{showEstimate && (
|
||||
<p className="dashboard__estimate">
|
||||
<span className="dashboard__estimate-badge" data-testid="dashboard-estimate-badge">
|
||||
{t('dashboard.estimatedBadge')}
|
||||
</span>{' '}
|
||||
{t('dashboard.estimatedBadgeCaption')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{age4Bars.length > 0 && <Age4Bars bars={age4Bars} />}
|
||||
|
||||
<section className="dashboard__section dashboard__fetch-actions">
|
||||
<button type="button" className="btn btn--primary" onClick={handleFetchFK} disabled={!selectedChildId}>
|
||||
{t('dashboard.fetchFromFk')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenManualFetch}
|
||||
disabled={!selectedChildId}
|
||||
className="btn btn--secondary"
|
||||
>
|
||||
{t('dashboard.enterManually')}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{showBalancePicker && (
|
||||
<div className="card dashboard__balance-picker" data-testid="dashboard-balance-parent-picker">
|
||||
<p className="dashboard__balance-picker-label">{t('dashboard.balancePickerPrompt')}</p>
|
||||
<div className="dashboard__balance-picker-actions">
|
||||
{orderSelfFirst(members, personalDataUuid).map((member) => (
|
||||
<button
|
||||
key={member.uuid}
|
||||
type="button"
|
||||
className="btn btn--secondary"
|
||||
onClick={() => handlePickManualFetchTarget(member.uuid)}
|
||||
data-testid={`dashboard-balance-parent-${member.uuid}`}
|
||||
>
|
||||
{member.name}
|
||||
{isSelf(member, personalDataUuid) && ` ${t('settings.youBadge')}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{analytics && (
|
||||
<KeyFigures
|
||||
daysLeft={analytics.combinedDaysLeft}
|
||||
daysUsed={analytics.combinedDaysUsed}
|
||||
expiringAtB4={analytics.combinedExpiringAtB4}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedChild && (
|
||||
<YearTimeline
|
||||
plannedDays={plannedDays ?? []}
|
||||
startDate={selectedChild.birth_date}
|
||||
endDate={fourthBirthday}
|
||||
onBandClick={handleBandClick}
|
||||
/>
|
||||
<section className="dashboard__section year-overview" data-testid="year-overview">
|
||||
<h2>{t('dashboard.leaveOverviewTitle')}</h2>
|
||||
{age4Bars.length > 0 && <Age4Bars bars={age4Bars} />}
|
||||
{proposalError && (
|
||||
<div className="alert alert--error" role="alert" data-testid="dashboard-proposal-error">
|
||||
{proposalError}
|
||||
</div>
|
||||
)}
|
||||
<YearRows
|
||||
parents={yearRowParents}
|
||||
plannedDays={plannedDays ?? []}
|
||||
startDate={selectedChild.birth_date}
|
||||
endDate={fourthBirthday}
|
||||
outcomes={proposal?.outcomes ?? []}
|
||||
onBandClick={handleBandClick}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{tips && tips.length > 0 && (
|
||||
{hasTips && (
|
||||
<section className="dashboard__section" data-testid="dashboard-tips">
|
||||
<h2>{t('tips.title')}</h2>
|
||||
<div className="dashboard__tips-list">
|
||||
{tips.map((tip) => (
|
||||
{(tips ?? []).map((tip) => (
|
||||
<TipCard key={tip.id} tip={tip} onDismiss={() => dismissTip(tip.id)} />
|
||||
))}
|
||||
{surplusAdvice.map((advice, index) => (
|
||||
<SurplusAdviceTip key={`${advice.kind}-${index}`} advice={advice} nameFor={nameForAdvice} index={index} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{workspace && <PlanFold workspaceId={workspace.id} members={members} />}
|
||||
|
||||
<details className="dashboard__more-details" data-testid="dashboard-more-details">
|
||||
<summary>{t('dashboard.moreDetailsSummary')}</summary>
|
||||
|
||||
<section className="dashboard__section">
|
||||
{analytics && (
|
||||
<OverviewMetrics
|
||||
title={t('dashboard.overviewTitle')}
|
||||
metrics={overviewMetrics}
|
||||
/>
|
||||
)}
|
||||
<ParentalLeaveBreakdown data={parentalLeave} />
|
||||
</section>
|
||||
|
||||
<section className="dashboard__section">
|
||||
<h2>{t('dashboard.plannedDaysTitle')}</h2>
|
||||
<DaysBreakdown breakdown={breakdown} />
|
||||
</section>
|
||||
</details>
|
||||
|
||||
<QRCodeModal
|
||||
isOpen={showModal}
|
||||
onClose={handleModalClose}
|
||||
onDone={handleFetchDone}
|
||||
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}
|
||||
/>
|
||||
|
||||
{showManualFetchModal && manualFetchInitialLoading && (
|
||||
<div className="modal__overlay" data-testid="dashboard-balance-loading">
|
||||
<LoadingSpinner message={t('parentBalance.loadingBalance')} />
|
||||
<details className="disclosure dashboard__details" data-testid="dashboard-more-details">
|
||||
<summary className="disclosure__summary">{t('dashboard.moreDetailsSummary')}</summary>
|
||||
<div className="disclosure__body dashboard__details-body">
|
||||
{analytics && <OverviewMetrics title={t('dashboard.overviewTitle')} metrics={overviewMetrics} />}
|
||||
<ParentalLeaveBreakdown parents={breakdownParents} />
|
||||
<Link to="/settings#saldon" className="btn btn--link" data-testid="dashboard-balance-link">
|
||||
{t('dashboard.balanceLink')}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showManualFetchModal && !manualFetchInitialLoading && (
|
||||
<ParentBalanceForm
|
||||
parentName={members.find((m) => m.uuid === manualFetchTargetUuid)?.name ?? t('calendar.you')}
|
||||
isSelf={manualFetchTargetUuid === personalDataUuid}
|
||||
totalDays={members.length >= 2 ? 240 : 480}
|
||||
initial={manualFetchInitial}
|
||||
onSave={handleSaveManualFetch}
|
||||
onClose={() => {
|
||||
setShowManualFetchModal(false)
|
||||
setManualFetchTargetUuid(null)
|
||||
}}
|
||||
isSaving={isManualFetchSaving}
|
||||
error={null}
|
||||
/>
|
||||
)}
|
||||
</details>
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
+536
-412
File diff suppressed because it is too large
Load Diff
@@ -77,6 +77,9 @@
|
||||
/* Leave types: fill / ink / outline */
|
||||
--c-leave-vacation: #eef2fe;
|
||||
--c-leave-vacation-ink: #2f5bea;
|
||||
/* Stronger vacation step for thin (8px) timeline tracks only — the tint
|
||||
* above vanishes on white at that height. Calendar bands keep the tint. */
|
||||
--c-leave-vacation-bar: #b9c9f7;
|
||||
--c-leave-sjukpenning: #2a2d35;
|
||||
--c-leave-sjukpenning-ink: #fff;
|
||||
--c-leave-lagsta: #fff;
|
||||
|
||||
@@ -74,12 +74,6 @@
|
||||
margin-bottom: var(--sp-4);
|
||||
}
|
||||
|
||||
/* Metrics - single column */
|
||||
.overview-metrics__list,
|
||||
[data-testid='metrics-list'] {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* Forms - full width */
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
@@ -100,21 +94,55 @@
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.days-breakdown__table,
|
||||
.parental-leave-breakdown__table,
|
||||
.vacation-days-table {
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.days-breakdown__table th,
|
||||
.days-breakdown__table td,
|
||||
.parental-leave-breakdown__table th,
|
||||
.parental-leave-breakdown__table td,
|
||||
.vacation-days-table th,
|
||||
.vacation-days-table td {
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
}
|
||||
|
||||
/* Dashboard — the three key figures tighten; a year row keeps year +
|
||||
* track on the first line and wraps the per-parent figures under the
|
||||
* track, each parent on its own line. */
|
||||
.key-figures {
|
||||
gap: var(--sp-4) var(--sp-6);
|
||||
}
|
||||
|
||||
.year-row__summary {
|
||||
grid-template-columns: 3rem minmax(0, 1fr) var(--icon-sm);
|
||||
}
|
||||
|
||||
.year-row__summary::after {
|
||||
grid-column: 3;
|
||||
}
|
||||
|
||||
.year-row__figures {
|
||||
grid-row: 2;
|
||||
grid-column: 2 / -1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-1);
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.year-row__parent {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
column-gap: var(--sp-3);
|
||||
row-gap: 0;
|
||||
}
|
||||
|
||||
.year-row__body {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.year-row__table,
|
||||
.parental-leave-breakdown__table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.vacation-days-table__input {
|
||||
width: 70px;
|
||||
}
|
||||
@@ -154,13 +182,6 @@
|
||||
order: -10;
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
.dashboard__break-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
/* Loading spinner */
|
||||
.loading-spinner {
|
||||
padding: var(--sp-5);
|
||||
@@ -207,10 +228,6 @@
|
||||
|
||||
/* Tablet - Medium screens (641px to 1024px) */
|
||||
@media (min-width: 641px) and (max-width: 1024px) {
|
||||
.overview-metrics__list {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user