Files
F-r-ldraledigplannerare/frontend/src/pages/Settings.tsx
T
jacobh2 d5596afe10 Share one modal shell and inline confirm across the app
Modal owns the overlay, header, close button and footer for the balance,
planned-day and QR modals, and closes on Escape (held off while the QR
fetch runs). InlineConfirm replaces the hand-built delete and
remove-member confirmations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 22:14:05 +02:00

957 lines
39 KiB
TypeScript

import { ReactNode, useEffect, useMemo, useRef, useState } from 'react'
import { useWorkspace, usePlanSyncContextOptional } from '@/context'
import {
useVacationDays,
useShowWeekNumbers,
useChildSettings,
useDayOverrides,
useCalendarDays,
useWorkspaceMembers,
usePreschoolPlanningDays,
useDebouncedCallback,
useAnalytics,
} from '@/hooks'
import * as api from '@/services/api'
import { isApiError } from '@/services/errors'
import { useTranslation, type Locale, type TranslationKey } from '@/i18n'
import { Layout } from '@/components/layout/Layout'
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 { CalendarExceptions } from '@/components/calendar/CalendarExceptions'
import { BalanceFlow } from '@/components/balance/BalanceFlow'
import { PartnerInvitePanel } from '@/components/settings/PartnerInvitePanel'
import { Alert } from '@/components/common/Alert'
import { InlineConfirm } from '@/components/common/InlineConfirm'
import { orderSelfFirst } from '@/utils/members'
import type { VacationDays } from '@/types'
import './settings.css'
const RED_DAYS_WINDOW_YEARS = 4
const DEFAULT_VACATION_DAYS = 25
function isSalaryType(value: string): value is SalaryType {
return value === 'monthly' || value === 'hourly'
}
function isEmploymentStatus(value: string): value is EmploymentStatus {
return value === 'employed' || value === 'jobseeking' || value === 'none'
}
// Backend `message_key`s for the parent add/rename/remove endpoints (see
// workspace_service.py) mapped to real translation keys — same idiom as
// `PlanResults.tsx`'s `WARNING_BODY_KEYS` etc. A key not present here is
// intentionally never shown raw to the user (§ "Render the refusal cases
// properly") — callers fall back to a generic message instead.
const PARTNER_ERROR_KEYS: Record<string, TranslationKey> = {
'workspace.notFound': 'settings.partnerActionErrorNotFound',
'workspace.parents.notFound': 'settings.partnerActionErrorNotFound',
'workspace.parents.invalidName': 'settings.partnerNameInvalid',
'workspace.parents.remove.self': 'settings.removePartnerErrorSelf',
'workspace.parents.remove.hasLogin': 'settings.removePartnerErrorHasLogin',
'workspace.parents.add.maxReached': 'settings.partnerActionErrorMaxReached',
}
// `hasDependentData`'s `data.blockers` is a subset of these four kinds —
// each gets its own i18n label so the refusal message can name what's
// actually blocking removal instead of a generic "can't remove".
const BLOCKER_KEYS: Record<string, TranslationKey> = {
leave_wishes: 'settings.removeBlockerLeaveWishes',
planned_leave: 'settings.removeBlockerPlannedLeave',
parental_leave: 'settings.removeBlockerParentalLeave',
vacation_days: 'settings.removeBlockerVacationDays',
}
function isBlockerList(value: unknown): value is string[] {
return Array.isArray(value) && value.every((v) => typeof v === 'string')
}
/** Turns a caught error from an add/rename/remove-partner call into a safe,
* translated message. Never surfaces a raw `message_key` — an unrecognised
* structured error falls back to `fallback`, same as a non-API error would. */
function describePartnerError(err: unknown, t: (key: TranslationKey, params?: Record<string, string | number>) => string, fallback: string): string {
if (isApiError(err)) {
const details = err.error.details as { messageKey?: string; data?: Record<string, unknown> } | undefined
const messageKey = details?.messageKey
if (messageKey === 'workspace.parents.remove.hasDependentData') {
const blockers = isBlockerList(details?.data?.blockers) ? details.data.blockers : []
const blockerLabels = blockers
.map((b) => (BLOCKER_KEYS[b] ? t(BLOCKER_KEYS[b]) : b))
.join(', ')
return t('settings.removePartnerErrorHasDependentData', { blockers: blockerLabels })
}
if (messageKey && PARTNER_ERROR_KEYS[messageKey]) {
return t(PARTNER_ERROR_KEYS[messageKey])
}
// A structured error we don't recognise — never show the raw key.
if (messageKey) return fallback
}
return err instanceof Error ? err.message : fallback
}
/**
* The child's birth year (or the current year, if earlier/no birth date)
* through — at minimum — the year the child turns 4.
*
* Starting at the birth year, not `currentYear`, lets Settings offer an
* editable vacation-days row for every past year too — see
* `AnalyticsService._vacation_year_series`'s `current_year` rule, which
* stops assuming the full statutory default for a past year with no row
* precisely so a real figure can be entered here instead.
*/
function computeVacationYearRange(birthDate: string | undefined, currentYear: number): number[] {
let startYear = currentYear
let endYear = currentYear
if (birthDate) {
const birth = new Date(birthDate)
if (!isNaN(birth.getTime())) {
startYear = Math.min(startYear, birth.getFullYear())
const fourthBirthday = new Date(birthDate)
fourthBirthday.setFullYear(fourthBirthday.getFullYear() + 4)
endYear = Math.max(endYear, fourthBirthday.getFullYear())
}
}
const years: number[] = []
for (let year = startYear; year <= endYear; year++) {
years.push(year)
}
return years
}
const PRESCHOOL_AUTOSAVE_DELAY_MS = 800
// How long the recompute button (Kalender & språk panel) waits for
// `PlanSyncProvider`'s triggered sync to land before giving up and
// reporting a failure — a sync that errors instead of resolving is only
// ever logged (see `PlanSyncProvider.tsx`), so without a ceiling here the
// button would spin forever on that path.
const RECOMPUTE_TIMEOUT_MS = 10_000
/**
* One (child, year) row letting the user record the true number of
* preschool planning days actually taken that year — the child-level
* `preschoolPlanningDaysPerYear` field above is only ever an estimate used
* as this row's default until a real number is entered (§ user complaint
* "how many did you take"). Its own component so each row can hold its own
* `useDebouncedCallback` instance, autosaving independently per field/year —
* same reasoning as `VacationYearRow` in `VacationDaysTable.tsx`.
*/
function PreschoolPlanningYearRow({
childId,
year,
defaultPlannedDays,
}: {
childId: string
year: number
defaultPlannedDays: number
}): ReactNode {
const { t } = useTranslation()
const { data, setPlanned, recordTaken } = usePreschoolPlanningDays(childId, year)
const [plannedDraft, setPlannedDraft] = useState(data?.plannedDays ?? defaultPlannedDays)
// `''` represents "not recorded" (`takenDays === null`) — deliberately
// distinct from `0` ("recorded, none taken"), so the input starts empty
// rather than defaulting to zero.
const [takenDraft, setTakenDraft] = useState(data?.takenDays === null || data?.takenDays === undefined ? '' : String(data.takenDays))
const plannedDirtyRef = useRef(false)
const takenDirtyRef = useRef(false)
useEffect(() => {
if (plannedDirtyRef.current) return
setPlannedDraft(data?.plannedDays ?? defaultPlannedDays)
}, [data?.plannedDays, defaultPlannedDays])
useEffect(() => {
if (takenDirtyRef.current) return
setTakenDraft(data?.takenDays === null || data?.takenDays === undefined ? '' : String(data.takenDays))
}, [data?.takenDays])
const debouncedSetPlanned = useDebouncedCallback((value: number) => {
void setPlanned(value)
.then(() => {
plannedDirtyRef.current = false
})
.catch(() => {
// Leave `plannedDirtyRef` set so a stale server value can't creep
// back in over the value still shown on screen.
})
}, PRESCHOOL_AUTOSAVE_DELAY_MS)
const debouncedRecordTaken = useDebouncedCallback((value: number) => {
void recordTaken(value)
.then(() => {
takenDirtyRef.current = false
})
.catch(() => {})
}, PRESCHOOL_AUTOSAVE_DELAY_MS)
return (
<div className="preschool-year-row" data-testid={`preschool-year-row-${year}`}>
<span className="preschool-year-row__year">
{year}
{data?.isDefault && (
<span className="badge badge--warning preschool-year-row__assumed-badge">
{t('vacationDaysTable.assumedBadge')}
</span>
)}
</span>
<label className="field preschool-year-row__field">
<span className="field__label">{t('settings.preschoolPlannedLabel')}</span>
<input
type="number"
inputMode="numeric"
min={0}
max={30}
value={plannedDraft}
onChange={(e) => {
const value = Number(e.target.value)
plannedDirtyRef.current = true
setPlannedDraft(value)
debouncedSetPlanned(value)
}}
data-testid={`preschool-planned-${year}`}
className="field__control preschool-year-row__input"
/>
</label>
<label className="field preschool-year-row__field">
<span className="field__label">{t('settings.preschoolTakenLabel')}</span>
<input
type="number"
inputMode="numeric"
min={0}
max={366}
placeholder={t('settings.preschoolTakenPlaceholder')}
value={takenDraft}
onChange={(e) => {
const raw = e.target.value
takenDirtyRef.current = true
setTakenDraft(raw)
if (raw === '') return
const value = Number(raw)
if (Number.isNaN(value)) return
debouncedRecordTaken(value)
}}
data-testid={`preschool-taken-${year}`}
className="field__control preschool-year-row__input"
/>
</label>
</div>
)
}
export function Settings(): ReactNode {
const { t, explicitLocale, setLocale } = useTranslation()
const { workspace, isLoading: wsLoading, selectedChildId, children, personalDataUuid, refreshContext, bumpPlanVersion } = useWorkspace()
const { data: vacDays, isLoading: vacLoading, upsert: upsertVacationDays } = useVacationDays(workspace?.id ?? '')
const {
data: members,
isLoading: membersLoading,
refresh: refreshMembers,
} = useWorkspaceMembers(workspace?.id ?? null)
const {
data: childDetails,
isLoading: childDetailsLoading,
update: updateChildDetails,
} = useChildSettings(selectedChildId)
const {
data: dayOverrides,
isLoading: dayOverridesLoading,
upsert: upsertDayOverride,
remove: removeDayOverride,
} = useDayOverrides(workspace?.id ?? '')
const [showWeekNumbers, setShowWeekNumbers] = useShowWeekNumbers()
const [redDaysStartYear, setRedDaysStartYear] = useState(() => new Date().getFullYear())
const {
data: calendarWindow,
isLoading: calendarLoading,
error: calendarError,
} = useCalendarDays(workspace?.id ?? '', redDaysStartYear, RED_DAYS_WINDOW_YEARS)
const [childBirthDate, setChildBirthDate] = useState('')
const [preschoolPlanningDays, setPreschoolPlanningDays] = useState(6)
const [parentData, setParentData] = useState<{ salaryType?: SalaryType; employmentStatus?: EmploymentStatus }>({})
const [saveMessage, setSaveMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
// 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 [isSavingRedDayPref, setIsSavingRedDayPref] = useState(false)
const [isRecomputing, setIsRecomputing] = 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)
// Optional: in the real app `Settings` is always mounted inside
// `WorkspaceProvider` (and therefore `PlanSyncProvider`), but this page's
// own test suite renders it with `@/context` auto-mocked wholesale — the
// optional variant keeps the recompute button below testable without
// requiring a real `PlanSyncProvider` in every existing test. See
// `PlanSyncProvider.tsx`.
const planSyncContext = usePlanSyncContextOptional()
const latestProposalVersion = planSyncContext?.latestProposalVersion ?? 0
// The `latestProposalVersion` the recompute button's own triggered sync
// should land past — `null` while no recompute is in flight, so a sync
// started by something else entirely (e.g. a vacation-day autosave
// elsewhere on this same page) never flips this button's spinner off.
const recomputeBaselineRef = useRef<number | null>(null)
const recomputeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Detects the recompute button's own triggered sync landing.
// `bumpPlanVersion()` (in `handleRecompute` below) doesn't itself return
// anything to await — the sync it causes runs inside `PlanSyncProvider`,
// entirely outside this component. `latestProposalVersion` moving past
// the baseline captured when the button was clicked is this component's
// only signal that *that* sync (as opposed to some unrelated one) has
// completed. A `setTimeout` fallback covers a sync that errors instead of
// resolving (`PlanSyncProvider` only logs that case, see its doc comment)
// — without it a failed sync would leave this button spinning forever.
useEffect(() => {
if (recomputeBaselineRef.current === null) return
if (latestProposalVersion <= recomputeBaselineRef.current) return
recomputeBaselineRef.current = null
if (recomputeTimeoutRef.current !== null) {
clearTimeout(recomputeTimeoutRef.current)
recomputeTimeoutRef.current = null
}
setIsRecomputing(false)
setSaveMessage({ type: 'success', text: t('settings.recomputeSuccess') })
setTimeout(() => setSaveMessage(null), 3000)
}, [latestProposalVersion, t])
// Settings isn't scoped to a single child the way Onboarding/Dashboard are
// — 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
// Supplies the vacation-days table's "at risk" column (already computed by
// the same analytics endpoint Dashboard.tsx uses — see
// `AnalyticsService._vacation_year_series`). Deliberately not folded into
// `isLoading` below: it's an annotation on the editable table, not data
// the table needs to render at all, and a new/unselected-child workspace
// (no `balanceChildId` yet) must not block the rest of the page on it.
const { data: analytics } = useAnalytics(workspace?.id ?? null, balanceChildId ?? undefined)
const isLoading = wsLoading || vacLoading || childDetailsLoading || dayOverridesLoading
useEffect(() => {
if (childDetails) {
setChildBirthDate(childDetails.birthDate)
setPreschoolPlanningDays(childDetails.preschoolPlanningDaysPerYear)
}
}, [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)
// Self first, matching `BalanceFlow`'s ordering for the same list of
// members — used to render one `VacationDaysTable` per member (§3.3).
const orderedMembers = orderSelfFirst(members, personalDataUuid)
const currentYear = new Date().getFullYear()
const vacationYears = useMemo(
() => computeVacationYearRange(selectedChild?.birth_date, currentYear),
[selectedChild?.birth_date, currentYear],
)
// Preschool planning-day rows only ever make sense current-year forward
// (§Task 1 preschool reserve is a forecast, not a historical record) —
// `vacationYears` above was widened back to the child's birth year for
// the vacation-days table only, so this block gets its own slice rather
// than sharing it.
const preschoolYears = useMemo(
() => vacationYears.filter((year) => year >= currentYear),
[vacationYears, currentYear],
)
// Keyed on (personalDataUuid, year), NOT year alone — with more than one
// workspace member, matching on year alone would silently pick up
// whichever member's row for that year happened to come first in
// `vacDays`, showing (and letting you overwrite) the wrong parent's data.
const vacationRowsByMember: VacationDaysTableRow[][] = useMemo(
() =>
orderedMembers.map((member) => {
const projection = analytics?.parentProjections.find((p) => p.personalDataUuid === member.uuid)
return vacationYears.map((year) => {
const existing = vacDays.find((v: VacationDays) => v.personalDataUuid === member.uuid && v.year === year)
// Analytics may not have loaded yet (or there's no child selected)
// — the at-risk figures then default to 0 rather than block the
// rest of this editable row on a supplementary fetch.
const vacationBalance = projection?.yearBalances.find((b) => b.year === year)?.vacation
// No row entered: fall back to the SAME figure analytics assumed
// for this year (`AnalyticsService._vacation_year_series`'s
// `earned` — statutory default for a current/future year, the
// floor for a past one) rather than re-deriving that rule here.
// Only falls back further to the statutory default when analytics
// hasn't loaded yet, matching the pre-existing no-data behaviour.
const fallbackTotalDays = vacationBalance?.earned ?? DEFAULT_VACATION_DAYS
return {
year,
totalDays: existing?.totalDays ?? fallbackTotalDays,
savedDaysIn: existing?.savedDaysIn ?? 0,
// §3.2 — no vacation_days row for this year yet: an assumed
// default is shown, not an entered value. Mirrors the backend's
// `VacationBalanceResponse.is_assumed` for the propose-time table.
isAssumed: existing === undefined,
isPast: year < currentYear,
expiring: vacationBalance?.expiring ?? 0,
expiringAge: vacationBalance?.expiringAge ?? 0,
expiringOverCap: vacationBalance?.expiringOverCap ?? 0,
}
})
}),
[orderedMembers, vacationYears, vacDays, analytics, currentYear],
)
if (isLoading) {
return (
<Layout>
<PageSkeleton rows={4} />
</Layout>
)
}
const handleLanguageChange = (value: string) => {
setLocale(value === 'auto' ? null : (value as Locale))
}
const showMessage = (type: 'success' | 'error', text: string) => {
setSaveMessage({ type, text })
setTimeout(() => setSaveMessage(null), 3000)
}
// 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) {
await updateChildDetails({
birthDate: childBirthDate,
preschoolPlanningDaysPerYear: preschoolPlanningDays,
})
}
if (personalDataUuid) {
await api.updateParentSettings(personalDataUuid, {
salary_type: parentData.salaryType,
employment_status: parentData.employmentStatus,
})
// Salary type feeds the allocator's cost calculations (sjukpenning
// vs lägstanivå tradeoffs) — re-type the rest of the plan's
// unpinned days. See `WorkspaceContext.planVersion`. (Child-details
// saves above already bump via `useChildSettings.update`.)
bumpPlanVersion()
}
showMessage('success', t('settings.saveFamilySuccess'))
} catch (err) {
showMessage('error', err instanceof Error ? err.message : t('settings.saveFamilyError'))
} finally {
setIsSavingFamily(false)
}
}
const handleParentChange = (field: string, val: string) => {
setParentData((prev) => {
const next = { ...prev }
if (field === 'salaryType') {
if (isSalaryType(val)) {
next.salaryType = val
} else {
delete next.salaryType
}
} else if (field === 'employmentStatus') {
if (isEmploymentStatus(val)) {
next.employmentStatus = val
} else {
delete next.employmentStatus
}
}
return next
})
}
// Takes the owning member's uuid explicitly (§3.3) rather than always
// saving under the logged-in user's own `personalDataUuid` — each
// `VacationDaysTable` instance below binds this to its own member.
// Deliberately doesn't catch: `VacationDaysTable`'s row calls this from
// its own autosave and shows a per-row saving/saved/error indicator from
// the settled promise, so a page-wide toast here would be redundant (and
// noisy — several rows across several members can be autosaving at once).
const handleSaveVacationYear = async (
memberUuid: string,
year: number,
totalDays: number,
savedDaysIn: number,
): Promise<void> => {
if (!workspace?.id) return
await upsertVacationDays({
personalDataUuid: memberUuid,
workspaceId: workspace.id,
year,
totalDays,
savedDaysIn,
})
}
const handleRedDaysPrevWindow = () => {
setRedDaysStartYear((y) => y - RED_DAYS_WINDOW_YEARS)
}
const handleRedDaysNextWindow = () => {
setRedDaysStartYear((y) => y + RED_DAYS_WINDOW_YEARS)
}
const handleAddPartner = async () => {
if (!newPartnerName.trim() || !workspace?.id) {
showMessage('error', t('settings.partnerNameRequired'))
return
}
try {
setIsSavingFamily(true)
setSaveMessage(null)
await api.addPartner(workspace.id, newPartnerName)
await refreshMembers()
// A new partner changes which parent days the allocator can assign
// leave to — re-type the rest of the plan's unpinned days. See
// `WorkspaceContext.planVersion`.
bumpPlanVersion()
setNewPartnerName('')
showMessage('success', t('settings.addPartnerSuccess'))
} catch (err) {
showMessage('error', describePartnerError(err, t, t('settings.addPartnerError')))
} finally {
setIsSavingFamily(false)
}
}
const handleStartRename = (member: { uuid: string; name: string }) => {
setMemberActionError(null)
setRemoveConfirmUuid(null)
setRenamingUuid(member.uuid)
setRenameValue(member.name)
}
const handleCancelRename = () => {
setRenamingUuid(null)
setRenameValue('')
}
const handleSaveRename = async (uuid: string) => {
if (!workspace?.id || !renameValue.trim()) {
setMemberActionError({ uuid, text: t('settings.partnerNameRequired') })
return
}
try {
setIsSavingFamily(true)
setMemberActionError(null)
await api.renameWorkspaceParent(workspace.id, uuid, renameValue)
await refreshMembers()
if (uuid === personalDataUuid) {
await refreshContext()
}
// No `bumpPlanVersion()` here, unlike add/remove below — a rename is
// cosmetic and doesn't change which days the allocator can assign to
// which parent, so there's nothing for a re-plan to fix.
setRenamingUuid(null)
setRenameValue('')
} catch (err) {
setMemberActionError({ uuid, text: describePartnerError(err, t, t('settings.renamePartnerError')) })
} finally {
setIsSavingFamily(false)
}
}
const handleRequestRemove = (uuid: string) => {
setMemberActionError(null)
setRenamingUuid(null)
setRemoveConfirmUuid(uuid)
}
const handleCancelRemove = () => {
setRemoveConfirmUuid(null)
}
const handleConfirmRemove = async (uuid: string) => {
if (!workspace?.id) return
try {
setIsSavingFamily(true)
setMemberActionError(null)
await api.removeWorkspaceParent(workspace.id, uuid)
setRemoveConfirmUuid(null)
await refreshMembers()
// Removing a partner changes which parent days the allocator can
// assign leave to — re-type the rest of the plan's unpinned days.
// See `WorkspaceContext.planVersion`.
bumpPlanVersion()
} catch (err) {
setMemberActionError({ uuid, text: describePartnerError(err, t, t('settings.removePartnerError')) })
} finally {
setIsSavingFamily(false)
}
}
const handleToggleTakePlOnRedDays = async (checked: boolean) => {
if (!workspace?.id) return
try {
setIsSavingRedDayPref(true)
await api.updateWorkspace(workspace.id, { takePlOnRedDays: checked })
await refreshContext()
// Whether PL may be taken on red days changes which dates the
// allocator can place leave on — re-type the rest of the plan's
// unpinned days. See `WorkspaceContext.planVersion`.
bumpPlanVersion()
} catch (err) {
showMessage('error', err instanceof Error ? err.message : t('settings.takePlOnRedDaysError'))
} finally {
setIsSavingRedDayPref(false)
}
}
// Manual recompute: bumping `planVersion` is the whole implementation —
// `PlanSyncProvider` (mounted app-wide) turns the bump into a
// `POST /api/plan/sync` itself. Calling `api.syncPlan` directly here
// instead would bypass that provider's burst-coalescing. `sync_plan`
// always writes now (displacement is informational, never a refusal —
// see `PlanChange.evicted`), so the effect above only ever reports
// success once the triggered sync's proposal version lands.
const handleRecompute = async () => {
recomputeBaselineRef.current = latestProposalVersion
setIsRecomputing(true)
setSaveMessage(null)
if (recomputeTimeoutRef.current !== null) {
clearTimeout(recomputeTimeoutRef.current)
}
recomputeTimeoutRef.current = setTimeout(() => {
recomputeBaselineRef.current = null
recomputeTimeoutRef.current = null
setIsRecomputing(false)
showMessage('error', t('settings.recomputeTimeout'))
}, RECOMPUTE_TIMEOUT_MS)
bumpPlanVersion()
}
return (
<Layout>
<div data-testid="settings-page" className="settings-page">
<h1>{t('settings.title')}</h1>
{saveMessage && (
<Alert variant={saveMessage.type} testId="save-message">
{saveMessage.text}
</Alert>
)}
<section className="settings-panel">
<h2>{t('settings.familySectionTitle')}</h2>
{selectedChild ? (
<div className="settings-panel__block">
<p>
{t('settings.selectedChildLabel')} <strong>{selectedChild.name}</strong>
</p>
<ChildBirthdayInput
birthDate={childBirthDate}
onBirthDateChange={setChildBirthDate}
label={t('settings.childBirthDateLabel')}
/>
<div className="field settings-panel__field">
<p className="settings-panel__help">{t('settings.preschoolHelp')}</p>
<VacationDaysInput
label={t('settings.preschoolPlanningDaysLabel')}
value={preschoolPlanningDays}
onChange={setPreschoolPlanningDays}
min={0}
max={30}
/>
</div>
{selectedChildId && (
<div className="settings-panel__field">
<h4>{t('settings.preschoolPerYearTitle')}</h4>
<p className="settings-panel__help">{t('settings.preschoolPerYearHelp')}</p>
<div className="preschool-year-rows" data-testid="preschool-year-rows">
{preschoolYears.map((year) => (
<PreschoolPlanningYearRow
key={year}
childId={selectedChildId}
year={year}
defaultPlannedDays={preschoolPlanningDays}
/>
))}
</div>
</div>
)}
</div>
) : (
<p>{t('settings.noChildSelected')}</p>
)}
<h3>{t('settings.profileSectionTitle')}</h3>
<p className="settings-panel__help">{t('settings.profileHelp')}</p>
<ParentProfileInput parent={parentData} onChange={handleParentChange} />
<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>
{/* A member added via "Lägg till partner" (or waiting on an invite)
* is a placeholder with no login of their own — `partnerLinkedIntro`
* used to claim an account link that doesn't exist for that case.
* `invite.partnerPlaceholderIntro` describes what's actually true:
* the slot is reserved, an invite connects it to a real account. */}
<p>{hasPartner ? t('invite.partnerPlaceholderIntro') : t('settings.partnerIntro')}</p>
{membersLoading ? (
<p>{t('settings.membersLoading')}</p>
) : (
<ul className="settings-panel__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-panel__member-row"
data-testid={`member-row-${member.uuid}`}
>
{isRenaming ? (
<>
<input
type="text"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
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={isSavingFamily}
data-testid={`rename-save-${member.uuid}`}
>
{t('settings.saveRenameButton')}
</button>
<button
onClick={handleCancelRename}
className="btn btn--secondary btn--sm"
disabled={isSavingFamily}
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={isSavingFamily}
data-testid={`rename-button-${member.uuid}`}
>
{t('settings.renameButton')}
</button>
{!isSelf &&
(isConfirmingRemove ? (
<InlineConfirm
className="settings-panel__remove-confirm"
prompt={t('settings.removeConfirmPrompt', { name: member.name })}
confirmLabel={t('settings.removeConfirmButton')}
cancelLabel={t('settings.cancelButton')}
onConfirm={() => handleConfirmRemove(member.uuid)}
onCancel={handleCancelRemove}
isBusy={isSavingFamily}
confirmTestId={`remove-confirm-${member.uuid}`}
cancelTestId={`remove-cancel-${member.uuid}`}
confirmFirst
small
/>
) : (
<button
onClick={() => handleRequestRemove(member.uuid)}
className="btn btn--danger btn--sm"
disabled={isSavingFamily}
data-testid={`remove-button-${member.uuid}`}
>
{t('settings.removePartnerButton')}
</button>
))}
</>
)}
{memberActionError?.uuid === member.uuid && (
<Alert variant="error" testId={`member-action-error-${member.uuid}`}>
{memberActionError.text}
</Alert>
)}
</li>
)
})}
</ul>
)}
{!hasPartner && (
<div className="settings-panel__partner-row">
<input
type="text"
placeholder={t('settings.partnerNamePlaceholder')}
value={newPartnerName}
onChange={(e) => setNewPartnerName(e.target.value)}
disabled={isSavingFamily}
className="field__control settings-panel__partner-input"
/>
<button
onClick={() => void handleAddPartner()}
className="btn btn--primary"
disabled={isSavingFamily}
data-testid="add-partner-button"
>
{isSavingFamily ? t('settings.addingPartner') : t('settings.addPartnerButton')}
</button>
</div>
)}
<PartnerInvitePanel workspaceId={workspace?.id ?? ''} />
</section>
<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>
{!membersLoading &&
orderedMembers.map((member, index) => (
<div key={member.uuid} className="settings-panel__vacation-member">
{orderedMembers.length > 1 && (
<p className="settings-panel__vacation-member-name">
{member.name}
{member.uuid === personalDataUuid && (
<span className="badge badge--muted"> {t('settings.youBadge')}</span>
)}
</p>
)}
<VacationDaysTable
rows={vacationRowsByMember[index] ?? []}
onSave={(year, totalDays, savedDaysIn) =>
handleSaveVacationYear(member.uuid, year, totalDays, savedDaysIn)
}
personalDataUuid={member.uuid}
/>
</div>
))}
</section>
<section className="settings-panel">
<h2>{t('settings.calendarLanguageSectionTitle')}</h2>
<label className="settings-panel__checkbox-label">
<input
type="checkbox"
checked={showWeekNumbers}
onChange={(e) => setShowWeekNumbers(e.target.checked)}
data-testid="show-week-numbers-checkbox"
/>
<span>{t('settings.showWeekNumbers')}</span>
</label>
<label className="settings-panel__checkbox-label">
<input
type="checkbox"
checked={workspace?.takePlOnRedDays ?? false}
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-panel__help">{t('settings.takePlOnRedDaysHelp')}</p>
<label htmlFor="language-select" className="settings-panel__label">
{t('settings.languageLabel')}
</label>
<select
id="language-select"
className="field__control settings-panel__select"
value={explicitLocale ?? 'auto'}
onChange={(e) => handleLanguageChange(e.target.value)}
data-testid="language-select"
>
<option value="auto">{t('settings.languageAutoOption')}</option>
<option value="sv">{t('settings.languageSwedishOption')}</option>
<option value="en">{t('settings.languageEnglishOption')}</option>
</select>
<p className="settings-panel__help">{t('settings.recomputeHelp')}</p>
<div className="actions settings-panel__actions">
<button
type="button"
onClick={() => void handleRecompute()}
className="btn btn--secondary"
disabled={isRecomputing}
data-testid="recompute-plan-button"
>
{isRecomputing ? t('settings.recomputing') : t('settings.recomputeButton')}
</button>
</div>
</section>
<section className="settings-panel">
<h2>{t('settings.exceptionsSectionTitle')}</h2>
<CalendarExceptions
days={calendarWindow?.days ?? []}
startYear={redDaysStartYear}
years={RED_DAYS_WINDOW_YEARS}
endYear={calendarWindow?.endYear}
minYear={calendarWindow?.minYear ?? null}
maxYear={calendarWindow?.maxYear ?? null}
isLoading={calendarLoading}
error={calendarError}
onPrevWindow={handleRedDaysPrevWindow}
onNextWindow={handleRedDaysNextWindow}
overrides={dayOverrides}
workspaceId={workspace?.id ?? ''}
onUpsertOverride={upsertDayOverride}
onRemoveOverride={removeDayOverride}
/>
</section>
</div>
</Layout>
)
}