Group settings into four panels and give balances one home

Settings becomes Barn & familj, Saldon, Kalender & språk and
Undantag & röda dagar, each saving on its own. A BalanceFlow
component owns fetching from Försäkringskassan and manual entry for
every parent, used by the Saldon panel and by the onboarding wizard,
which now runs without the app chrome. The red-days list and the
day-override form merge into one exceptions view, the vacation table
becomes wrapping rows that fit a phone, and the BankID modal speaks
translated status lines instead of emoji.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XBRLomdLK9jF7wU7a8AA6H
This commit is contained in:
2026-09-20 06:30:46 +02:00
parent 80b4f1cb52
commit b3fe2ac645
26 changed files with 2297 additions and 1142 deletions
+109 -196
View File
@@ -9,22 +9,17 @@ 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 { PageSkeleton } from '@/components/common/PageSkeleton'
import { ChildBirthdayInput } from '@/components/inputs/ChildBirthdayInput'
import { ParentProfileInput, type SalaryType, type EmploymentStatus } from '@/components/inputs/ParentProfileInput'
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'
import { CalendarExceptions } from '@/components/calendar/CalendarExceptions'
import { BalanceFlow } from '@/components/balance/BalanceFlow'
import './settings.css'
const RED_DAYS_WINDOW_YEARS = 4
@@ -137,23 +132,23 @@ export function Settings(): ReactNode {
const [preschoolPlanningDays, setPreschoolPlanningDays] = useState(6)
const [parentData, setParentData] = useState<{ salaryType?: SalaryType; employmentStatus?: EmploymentStatus }>({})
const [saveMessage, setSaveMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
const [isSaving, setIsSaving] = useState(false)
// Each panel owns exactly one `isSaving` flag, scoped to its own actions —
// previously a single page-wide `isSaving` meant saving a vacation year
// (Saldon panel) also disabled the "Spara" button in the family panel,
// and vice versa.
const [isSavingFamily, setIsSavingFamily] = useState(false)
const [isSavingVacation, setIsSavingVacation] = useState(false)
const [isSavingRedDayPref, setIsSavingRedDayPref] = useState(false)
const [newPartnerName, setNewPartnerName] = useState('')
const [renamingUuid, setRenamingUuid] = useState<string | null>(null)
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)
const [isSavingRedDayPref, setIsSavingRedDayPref] = useState(false)
// Settings isn't scoped to a single child the way Onboarding/Dashboard are
// — mirrors the fallback `handleSaveBalance` below uses when saving.
// — the balance flow needs somewhere to attach a saved balance even before
// a child is chosen/created elsewhere in the app.
const balanceChildId = selectedChildId ?? children[0]?.id ?? null
const { initial: balanceInitial, isLoading: balanceInitialLoading } = useLatestBalance(
balanceFormUuid,
balanceChildId,
)
const isLoading = wsLoading || vacLoading || childDetailsLoading || dayOverridesLoading
@@ -164,6 +159,15 @@ export function Settings(): ReactNode {
}
}, [childDetails])
// `#saldon` in the URL scrolls that panel into view once the page's
// content has actually mounted.
useEffect(() => {
if (isLoading) return
if (window.location.hash === '#saldon') {
document.getElementById('saldon')?.scrollIntoView()
}
}, [isLoading])
const selectedChild = children.find((c) => c.id === selectedChildId)
const hasPartner = members.some((m) => m.uuid !== personalDataUuid)
@@ -206,20 +210,29 @@ export function Settings(): ReactNode {
setTimeout(() => setSaveMessage(null), 3000)
}
const handleSaveChild = async () => {
// One combined save for the "Barn & familj" panel's editable fields (child
// birth date/preschool days, own salary/employment) — one button, one
// `isSavingFamily` flag, matching the rest of that panel's actions below.
const handleSaveFamily = async () => {
try {
setIsSavingFamily(true)
if (selectedChildId && childBirthDate) {
setIsSaving(true)
await updateChildDetails({
birthDate: childBirthDate,
preschoolPlanningDaysPerYear: preschoolPlanningDays,
})
showMessage('success', t('settings.saveChildSuccess'))
}
if (personalDataUuid) {
await api.updateParentSettings(personalDataUuid, {
salary_type: parentData.salaryType,
employment_status: parentData.employmentStatus,
})
}
showMessage('success', t('settings.saveFamilySuccess'))
} catch (err) {
showMessage('error', err instanceof Error ? err.message : t('settings.saveChildError'))
showMessage('error', err instanceof Error ? err.message : t('settings.saveFamilyError'))
} finally {
setIsSaving(false)
setIsSavingFamily(false)
}
}
@@ -243,27 +256,10 @@ export function Settings(): ReactNode {
})
}
const handleSaveParentProfile = async () => {
try {
if (personalDataUuid) {
setIsSaving(true)
await api.updateParentSettings(personalDataUuid, {
salary_type: parentData.salaryType,
employment_status: parentData.employmentStatus,
})
showMessage('success', t('settings.saveProfileSuccess'))
}
} catch (err) {
showMessage('error', err instanceof Error ? err.message : t('settings.saveProfileError'))
} finally {
setIsSaving(false)
}
}
const handleSaveVacationYear = async (year: number, totalDays: number, savedDaysIn: number) => {
try {
if (workspace?.id && personalDataUuid) {
setIsSaving(true)
setIsSavingVacation(true)
await upsertVacationDays({
personalDataUuid,
workspaceId: workspace.id,
@@ -276,7 +272,7 @@ export function Settings(): ReactNode {
} catch (err) {
showMessage('error', err instanceof Error ? err.message : t('settings.saveVacationError'))
} finally {
setIsSaving(false)
setIsSavingVacation(false)
}
}
@@ -295,7 +291,7 @@ export function Settings(): ReactNode {
}
try {
setIsSaving(true)
setIsSavingFamily(true)
setSaveMessage(null)
await api.addPartner(workspace.id, newPartnerName)
await refreshMembers()
@@ -304,7 +300,7 @@ export function Settings(): ReactNode {
} catch (err) {
showMessage('error', describePartnerError(err, t, t('settings.addPartnerError')))
} finally {
setIsSaving(false)
setIsSavingFamily(false)
}
}
@@ -326,7 +322,7 @@ export function Settings(): ReactNode {
return
}
try {
setIsSaving(true)
setIsSavingFamily(true)
setMemberActionError(null)
await api.renameWorkspaceParent(workspace.id, uuid, renameValue)
await refreshMembers()
@@ -338,7 +334,7 @@ export function Settings(): ReactNode {
} catch (err) {
setMemberActionError({ uuid, text: describePartnerError(err, t, t('settings.renamePartnerError')) })
} finally {
setIsSaving(false)
setIsSavingFamily(false)
}
}
@@ -355,7 +351,7 @@ export function Settings(): ReactNode {
const handleConfirmRemove = async (uuid: string) => {
if (!workspace?.id) return
try {
setIsSaving(true)
setIsSavingFamily(true)
setMemberActionError(null)
await api.removeWorkspaceParent(workspace.id, uuid)
setRemoveConfirmUuid(null)
@@ -363,7 +359,7 @@ export function Settings(): ReactNode {
} catch (err) {
setMemberActionError({ uuid, text: describePartnerError(err, t, t('settings.removePartnerError')) })
} finally {
setIsSaving(false)
setIsSavingFamily(false)
}
}
@@ -380,44 +376,6 @@ 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">
@@ -433,10 +391,10 @@ export function Settings(): ReactNode {
</div>
)}
<section className="settings-page__section">
<h2>{t('settings.childSectionTitle')}</h2>
<section className="settings-panel">
<h2>{t('settings.familySectionTitle')}</h2>
{selectedChild ? (
<>
<div className="settings-panel__block">
<p>
{t('settings.selectedChildLabel')} <strong>{selectedChild.name}</strong>
</p>
@@ -445,8 +403,8 @@ export function Settings(): ReactNode {
onBirthDateChange={setChildBirthDate}
label={t('settings.childBirthDateLabel')}
/>
<div className="settings-page__field">
<p className="settings-page__help">{t('settings.preschoolHelp')}</p>
<div className="field settings-panel__field">
<p className="settings-panel__help">{t('settings.preschoolHelp')}</p>
<VacationDaysInput
label={t('settings.preschoolPlanningDaysLabel')}
value={preschoolPlanningDays}
@@ -455,43 +413,34 @@ export function Settings(): ReactNode {
max={30}
/>
</div>
<button
type="button"
onClick={handleSaveChild}
className="btn btn--primary"
disabled={isSaving}
data-testid="save-child-button"
>
{isSaving ? t('common.saving') : t('settings.saveChildButton')}
</button>
</>
</div>
) : (
<p>{t('settings.noChildSelected')}</p>
)}
</section>
<section className="settings-page__section">
<h2>{t('settings.profileSectionTitle')}</h2>
<p className="settings-page__help">{t('settings.profileHelp')}</p>
<h3>{t('settings.profileSectionTitle')}</h3>
<p className="settings-panel__help">{t('settings.profileHelp')}</p>
<ParentProfileInput parent={parentData} onChange={handleParentChange} />
<button
onClick={handleSaveParentProfile}
className="btn btn--primary"
disabled={isSaving}
data-testid="save-parent-button"
>
{isSaving ? t('common.saving') : t('settings.saveProfileButton')}
</button>
</section>
<section className="settings-page__section">
<h2>{t('settings.partnerSectionTitle')}</h2>
<div className="actions settings-panel__actions">
<button
type="button"
onClick={() => void handleSaveFamily()}
className="btn btn--primary"
disabled={isSavingFamily}
data-testid="save-family-button"
>
{isSavingFamily ? t('common.saving') : t('settings.saveFamilyButton')}
</button>
</div>
<h3>{t('settings.partnerSectionTitle')}</h3>
<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">
<ul className="settings-panel__member-list" data-testid="member-list">
{members.map((member) => {
const isSelf = member.uuid === personalDataUuid
const isRenaming = renamingUuid === member.uuid
@@ -499,7 +448,7 @@ export function Settings(): ReactNode {
return (
<li
key={member.uuid}
className="settings-page__member-row"
className="settings-panel__member-row"
data-testid={`member-row-${member.uuid}`}
>
{isRenaming ? (
@@ -508,14 +457,14 @@ export function Settings(): ReactNode {
type="text"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
disabled={isSaving}
className="field__control settings-page__partner-input"
disabled={isSavingFamily}
className="field__control settings-panel__partner-input"
data-testid={`rename-input-${member.uuid}`}
/>
<button
onClick={() => handleSaveRename(member.uuid)}
className="btn btn--secondary btn--sm"
disabled={isSaving}
disabled={isSavingFamily}
data-testid={`rename-save-${member.uuid}`}
>
{t('settings.saveRenameButton')}
@@ -523,7 +472,7 @@ export function Settings(): ReactNode {
<button
onClick={handleCancelRename}
className="btn btn--secondary btn--sm"
disabled={isSaving}
disabled={isSavingFamily}
data-testid={`rename-cancel-${member.uuid}`}
>
{t('settings.cancelButton')}
@@ -540,27 +489,19 @@ export function Settings(): ReactNode {
<button
onClick={() => handleStartRename(member)}
className="btn btn--secondary btn--sm"
disabled={isSaving}
disabled={isSavingFamily}
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">
<span className="settings-panel__remove-confirm">
{t('settings.removeConfirmPrompt', { name: member.name })}
<button
onClick={() => handleConfirmRemove(member.uuid)}
className="btn btn--danger btn--sm"
disabled={isSaving}
disabled={isSavingFamily}
data-testid={`remove-confirm-${member.uuid}`}
>
{t('settings.removeConfirmButton')}
@@ -568,7 +509,7 @@ export function Settings(): ReactNode {
<button
onClick={handleCancelRemove}
className="btn btn--secondary btn--sm"
disabled={isSaving}
disabled={isSavingFamily}
data-testid={`remove-cancel-${member.uuid}`}
>
{t('settings.cancelButton')}
@@ -578,7 +519,7 @@ export function Settings(): ReactNode {
<button
onClick={() => handleRequestRemove(member.uuid)}
className="btn btn--danger btn--sm"
disabled={isSaving}
disabled={isSavingFamily}
data-testid={`remove-button-${member.uuid}`}
>
{t('settings.removePartnerButton')}
@@ -602,36 +543,49 @@ export function Settings(): ReactNode {
)}
{!hasPartner && (
<div className="settings-page__partner-row">
<div className="settings-panel__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"
disabled={isSavingFamily}
className="field__control settings-panel__partner-input"
/>
<button
onClick={handleAddPartner}
onClick={() => void handleAddPartner()}
className="btn btn--primary"
disabled={isSaving}
disabled={isSavingFamily}
data-testid="add-partner-button"
>
{isSaving ? t('settings.addingPartner') : t('settings.addPartnerButton')}
{isSavingFamily ? t('settings.addingPartner') : t('settings.addPartnerButton')}
</button>
</div>
)}
</section>
<section className="settings-page__section">
<h2>{t('settings.vacationSectionTitle')}</h2>
<p className="settings-page__help">{t('settings.vacationHelp')}</p>
<VacationDaysTable rows={vacationRows} onSave={handleSaveVacationYear} isSaving={isSaving} />
<section id="saldon" className="settings-panel">
<h2>{t('settings.balanceSectionTitle')}</h2>
{membersLoading ? (
<p>{t('settings.membersLoading')}</p>
) : (
<BalanceFlow
members={members}
personalDataUuid={personalDataUuid}
workspaceId={workspace?.id ?? ''}
selectedChildId={balanceChildId}
mode="panel"
onSaved={() => void refreshMembers()}
/>
)}
<p className="settings-panel__help">{t('settings.vacationHelp')}</p>
<VacationDaysTable rows={vacationRows} onSave={handleSaveVacationYear} isSaving={isSavingVacation} />
</section>
<section className="settings-page__section">
<h2>{t('settings.calendarSectionTitle')}</h2>
<label className="settings-page__checkbox-label">
<section className="settings-panel">
<h2>{t('settings.calendarLanguageSectionTitle')}</h2>
<label className="settings-panel__checkbox-label">
<input
type="checkbox"
checked={showWeekNumbers}
@@ -640,27 +594,24 @@ export function Settings(): ReactNode {
/>
<span>{t('settings.showWeekNumbers')}</span>
</label>
<label className="settings-page__checkbox-label">
<label className="settings-panel__checkbox-label">
<input
type="checkbox"
checked={workspace?.takePlOnRedDays ?? false}
onChange={(e) => handleToggleTakePlOnRedDays(e.target.checked)}
onChange={(e) => void handleToggleTakePlOnRedDays(e.target.checked)}
disabled={isSavingRedDayPref}
data-testid="take-pl-on-red-days-checkbox"
/>
<span>{t('settings.takePlOnRedDays')}</span>
</label>
<p className="settings-page__help">{t('settings.takePlOnRedDaysHelp')}</p>
</section>
<p className="settings-panel__help">{t('settings.takePlOnRedDaysHelp')}</p>
<section className="settings-page__section">
<h2>{t('settings.languageSectionTitle')}</h2>
<label htmlFor="language-select" className="settings-page__label">
<label htmlFor="language-select" className="settings-panel__label">
{t('settings.languageLabel')}
</label>
<select
id="language-select"
className="field__control settings-page__select"
className="field__control settings-panel__select"
value={explicitLocale ?? 'auto'}
onChange={(e) => handleLanguageChange(e.target.value)}
data-testid="language-select"
@@ -671,22 +622,9 @@ export function Settings(): ReactNode {
</select>
</section>
<section className="settings-page__section">
<h2>{t('settings.dayOverridesSectionTitle')}</h2>
<p className="settings-page__help">{t('settings.dayOverridesHelp')}</p>
<DayOverridesInput
overrides={dayOverrides}
isLoading={dayOverridesLoading || isSaving}
onAdd={upsertDayOverride}
onRemove={removeDayOverride}
workspaceId={workspace?.id ?? ''}
/>
</section>
<section className="settings-page__section">
<h2>{t('settings.redDaysSectionTitle')}</h2>
<p className="settings-page__help">{t('settings.redDaysHelp')}</p>
<RedDaysList
<section className="settings-panel">
<h2>{t('settings.exceptionsSectionTitle')}</h2>
<CalendarExceptions
days={calendarWindow?.days ?? []}
startYear={redDaysStartYear}
years={RED_DAYS_WINDOW_YEARS}
@@ -703,31 +641,6 @@ export function Settings(): ReactNode {
onRemoveOverride={removeDayOverride}
/>
</section>
{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}
/>
)
})()}
</div>
</Layout>
)