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