Lead the overview with the days about to be lost
The page opened with generic tiles and a bar reading forty-eight of ninety-six weeks, which was neither weeks nor connected to the age-four ceiling it appeared to describe. It is replaced by one headline and a bar per parent, in days, against each parent's own savable limit as the server reports it. The headline has two framings on the same arithmetic. Before anything is planned it says what there is to plan and what will be lost without a plan, because on the first day almost the whole entitlement is technically at risk and saying so bluntly reads as an accusation. Once a plan exists it warns about the days genuinely at stake. A timeline of the years to the fourth birthday answers what the next four years look like, which a month-at-a-time calendar cannot. Shortfalls, surplus advice and balances sit below the fold, read-only: applying is automatic now, so a second apply button would be a way to get two different answers. Rejection messages move to one shared place that resolves a parent's name, so an identifier can no longer surface in their place.
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Folds the wished-vs-funded/surplus/balance content that used to live only
|
||||
* on the Plan tab's `PlanResults.tsx` into Översikt, collapsed below the
|
||||
* fold. Read `PlanResults.tsx` for the reference implementation; this is a
|
||||
* separate, trimmed-down component (not a shared import) because
|
||||
* `PlanResults.tsx` is scheduled for deletion by another task and must not
|
||||
* be edited here.
|
||||
*
|
||||
* 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 `PlanResults.tsx` (kept there / on the Plan tab
|
||||
* instead, to avoid duplicating content the Hero and `Age4Bars` already
|
||||
* cover): 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 — mirrors `PlanResults.tsx`'s `SURPLUS_ADVICE_KEYS`/
|
||||
// `SURPLUS_ADVICE_BODY_KEYS` (the two catalogues are independent copies, not
|
||||
// a shared import — 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="plan-fold__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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user