Files
F-r-ldraledigplannerare/frontend/src/pages/Dashboard.tsx
T
jacobh2 e9d7cee66b Pull form fields and alert banners into shared components
FormField owns the .field markup and wires id/aria-invalid/aria-describedby
onto its control; Alert owns role=alert banners. Auth, Join and the partner
invite panel drop their one-off error classes for the shared alert style.
VacationDaysInput and ParentProfileInput move onto the .field primitives.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 22:02:09 +02:00

446 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useWorkspace } from '@/context'
import {
useAnalytics,
useLeaveWishes,
usePlanProposal,
usePlannedDays,
usePreschoolPlanningDaysList,
useWorkspaceMembers,
useOptimizationTips,
} from '@/hooks'
// 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 { orderSelfFirst } from '@/utils/members'
import { useTranslation } from '@/i18n'
import { Layout } from '@/components/layout/Layout'
import { PageSkeleton } from '@/components/common/PageSkeleton'
import { OverviewMetrics } from '@/components/dashboard/OverviewMetrics'
import { Hero } from '@/components/dashboard/Hero'
import { KeyFigures } from '@/components/dashboard/KeyFigures'
import { Age4Bars } from '@/components/dashboard/Age4Bars'
import { YearRows } from '@/components/dashboard/YearRows'
import { SurplusAdviceTip } from '@/components/dashboard/SurplusAdviceTip'
import { PlanWarningCard } from '@/components/dashboard/PlanWarningCard'
import { VacationRescueTip } from '@/components/dashboard/VacationRescueTip'
import { ParentalLeaveBreakdown } from '@/components/dashboard/ParentalLeaveBreakdown'
import { TipCard } from '@/components/inputs/TipCard'
import { Onboarding } from '@/pages/Onboarding'
import { Alert } from '@/components/common/Alert'
import type { RescueDay, Tip } from '@/types'
/** Key matching `rescueDayGroups`' own `Map` key below — shared so a
* locally-dismissed group (Defect 2, §vacation-rescue) can be filtered back
* out by the same identity it was grouped by. */
function rescueGroupKey(parentPersonalDataUuid: string, year: number): string {
return `${parentPersonalDataUuid}:${year}`
}
/** Whether a `use_vacation_before_expiry` tip (`TipsService`, backend) is
* superseded by an existing vacation-rescue group for the same parent and
* year (Defect 3, §vacation-rescue) — otherwise the dashboard shows three
* different numbers for what a user reads as one problem. The tip's `data`
* only carries a resolved display name (`parent_name`), never a parent
* uuid (see `_rule_use_vacation_before_expiry` in `tips_service.py`), so
* matching goes through the same `nameFor` resolver both cards already use
* to render a name, rather than inventing a backend field to compare
* uuids directly. */
function isSupersededByRescueGroup(
tip: Tip,
rescueDayGroups: RescueDay[][],
nameFor: (uuid: string) => string,
): boolean {
if (tip.messageKey !== 'tips.use_vacation_before_expiry') return false
const { parent_name, year } = tip.data
if (typeof parent_name !== 'string' || typeof year !== 'number') return false
return rescueDayGroups.some((group) => {
const first = group[0]
return first !== undefined && first.year === year && nameFor(first.parentPersonalDataUuid) === parent_name
})
}
/** Fallback horizon end when the analytics projection isn't available yet
* (e.g. mid-load) — plain date arithmetic, not a business rule, so deriving
* it here does not duplicate the age-4 savable-cap logic the backend owns. */
function computeFourthBirthday(birthDateIso: string): string {
const d = new Date(birthDateIso + 'T00:00:00Z')
d.setUTCFullYear(d.getUTCFullYear() + 4)
return d.toISOString().slice(0, 10)
}
interface HeroRemedy {
transferDays: number
fromParent: string
toParent: string
}
/** Surfaces the exact age-4 remedy in the hero, derived from the
* `tips.age4_pooling_loss_exact` tip's `data` — no separate API call, no
* new backend field. `Tip['data']` is `Record<string, unknown>`, so every
* field is narrowed by hand rather than cast. */
function deriveHeroRemedy(tips: Tip[] | null): HeroRemedy | null {
const tip = tips?.find((t) => t.messageKey === 'tips.age4_pooling_loss_exact')
if (!tip) return null
const { transfer_days, surplus_parent, lagging_parent } = tip.data
if (typeof transfer_days !== 'number' || !Number.isFinite(transfer_days) || transfer_days <= 0) return null
if (typeof surplus_parent !== 'string' || surplus_parent.length === 0) return null
if (typeof lagging_parent !== 'string' || lagging_parent.length === 0) return null
return { transferDays: transfer_days, fromParent: surplus_parent, toParent: lagging_parent }
}
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 } = useAnalytics(
workspace?.id ?? null,
selectedChildId ?? ''
)
const { data: plannedDays, isLoading: pdLoading } = usePlannedDays(selectedChildId ?? undefined)
// Supplementary to the page's main figures — never gates the loading
// skeleton; an empty array while it loads renders the same "–" as an
// absent year, which is the correct fallback either way.
const { data: preschoolPlanningDays } = usePreschoolPlanningDaysList(selectedChildId ?? null)
const { data: members, isLoading: membersLoading, refresh: refreshMembers } = useWorkspaceMembers(workspace?.id ?? null)
// Unlike `usePlanProposal` below, `useOptimizationTips` still only fetches
// once (no `planVersion` dependency) — a plan-changing write elsewhere
// (Settings, a bumped partner add/remove, PlanSyncProvider's own
// background sync) never refreshes the tips shown here. Same staleness
// shape as the bug `usePlanProposal` was just fixed for; left alone here,
// deliberately out of scope for this change.
const { data: tips, dismiss: dismissTip } = useOptimizationTips(workspace?.id ?? null)
const { data: wishes, isLoading: wishesLoading } = useLeaveWishes(workspace?.id ?? null)
// `proposal` (`data` from `usePlanProposal`) is already the freshest
// proposal available — that hook itself prefers `PlanSyncProvider`'s
// latest synced proposal over its own one-shot `propose()` result once a
// sync has completed (see `usePlanProposal.ts`'s doc comment). No
// `planVersion` dependency is needed *here* the way `useAnalytics.ts`
// has one: `usePlanProposal` calls `usePlanSyncContextOptional()`
// internally, a React Context read, so this component re-renders
// whenever `PlanSyncProvider`'s state changes — the same plumbing
// `planVersion` deps elsewhere rely on, just via Context instead of a
// dependency array.
const { data: proposal, isLoading: isProposing, error: proposalError, propose } = usePlanProposal(workspace?.id ?? null)
// §vacation-rescue — group the proposal's suggest-only rescue days by
// (parent, year); each group renders as one `VacationRescueTip` card
// below. A `Map` keeps groups in first-seen order, which is already date
// order since the backend returns `rescue_days` sorted by (date, parent)
// — see `RescueDay`'s docstring (allocation_service.py). Computed above
// every early return (Rules of Hooks — this page returns early for both
// the loading skeleton and onboarding).
// §vacation-rescue dismissal (Defect 2) — a successful dismiss writes no
// plan data (just a `tip_dismissal` row server-side), so it must never
// trigger a plan re-sync (`bumpPlanVersion`) the way accept does. Instead
// the dismissed group is removed from view here, optimistically, in local
// state — keyed the same way `rescueDayGroups` itself groups, so it
// survives that memo re-running on any unrelated proposal refresh.
const [dismissedRescueGroupKeys, setDismissedRescueGroupKeys] = useState<Set<string>>(new Set())
const handleRescueDismissed = useCallback((parentPersonalDataUuid: string, year: number) => {
setDismissedRescueGroupKeys((prev) => {
const next = new Set(prev)
next.add(rescueGroupKey(parentPersonalDataUuid, year))
return next
})
}, [])
const rescueDayGroups = useMemo(() => {
const groups = new Map<string, RescueDay[]>()
for (const day of proposal?.rescueDays ?? []) {
const key = rescueGroupKey(day.parentPersonalDataUuid, day.year)
if (dismissedRescueGroupKeys.has(key)) continue
const group = groups.get(key)
if (group) {
group.push(day)
} else {
groups.set(key, [day])
}
}
return Array.from(groups.values())
}, [proposal, dismissedRescueGroupKeys])
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
// `Onboarding.tsx`'s own direct `api.addPartner` call, is invisible to
// this hook's already-fetched `data` until something explicitly refetches
// it. `Onboarding` only refreshes `WorkspaceContext` (which flips
// `children` from empty to non-empty) when it finishes — it has no way to
// reach into this component's `useWorkspaceMembers` instance. Watching
// that same empty-to-non-empty transition here is what tells this
// component "onboarding just finished, the members list might be stale."
const wasAwaitingFirstChildRef = useRef(children.length === 0)
useEffect(() => {
if (wasAwaitingFirstChildRef.current && children.length > 0) {
void refreshMembers()
}
wasAwaitingFirstChildRef.current = children.length === 0
}, [children.length, refreshMembers])
// Initial-load-only fetch: this is what supplies `proposal` before
// `PlanSyncProvider`'s own background sync has completed even once (e.g.
// right after login). Once that first sync lands, `usePlanProposal`
// switches to its result and this effect's own `propose()` calls stop
// mattering — see `usePlanProposal.ts`. `proposalError === null` still
// guards it so a failed `propose()` never becomes a per-render retry loop.
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) {
return (
<Layout>
<PageSkeleton />
</Layout>
)
}
const handleBandClick = (date: string) => {
navigate(`/calendar?date=${date}`)
}
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
// Vacation days forecast lost (age-out or over the carry-forward cap),
// across every parent, from this year onward — a past year's loss already
// happened and is no longer actionable (same guard `YearRows` uses for
// its own per-year expiring figure).
const currentYear = new Date().getFullYear()
const vacationAtRisk = parentProjections.reduce(
(sum, projection) =>
sum +
projection.yearBalances
.filter((balance) => balance.year >= currentYear)
.reduce((yearSum, balance) => yearSum + balance.vacation.expiring, 0),
0,
)
const parentDisplayName = (uuid: string, index: number): string => {
const resolved = membersLoading ? uuid : nameFor(uuid)
return resolved === uuid
? 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
? [
{ label: t('dashboard.usedDays'), value: analytics.combinedDaysUsed },
{ label: t('dashboard.daysLeft'), value: analytics.combinedDaysLeft },
...(showPerParentExpiring
? parentProjections.map((projection, index) => ({
label: t('dashboard.expiringAtB4ForParent', {
name: parentDisplayName(projection.personalDataUuid, index),
}),
value: projection.expiringAtB4,
testId: `metric-expiring-parent-${projection.personalDataUuid}`,
}))
: []),
{
label: t('dashboard.expiringAtB4'),
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.
isSummary: showPerParentExpiring,
},
]
: []
// A new account has no child yet — hand off to the onboarding wizard
// (add child, solo/couple, balances ladder) instead of rendering the
// dashboard itself. `Onboarding` only calls `refreshContext()` once the
// whole wizard is done, at which point `children` here becomes non-empty
// and this component naturally re-renders past this branch.
if (children.length === 0) {
return <Onboarding />
}
const selectedChild = children.find((c) => c.id === selectedChildId)
const hasPlan = (plannedDays?.length ?? 0) > 0
const fourthBirthday =
parentProjections[0]?.fourthBirthday ?? (selectedChild ? computeFourthBirthday(selectedChild.birth_date) : '')
// 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: 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,
}))
// `proposal` (and therefore these, and `outcomes` passed to `YearRows`
// below) is always the freshest one available — see the comment by
// `usePlanProposal(...)` above. A partner add/remove, a Settings save, or
// the new manual recompute button (Settings.tsx) all eventually bump
// `planVersion`, which `PlanSyncProvider` turns into a background sync,
// which lands here without this component doing anything sync-specific.
const surplusAdvice = proposal?.surplusAdvice ?? []
const warnings = proposal?.warnings ?? []
const hasTips = (tips?.length ?? 0) > 0 || surplusAdvice.length > 0 || rescueDayGroups.length > 0
const heroRemedy = deriveHeroRemedy(tips)
return (
<Layout>
<div data-testid="dashboard" className="dashboard">
{analyticsError && (
<Alert variant="error" testId="error-banner">
{analyticsError}
</Alert>
)}
{analytics && selectedChild && (
<Hero
hasPlan={hasPlan}
daysLeft={analytics.combinedDaysLeft}
daysAtRisk={analytics.combinedExpiringAtB4}
childName={selectedChild.name}
fourthBirthday={fourthBirthday}
remedy={heroRemedy}
/>
)}
{showEstimate && (
<p className="dashboard__estimate">
<span className="dashboard__estimate-badge" data-testid="dashboard-estimate-badge">
{t('dashboard.estimatedBadge')}
</span>{' '}
{t('dashboard.estimatedBadgeCaption')}
</p>
)}
{analytics && (
<KeyFigures
daysLeft={analytics.combinedDaysLeft}
daysUsed={analytics.combinedDaysUsed}
expiringAtB4={analytics.combinedExpiringAtB4}
vacationAtRisk={vacationAtRisk}
/>
)}
{selectedChild && (
<section className="dashboard__section year-overview" data-testid="year-overview">
<h2>{t('dashboard.leaveOverviewTitle')}</h2>
{age4Bars.length > 0 && <Age4Bars bars={age4Bars} />}
{proposalError && (
<Alert variant="error" testId="dashboard-proposal-error">
{proposalError}
</Alert>
)}
<YearRows
parents={yearRowParents}
plannedDays={plannedDays ?? []}
startDate={selectedChild.birth_date}
endDate={fourthBirthday}
outcomes={proposal?.outcomes ?? []}
onBandClick={handleBandClick}
preschoolPlanningDays={preschoolPlanningDays}
/>
</section>
)}
{warnings.length > 0 && (
<section className="dashboard__section" data-testid="dashboard-warnings">
<h2>{t('planWarnings.sectionTitle')}</h2>
<div className="dashboard__tips-list">
{warnings.map((warning, index) => (
<PlanWarningCard key={`${warning.kind}-${index}`} warning={warning} nameFor={nameForAdvice} index={index} />
))}
</div>
</section>
)}
{hasTips && (
<section className="dashboard__section" data-testid="dashboard-tips">
<h2>{t('tips.title')}</h2>
<div className="dashboard__tips-list">
{(tips ?? [])
// Defect 3 — a `use_vacation_before_expiry` tip for a
// parent/year that already has a rescue card is redundant
// (three different numbers for one problem); the rescue
// card supersedes it, being both actionable and current.
.filter((tip) => !isSupersededByRescueGroup(tip, rescueDayGroups, nameForAdvice))
.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} />
))}
{rescueDayGroups.map((group, index) => (
<VacationRescueTip
key={`${group[0]!.parentPersonalDataUuid}-${group[0]!.year}`}
workspaceId={workspace?.id ?? null}
days={group}
nameFor={nameForAdvice}
index={index}
onDismissed={handleRescueDismissed}
/>
))}
</div>
</section>
)}
<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>
</details>
</div>
</Layout>
)
}