61e5487078
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
89 lines
3.5 KiB
TypeScript
89 lines
3.5 KiB
TypeScript
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>
|
|
)
|
|
}
|