Free days, vacation forfeiture, and global auto-replanning

Free days (Sat/Sun/red days, subject to DayOverride) can never be
vacation, and can only be parental leave when the workspace opts in via
take_pl_on_red_days. The allocator already honoured this; the hand-pin
path bypassed it entirely.

- PlanningService gains optional workspace_repo/day_override_repo deps
  and _reject_illegal_free_day, enforced in add_planned_day and update.
  Reuses allocation_service.is_free_day rather than re-deriving it, and
  no-ops when the deps are unset so existing callers are unaffected.
- update_planned_day had no ValidationError handler at all, so a
  validation failure there 500'd instead of 422'ing. Fixed.
- Calendar filters the pin write loop by the day's isFree; the modal
  explains free days stay off, and that pinning opts a day out of
  automatic re-planning.

Vacation days at risk of forfeiture were computed and sent over the
wire but never rendered: an at-risk column with the age-out/over-cap
split, and a dashboard key figure.

PlanProposal.warnings was likewise parsed but rendered nowhere, for all
three kinds. Adds PlanWarningCard in a Risks section above the tips,
with parent names resolved and dates formatted.

POST /api/plan/sync now fires from the workspace layer on every
plan-input change rather than only from Calendar, so a Settings edit or
a pinned-day save re-plans day types instead of merely refetching.
Overlapping bumps coalesce into one trailing run; a rejection cannot
wedge the in-flight guard. eviction_preview was silently dropped by the
frontend despite always being on the wire, and is now surfaced through
context.

