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>
This commit is contained in:
2026-09-19 12:27:29 +02:00
parent e4719cb9ae
commit ae6db7e16f
105 changed files with 5683 additions and 3690 deletions
+108 -31
View File
@@ -9,10 +9,15 @@ import {
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'
import { nameResolver } from '@/utils/parentNames'
import { isSelf, orderSelfFirst } from '@/utils/members'
import { useTranslation } from '@/i18n'
import { Layout } from '@/components/layout/Layout'
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
@@ -25,7 +30,7 @@ import { PlanFold } from '@/components/dashboard/PlanFold'
import { ParentalLeaveBreakdown } from '@/components/dashboard/ParentalLeaveBreakdown'
import { TipCard } from '@/components/inputs/TipCard'
import { QRCodeModal } from '@/components/modals/QRCodeModal'
import { ManualFetchModal } from '@/components/modals/ManualFetchModal'
import { ParentBalanceForm } from '@/components/modals/ParentBalanceForm'
import { Onboarding } from '@/pages/Onboarding'
/** Fallback horizon end when the analytics projection isn't available yet
@@ -52,9 +57,19 @@ export function Dashboard(): ReactNode {
const scraper = useScraperFetch()
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)
const [manualFetchError, setManualFetchError] = useState<string | null>(null)
// 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)
// `useWorkspaceMembers` fetches once per `workspace.id`, which never
// changes across onboarding — so a couple's partner, added mid-wizard via
@@ -85,43 +100,65 @@ export function Dashboard(): ReactNode {
}
const handleSaveManualFetch = async (data: {
totalDays: number
daysUsed: number
reservedDaysLeft: number
transferableDaysLeft: number
sjukpenningLeft: number
lagstaNivaLeft: number
dubbeldagarLeft: number
sjukpenningUsed: number
lagstaNivaUsed: number
reservedDaysLeft: number | null
}) => {
if (!workspace?.id || !selectedChildId || !personalDataUuid) return
// `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)
setManualFetchError(null)
await api.saveManualFetch({
personalDataUuid,
personalDataUuid: targetUuid,
workspaceId: workspace.id,
childId: selectedChildId,
totalDays: data.totalDays,
daysUsed: data.daysUsed,
reservedDaysLeft: data.reservedDaysLeft,
transferableDaysLeft: data.transferableDaysLeft,
sjukpenningLeft: data.sjukpenningLeft,
lagstaNivaLeft: data.lagstaNivaLeft,
dubbeldagarLeft: data.dubbeldagarLeft,
sjukpenningUsed: data.sjukpenningUsed,
lagstaNivaUsed: data.lagstaNivaUsed,
reservedDaysLeft: data.reservedDaysLeft,
source: 'manual',
})
await refreshParentalLeave()
await refreshAnalytics()
setShowManualFetchModal(false)
} catch (err) {
setManualFetchError(err instanceof Error ? err.message : t('dashboard.saveManualFetchError'))
// 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)
@@ -173,6 +210,11 @@ export function Dashboard(): ReactNode {
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 — 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.
isSummary: showPerParentExpiring,
},
]
: []
@@ -202,7 +244,7 @@ export function Dashboard(): ReactNode {
<Layout>
<div data-testid="dashboard" className="dashboard">
{analyticsError && (
<div className="dashboard__error" data-testid="error-banner" role="alert">
<div className="alert alert--error" data-testid="error-banner" role="alert">
{analyticsError}
</div>
)}
@@ -219,7 +261,7 @@ export function Dashboard(): ReactNode {
{!parentalLeave && (
<p
className="dashboard__estimate-badge"
className="badge badge--warning dashboard__estimate-badge"
data-testid="dashboard-estimate-badge"
title={t('dashboard.estimatedBadgeCaption')}
>
@@ -230,18 +272,39 @@ export function Dashboard(): ReactNode {
{age4Bars.length > 0 && <Age4Bars bars={age4Bars} />}
<section className="dashboard__section dashboard__fetch-actions">
<button onClick={handleFetchFK} disabled={!selectedChildId}>
<button type="button" className="btn btn--primary" onClick={handleFetchFK} disabled={!selectedChildId}>
{t('dashboard.fetchFromFk')}
</button>
<button
onClick={() => setShowManualFetchModal(true)}
type="button"
onClick={handleOpenManualFetch}
disabled={!selectedChildId}
className="button--secondary"
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>
)}
{selectedChild && (
<YearTimeline
plannedDays={plannedDays ?? []}
@@ -296,13 +359,27 @@ export function Dashboard(): ReactNode {
onCancel={scraper.cancel}
/>
<ManualFetchModal
isOpen={showManualFetchModal}
onSave={handleSaveManualFetch}
onClose={() => setShowManualFetchModal(false)}
isSaving={isManualFetchSaving}
error={manualFetchError}
/>
{showManualFetchModal && manualFetchInitialLoading && (
<div className="modal__overlay" data-testid="dashboard-balance-loading">
<LoadingSpinner message={t('parentBalance.loadingBalance')} />
</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}
/>
)}
</div>
</Layout>
)