e9d7cee66b
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>
446 lines
20 KiB
TypeScript
446 lines
20 KiB
TypeScript
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>
|
||
)
|
||
}
|