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
+217 -167
View File
@@ -9,17 +9,21 @@ import {
useWorkspaceMembers,
} from '@/hooks'
import * as api from '@/services/api'
// 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 { isApiError } from '@/services/errors'
import { useTranslation, type Locale, type TranslationKey } from '@/i18n'
import { Layout } from '@/components/layout/Layout'
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
import { ChildBirthdayInput } from '@/components/inputs/ChildBirthdayInput'
import { ParentProfileInput, type SalaryType, type EmploymentStatus } from '@/components/inputs/ParentProfileInput'
import { PlanningModeSelector, type PlanningMode } from '@/components/inputs/PlanningModeSelector'
import { VacationDaysInput } from '@/components/inputs/VacationDaysInput'
import { VacationDaysTable, type VacationDaysTableRow } from '@/components/vacation-days/VacationDaysTable'
import { DayOverridesInput } from '@/components/inputs/DayOverridesInput'
import { RedDaysList } from '@/components/calendar/RedDaysList'
import { ParentBalanceForm } from '@/components/modals/ParentBalanceForm'
const RED_DAYS_WINDOW_YEARS = 4
@@ -131,7 +135,6 @@ export function Settings(): ReactNode {
const [childBirthDate, setChildBirthDate] = useState('')
const [preschoolPlanningDays, setPreschoolPlanningDays] = useState(6)
const [parentData, setParentData] = useState<{ salaryType?: SalaryType; employmentStatus?: EmploymentStatus }>({})
const [planningMode, setPlanningMode] = useState<PlanningMode>(workspace?.planningMode ?? 'solo')
const [saveMessage, setSaveMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
const [isSaving, setIsSaving] = useState(false)
const [newPartnerName, setNewPartnerName] = useState('')
@@ -139,6 +142,16 @@ export function Settings(): ReactNode {
const [renameValue, setRenameValue] = useState('')
const [removeConfirmUuid, setRemoveConfirmUuid] = useState<string | null>(null)
const [memberActionError, setMemberActionError] = useState<{ uuid: string; text: string } | null>(null)
const [balanceFormUuid, setBalanceFormUuid] = useState<string | null>(null)
const [isSavingBalance, setIsSavingBalance] = useState(false)
// Settings isn't scoped to a single child the way Onboarding/Dashboard are
// — mirrors the fallback `handleSaveBalance` below uses when saving.
const balanceChildId = selectedChildId ?? children[0]?.id ?? null
const { initial: balanceInitial, isLoading: balanceInitialLoading } = useLatestBalance(
balanceFormUuid,
balanceChildId,
)
const isLoading = wsLoading || vacLoading || childDetailsLoading || dayOverridesLoading
@@ -149,12 +162,6 @@ export function Settings(): ReactNode {
}
}, [childDetails])
useEffect(() => {
if (workspace?.planningMode) {
setPlanningMode(workspace.planningMode)
}
}, [workspace?.planningMode])
const selectedChild = children.find((c) => c.id === selectedChildId)
const hasPartner = members.some((m) => m.uuid !== personalDataUuid)
@@ -247,21 +254,6 @@ export function Settings(): ReactNode {
}
}
const handleSavePlanning = async () => {
try {
if (workspace?.id) {
setIsSaving(true)
await api.updateWorkspace(workspace.id, { planningMode })
await refreshContext()
showMessage('success', t('settings.savePlanningSuccess'))
}
} catch (err) {
showMessage('error', err instanceof Error ? err.message : t('settings.savePlanningError'))
} finally {
setIsSaving(false)
}
}
const handleSaveVacationYear = async (year: number, totalDays: number, savedDaysIn: number) => {
try {
if (workspace?.id && personalDataUuid) {
@@ -369,6 +361,44 @@ export function Settings(): ReactNode {
}
}
const handleOpenBalanceForm = (uuid: string) => {
setBalanceFormUuid(uuid)
}
// No try/catch around the save call — a rejection propagates 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).
const handleSaveBalance = async (data: {
sjukpenningLeft: number
lagstaNivaLeft: number
dubbeldagarLeft: number
reservedDaysLeft: number | null
}) => {
// Settings isn't scoped to a single child the way Onboarding/Dashboard
// are guaranteed to be — `selectedChildId` can be unset before a child
// is chosen elsewhere in the app. Fall back to the workspace's first
// child so the balance still has somewhere to attach.
const childId = selectedChildId ?? children[0]?.id
if (!workspace?.id || !balanceFormUuid || !childId) return
try {
setIsSavingBalance(true)
await api.saveManualFetch({
personalDataUuid: balanceFormUuid,
workspaceId: workspace.id,
childId,
sjukpenningLeft: data.sjukpenningLeft,
lagstaNivaLeft: data.lagstaNivaLeft,
dubbeldagarLeft: data.dubbeldagarLeft,
reservedDaysLeft: data.reservedDaysLeft,
source: 'manual',
})
setBalanceFormUuid(null)
} finally {
setIsSavingBalance(false)
}
}
return (
<Layout>
<div data-testid="settings-page" className="settings-page">
@@ -376,7 +406,7 @@ export function Settings(): ReactNode {
{saveMessage && (
<div
className={`settings-page__message settings-page__message--${saveMessage.type}`}
className={`alert alert--${saveMessage.type}`}
data-testid="save-message"
role="alert"
>
@@ -407,8 +437,9 @@ export function Settings(): ReactNode {
/>
</div>
<button
type="button"
onClick={handleSaveChild}
className="settings-page__button"
className="btn btn--primary"
disabled={isSaving}
data-testid="save-child-button"
>
@@ -426,7 +457,7 @@ export function Settings(): ReactNode {
<ParentProfileInput parent={parentData} onChange={handleParentChange} />
<button
onClick={handleSaveParentProfile}
className="settings-page__button"
className="btn btn--primary"
disabled={isSaving}
data-testid="save-parent-button"
>
@@ -435,17 +466,142 @@ export function Settings(): ReactNode {
</section>
<section className="settings-page__section">
<h2>{t('settings.planningSectionTitle')}</h2>
<p className="settings-page__help">{t('settings.planningHelp')}</p>
<PlanningModeSelector value={planningMode} onChange={setPlanningMode} />
<button
onClick={handleSavePlanning}
className="settings-page__button"
disabled={isSaving}
data-testid="save-planning-goal-button"
>
{isSaving ? t('common.saving') : t('settings.savePlanningButton')}
</button>
<h2>{t('settings.partnerSectionTitle')}</h2>
<p>{hasPartner ? t('settings.partnerLinkedIntro') : t('settings.partnerIntro')}</p>
{membersLoading ? (
<p>{t('settings.membersLoading')}</p>
) : (
<ul className="settings-page__member-list" data-testid="member-list">
{members.map((member) => {
const isSelf = member.uuid === personalDataUuid
const isRenaming = renamingUuid === member.uuid
const isConfirmingRemove = removeConfirmUuid === member.uuid
return (
<li
key={member.uuid}
className="settings-page__member-row"
data-testid={`member-row-${member.uuid}`}
>
{isRenaming ? (
<>
<input
type="text"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
disabled={isSaving}
className="field__control settings-page__partner-input"
data-testid={`rename-input-${member.uuid}`}
/>
<button
onClick={() => handleSaveRename(member.uuid)}
className="btn btn--secondary btn--sm"
disabled={isSaving}
data-testid={`rename-save-${member.uuid}`}
>
{t('settings.saveRenameButton')}
</button>
<button
onClick={handleCancelRename}
className="btn btn--secondary btn--sm"
disabled={isSaving}
data-testid={`rename-cancel-${member.uuid}`}
>
{t('settings.cancelButton')}
</button>
</>
) : (
<>
<span data-testid={`member-name-${member.uuid}`}>
{member.name}
{isSelf && (
<span className="badge badge--muted"> {t('settings.youBadge')}</span>
)}
</span>
<button
onClick={() => handleStartRename(member)}
className="btn btn--secondary btn--sm"
disabled={isSaving}
data-testid={`rename-button-${member.uuid}`}
>
{t('settings.renameButton')}
</button>
<button
onClick={() => handleOpenBalanceForm(member.uuid)}
className="btn btn--secondary btn--sm"
disabled={isSaving}
data-testid={`settings-balance-${member.uuid}`}
>
{t('settings.balanceButton')}
</button>
{!isSelf &&
(isConfirmingRemove ? (
<span className="settings-page__remove-confirm">
{t('settings.removeConfirmPrompt', { name: member.name })}
<button
onClick={() => handleConfirmRemove(member.uuid)}
className="btn btn--danger btn--sm"
disabled={isSaving}
data-testid={`remove-confirm-${member.uuid}`}
>
{t('settings.removeConfirmButton')}
</button>
<button
onClick={handleCancelRemove}
className="btn btn--secondary btn--sm"
disabled={isSaving}
data-testid={`remove-cancel-${member.uuid}`}
>
{t('settings.cancelButton')}
</button>
</span>
) : (
<button
onClick={() => handleRequestRemove(member.uuid)}
className="btn btn--danger btn--sm"
disabled={isSaving}
data-testid={`remove-button-${member.uuid}`}
>
{t('settings.removePartnerButton')}
</button>
))}
</>
)}
{memberActionError?.uuid === member.uuid && (
<p
role="alert"
className="alert alert--error"
data-testid={`member-action-error-${member.uuid}`}
>
{memberActionError.text}
</p>
)}
</li>
)
})}
</ul>
)}
{!hasPartner && (
<div className="settings-page__partner-row">
<input
type="text"
placeholder={t('settings.partnerNamePlaceholder')}
value={newPartnerName}
onChange={(e) => setNewPartnerName(e.target.value)}
disabled={isSaving}
className="field__control settings-page__partner-input"
/>
<button
onClick={handleAddPartner}
className="btn btn--primary"
disabled={isSaving}
data-testid="add-partner-button"
>
{isSaving ? t('settings.addingPartner') : t('settings.addPartnerButton')}
</button>
</div>
)}
</section>
<section className="settings-page__section">
@@ -474,7 +630,7 @@ export function Settings(): ReactNode {
</label>
<select
id="language-select"
className="settings-page__select"
className="field__control settings-page__select"
value={explicitLocale ?? 'auto'}
onChange={(e) => handleLanguageChange(e.target.value)}
data-testid="language-select"
@@ -514,136 +670,30 @@ export function Settings(): ReactNode {
/>
</section>
<section className="settings-page__section">
<h2>{t('settings.partnerSectionTitle')}</h2>
<p>{hasPartner ? t('settings.partnerLinkedIntro') : t('settings.partnerIntro')}</p>
{membersLoading ? (
<p>{t('settings.membersLoading')}</p>
) : (
<ul className="settings-page__member-list" data-testid="member-list">
{members.map((member) => {
const isSelf = member.uuid === personalDataUuid
const isRenaming = renamingUuid === member.uuid
const isConfirmingRemove = removeConfirmUuid === member.uuid
return (
<li
key={member.uuid}
className="settings-page__member-row"
data-testid={`member-row-${member.uuid}`}
>
{isRenaming ? (
<>
<input
type="text"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
disabled={isSaving}
className="settings-page__partner-input"
data-testid={`rename-input-${member.uuid}`}
/>
<button
onClick={() => handleSaveRename(member.uuid)}
className="settings-page__button"
disabled={isSaving}
data-testid={`rename-save-${member.uuid}`}
>
{t('settings.saveRenameButton')}
</button>
<button
onClick={handleCancelRename}
className="settings-page__button settings-page__button--secondary"
disabled={isSaving}
data-testid={`rename-cancel-${member.uuid}`}
>
{t('settings.cancelButton')}
</button>
</>
) : (
<>
<span data-testid={`member-name-${member.uuid}`}>
{member.name}
{isSelf && (
<span className="settings-page__you-badge"> {t('settings.youBadge')}</span>
)}
</span>
<button
onClick={() => handleStartRename(member)}
className="settings-page__button settings-page__button--secondary"
disabled={isSaving}
data-testid={`rename-button-${member.uuid}`}
>
{t('settings.renameButton')}
</button>
{!isSelf &&
(isConfirmingRemove ? (
<span className="settings-page__remove-confirm">
{t('settings.removeConfirmPrompt', { name: member.name })}
<button
onClick={() => handleConfirmRemove(member.uuid)}
className="settings-page__button settings-page__button--danger"
disabled={isSaving}
data-testid={`remove-confirm-${member.uuid}`}
>
{t('settings.removeConfirmButton')}
</button>
<button
onClick={handleCancelRemove}
className="settings-page__button settings-page__button--secondary"
disabled={isSaving}
data-testid={`remove-cancel-${member.uuid}`}
>
{t('settings.cancelButton')}
</button>
</span>
) : (
<button
onClick={() => handleRequestRemove(member.uuid)}
className="settings-page__button settings-page__button--danger"
disabled={isSaving}
data-testid={`remove-button-${member.uuid}`}
>
{t('settings.removePartnerButton')}
</button>
))}
</>
)}
{memberActionError?.uuid === member.uuid && (
<p
role="alert"
className="settings-page__message settings-page__message--error"
data-testid={`member-action-error-${member.uuid}`}
>
{memberActionError.text}
</p>
)}
</li>
)
})}
</ul>
)}
{!hasPartner && (
<div className="settings-page__partner-row">
<input
type="text"
placeholder={t('settings.partnerNamePlaceholder')}
value={newPartnerName}
onChange={(e) => setNewPartnerName(e.target.value)}
disabled={isSaving}
className="settings-page__partner-input"
{balanceFormUuid &&
(() => {
const balanceFormMember = members.find((m) => m.uuid === balanceFormUuid)
if (!balanceFormMember) return null
if (balanceInitialLoading) {
return (
<div className="modal__overlay" data-testid="settings-balance-loading">
<LoadingSpinner message={t('parentBalance.loadingBalance')} />
</div>
)
}
return (
<ParentBalanceForm
parentName={balanceFormMember.name}
isSelf={balanceFormMember.uuid === personalDataUuid}
totalDays={members.length >= 2 ? 240 : 480}
initial={balanceInitial}
onSave={handleSaveBalance}
onClose={() => setBalanceFormUuid(null)}
isSaving={isSavingBalance}
error={null}
/>
<button
onClick={handleAddPartner}
className="settings-page__button"
disabled={isSaving}
data-testid="add-partner-button"
>
{isSaving ? t('settings.addingPartner') : t('settings.addPartnerButton')}
</button>
</div>
)}
</section>
)
})()}
</div>
</Layout>
)