ae6db7e16f
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>
222 lines
9.8 KiB
TypeScript
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>
|
|
)
|
|
}
|