Files
F-r-ldraledigplannerare/frontend/src/components/dashboard/PlanFold.tsx
T
jacobh2 ae6db7e16f Redesign the setup flow and consolidate the CSS into one primitives layer
Onboarding asks explicitly for one or two parents and shows one card per
parent for balances; the balance form is per parent with inline
validation; the partner can be filled from the FK deduction or added
from it. Settings and Dashboard reuse the same form.

CSS: bare buttons carry no colour any more; buttons, cards, choice
cards, fields, alerts, badges, eyebrow and modal chrome are defined once
in primitives.css. Media queries live only in responsive.css. A style
guard test blocks hex, !important and stray media queries. Bilingual
fixes: calendar selects size by content, header no longer wraps. Vitest
is capped at four workers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 12:27:29 +02:00

222 lines
9.8 KiB
TypeScript

/**
* Folds the wished-vs-funded/surplus/balance content that used to live only
* on the Plan tab's `PlanResults.tsx` (since deleted) into Översikt,
* collapsed below the fold. This is now the sole implementation of that
* content — a trimmed-down component, not a shared import.
*
* Read-only. The calendar's `POST /api/plan/sync` (see `Calendar.tsx`) is
* now the only way a plan is written — it proposes and persists in one
* step, so there is no separate "propose, review, then apply" flow left
* anywhere in the app, and this fold never mutates anything. It still calls
* `usePlanProposal` (`POST /api/plan/propose`) to get the wished-vs-funded
* figures, surplus advice and balance tables below, because that data has
* no other source: there is no GET endpoint for "the current plan" shaped
* like a proposal, and `/api/plan/propose` is documented read-only —
* "persists nothing, safe to spam" (`backend/app/routers/plan.py`).
*
* Dropped relative to the old `PlanResults.tsx` (content not duplicated here
* since the Hero and `Age4Bars` already cover it): the warnings section, the
* age4Projections table, and the per-window explanation/remedy detail cards.
* Also dropped, as of the move to `/api/plan/sync`: the apply button,
* collision-confirmation UI, and rejection rendering — applying is no
* longer a thing this fold does (see `frontend/src/utils/rejectionMessages.ts`
* for where rejection rendering now lives, for the sync toast in
* `Calendar.tsx`).
*/
import { ReactNode, useEffect } from 'react'
import type { PersonalData, SurplusAdvice, WindowOutcome } from '@/types'
import { useLeaveWishes, usePlanProposal } from '@/hooks'
import { useTranslation, toTranslationParams, type TranslationKey, type TranslationParams } from '@/i18n'
import { nameResolver } from '@/utils/parentNames'
import { resolveWishName } from '@/utils/wishName'
type Translate = (key: TranslationKey, params?: TranslationParams) => string
// §9.2 surplus-advice kinds — originally mirrored the deleted `PlanResults.tsx`'s
// `SURPLUS_ADVICE_KEYS`/`SURPLUS_ADVICE_BODY_KEYS`; this is now the sole copy
// (see the file banner above).
const SURPLUS_ADVICE_KEYS: Record<string, TranslationKey> = {
wish_more_leave: 'planResults.surplusAdviceWishMoreLeave',
shift_mix_to_fp: 'planResults.surplusAdviceShiftMixToFp',
front_load_early_years: 'planResults.surplusAdviceFrontLoadEarlyYears',
use_less_vacation: 'planResults.surplusAdviceUseLessVacation',
}
const SURPLUS_ADVICE_BODY_KEYS: Record<string, TranslationKey> = {
'planSurplusAdvice.wish_more_leave': 'planResults.surplusAdviceBodyWishMoreLeave',
'planSurplusAdvice.shift_mix_to_fp': 'planResults.surplusAdviceBodyShiftMixToFp',
'planSurplusAdvice.front_load_early_years': 'planResults.surplusAdviceBodyFrontLoadEarlyYears',
'planSurplusAdvice.use_less_vacation': 'planResults.surplusAdviceBodyUseLessVacation',
}
function nameForSafe(members: PersonalData[], t: Translate): (uuid: string) => string {
const resolve = nameResolver(members)
return (uuid: string) => {
const name = resolve(uuid)
return name === uuid ? t('dashboard.planFold.unknownParent') : name
}
}
function renderSurplusAdviceBody(advice: SurplusAdvice, t: Translate, nameFor: (uuid: string) => string): string {
const key = SURPLUS_ADVICE_BODY_KEYS[advice.messageKey]
if (!key) return advice.messageKey
const params = toTranslationParams(advice.data)
const uuid = advice.data.parent_personal_data_uuid
if (typeof uuid === 'string') {
params.parent_name = nameFor(uuid)
}
params.days_involved = advice.daysInvolved
return t(key, params)
}
function groupByYear(outcomes: WindowOutcome[]): Array<{ year: number; outcomes: WindowOutcome[] }> {
const years = Array.from(new Set(outcomes.map((o) => o.year))).sort((a, b) => a - b)
return years.map((year) => ({
year,
outcomes: outcomes.filter((o) => o.year === year).sort((a, b) => a.startDate.localeCompare(b.startDate)),
}))
}
export interface PlanFoldProps {
workspaceId: string
members: PersonalData[]
}
export function PlanFold({ workspaceId, members }: PlanFoldProps): ReactNode {
const { t } = useTranslation()
const { data: wishes, isLoading: wishesLoading } = useLeaveWishes(workspaceId)
const { data: proposal, isLoading: isProposing, error: proposalError, propose } = usePlanProposal(workspaceId)
const nameFor = nameForSafe(members, t)
// Auto-propose once wishes are available — this section is read-only-by-
// default (collapsed), so nothing should require the user to first find
// and press a separate "suggest" button just to see it. `propose` calls
// the read-only `/api/plan/propose` endpoint — it persists nothing.
useEffect(() => {
if (!wishesLoading && wishes.length > 0 && proposal === null && !isProposing) {
void propose()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wishesLoading, wishes.length, proposal])
return (
<details className="plan-fold" data-testid="plan-fold">
<summary className="plan-fold__summary" data-testid="plan-fold-summary">
{t('dashboard.planFold.summary')}
</summary>
<div className="plan-fold__content">
{!wishesLoading && wishes.length === 0 && (
<p className="plan-fold__hint" data-testid="plan-fold-no-wishes">
{t('dashboard.planFold.noWishesHint')}
</p>
)}
{proposalError && (
<div className="alert alert--error" role="alert" data-testid="plan-fold-proposal-error">
{proposalError}
</div>
)}
{proposal && (
<>
<section className="plan-fold__section" data-testid="plan-fold-overview">
<h4>{t('planResults.yearOverviewTitle')}</h4>
{proposal.outcomes.length === 0 ? (
<p>{t('planResults.noWindowsInHorizon')}</p>
) : (
<table className="plan-overview-table" data-testid="plan-fold-overview-table">
<thead>
<tr>
<th>{t('planResults.yearHeader')}</th>
<th>{t('planResults.leavesHeader')}</th>
</tr>
</thead>
<tbody>
{groupByYear(proposal.outcomes).map(({ year, outcomes }) => (
<tr key={year} data-testid={`plan-fold-overview-year-${year}`}>
<td>{year}</td>
<td>
{outcomes.map((outcome) => (
<span key={outcome.wishId} className="plan-overview-badge">
{resolveWishName({ name: outcome.wishName, nameMessageKey: outcome.wishNameMessageKey }, t)}
: {outcome.weeksFunded}/{outcome.weeksWished} {t('planResults.weeksUnit')}
</span>
))}
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
{proposal.surplusAdvice.length > 0 && (
<section className="plan-fold__section" data-testid="plan-fold-surplus-advice">
<h4>{t('planResults.surplusAdviceTitle')}</h4>
<p>{t('planResults.surplusAdviceIntro')}</p>
<ul>
{proposal.surplusAdvice.map((advice, idx) => {
const titleKey = SURPLUS_ADVICE_KEYS[advice.kind]
return (
<li key={`${advice.kind}-${idx}`} data-testid={`plan-fold-surplus-advice-${idx}`}>
<strong>{titleKey ? t(titleKey) : advice.kind}</strong>
<p>{renderSurplusAdviceBody(advice, t, nameFor)}</p>
</li>
)
})}
</ul>
</section>
)}
<section className="plan-fold__section" data-testid="plan-fold-balances">
<h4>{t('planResults.balancesTitle')}</h4>
<table className="plan-balances-table" data-testid="plan-fold-pool-balances">
<thead>
<tr>
<th>{t('planResults.parentHeader')}</th>
<th>{t('planResults.sjukpenningLeftHeader')}</th>
<th>{t('planResults.lagstaLeftHeader')}</th>
<th>{t('planResults.dubbeldagarLeftHeader')}</th>
</tr>
</thead>
<tbody>
{proposal.poolBalances.map((pool) => (
<tr key={`${pool.parentPersonalDataUuid}-${pool.childId}`}>
<td>{nameFor(pool.parentPersonalDataUuid)}</td>
<td>{pool.sjukpenningLeft}</td>
<td>{pool.lagstaLeft}</td>
<td>{pool.dubbeldagarLeft}</td>
</tr>
))}
</tbody>
</table>
<table className="plan-balances-table" data-testid="plan-fold-vacation-balances">
<thead>
<tr>
<th>{t('planResults.parentHeader')}</th>
<th>{t('planResults.yearHeader')}</th>
<th>{t('planResults.usedHeader')}</th>
<th>{t('planResults.totalAvailableHeader')}</th>
</tr>
</thead>
<tbody>
{proposal.vacationBalances.map((budget) => (
<tr key={`${budget.parentPersonalDataUuid}-${budget.year}`}>
<td>{nameFor(budget.parentPersonalDataUuid)}</td>
<td>{budget.year}</td>
<td>{budget.alreadyUsed}</td>
<td>{budget.totalDays + budget.savedDaysIn}</td>
</tr>
))}
</tbody>
</table>
</section>
</>
)}
</div>
</details>
)
}