Verified: backend 890 passed (91.49% coverage), frontend 1297 passed,
ruff/eslint/tsc clean. Browser-checked in Swedish and English.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVQx4NUvPbJhn6UcvRd2QY
This commit is contained in:
2026-09-20 13:07:36 +02:00
parent 16390fec50
commit 61e5487078
44 changed files with 1891 additions and 60 deletions
@@ -3,22 +3,24 @@ 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} />)
it('renders the four combined figures with their labels', () => {
render(<KeyFigures daysLeft={270} daysUsed={30} expiringAtB4={100} vacationAtRisk={12} />)
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')
expect(screen.getByTestId('dashboard-figure-vacationAtRisk')).toHaveTextContent('12')
expect(screen.getByTestId('dashboard-figure-vacationAtRisk')).toHaveTextContent('Semester som riskerar att gå förlorad')
})
it('wears danger ink on the expiring figure only when it is non-zero', () => {
const { rerender } = render(<KeyFigures daysLeft={270} daysUsed={30} expiringAtB4={100} />)
const { rerender } = render(<KeyFigures daysLeft={270} daysUsed={30} expiringAtB4={100} vacationAtRisk={0} />)
expect(screen.getByTestId('dashboard-figure-expiring').querySelector('dd')).toHaveClass(
'key-figures__value--danger',
)
rerender(<KeyFigures daysLeft={270} daysUsed={30} expiringAtB4={0} />)
rerender(<KeyFigures daysLeft={270} daysUsed={30} expiringAtB4={0} vacationAtRisk={0} />)
expect(screen.getByTestId('dashboard-figure-expiring').querySelector('dd')).not.toHaveClass(
'key-figures__value--danger',
)
@@ -26,4 +28,15 @@ describe('KeyFigures', () => {
'key-figures__value--danger',
)
})
it('wears danger ink on the vacation-at-risk figure only when it is non-zero', () => {
const { rerender } = render(<KeyFigures daysLeft={270} daysUsed={30} expiringAtB4={0} vacationAtRisk={12} />)
expect(screen.getByTestId('dashboard-figure-vacationAtRisk').querySelector('dd')).toHaveClass(
'key-figures__value--danger',
)
rerender(<KeyFigures daysLeft={270} daysUsed={30} expiringAtB4={0} vacationAtRisk={0} />)
expect(screen.getByTestId('dashboard-figure-vacationAtRisk').querySelector('dd')).not.toHaveClass(
'key-figures__value--danger',
)
})
})
@@ -8,19 +8,31 @@ export interface KeyFiguresProps {
daysUsed: number
/** `combinedExpiringAtB4` — projected to be lost at the 4th birthday. */
expiringAtB4: number
/** Sum of every parent/year's `VacationYearBalance.expiring` from the
* current year onward — vacation days forecast to be forfeited (age-out
* or over the carry-forward cap), the reason the planner may shift
* placements toward spending vacation sooner. */
vacationAtRisk: 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.
* The four headline figures under the hero — large tabular numerals with
* a 14px label each, no tiles. The expiring and vacation-at-risk figures
* wear danger ink only when non-zero: a zero is good news and should read
* as quiet.
*/
export function KeyFigures({ daysLeft, daysUsed, expiringAtB4 }: KeyFiguresProps): ReactNode {
export function KeyFigures({ daysLeft, daysUsed, expiringAtB4, vacationAtRisk }: 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 },
{
key: 'vacationAtRisk',
label: t('dashboard.figureVacationAtRisk'),
value: vacationAtRisk,
danger: vacationAtRisk > 0,
},
]
return (
<dl className="key-figures" data-testid="key-figures">
@@ -0,0 +1,93 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { PlanWarningCard } from './PlanWarningCard'
import { makePlanWarning } from '../../../tests/fixtures'
const nameFor = (uuid: string) => (uuid === 'pd-b' ? 'Sam' : 'Okänd förälder')
describe('PlanWarningCard', () => {
it('renders a sgi_risk warning with the parent name and a formatted (non-ISO) week date', () => {
render(
<PlanWarningCard
index={0}
nameFor={nameFor}
warning={makePlanWarning({
kind: 'sgi_risk',
messageKey: 'planWarnings.sgi_risk',
data: {
parent_personal_data_uuid: 'pd-b',
week_start: '2027-03-08',
days_planned: 1,
min_days_per_week: 3,
},
})}
/>,
)
const warning = screen.getByTestId('dashboard-plan-warning-0')
expect(warning).toHaveClass('tip-card', 'tip-card--danger')
expect(warning).toHaveTextContent('Risk för SGI-skyddet')
expect(warning).toHaveTextContent('Sam är planerad för endast 1 av minst 3 ledighetsdagar veckan som börjar 8 mars 2027')
expect(warning).not.toHaveTextContent('pd-b')
expect(warning).not.toHaveTextContent('2027-03-08')
})
it('renders a days_lost_at_age_4 warning with the parent name and a formatted (non-ISO) birthday', () => {
render(
<PlanWarningCard
index={1}
nameFor={nameFor}
warning={makePlanWarning({
kind: 'days_lost_at_age_4',
messageKey: 'planWarnings.days_lost_at_age_4',
data: {
parent_personal_data_uuid: 'pd-b',
child_id: 'child-1',
expiring_days: 15,
projected_left_at_b4: 40,
fourth_birthday: '2029-06-01',
},
})}
/>,
)
const warning = screen.getByTestId('dashboard-plan-warning-1')
expect(warning).toHaveTextContent('Dagar riskerar att gå förlorade vid 4 år')
expect(warning).toHaveTextContent('Sam riskerar att förlora 15 föräldrapenningdagar vid barnets 4-årsdag (1 juni 2029)')
expect(warning).toHaveTextContent('40 dagar beräknas då finnas kvar totalt')
expect(warning).not.toHaveTextContent('2029-06-01')
})
it('renders a vacation_expiring warning with the parent name and its figures', () => {
render(
<PlanWarningCard
index={2}
nameFor={nameFor}
warning={makePlanWarning({
kind: 'vacation_expiring',
messageKey: 'planWarnings.vacation_expiring',
data: { parent_personal_data_uuid: 'pd-b', year: 2027, expiring_days: 4 },
})}
/>,
)
const warning = screen.getByTestId('dashboard-plan-warning-2')
expect(warning).toHaveTextContent('Semesterdagar riskerar att gå förlorade')
expect(warning).toHaveTextContent('4 semesterdagar riskerar att gå förlorade 2027')
})
it('falls back to the raw kind and messageKey for an unrecognised warning', () => {
render(
<PlanWarningCard
index={3}
nameFor={nameFor}
warning={makePlanWarning({ kind: 'brand_new_kind', messageKey: 'planWarnings.brand_new_kind', data: {} })}
/>,
)
const warning = screen.getByTestId('dashboard-plan-warning-3')
expect(warning).toHaveTextContent('brand_new_kind')
expect(warning).toHaveTextContent('planWarnings.brand_new_kind')
})
it('has no dismiss control', () => {
render(<PlanWarningCard index={0} nameFor={nameFor} warning={makePlanWarning()} />)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,88 @@
import { ReactNode } from 'react'
import type { PlanWarning } from '@/types'
import { useTranslation, toTranslationParams, type TranslationKey } from '@/i18n'
import { formatDateForDisplay } from '@/utils/dates'
// The three `PlanWarning` kinds the backend can emit
// (`allocation_service.py`'s `_sgi_risk_warnings`, `_age4_warnings`,
// `_vacation_expiry_warnings`). `kind` -> title, `messageKey` -> the body
// template. The backend's own `message_key` is already `f"planWarnings.
// {kind}"`, i.e. a real translation key — routed through a lookup anyway
// (like `SurplusAdviceTip`'s `SURPLUS_ADVICE_BODY_KEYS`) so an unrecognised
// kind (a newer backend rule this catalogue hasn't caught up with) falls
// back to the raw value instead of rendering a missing-key placeholder.
const PLAN_WARNING_TITLE_KEYS: Record<string, TranslationKey> = {
sgi_risk: 'planWarnings.sgiRiskTitle',
days_lost_at_age_4: 'planWarnings.daysLostAtAge4Title',
vacation_expiring: 'planWarnings.vacationExpiringTitle',
}
const PLAN_WARNING_BODY_KEYS: Record<string, TranslationKey> = {
'planWarnings.sgi_risk': 'planWarnings.sgi_risk',
'planWarnings.days_lost_at_age_4': 'planWarnings.days_lost_at_age_4',
'planWarnings.vacation_expiring': 'planWarnings.vacation_expiring',
}
// `data` fields that carry an ISO-8601 date and must go through
// `formatDateForDisplay` before interpolation — a raw ISO string is never
// shown to the user.
const DATE_FIELDS_BY_KIND: Record<string, readonly string[]> = {
sgi_risk: ['week_start'],
days_lost_at_age_4: ['fourth_birthday'],
vacation_expiring: [],
}
export interface PlanWarningCardProps {
warning: PlanWarning
/** Resolves a parent uuid to a display name (never a raw uuid) —
* `Dashboard.tsx`'s `nameForAdvice`, which already falls back to
* `planResults.unknownParent`. */
nameFor: (uuid: string) => string
/** Position in the warnings list — only used for the test id. */
index: number
}
/**
* One plan-proposal warning rendered in the same visual as a backend
* `TipCard` (same `tip-card` classes) so the dashboard reads as one system,
* but with the danger-ink dot (`var(--c-danger)`, the same colour
* `KeyFigures`/`Age4Bars`/`YearRows` already use for at-risk figures) —
* warnings are risks, not suggestions. No dismiss: it is derived from the
* plan and goes away when the plan changes.
*/
export function PlanWarningCard({ warning, nameFor, index }: PlanWarningCardProps): ReactNode {
const { t, locale } = useTranslation()
const titleKey = PLAN_WARNING_TITLE_KEYS[warning.kind]
const bodyKey = PLAN_WARNING_BODY_KEYS[warning.messageKey]
const title = titleKey ? t(titleKey) : warning.kind
let body = warning.messageKey
if (bodyKey) {
const params = toTranslationParams(warning.data)
const uuid = warning.data.parent_personal_data_uuid
if (typeof uuid === 'string') {
params.parent_name = nameFor(uuid)
}
for (const field of DATE_FIELDS_BY_KIND[warning.kind] ?? []) {
const raw = warning.data[field]
if (typeof raw === 'string') {
params[field] = formatDateForDisplay(raw, locale)
}
}
body = t(bodyKey, params)
}
return (
<div
className="tip-card tip-card--danger"
role="article"
aria-label={t('tip.aria', { title })}
data-testid={`dashboard-plan-warning-${index}`}
>
<div className="tip-card__header">
<h4 className="tip-card__title">{title}</h4>
</div>
<p className="tip-card__body">{body}</p>
</div>
)
}
@@ -701,6 +701,80 @@ describe('PlannedDayModal', () => {
})
})
describe('free-day hand-pin guard notices (§ free days never consume vacation)', () => {
it('warns that free days stay off when pinning vacation over a selection that includes one', async () => {
render(
<PlannedDayModal
{...defaultProps}
isOpen={true}
isRangeMode
dayCount={2}
freeDatesInSelection={['2026-06-13']}
/>,
)
await pickType('vacation')
expect(screen.getByTestId('free-days-stay-off-notice')).toHaveTextContent(
'Lediga dagar (helg/röd dag) i urvalet lämnas lediga',
)
})
it('warns for a PL type on a free day when takePlOnRedDays is off', async () => {
render(
<PlannedDayModal
{...defaultProps}
isOpen={true}
isRangeMode
dayCount={2}
freeDatesInSelection={['2026-06-13']}
takePlOnRedDays={false}
/>,
)
await pickType('sjukpenning')
expect(screen.getByTestId('free-days-stay-off-notice')).toBeInTheDocument()
})
it('does not warn for a PL type on a free day once takePlOnRedDays is on', async () => {
render(
<PlannedDayModal
{...defaultProps}
isOpen={true}
isRangeMode
dayCount={2}
freeDatesInSelection={['2026-06-13']}
takePlOnRedDays={true}
/>,
)
await pickType('sjukpenning')
expect(screen.queryByTestId('free-days-stay-off-notice')).not.toBeInTheDocument()
})
it('does not warn when the selection has no free days', async () => {
render(<PlannedDayModal {...defaultProps} isOpen={true} isRangeMode dayCount={2} freeDatesInSelection={[]} />)
await pickType('vacation')
expect(screen.queryByTestId('free-days-stay-off-notice')).not.toBeInTheDocument()
})
it('notes that pinning excludes the day from automatic re-planning', async () => {
render(<PlannedDayModal {...coupleProps} isOpen={true} />)
await openAdvanced()
await pickType('vacation')
expect(screen.getByTestId('pin-exclusion-notice')).toBeInTheDocument()
})
it('does not duplicate the pin-exclusion notice when the day is already pinned (the pinned notice already says so)', () => {
const existingDay = makePlannedLeaveDay({ id: 'day-1', parentPersonalDataUuid: 'p1', isPinned: true })
render(<PlannedDayModal {...defaultProps} isOpen={true} existingDay={existingDay} />)
expect(screen.getByTestId('pinned-notice')).toBeInTheDocument()
expect(screen.queryByTestId('pin-exclusion-notice')).not.toBeInTheDocument()
})
it('shows neither notice while the type select is still on "let the planner choose"', () => {
render(<PlannedDayModal {...defaultProps} isOpen={true} freeDatesInSelection={['2026-06-13']} />)
expect(screen.queryByTestId('free-days-stay-off-notice')).not.toBeInTheDocument()
expect(screen.queryByTestId('pin-exclusion-notice')).not.toBeInTheDocument()
})
})
describe('removed days (PlannedDayExclusion) — the way back', () => {
it('names the parent whose day was removed and offers to restore it', async () => {
const onRestoreExcluded = vi.fn().mockResolvedValue(undefined)
@@ -68,6 +68,14 @@ export interface PlannedDayModalProps {
isRangeMode?: boolean | undefined
dayCount?: number | undefined
defaultDayType?: 'sjukpenning' | 'lagsta' | 'vacation' | undefined
/** Dates in the current selection that are free days (weekend/red day,
* per workspace `DayOverride`) — a `vacation` pin always leaves these off,
* and a `sjukpenning`/`lagsta` pin does too unless `takePlOnRedDays`.
* Drives the "will stay off" notice below the type select. */
freeDatesInSelection?: string[] | undefined
/** Mirrors `Workspace.takePlOnRedDays` (see Settings) — whether this
* workspace opted in to funding free days with parental leave. */
takePlOnRedDays?: boolean | undefined
}
export function PlannedDayModal({
@@ -88,6 +96,8 @@ export function PlannedDayModal({
isRangeMode = false,
dayCount = 1,
defaultDayType,
freeDatesInSelection = [],
takePlOnRedDays = false,
}: PlannedDayModalProps): ReactNode {
const { t, locale } = useTranslation()
const hasMultipleParents = parents.length > 1
@@ -118,6 +128,23 @@ export function PlannedDayModal({
const pinnedPath = forcedAdvanced || (soloDirect ? dayType !== 'auto' : isAdvancedOpen)
const showPinnedControls = forcedAdvanced || soloDirect || isAdvancedOpen
// A concrete type is about to be pinned (not the 'auto' planner-decides
// choice) — the two notices below only make sense once that's true.
const isPinningConcreteType = pinnedPath && dayType !== 'auto'
// The chosen type would leave every free day in the selection (weekend/red
// day) as a day off: always true for `vacation`, and true for a PL type
// unless the workspace opted in via `takePlOnRedDays` — mirrors
// `Calendar.tsx`'s `handleSavePinnedDay` filter exactly.
const freeDaysWillStayOff =
isPinningConcreteType &&
freeDatesInSelection.length > 0 &&
(dayType === 'vacation' || !takePlOnRedDays)
// Pinning excludes the day from `sync_plan`'s automatic re-planning —
// worth saying prospectively (about to pin) even when the day isn't
// already pinned; `existingDay?.isPinned` below already covers the
// already-pinned case, so this doesn't repeat there.
const showPinExclusionNotice = isPinningConcreteType && !existingDay?.isPinned
const [localError, setLocalError] = useState<string | null>(null)
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false)
@@ -359,6 +386,18 @@ export function PlannedDayModal({
</select>
</div>
{freeDaysWillStayOff && (
<p className="planned-day-modal__hint" data-testid="free-days-stay-off-notice">
{t('plannedDayModal.freeDaysStayOffNotice')}
</p>
)}
{showPinExclusionNotice && (
<p className="planned-day-modal__hint" data-testid="pin-exclusion-notice">
{t('plannedDayModal.pinExcludesFromSyncNotice')}
</p>
)}
{hasMultipleParents && (
<div className="field">
<label htmlFor="parent-select" className="field__label">
@@ -3,9 +3,9 @@ import { render, fireEvent, screen, act } from '@testing-library/react'
import { VacationDaysTable, type VacationDaysTableRow } from './VacationDaysTable'
const ROWS: VacationDaysTableRow[] = [
{ year: 2026, totalDays: 10, savedDaysIn: 2 },
{ year: 2027, totalDays: 25, savedDaysIn: 0 },
{ year: 2028, totalDays: 25, savedDaysIn: 0 },
{ year: 2026, totalDays: 10, savedDaysIn: 2, expiring: 0, expiringAge: 0, expiringOverCap: 0 },
{ year: 2027, totalDays: 25, savedDaysIn: 0, expiring: 0, expiringAge: 0, expiringOverCap: 0 },
{ year: 2028, totalDays: 25, savedDaysIn: 0, expiring: 0, expiringAge: 0, expiringOverCap: 0 },
]
function renderTable(
@@ -184,10 +184,43 @@ describe('VacationDaysTable', () => {
})
it('shows an assumed badge for a year with no entered data (§3.2)', () => {
const rows = [...ROWS, { year: 2029, totalDays: 25, savedDaysIn: 0, isAssumed: true }]
const rows = [
...ROWS,
{ year: 2029, totalDays: 25, savedDaysIn: 0, isAssumed: true, expiring: 0, expiringAge: 0, expiringOverCap: 0 },
]
renderTable(rows, vi.fn().mockResolvedValue(undefined))
expect(screen.getByTestId('vacation-assumed-badge-2029')).toBeInTheDocument()
expect(screen.queryByTestId('vacation-assumed-badge-2026')).not.toBeInTheDocument()
})
it('shows the at-risk figure in neutral ink when nothing is expiring', () => {
renderTable(ROWS, vi.fn().mockResolvedValue(undefined))
const atRisk = screen.getByTestId('vacation-at-risk-2026')
expect(atRisk).toHaveTextContent('0')
expect(atRisk).not.toHaveClass('vacation-rows__at-risk-value--danger')
})
it('shows the at-risk figure in danger ink when days are expiring, with a tooltip splitting the cause', () => {
const rows = [
{ year: 2026, totalDays: 30, savedDaysIn: 8, expiring: 5, expiringAge: 2, expiringOverCap: 3 },
...ROWS.slice(1),
]
renderTable(rows, vi.fn().mockResolvedValue(undefined))
const atRisk = screen.getByTestId('vacation-at-risk-2026')
expect(atRisk).toHaveTextContent('5')
expect(atRisk).toHaveClass('vacation-rows__at-risk-value--danger')
expect(atRisk.title).toContain('2')
expect(atRisk.title).toContain('3')
})
it('shows only the relevant cause in the at-risk tooltip when the other is zero', () => {
const rows = [
{ year: 2026, totalDays: 30, savedDaysIn: 8, expiring: 4, expiringAge: 4, expiringOverCap: 0 },
...ROWS.slice(1),
]
renderTable(rows, vi.fn().mockResolvedValue(undefined))
const atRisk = screen.getByTestId('vacation-at-risk-2026')
expect(atRisk.title).toBe('4 dagar har passerat 5-årsgränsen för att spara semester')
})
})
@@ -62,6 +62,14 @@ export interface VacationDaysTableRow {
// shown is the statutory default (25 days) rather than something the user
// entered — §3.2. Prompts the user to confirm/override.
isAssumed?: boolean
// Every vacation day forecast lost at this year's end — the sum of
// `expiringAge` + `expiringOverCap` (see `VacationYearBalance` in
// `types/entities.ts`). 0 when nothing is at risk.
expiring: number
// Subset of `expiring` that hit the 5-year carry-forward age-out.
expiringAge: number
// Subset of `expiring` above the savable carry-forward cap.
expiringOverCap: number
}
export interface VacationDaysTableProps {
@@ -134,6 +142,18 @@ function VacationYearRow({ row, onSave }: VacationYearRowProps): ReactNode {
})
}
// Splits the single `expiring` figure back into its two causes for the
// tooltip — the column itself stays a bare number so it never fights the
// narrow flex field for width in either language.
const atRiskDetailParts: string[] = []
if (row.expiringAge > 0) {
atRiskDetailParts.push(t('vacationDaysTable.atRiskAgeDetail', { days: row.expiringAge }))
}
if (row.expiringOverCap > 0) {
atRiskDetailParts.push(t('vacationDaysTable.atRiskOverCapDetail', { days: row.expiringOverCap }))
}
const atRiskDetail = atRiskDetailParts.join(' · ')
return (
<div className="vacation-rows__row" data-testid={`vacation-year-row-${row.year}`}>
<div className="vacation-rows__year">
@@ -177,6 +197,21 @@ function VacationYearRow({ row, onSave }: VacationYearRowProps): ReactNode {
/>
</label>
<div className="field vacation-rows__field vacation-rows__at-risk">
<span className="field__label">{t('vacationDaysTable.atRiskHeader')}</span>
<span
className={
row.expiring > 0
? 'vacation-rows__at-risk-value vacation-rows__at-risk-value--danger tnum'
: 'vacation-rows__at-risk-value tnum'
}
data-testid={`vacation-at-risk-${row.year}`}
title={atRiskDetail || undefined}
>
{row.expiring}
</span>
</div>
<span
className={`vacation-rows__status vacation-rows__status--${status}`}
data-testid={`vacation-status-${row.year}`}
@@ -65,6 +65,29 @@
cursor: help;
}
/* At-risk column — a bare tabular number, danger ink only when non-zero
* (see `.key-figures__value--danger` / `.year-row__cell--danger`, the same
* pattern used elsewhere for this figure). Sized like the other fields so
* neither "At risk"/"Riskerar" nor the short two-digit value ever wraps. */
.vacation-rows__at-risk {
flex: 0 1 90px;
min-width: 70px;
max-width: 130px;
}
.vacation-rows__at-risk-value {
display: flex;
align-items: center;
min-height: var(--control-h);
font-weight: 500;
color: var(--c-text);
}
.vacation-rows__at-risk-value--danger {
color: var(--c-danger);
cursor: help;
}
.vacation-rows__empty {
color: var(--c-text-muted);
font-size: var(--fs-sm);