Make the overview answer the year-by-year question
The dashboard now leads with a typographic headline, three key figures, per-parent age-4 bars and one row per year until the fourth birthday. Each year row shows the timeline bands beside each parent's parental-leave days left at year end and vacation days left, carried over or expiring, and opens into a per-parent table with that year's wish outcomes. Tips become a plain list and absorb the planner's surplus advice; "Detaljer" keeps the metrics and a balance snapshot for every parent. The separate plan-proposal fold and the type-count table are gone. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XBRLomdLK9jF7wU7a8AA6H
This commit is contained in:
+112
-227
@@ -1,37 +1,30 @@
|
||||
import { ReactNode, useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { ReactNode, useEffect, useRef } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useWorkspace } from '@/context'
|
||||
import {
|
||||
useAnalytics,
|
||||
useLeaveWishes,
|
||||
usePlanProposal,
|
||||
usePlannedDays,
|
||||
useScraperFetch,
|
||||
useParentalLeave,
|
||||
useWorkspaceMembers,
|
||||
useOptimizationTips,
|
||||
} from '@/hooks'
|
||||
// Imported directly (not via the `@/hooks` barrel) so this page's tests can
|
||||
// drive it through a mocked `api.getParentalLeave` — see the hook's doc
|
||||
// comment for why.
|
||||
import { useLatestBalance } from '@/hooks/useLatestBalance'
|
||||
import type { PlannedLeaveDay } from '@/types'
|
||||
import * as api from '@/services/api'
|
||||
import { getLabelForType } from '@/utils/leaveTypes'
|
||||
// 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 { isSelf, orderSelfFirst } from '@/utils/members'
|
||||
import { orderSelfFirst } from '@/utils/members'
|
||||
import { useTranslation } from '@/i18n'
|
||||
import { Layout } from '@/components/layout/Layout'
|
||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
|
||||
import { PageSkeleton } from '@/components/common/PageSkeleton'
|
||||
import { OverviewMetrics } from '@/components/dashboard/OverviewMetrics'
|
||||
import { DaysBreakdown } from '@/components/dashboard/DaysBreakdown'
|
||||
import { Hero } from '@/components/dashboard/Hero'
|
||||
import { KeyFigures } from '@/components/dashboard/KeyFigures'
|
||||
import { Age4Bars } from '@/components/dashboard/Age4Bars'
|
||||
import { YearTimeline } from '@/components/dashboard/YearTimeline'
|
||||
import { PlanFold } from '@/components/dashboard/PlanFold'
|
||||
import { YearRows } from '@/components/dashboard/YearRows'
|
||||
import { SurplusAdviceTip } from '@/components/dashboard/SurplusAdviceTip'
|
||||
import { ParentalLeaveBreakdown } from '@/components/dashboard/ParentalLeaveBreakdown'
|
||||
import { TipCard } from '@/components/inputs/TipCard'
|
||||
import { QRCodeModal } from '@/components/modals/QRCodeModal'
|
||||
import { ParentBalanceForm } from '@/components/modals/ParentBalanceForm'
|
||||
import { Onboarding } from '@/pages/Onboarding'
|
||||
|
||||
/** Fallback horizon end when the analytics projection isn't available yet
|
||||
@@ -47,30 +40,21 @@ 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, refresh: refreshAnalytics } = useAnalytics(
|
||||
const { data: analytics, isLoading: analyticsLoading, error: analyticsError } = useAnalytics(
|
||||
workspace?.id ?? null,
|
||||
selectedChildId ?? ''
|
||||
)
|
||||
const { data: plannedDays, isLoading: pdLoading } = usePlannedDays(selectedChildId ?? undefined)
|
||||
const { data: parentalLeave, refresh: refreshParentalLeave } = useParentalLeave(personalDataUuid, selectedChildId ?? null)
|
||||
const { data: members, isLoading: membersLoading, refresh: refreshMembers } = useWorkspaceMembers(workspace?.id ?? null)
|
||||
const { data: tips, dismiss: dismissTip } = useOptimizationTips(workspace?.id ?? null)
|
||||
const scraper = useScraperFetch()
|
||||
const { data: wishes, isLoading: wishesLoading } = useLeaveWishes(workspace?.id ?? null)
|
||||
const { data: proposal, isLoading: isProposing, error: proposalError, propose } = usePlanProposal(workspace?.id ?? null)
|
||||
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [showBalancePicker, setShowBalancePicker] = useState(false)
|
||||
const [showManualFetchModal, setShowManualFetchModal] = useState(false)
|
||||
const [manualFetchTargetUuid, setManualFetchTargetUuid] = useState<string | null>(null)
|
||||
const [isManualFetchSaving, setIsManualFetchSaving] = useState(false)
|
||||
|
||||
// Reopening the balance form for a parent should show what was actually
|
||||
// saved for them, not always the 195/45/60 preset — `manualFetchTargetUuid`
|
||||
// is null except while the form is open/opening, so this no-ops the rest
|
||||
// of the time.
|
||||
const {
|
||||
initial: manualFetchInitial,
|
||||
isLoading: manualFetchInitialLoading,
|
||||
} = useLatestBalance(manualFetchTargetUuid, selectedChildId ?? null)
|
||||
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
|
||||
@@ -89,6 +73,16 @@ export function Dashboard(): ReactNode {
|
||||
wasAwaitingFirstChildRef.current = children.length === 0
|
||||
}, [children.length, refreshMembers])
|
||||
|
||||
// The plan proposal (`POST /api/plan/propose`, read-only — persists
|
||||
// nothing) is the only source for wished-vs-funded per year and the
|
||||
// surplus advice, so it is requested automatically once wishes exist.
|
||||
// Stops after one failure rather than retrying on every render.
|
||||
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) {
|
||||
@@ -99,96 +93,15 @@ export function Dashboard(): ReactNode {
|
||||
)
|
||||
}
|
||||
|
||||
const handleFetchFK = async () => {
|
||||
setShowModal(true)
|
||||
await scraper.start(selectedChildId ?? undefined)
|
||||
}
|
||||
|
||||
const handleSaveManualFetch = async (data: {
|
||||
sjukpenningLeft: number
|
||||
lagstaNivaLeft: number
|
||||
dubbeldagarLeft: number
|
||||
reservedDaysLeft: number | null
|
||||
}) => {
|
||||
// `manualFetchTargetUuid` is always set to a real uuid by
|
||||
// `handleOpenManualFetch`/`handlePickManualFetchTarget` before the form
|
||||
// is ever shown — there is no path that calls this with it unset.
|
||||
const targetUuid = manualFetchTargetUuid
|
||||
if (!workspace?.id || !selectedChildId || !targetUuid) return
|
||||
|
||||
// No try/catch here on purpose — a rejection propagates back to
|
||||
// `ParentBalanceForm`'s own `onSave` handler, which owns showing it
|
||||
// (inline under the offending field for a mapped 422, or its own top
|
||||
// banner otherwise). See the audit note on `ParentBalanceForm.tsx`.
|
||||
try {
|
||||
setIsManualFetchSaving(true)
|
||||
|
||||
await api.saveManualFetch({
|
||||
personalDataUuid: targetUuid,
|
||||
workspaceId: workspace.id,
|
||||
childId: selectedChildId,
|
||||
sjukpenningLeft: data.sjukpenningLeft,
|
||||
lagstaNivaLeft: data.lagstaNivaLeft,
|
||||
dubbeldagarLeft: data.dubbeldagarLeft,
|
||||
reservedDaysLeft: data.reservedDaysLeft,
|
||||
source: 'manual',
|
||||
})
|
||||
|
||||
await refreshParentalLeave()
|
||||
await refreshAnalytics()
|
||||
setShowManualFetchModal(false)
|
||||
// Reset so the next open of this same target refetches instead of
|
||||
// reusing the (now stale) balance `useLatestBalance` already cached.
|
||||
setManualFetchTargetUuid(null)
|
||||
} finally {
|
||||
setIsManualFetchSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** "Ange manuellt" — for a solo workspace this opens the form straight for
|
||||
* self; for a couple it shows a small parent picker first (see
|
||||
* `dashboard-balance-parent-picker` below) since either parent's balance
|
||||
* can be entered from here. */
|
||||
const handleOpenManualFetch = () => {
|
||||
if (members.length >= 2) {
|
||||
setShowBalancePicker(true)
|
||||
return
|
||||
}
|
||||
setManualFetchTargetUuid(personalDataUuid)
|
||||
setShowManualFetchModal(true)
|
||||
}
|
||||
|
||||
const handlePickManualFetchTarget = (uuid: string) => {
|
||||
setManualFetchTargetUuid(uuid)
|
||||
setShowBalancePicker(false)
|
||||
setShowManualFetchModal(true)
|
||||
}
|
||||
|
||||
const handleModalClose = () => {
|
||||
if (scraper.isRunning) return
|
||||
setShowModal(false)
|
||||
}
|
||||
|
||||
const handleFetchDone = async () => {
|
||||
setShowModal(false)
|
||||
await Promise.all([refreshAnalytics(), refreshParentalLeave()])
|
||||
}
|
||||
|
||||
const handleBandClick = (date: string) => {
|
||||
navigate(`/calendar?date=${date}`)
|
||||
}
|
||||
|
||||
const breakdown = [
|
||||
{ type: getLabelForType('vacation', t), count: plannedDays?.filter((d: PlannedLeaveDay) => d.dayType === 'vacation').length ?? 0 },
|
||||
{ type: getLabelForType('sjukpenning', t), count: plannedDays?.filter((d: PlannedLeaveDay) => d.dayType === 'sjukpenning').length ?? 0 },
|
||||
{ type: getLabelForType('lagsta', t), count: plannedDays?.filter((d: PlannedLeaveDay) => d.dayType === 'lagsta').length ?? 0 },
|
||||
]
|
||||
|
||||
// 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 below, so skip the breakdown in that case.
|
||||
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
|
||||
|
||||
const parentDisplayName = (uuid: string, index: number): string => {
|
||||
@@ -197,6 +110,10 @@ export function Dashboard(): ReactNode {
|
||||
? 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
|
||||
? [
|
||||
@@ -216,9 +133,7 @@ export function Dashboard(): ReactNode {
|
||||
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 — for a single
|
||||
// parent it's just this workspace's one figure, so it renders as
|
||||
// an ordinary grid cell instead of a footer row.
|
||||
// a per-parent breakdown above it to summarize.
|
||||
isSummary: showPerParentExpiring,
|
||||
},
|
||||
]
|
||||
@@ -237,13 +152,43 @@ export function Dashboard(): ReactNode {
|
||||
const hasPlan = (plannedDays?.length ?? 0) > 0
|
||||
const fourthBirthday =
|
||||
parentProjections[0]?.fourthBirthday ?? (selectedChild ? computeFourthBirthday(selectedChild.birth_date) : '')
|
||||
const age4Bars = parentProjections.map((projection, index) => ({
|
||||
|
||||
// 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: parentDisplayName(projection.personalDataUuid, index),
|
||||
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,
|
||||
}))
|
||||
|
||||
const surplusAdvice = proposal?.surplusAdvice ?? []
|
||||
const hasTips = (tips?.length ?? 0) > 0 || surplusAdvice.length > 0
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
@@ -264,127 +209,67 @@ export function Dashboard(): ReactNode {
|
||||
/>
|
||||
)}
|
||||
|
||||
{!parentalLeave && (
|
||||
<p
|
||||
className="badge badge--warning dashboard__estimate-badge"
|
||||
data-testid="dashboard-estimate-badge"
|
||||
title={t('dashboard.estimatedBadgeCaption')}
|
||||
>
|
||||
{t('dashboard.estimatedBadge')}
|
||||
{showEstimate && (
|
||||
<p className="dashboard__estimate">
|
||||
<span className="dashboard__estimate-badge" data-testid="dashboard-estimate-badge">
|
||||
{t('dashboard.estimatedBadge')}
|
||||
</span>{' '}
|
||||
{t('dashboard.estimatedBadgeCaption')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{age4Bars.length > 0 && <Age4Bars bars={age4Bars} />}
|
||||
|
||||
<section className="dashboard__section dashboard__fetch-actions">
|
||||
<button type="button" className="btn btn--primary" onClick={handleFetchFK} disabled={!selectedChildId}>
|
||||
{t('dashboard.fetchFromFk')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenManualFetch}
|
||||
disabled={!selectedChildId}
|
||||
className="btn btn--secondary"
|
||||
>
|
||||
{t('dashboard.enterManually')}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{showBalancePicker && (
|
||||
<div className="card dashboard__balance-picker" data-testid="dashboard-balance-parent-picker">
|
||||
<p className="dashboard__balance-picker-label">{t('dashboard.balancePickerPrompt')}</p>
|
||||
<div className="dashboard__balance-picker-actions">
|
||||
{orderSelfFirst(members, personalDataUuid).map((member) => (
|
||||
<button
|
||||
key={member.uuid}
|
||||
type="button"
|
||||
className="btn btn--secondary"
|
||||
onClick={() => handlePickManualFetchTarget(member.uuid)}
|
||||
data-testid={`dashboard-balance-parent-${member.uuid}`}
|
||||
>
|
||||
{member.name}
|
||||
{isSelf(member, personalDataUuid) && ` ${t('settings.youBadge')}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{analytics && (
|
||||
<KeyFigures
|
||||
daysLeft={analytics.combinedDaysLeft}
|
||||
daysUsed={analytics.combinedDaysUsed}
|
||||
expiringAtB4={analytics.combinedExpiringAtB4}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedChild && (
|
||||
<YearTimeline
|
||||
plannedDays={plannedDays ?? []}
|
||||
startDate={selectedChild.birth_date}
|
||||
endDate={fourthBirthday}
|
||||
onBandClick={handleBandClick}
|
||||
/>
|
||||
<section className="dashboard__section year-overview" data-testid="year-overview">
|
||||
<h2>{t('dashboard.leaveOverviewTitle')}</h2>
|
||||
{age4Bars.length > 0 && <Age4Bars bars={age4Bars} />}
|
||||
{proposalError && (
|
||||
<div className="alert alert--error" role="alert" data-testid="dashboard-proposal-error">
|
||||
{proposalError}
|
||||
</div>
|
||||
)}
|
||||
<YearRows
|
||||
parents={yearRowParents}
|
||||
plannedDays={plannedDays ?? []}
|
||||
startDate={selectedChild.birth_date}
|
||||
endDate={fourthBirthday}
|
||||
outcomes={proposal?.outcomes ?? []}
|
||||
onBandClick={handleBandClick}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{tips && tips.length > 0 && (
|
||||
{hasTips && (
|
||||
<section className="dashboard__section" data-testid="dashboard-tips">
|
||||
<h2>{t('tips.title')}</h2>
|
||||
<div className="dashboard__tips-list">
|
||||
{tips.map((tip) => (
|
||||
{(tips ?? []).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} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{workspace && <PlanFold workspaceId={workspace.id} members={members} />}
|
||||
|
||||
<details className="dashboard__more-details" data-testid="dashboard-more-details">
|
||||
<summary>{t('dashboard.moreDetailsSummary')}</summary>
|
||||
|
||||
<section className="dashboard__section">
|
||||
{analytics && (
|
||||
<OverviewMetrics
|
||||
title={t('dashboard.overviewTitle')}
|
||||
metrics={overviewMetrics}
|
||||
/>
|
||||
)}
|
||||
<ParentalLeaveBreakdown data={parentalLeave} />
|
||||
</section>
|
||||
|
||||
<section className="dashboard__section">
|
||||
<h2>{t('dashboard.plannedDaysTitle')}</h2>
|
||||
<DaysBreakdown breakdown={breakdown} />
|
||||
</section>
|
||||
</details>
|
||||
|
||||
<QRCodeModal
|
||||
isOpen={showModal}
|
||||
onClose={handleModalClose}
|
||||
onDone={handleFetchDone}
|
||||
status={scraper.pollData?.status ?? null}
|
||||
qrData={scraper.pollData?.qr_data}
|
||||
result={scraper.pollData?.result}
|
||||
message={scraper.pollData?.message}
|
||||
error={scraper.error}
|
||||
isRunning={scraper.isRunning}
|
||||
onCancel={scraper.cancel}
|
||||
/>
|
||||
|
||||
{showManualFetchModal && manualFetchInitialLoading && (
|
||||
<div className="modal__overlay" data-testid="dashboard-balance-loading">
|
||||
<LoadingSpinner message={t('parentBalance.loadingBalance')} />
|
||||
<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>
|
||||
)}
|
||||
|
||||
{showManualFetchModal && !manualFetchInitialLoading && (
|
||||
<ParentBalanceForm
|
||||
parentName={members.find((m) => m.uuid === manualFetchTargetUuid)?.name ?? t('calendar.you')}
|
||||
isSelf={manualFetchTargetUuid === personalDataUuid}
|
||||
totalDays={members.length >= 2 ? 240 : 480}
|
||||
initial={manualFetchInitial}
|
||||
onSave={handleSaveManualFetch}
|
||||
onClose={() => {
|
||||
setShowManualFetchModal(false)
|
||||
setManualFetchTargetUuid(null)
|
||||
}}
|
||||
isSaving={isManualFetchSaving}
|
||||
error={null}
|
||||
/>
|
||||
)}
|
||||
</details>
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user