diff --git a/frontend/src/components/balance/BalanceFlow.test.tsx b/frontend/src/components/balance/BalanceFlow.test.tsx index 1fce4e9..2607e34 100644 --- a/frontend/src/components/balance/BalanceFlow.test.tsx +++ b/frontend/src/components/balance/BalanceFlow.test.tsx @@ -281,7 +281,7 @@ describe('BalanceFlow', () => { />, ) expect(await screen.findAllByTestId('onboarding-parent-card')).toHaveLength(2) - expect(getCard('pd-1')).toHaveTextContent('(du)') + expect(getCard('pd-1')).toHaveTextContent('(Du)') }) it('shows the deduced-partner prompt for a solo family once the fetch completes', async () => { diff --git a/frontend/src/components/balance/BalanceFlow.tsx b/frontend/src/components/balance/BalanceFlow.tsx index b678621..f89eb8a 100644 --- a/frontend/src/components/balance/BalanceFlow.tsx +++ b/frontend/src/components/balance/BalanceFlow.tsx @@ -148,7 +148,7 @@ function BalanceRow({
{member.name} - {isSelfRow && {t('balance.youBadge')}} + {isSelfRow && {t('settings.youBadge')}} {deduction && {t('balance.deducedBadge')}}
diff --git a/frontend/src/components/balance/balance-flow.css b/frontend/src/components/balance/balance-flow.css index 03ede0e..9ee121d 100644 --- a/frontend/src/components/balance/balance-flow.css +++ b/frontend/src/components/balance/balance-flow.css @@ -12,6 +12,10 @@ border-bottom: 1px solid var(--c-border); } +.balance-flow__row:last-child { + border-bottom: none; +} + .balance-flow__row:first-child { padding-top: 0; } diff --git a/frontend/src/components/common/InlineConfirm.tsx b/frontend/src/components/common/InlineConfirm.tsx index 31e1567..0709788 100644 --- a/frontend/src/components/common/InlineConfirm.tsx +++ b/frontend/src/components/common/InlineConfirm.tsx @@ -10,7 +10,7 @@ export interface InlineConfirmProps { confirmTestId?: string cancelTestId?: string /** Class for the outer wrapper — the two callers use different elements - * for this (`.modal__delete-confirm`, `.settings-panel__remove-confirm`), + * for this (`.modal__delete-confirm`, `.settings-card__remove-confirm`), * so the wrapper's look stays theirs rather than being unified here. */ className?: string /** Class for the span wrapping `prompt` — only `PlannedDayModal` needs diff --git a/frontend/src/components/layout/layout.css b/frontend/src/components/layout/layout.css index ac43d22..aba8fbb 100644 --- a/frontend/src/components/layout/layout.css +++ b/frontend/src/components/layout/layout.css @@ -31,7 +31,6 @@ * hairline — never a stack of bordered cards. Every section looking equally * "boxed" flattens the hierarchy the headline figures are meant to have. */ .dashboard__section, -.settings-page__section, .calendar__section, .tips-page__section { background-color: transparent; @@ -44,7 +43,6 @@ } .dashboard__section:first-child, -.settings-page__section:first-child, .calendar__section:first-child, .tips-page__section:first-child { border-top: none; @@ -52,14 +50,12 @@ } .dashboard__section:last-child, -.settings-page__section:last-child, .calendar__section:last-child, .tips-page__section:last-child { margin-bottom: 0; } .dashboard__section h2, -.settings-page__section h2, .calendar__section h2, .tips-page__section h2 { margin-top: 0; diff --git a/frontend/src/components/settings/BalancesSection.tsx b/frontend/src/components/settings/BalancesSection.tsx new file mode 100644 index 0000000..0cc1925 --- /dev/null +++ b/frontend/src/components/settings/BalancesSection.tsx @@ -0,0 +1,166 @@ +import { ReactNode, useMemo } from 'react' +import { useWorkspace } from '@/context' +import { useAnalytics, useVacationDays, useWorkspaceMembers } from '@/hooks' +import { useTranslation } from '@/i18n' +import { PageSkeleton } from '@/components/common/PageSkeleton' +import { BalanceFlow } from '@/components/balance/BalanceFlow' +import { VacationDaysTable, type VacationDaysTableRow } from '@/components/vacation-days/VacationDaysTable' +import { orderSelfFirst } from '@/utils/members' +import type { VacationDays } from '@/types' +import { computeVacationYearRange } from './settingsShared' + +const DEFAULT_VACATION_DAYS = 25 + +/** + * Settings → Saldon (`#saldon`, linked from the Dashboard): the FK + * parental-leave balances (`BalanceFlow`) and a vacation-days card with one + * `VacationDaysTable` per workspace member, self first. + */ +export function BalancesSection(): ReactNode { + const { t } = useTranslation() + const { workspace, selectedChildId, children, personalDataUuid } = useWorkspace() + const { data: vacDays, isLoading: vacLoading, upsert: upsertVacationDays } = useVacationDays(workspace?.id ?? '') + const { + data: members, + isLoading: membersLoading, + refresh: refreshMembers, + } = useWorkspaceMembers(workspace?.id ?? null) + + // 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 a loading + // gate: 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 section on it. + const { data: analytics } = useAnalytics(workspace?.id ?? null, balanceChildId ?? undefined) + + const selectedChild = children.find((c) => c.id === selectedChildId) + // Self first, matching `BalanceFlow`'s ordering for the same list of + // members — used to render one `VacationDaysTable` per member (§3.3). + const orderedMembers = useMemo(() => orderSelfFirst(members, personalDataUuid), [members, personalDataUuid]) + + const currentYear = new Date().getFullYear() + const vacationYears = useMemo( + () => computeVacationYearRange(selectedChild?.birth_date, currentYear), + [selectedChild?.birth_date, 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 (vacLoading) { + return + } + + // 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 => { + if (!workspace?.id) return + await upsertVacationDays({ + personalDataUuid: memberUuid, + workspaceId: workspace.id, + year, + totalDays, + savedDaysIn, + }) + } + + return ( + <> +
+

+ {t('settings.balancesCardTitle')} +

+ {membersLoading ? ( +

{t('settings.membersLoading')}

+ ) : ( + void refreshMembers()} + /> + )} +
+ +
+

+ {t('settings.vacationCardTitle')} +

+

{t('settings.vacationHelp')}

+ {!membersLoading && + orderedMembers.map((member, index) => ( +
+ {orderedMembers.length > 1 && ( +

+ {member.name} + {member.uuid === personalDataUuid && ( + {t('settings.youBadge')} + )} +

+ )} + + handleSaveVacationYear(member.uuid, year, totalDays, savedDaysIn) + } + personalDataUuid={member.uuid} + /> +
+ ))} +
+ + ) +} diff --git a/frontend/src/components/settings/DaysOffSection.tsx b/frontend/src/components/settings/DaysOffSection.tsx new file mode 100644 index 0000000..784371e --- /dev/null +++ b/frontend/src/components/settings/DaysOffSection.tsx @@ -0,0 +1,309 @@ +import { ReactNode, useEffect, useMemo, useRef, useState } from 'react' +import { useWorkspace } from '@/context' +import { + useCalendarDays, + useChildSettings, + useDayOverrides, + useDebouncedCallback, + usePreschoolPlanningDays, +} from '@/hooks' +import * as api from '@/services/api' +import { useTranslation } from '@/i18n' +import { PageSkeleton } from '@/components/common/PageSkeleton' +import { VacationDaysInput } from '@/components/inputs/VacationDaysInput' +import { CalendarExceptions } from '@/components/calendar/CalendarExceptions' +import { computeVacationYearRange, type ShowSettingsMessage } from './settingsShared' + +const RED_DAYS_WINDOW_YEARS = 4 + +const PRESCHOOL_AUTOSAVE_DELAY_MS = 800 + +/** + * One (child, year) row letting the user record the true number of + * preschool planning days actually taken that year — the child-level + * `preschoolPlanningDaysPerYear` default 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(() => { + // Same as above: keep the dirty flag so the typed value stays put. + }) + }, PRESCHOOL_AUTOSAVE_DELAY_MS) + + return ( +
+ + {year} + {data?.isDefault && ( + + {t('vacationDaysTable.assumedBadge')} + + )} + + + +
+ ) +} + +export interface DaysOffSectionProps { + showMessage: ShowSettingsMessage +} + +/** + * Settings → Lediga dagar: the preschool planning-days card (default per + * year with its own Save, plus per-year planned/taken rows), the red-days + * card ("take PL on red days" toggle) and the exceptions card + * (`CalendarExceptions`, paged four years at a time). + */ +export function DaysOffSection({ showMessage }: DaysOffSectionProps): ReactNode { + const { t } = useTranslation() + const { workspace, selectedChildId, children, refreshContext, bumpPlanVersion } = useWorkspace() + const { + data: childDetails, + isLoading: childDetailsLoading, + update: updateChildDetails, + } = useChildSettings(selectedChildId) + const { + data: dayOverrides, + isLoading: dayOverridesLoading, + upsert: upsertDayOverride, + remove: removeDayOverride, + } = useDayOverrides(workspace?.id ?? '') + const [redDaysStartYear, setRedDaysStartYear] = useState(() => new Date().getFullYear()) + const { + data: calendarWindow, + isLoading: calendarLoading, + error: calendarError, + } = useCalendarDays(workspace?.id ?? '', redDaysStartYear, RED_DAYS_WINDOW_YEARS) + + const [preschoolPlanningDays, setPreschoolPlanningDays] = useState(6) + const [isSavingPreschool, setIsSavingPreschool] = useState(false) + const [isSavingRedDayPref, setIsSavingRedDayPref] = useState(false) + + useEffect(() => { + if (childDetails) { + setPreschoolPlanningDays(childDetails.preschoolPlanningDaysPerYear) + } + }, [childDetails]) + + const selectedChild = children.find((c) => c.id === selectedChildId) + const currentYear = new Date().getFullYear() + // Preschool planning-day rows only ever make sense current-year forward + // (§Task 1 preschool reserve is a forecast, not a historical record) — + // the shared year range reaches back to the child's birth year for the + // vacation-days table only, so this card keeps the current year onward. + const preschoolYears = useMemo( + () => + computeVacationYearRange(selectedChild?.birth_date, currentYear).filter((year) => year >= currentYear), + [selectedChild?.birth_date, currentYear], + ) + + if (childDetailsLoading || dayOverridesLoading) { + return + } + + // Preschool default only — `useChildSettings.update` bumps the plan + // version itself (preschool closures feed the allocator's demand). + const handleSavePreschool = async () => { + try { + setIsSavingPreschool(true) + await updateChildDetails({ preschoolPlanningDaysPerYear: preschoolPlanningDays }) + showMessage('success', t('settings.saveSuccess')) + } catch (err) { + showMessage('error', err instanceof Error ? err.message : t('settings.saveError')) + } finally { + setIsSavingPreschool(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) + } + } + + return ( + <> +
+

+ {t('settings.preschoolCardTitle')} +

+ {selectedChildId && selectedChild ? ( + <> +

{t('settings.preschoolHelp')}

+ +
+ +
+ +

{t('settings.preschoolPerYearTitle')}

+

{t('settings.preschoolPerYearHelp')}

+
+ {preschoolYears.map((year) => ( + + ))} +
+ + ) : ( +

{t('settings.noChildSelected')}

+ )} +
+ +
+

+ {t('settings.redDaysCardTitle')} +

+ +

{t('settings.takePlOnRedDaysHelp')}

+
+ +
+

+ {t('settings.exceptionsCardTitle')} +

+ setRedDaysStartYear((y) => y - RED_DAYS_WINDOW_YEARS)} + onNextWindow={() => setRedDaysStartYear((y) => y + RED_DAYS_WINDOW_YEARS)} + overrides={dayOverrides} + workspaceId={workspace?.id ?? ''} + onUpsertOverride={upsertDayOverride} + onRemoveOverride={removeDayOverride} + /> +
+ + ) +} diff --git a/frontend/src/components/settings/FamilySection.tsx b/frontend/src/components/settings/FamilySection.tsx new file mode 100644 index 0000000..84469b4 --- /dev/null +++ b/frontend/src/components/settings/FamilySection.tsx @@ -0,0 +1,474 @@ +import { ReactNode, useEffect, useRef, useState } from 'react' +import { useWorkspace } from '@/context' +import { useChildSettings, useWorkspaceMembers } from '@/hooks' +import * as api from '@/services/api' +import { isApiError } from '@/services/errors' +import { useTranslation, type TranslationKey } from '@/i18n' +import { PageSkeleton } from '@/components/common/PageSkeleton' +import { ChildBirthdayInput } from '@/components/inputs/ChildBirthdayInput' +import { ParentProfileInput, type SalaryType, type EmploymentStatus } from '@/components/inputs/ParentProfileInput' +import { PartnerInvitePanel } from '@/components/settings/PartnerInvitePanel' +import { Alert } from '@/components/common/Alert' +import { InlineConfirm } from '@/components/common/InlineConfirm' +import type { ShowSettingsMessage } from './settingsShared' + +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 = { + '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 = { + 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, fallback: string): string { + if (isApiError(err)) { + const details = err.error.details as { messageKey?: string; data?: Record } | 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 +} + +type ProfileDraft = { salaryType?: SalaryType; employmentStatus?: EmploymentStatus } + +export interface FamilySectionProps { + showMessage: ShowSettingsMessage + clearMessage: () => void +} + +/** + * Settings → Familj: the child card (birth date, own Save), the parents card + * (rename/remove members, add a partner, invites) and the logged-in parent's + * profile card (salary type/employment status, own Save). Each card owns its + * own busy flag, so saving one never disables another. + */ +export function FamilySection({ showMessage, clearMessage }: FamilySectionProps): ReactNode { + const { t } = useTranslation() + const { workspace, selectedChildId, children, personalDataUuid, refreshContext, bumpPlanVersion } = useWorkspace() + const { + data: members, + isLoading: membersLoading, + refresh: refreshMembers, + } = useWorkspaceMembers(workspace?.id ?? null) + const { + data: childDetails, + isLoading: childDetailsLoading, + update: updateChildDetails, + } = useChildSettings(selectedChildId) + + const [childBirthDate, setChildBirthDate] = useState('') + const [profile, setProfile] = useState({}) + // Once the user touches the profile form, a later members refetch (e.g. + // after a rename) must not overwrite their unsaved choice. + const profileDirtyRef = useRef(false) + const [isSavingChild, setIsSavingChild] = useState(false) + const [isSavingProfile, setIsSavingProfile] = useState(false) + const [isSavingMembers, setIsSavingMembers] = useState(false) + const [newPartnerName, setNewPartnerName] = useState('') + const [renamingUuid, setRenamingUuid] = useState(null) + const [renameValue, setRenameValue] = useState('') + const [removeConfirmUuid, setRemoveConfirmUuid] = useState(null) + const [memberActionError, setMemberActionError] = useState<{ uuid: string; text: string } | null>(null) + + useEffect(() => { + if (childDetails) { + setChildBirthDate(childDetails.birthDate) + } + }, [childDetails]) + + const selfMember = members.find((m) => m.uuid === personalDataUuid) + const selfSalaryType = selfMember?.salaryType + const selfEmploymentStatus = selfMember?.employmentStatus + + // Prefill from the logged-in parent's stored profile rather than always + // starting empty — an empty form made "Spara" look like it would clear + // what was already saved. + useEffect(() => { + if (profileDirtyRef.current) return + setProfile({ + ...(selfSalaryType ? { salaryType: selfSalaryType } : {}), + ...(selfEmploymentStatus ? { employmentStatus: selfEmploymentStatus } : {}), + }) + }, [selfSalaryType, selfEmploymentStatus]) + + if (childDetailsLoading) { + return + } + + const selectedChild = children.find((c) => c.id === selectedChildId) + const hasPartner = members.some((m) => m.uuid !== personalDataUuid) + + // Child details only — `useChildSettings.update` bumps the plan version + // itself (birth date feeds the age-4 cutoff). + // The button is disabled while the birth date is empty. + const handleSaveChild = async () => { + try { + setIsSavingChild(true) + await updateChildDetails({ birthDate: childBirthDate }) + showMessage('success', t('settings.saveSuccess')) + } catch (err) { + showMessage('error', err instanceof Error ? err.message : t('settings.saveError')) + } finally { + setIsSavingChild(false) + } + } + + const handleSaveProfile = async () => { + if (!personalDataUuid) return + try { + setIsSavingProfile(true) + await api.updateParentSettings(personalDataUuid, { + salary_type: profile.salaryType, + employment_status: profile.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`. + bumpPlanVersion() + showMessage('success', t('settings.saveSuccess')) + } catch (err) { + showMessage('error', err instanceof Error ? err.message : t('settings.saveError')) + } finally { + setIsSavingProfile(false) + } + } + + const handleProfileChange = (field: string, val: string) => { + profileDirtyRef.current = true + setProfile((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 + }) + } + + const handleAddPartner = async () => { + if (!newPartnerName.trim() || !workspace?.id) { + showMessage('error', t('settings.partnerNameRequired')) + return + } + + try { + setIsSavingMembers(true) + clearMessage() + 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 { + setIsSavingMembers(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 { + setIsSavingMembers(true) + setMemberActionError(null) + await api.renameWorkspaceParent(workspace.id, uuid, renameValue) + await refreshMembers() + if (uuid === personalDataUuid) { + await refreshContext() + } + // No `bumpPlanVersion()` here, unlike add/remove — 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 { + setIsSavingMembers(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 { + setIsSavingMembers(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 { + setIsSavingMembers(false) + } + } + + return ( + <> +
+

+ {t('settings.childCardTitle')} +

+ {selectedChild ? ( + <> +

+ {t('settings.selectedChildLabel')} {selectedChild.name} +

+ +
+ +
+ + ) : ( +

{t('settings.noChildSelected')}

+ )} +
+ +
+

+ {t('settings.parentsCardTitle')} +

+ {/* A member added via "Lägg till partner" (or waiting on an invite) + * is a placeholder with no login of their own — `invite.partnerPlaceholderIntro` + * describes what's actually true: the slot is reserved, an invite + * connects it to a real account. */} +

+ {hasPartner ? t('invite.partnerPlaceholderIntro') : t('settings.partnerIntro')} +

+ + {membersLoading ? ( +

{t('settings.membersLoading')}

+ ) : ( +
    + {members.map((member) => { + const isSelf = member.uuid === personalDataUuid + const isRenaming = renamingUuid === member.uuid + const isConfirmingRemove = removeConfirmUuid === member.uuid + return ( +
  • + {isRenaming ? ( + <> + setRenameValue(e.target.value)} + disabled={isSavingMembers} + className="field__control settings-card__partner-input" + data-testid={`rename-input-${member.uuid}`} + /> + + + + ) : ( + <> + + {member.name} + {isSelf && ( + {t('settings.youBadge')} + )} + + + {!isSelf && + (isConfirmingRemove ? ( + handleConfirmRemove(member.uuid)} + onCancel={handleCancelRemove} + isBusy={isSavingMembers} + confirmTestId={`remove-confirm-${member.uuid}`} + cancelTestId={`remove-cancel-${member.uuid}`} + confirmFirst + small + /> + ) : ( + + ))} + + )} + {memberActionError?.uuid === member.uuid && ( + + {memberActionError.text} + + )} +
  • + ) + })} +
+ )} + + {!hasPartner && ( +
+ setNewPartnerName(e.target.value)} + disabled={isSavingMembers} + className="field__control settings-card__partner-input" + /> + +
+ )} + + +
+ +
+

+ {t('settings.profileCardTitle')} +

+

{t('settings.profileHelp')}

+ +
+ +
+
+ + ) +} diff --git a/frontend/src/components/settings/GeneralSection.tsx b/frontend/src/components/settings/GeneralSection.tsx new file mode 100644 index 0000000..b6bdc6c --- /dev/null +++ b/frontend/src/components/settings/GeneralSection.tsx @@ -0,0 +1,158 @@ +import { ReactNode, useEffect, useRef, useState } from 'react' +import { useWorkspace, usePlanSyncContextOptional } from '@/context' +import { useShowWeekNumbers } from '@/hooks' +import { useTranslation, type Locale } from '@/i18n' +import type { ShowSettingsMessage } from './settingsShared' + +// How long the recompute button 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 + +function isLocale(value: string): value is Locale { + return value === 'sv' || value === 'en' +} + +export interface GeneralSectionProps { + showMessage: ShowSettingsMessage + clearMessage: () => void +} + +/** + * Settings → Allmänt: language, calendar display (week numbers) and the + * manual "recompute plan" button. + */ +export function GeneralSection({ showMessage, clearMessage }: GeneralSectionProps): ReactNode { + const { t, explicitLocale, setLocale } = useTranslation() + const { bumpPlanVersion } = useWorkspace() + const [showWeekNumbers, setShowWeekNumbers] = useShowWeekNumbers() + const [isRecomputing, setIsRecomputing] = useState(false) + + // Optional: in the real app `Settings` is always mounted inside + // `WorkspaceProvider` (and therefore `PlanSyncProvider`), but the page's + // test suite renders it with `@/context` auto-mocked wholesale — the + // optional variant keeps the recompute button testable without requiring + // a real `PlanSyncProvider` in every 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) never + // flips this button's spinner off. + const recomputeBaselineRef = useRef(null) + const recomputeTimeoutRef = useRef | null>(null) + + // Detects the recompute button's own triggered sync landing. + // `bumpPlanVersion()` 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. + 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) + showMessage('success', t('settings.recomputeSuccess')) + }, [latestProposalVersion, t, showMessage]) + + // Leaving the section mid-recompute drops the pending timeout rather than + // raising a failure toast for a button that is no longer on screen. + useEffect( + () => () => { + if (recomputeTimeoutRef.current !== null) clearTimeout(recomputeTimeoutRef.current) + }, + [], + ) + + const handleLanguageChange = (value: string) => { + setLocale(isLocale(value) ? value : null) + } + + // 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 (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 = () => { + recomputeBaselineRef.current = latestProposalVersion + setIsRecomputing(true) + clearMessage() + 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 ( + <> +
+

+ {t('settings.languageCardTitle')} +

+ + +
+ +
+

+ {t('settings.displayCardTitle')} +

+ +
+ +
+

+ {t('settings.recomputeCardTitle')} +

+

{t('settings.recomputeHelp')}

+
+ +
+
+ + ) +} diff --git a/frontend/src/components/settings/SettingsNav.test.tsx b/frontend/src/components/settings/SettingsNav.test.tsx new file mode 100644 index 0000000..836598f --- /dev/null +++ b/frontend/src/components/settings/SettingsNav.test.tsx @@ -0,0 +1,102 @@ +import { afterEach, describe, it, expect, vi } from 'vitest' +import { act, render, screen, fireEvent } from '@testing-library/react' +import { SettingsNav } from './SettingsNav' +import type { SettingsSectionId } from './settingsShared' + +function renderNav(activeSection: SettingsSectionId = 'familj') { + const onSelect = vi.fn<(section: SettingsSectionId) => void>() + render() + return onSelect +} + +describe('SettingsNav', () => { + it('renders a labelled tablist with only the active tab in the tab order', () => { + renderNav('saldon') + expect(screen.getByRole('tablist', { name: 'Inställningar, avsnitt' })).toBeInTheDocument() + const active = screen.getByRole('tab', { name: 'Saldon' }) + expect(active).toHaveAttribute('aria-selected', 'true') + expect(active).toHaveAttribute('tabindex', '0') + expect(active).toHaveAttribute('aria-controls', 'settings-panel-saldon') + const inactive = screen.getByRole('tab', { name: 'Familj' }) + expect(inactive).toHaveAttribute('aria-selected', 'false') + expect(inactive).toHaveAttribute('tabindex', '-1') + expect(inactive).not.toHaveAttribute('aria-controls') + }) + + it('selects a section on click', () => { + const onSelect = renderNav() + fireEvent.click(screen.getByRole('tab', { name: 'Allmänt' })) + expect(onSelect).toHaveBeenCalledWith('allmant') + }) + + it.each([ + ['familj', 'ArrowRight', 'saldon', 'Saldon'], + ['familj', 'ArrowDown', 'saldon', 'Saldon'], + ['allmant', 'ArrowRight', 'familj', 'Familj'], + ['familj', 'ArrowLeft', 'allmant', 'Allmänt'], + ['saldon', 'ArrowUp', 'familj', 'Familj'], + ['ledighet', 'Home', 'familj', 'Familj'], + ['familj', 'End', 'allmant', 'Allmänt'], + ] as const)('from %s, %s selects and focuses %s', (from, key, to, label) => { + const onSelect = renderNav(from) + const current = screen.getAllByRole('tab').find((tab) => tab.getAttribute('aria-selected') === 'true') + expect(current).toBeDefined() + if (current) fireEvent.keyDown(current, { key }) + expect(onSelect).toHaveBeenCalledWith(to) + expect(screen.getByRole('tab', { name: label })).toHaveFocus() + }) + + describe('aria-orientation', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('is horizontal when matchMedia is unavailable (jsdom)', () => { + renderNav() + expect(screen.getByRole('tablist')).toHaveAttribute('aria-orientation', 'horizontal') + }) + + it('follows the 768px breakpoint, including live changes', () => { + let listener: (() => void) | null = null + const mql = { + matches: true, + addEventListener: vi.fn((_type: string, cb: () => void) => { + listener = cb + }), + removeEventListener: vi.fn(), + } + const matchMedia = vi.fn(() => mql) + vi.stubGlobal('matchMedia', matchMedia) + const { unmount } = render() + expect(matchMedia).toHaveBeenCalledWith('(min-width: 768px)') + expect(screen.getByRole('tablist')).toHaveAttribute('aria-orientation', 'vertical') + + mql.matches = false + act(() => listener?.()) + expect(screen.getByRole('tablist')).toHaveAttribute('aria-orientation', 'horizontal') + + unmount() + expect(mql.removeEventListener).toHaveBeenCalled() + }) + }) + + it('scrolls the active tab into view within the bar when it changes', () => { + const scrollIntoView = vi.fn() + const original = HTMLElement.prototype.scrollIntoView + HTMLElement.prototype.scrollIntoView = scrollIntoView + try { + const { rerender } = render() + rerender() + expect(scrollIntoView).toHaveBeenLastCalledWith({ block: 'nearest', inline: 'nearest' }) + expect(scrollIntoView.mock.contexts.at(-1)).toBe(screen.getByRole('tab', { name: 'Allmänt' })) + } finally { + HTMLElement.prototype.scrollIntoView = original + } + }) + + it('ignores other keys', () => { + const onSelect = renderNav() + fireEvent.keyDown(screen.getByRole('tab', { name: 'Familj' }), { key: 'a' }) + expect(onSelect).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/components/settings/SettingsNav.tsx b/frontend/src/components/settings/SettingsNav.tsx new file mode 100644 index 0000000..428b5a1 --- /dev/null +++ b/frontend/src/components/settings/SettingsNav.tsx @@ -0,0 +1,130 @@ +import { KeyboardEvent, ReactNode, useEffect, useRef, useState } from 'react' +import { useTranslation } from '@/i18n' +import { + SETTINGS_SECTIONS, + SETTINGS_SECTION_LABEL_KEYS, + settingsPanelId, + settingsTabId, + type SettingsSectionId, +} from './settingsShared' +import './settings-nav.css' + +// Must match the two-column breakpoint in responsive.css. +const VERTICAL_NAV_QUERY = '(min-width: 768px)' + +function matchVerticalNav(): MediaQueryList | null { + // jsdom (and very old browsers) have no `matchMedia`. + return typeof window.matchMedia === 'function' ? window.matchMedia(VERTICAL_NAV_QUERY) : null +} + +/** Whether the tabs currently render as a vertical list, so the tablist's + * `aria-orientation` matches what the user actually sees. */ +function useIsVerticalNav(): boolean { + const [isVertical, setIsVertical] = useState(() => matchVerticalNav()?.matches ?? false) + useEffect(() => { + const mql = matchVerticalNav() + if (!mql) return + const onChange = () => setIsVertical(mql.matches) + onChange() + mql.addEventListener('change', onChange) + return () => mql.removeEventListener('change', onChange) + }, []) + return isVertical +} + +export interface SettingsNavProps { + activeSection: SettingsSectionId + onSelect: (section: SettingsSectionId) => void +} + +/** + * The Settings page's section switcher — an ARIA tablist. A vertical, + * sticky list beside the content on wide screens; a horizontally scrolling + * tab bar under the page title on narrow ones (see responsive.css). Arrow + * keys (both axes, since the orientation depends on the viewport), Home and + * End move focus and select at once — automatic activation, since every + * section renders instantly from already-fetched hooks. + */ +export function SettingsNav({ activeSection, onSelect }: SettingsNavProps): ReactNode { + const { t } = useTranslation() + const tabRefs = useRef>>({}) + const isVertical = useIsVerticalNav() + + // On narrow screens the tab bar scrolls sideways — keep the active tab + // (e.g. after a `#allmant` deep link) inside it. `nearest` never moves a + // tab that is already visible. + useEffect(() => { + const tab = tabRefs.current[activeSection] + if (tab && typeof tab.scrollIntoView === 'function') { + tab.scrollIntoView({ block: 'nearest', inline: 'nearest' }) + } + }, [activeSection]) + + const selectAndFocus = (section: SettingsSectionId) => { + onSelect(section) + tabRefs.current[section]?.focus() + } + + const handleKeyDown = (event: KeyboardEvent, index: number) => { + const last = SETTINGS_SECTIONS.length - 1 + let nextIndex: number + switch (event.key) { + case 'ArrowRight': + case 'ArrowDown': + nextIndex = index === last ? 0 : index + 1 + break + case 'ArrowLeft': + case 'ArrowUp': + nextIndex = index === 0 ? last : index - 1 + break + case 'Home': + nextIndex = 0 + break + case 'End': + nextIndex = last + break + default: + return + } + event.preventDefault() + const next = SETTINGS_SECTIONS[nextIndex] + if (next) selectAndFocus(next) + } + + return ( +
+
+ {SETTINGS_SECTIONS.map((section, index) => { + const isActive = section === activeSection + return ( + + ) + })} +
+
+ ) +} diff --git a/frontend/src/components/settings/partner-invite-panel.css b/frontend/src/components/settings/partner-invite-panel.css index ff48506..c9602e3 100644 --- a/frontend/src/components/settings/partner-invite-panel.css +++ b/frontend/src/components/settings/partner-invite-panel.css @@ -10,6 +10,7 @@ .partner-invite-panel__description { margin: 0; + font-size: var(--fs-sm); color: var(--c-text-muted); } @@ -47,6 +48,7 @@ .partner-invite-panel__empty { margin: 0; + font-size: var(--fs-sm); color: var(--c-text-muted); } diff --git a/frontend/src/components/settings/settings-nav.css b/frontend/src/components/settings/settings-nav.css new file mode 100644 index 0000000..352e6f7 --- /dev/null +++ b/frontend/src/components/settings/settings-nav.css @@ -0,0 +1,55 @@ +/* Settings section tabs. Narrow screens (the default here): one + * horizontally scrolling row under the page title, with a hairline under + * the whole bar and an accent underline on the active tab. The row scrolls + * inside itself so the page never scrolls sideways. From 768px up, + * responsive.css turns it into a sticky vertical list beside the content. */ +.settings-nav { + min-width: 0; +} + +.settings-nav__list { + display: flex; + gap: var(--sp-1); + overflow-x: auto; + overscroll-behavior-x: contain; + scrollbar-width: none; + border-bottom: 1px solid var(--c-border); + /* A fade on the right edge signals that the bar scrolls; the end padding + * lets the last tab scroll fully clear of the fade. */ + padding-right: var(--sp-5); + mask-image: linear-gradient(to right, black calc(100% - var(--sp-5)), transparent); +} + +.settings-nav__list::-webkit-scrollbar { + display: none; +} + +.settings-nav__tab { + flex: none; + min-height: var(--control-h); + padding: var(--sp-2) var(--sp-3); + border: none; + border-bottom: 2px solid transparent; + background: none; + color: var(--c-text-muted); + font: inherit; + font-size: var(--fs-base); + white-space: nowrap; + cursor: pointer; +} + +.settings-nav__tab:hover { + color: var(--c-text); +} + +.settings-nav__tab:focus-visible { + outline: 2px solid var(--c-accent); + outline-offset: -2px; + border-radius: var(--radius-sm); +} + +.settings-nav__tab--active { + color: var(--c-accent); + border-bottom-color: var(--c-accent); + font-weight: 600; +} diff --git a/frontend/src/components/settings/settingsShared.ts b/frontend/src/components/settings/settingsShared.ts new file mode 100644 index 0000000..68cf025 --- /dev/null +++ b/frontend/src/components/settings/settingsShared.ts @@ -0,0 +1,70 @@ +import type { TranslationKey } from '@/i18n' + +/** The Settings page's sections, in nav order. Each id doubles as its URL + * hash (`/settings#saldon`) — `saldon` is linked from the Dashboard, so it + * must never be renamed. */ +export const SETTINGS_SECTIONS = ['familj', 'saldon', 'ledighet', 'allmant'] as const + +export type SettingsSectionId = (typeof SETTINGS_SECTIONS)[number] + +export const DEFAULT_SETTINGS_SECTION: SettingsSectionId = 'familj' + +export const SETTINGS_SECTION_LABEL_KEYS: Record = { + familj: 'settings.nav.family', + saldon: 'settings.nav.balances', + ledighet: 'settings.nav.daysOff', + allmant: 'settings.nav.general', +} + +export function isSettingsSectionId(value: string): value is SettingsSectionId { + return (SETTINGS_SECTIONS as readonly string[]).includes(value) +} + +/** `#saldon` → `'saldon'`; an empty or unknown hash falls back to the + * default section. */ +export function sectionFromHash(hash: string): SettingsSectionId { + const id = hash.replace(/^#/, '') + return isSettingsSectionId(id) ? id : DEFAULT_SETTINGS_SECTION +} + +export function settingsTabId(section: SettingsSectionId): string { + return `settings-tab-${section}` +} + +export function settingsPanelId(section: SettingsSectionId): string { + return `settings-panel-${section}` +} + +/** The page-level toast every section reports through. */ +export type ShowSettingsMessage = (type: 'success' | 'error', text: string) => void + +/** + * 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. Shared by the + * Saldon section (vacation table) and the Lediga dagar section (preschool + * rows, which keep only the current year onward). + */ +export 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 +} diff --git a/frontend/src/components/vacation-days/vacation-rows.css b/frontend/src/components/vacation-days/vacation-rows.css index bb2af12..078b3ff 100644 --- a/frontend/src/components/vacation-days/vacation-rows.css +++ b/frontend/src/components/vacation-days/vacation-rows.css @@ -30,7 +30,9 @@ display: flex; align-items: center; gap: var(--sp-2); - min-width: 56px; + /* Fixed basis wide enough for the year plus the "Antaget"/"Assumed" + * badge, so a badged row's inputs line up with an unbadged one's. */ + flex: 0 0 8.5rem; padding-bottom: var(--sp-2); font-weight: 600; align-self: center; diff --git a/frontend/src/i18n/en/balance.ts b/frontend/src/i18n/en/balance.ts index 8da90a5..14680f6 100644 --- a/frontend/src/i18n/en/balance.ts +++ b/frontend/src/i18n/en/balance.ts @@ -19,5 +19,4 @@ export const balance = { 'balance.status.launching': 'Starting…', 'balance.statusLine': '{left} of {total} days left', 'balance.statusNone': 'No details yet', - 'balance.youBadge': '(you)', } as const diff --git a/frontend/src/i18n/en/settings.ts b/frontend/src/i18n/en/settings.ts index 1e06552..7269006 100644 --- a/frontend/src/i18n/en/settings.ts +++ b/frontend/src/i18n/en/settings.ts @@ -59,13 +59,9 @@ export const settings = { 'settings.addPartnerError': 'Could not add partner', 'settings.addPartnerSuccess': 'Partner added!', 'settings.addingPartner': 'Adding...', - 'settings.balanceSectionTitle': 'Balances', - 'settings.calendarLanguageSectionTitle': 'Calendar & language', 'settings.cancelButton': 'Cancel', 'settings.childBirthDateLabel': 'Child\'s birth date', 'settings.dayOverridesHelp': 'The planner is based on the Swedish calendar (public holidays, bridge days, Christmas Eve, Midsummer Eve and Whitsun Eve are normally days off). Here you can enter exceptions for your workplace — if you have to work on Christmas Day, or are off on days that are otherwise working days.', - 'settings.exceptionsSectionTitle': 'Exceptions & red days', - 'settings.familySectionTitle': 'Child & family', 'settings.languageAutoOption': 'Automatic (browser language)', 'settings.languageEnglishOption': 'English', 'settings.languageLabel': 'Välj språk / Select language', @@ -79,13 +75,8 @@ export const settings = { 'settings.partnerNameInvalid': 'The partner\'s name is invalid.', 'settings.partnerNamePlaceholder': 'Partner\'s name', 'settings.partnerNameRequired': 'Enter a name for the partner', - 'settings.partnerSectionTitle': 'Partner', - 'settings.preschoolHelp': 'Preschools usually close for about 6 days a year for planning. Enter how many days your preschool has, so they are reserved in the plan.', 'settings.preschoolPlanningDaysLabel': 'Preschool planning days per year', - 'settings.profileHelp': 'Your salary type and employment status are used to estimate your SGI and plan your leave.', - 'settings.profileSectionTitle': 'Your profile', 'settings.recomputeButton': 'Recompute plan', - 'settings.recomputeHelp': 'The plan normally recomputes automatically whenever you change something. Use this button to recompute it right away, without waiting.', 'settings.recomputeSuccess': 'The plan was recomputed.', 'settings.recomputeTimeout': 'Recomputing the plan took too long. Try again in a little while.', 'settings.recomputing': 'Recomputing…', @@ -103,9 +94,6 @@ export const settings = { 'settings.removePartnerErrorSelf': 'You can\'t remove yourself from the workspace.', 'settings.renameButton': 'Rename', 'settings.renamePartnerError': 'Could not rename', - 'settings.saveFamilyButton': 'Save', - 'settings.saveFamilyError': 'Could not save the details', - 'settings.saveFamilySuccess': 'Details saved!', 'settings.saveRenameButton': 'Save name', 'settings.saveVacationError': 'Could not save vacation days', 'settings.saveVacationSuccess': 'Vacation days for {year} saved!', @@ -113,9 +101,7 @@ export const settings = { 'settings.showWeekNumbers': 'Show week numbers', 'settings.takePlOnRedDays': 'Take parental leave on red days', 'settings.takePlOnRedDaysError': 'Could not save the setting', - 'settings.takePlOnRedDaysHelp': 'You get paid for Saturdays, Sundays and red days in the plan — but you spend days you would otherwise have kept, since vacation cannot be taken on these days.', 'settings.title': 'Settings', - 'settings.vacationHelp': 'Enter vacation days per year. In the first year you are usually home more and have fewer days to spend on vacation, while later years have your full annual allotment. This controls how much time the planner has to work with. Years are also editable back to the child\'s birth year — for a past year, enter the days you actually had left, not the full allotment.', 'settings.youBadge': '(You)', 'vacationDaysTable.assumedBadge': 'Assumed', 'vacationDaysTable.assumedPrompt': 'No value is recorded for this year — the statutory 25 days has been assumed. Confirm or edit below.', @@ -134,4 +120,30 @@ export const settings = { 'settings.preschoolPlannedLabel': 'Planned days', 'settings.preschoolTakenLabel': 'Days taken', 'settings.preschoolTakenPlaceholder': 'Not recorded', + 'settings.nav.label': 'Settings sections', + 'settings.nav.family': 'Family', + 'settings.nav.balances': 'Balances', + 'settings.nav.daysOff': 'Days off', + 'settings.nav.general': 'General', + 'settings.childCardTitle': 'Child', + 'settings.parentsCardTitle': 'Parents', + 'settings.profileCardTitle': 'Your profile', + 'settings.profileHelp': 'Salary type and employment status are used to estimate your SGI.', + 'settings.saveChildButton': 'Save child details', + 'settings.saveProfileButton': 'Save profile', + 'settings.saveSuccess': 'Details saved!', + 'settings.saveError': 'Could not save the details', + 'settings.balancesCardTitle': 'Parental benefit days', + 'settings.vacationCardTitle': 'Vacation days', + 'settings.vacationHelp': 'Enter vacation days per year. For a past year, enter the days you actually had left, not the full allotment.', + 'settings.preschoolCardTitle': 'Preschool planning days', + 'settings.preschoolHelp': 'Preschools usually close about 6 days a year. They are reserved in the plan.', + 'settings.savePreschoolButton': 'Save default', + 'settings.redDaysCardTitle': 'Red days', + 'settings.takePlOnRedDaysHelp': 'You get paid for weekends and red days, but spend days you would otherwise have kept.', + 'settings.exceptionsCardTitle': 'Exceptions', + 'settings.languageCardTitle': 'Language', + 'settings.displayCardTitle': 'Display', + 'settings.recomputeCardTitle': 'The plan', + 'settings.recomputeHelp': 'The plan recomputes automatically when you change something. You can recompute it right away here.', } as const diff --git a/frontend/src/i18n/sv/balance.ts b/frontend/src/i18n/sv/balance.ts index 19fb722..0d671d6 100644 --- a/frontend/src/i18n/sv/balance.ts +++ b/frontend/src/i18n/sv/balance.ts @@ -28,5 +28,4 @@ export const balance = { 'balance.status.launching': 'Startar…', 'balance.statusLine': '{left} av {total} dagar kvar', 'balance.statusNone': 'Inga uppgifter ännu', - 'balance.youBadge': '(du)', } as const diff --git a/frontend/src/i18n/sv/settings.ts b/frontend/src/i18n/sv/settings.ts index 578e670..a74bc12 100644 --- a/frontend/src/i18n/sv/settings.ts +++ b/frontend/src/i18n/sv/settings.ts @@ -59,13 +59,9 @@ export const settings = { 'settings.addPartnerError': 'Kunde inte lägga till partner', 'settings.addPartnerSuccess': 'Partner tillagd!', 'settings.addingPartner': 'Lägger till...', - 'settings.balanceSectionTitle': 'Saldon', - 'settings.calendarLanguageSectionTitle': 'Kalender & språk', 'settings.cancelButton': 'Avbryt', 'settings.childBirthDateLabel': 'Barnets födelsedatum', 'settings.dayOverridesHelp': 'Planeraren utgår från den svenska kalendern (röda dagar, klämdagar, julafton, midsommarafton och pingstafton är normalt lediga). Här kan du ange undantag för din arbetsplats — om du måste jobba juldagen eller är ledig på annars arbetande dagar.', - 'settings.exceptionsSectionTitle': 'Undantag & röda dagar', - 'settings.familySectionTitle': 'Barn & familj', 'settings.languageAutoOption': 'Automatisk (webbläsarens språk)', 'settings.languageEnglishOption': 'English', 'settings.languageLabel': 'Välj språk / Select language', @@ -79,13 +75,8 @@ export const settings = { 'settings.partnerNameInvalid': 'Partnerns namn är ogiltigt.', 'settings.partnerNamePlaceholder': 'Partnerns namn', 'settings.partnerNameRequired': 'Ange ett namn för partnern', - 'settings.partnerSectionTitle': 'Partner', - 'settings.preschoolHelp': 'Förskolor stänger oftast ca 6 dagar per år för planering. Ange hur många dagar din förskola har, så reserveras de i planeringen.', 'settings.preschoolPlanningDaysLabel': 'Förskolans planeringsdagar per år', - 'settings.profileHelp': 'Din lönetyp och sysselsättning används för att uppskatta din SGI och planera din ledighet.', - 'settings.profileSectionTitle': 'Din profil', 'settings.recomputeButton': 'Räkna om planen', - 'settings.recomputeHelp': 'Planen räknas normalt om automatiskt när du ändrar något. Använd den här knappen för att räkna om den direkt, utan att vänta.', 'settings.recomputeSuccess': 'Planen räknades om.', 'settings.recomputeTimeout': 'Det tog för lång tid att räkna om planen. Försök igen om en stund.', 'settings.recomputing': 'Räknar om…', @@ -103,9 +94,6 @@ export const settings = { 'settings.removePartnerErrorSelf': 'Du kan inte ta bort dig själv från arbetsutrymmet.', 'settings.renameButton': 'Byt namn', 'settings.renamePartnerError': 'Kunde inte byta namn', - 'settings.saveFamilyButton': 'Spara', - 'settings.saveFamilyError': 'Kunde inte spara uppgifterna', - 'settings.saveFamilySuccess': 'Uppgifterna sparades!', 'settings.saveRenameButton': 'Spara namn', 'settings.saveVacationError': 'Kunde inte spara semesterdagar', 'settings.saveVacationSuccess': 'Semesterdagar för {year} sparade!', @@ -113,9 +101,7 @@ export const settings = { 'settings.showWeekNumbers': 'Visa veckonummer', 'settings.takePlOnRedDays': 'Ta ut föräldrapenning på röda dagar', 'settings.takePlOnRedDaysError': 'Kunde inte spara inställningen', - 'settings.takePlOnRedDaysHelp': 'Du får betalt för lördagar, söndagar och röda dagar i planeringen — men du förbrukar dagar du annars hade sparat, eftersom semester inte kan tas ut på dessa dagar.', 'settings.title': 'Inställningar', - 'settings.vacationHelp': 'Ange semesterdagar per år. Det första året är du oftast hemma mer och har färre dagar att lägga på semester, medan kommande år har din fulla årliga tilldelning. Detta styr hur mycket tid planeraren har att arbeta med. Åren går även att redigera bakåt till barnets födelseår — för ett tidigare år anger du hur många dagar du faktiskt hade kvar, inte hela tilldelningen.', 'settings.youBadge': '(Du)', 'vacationDaysTable.assumedBadge': 'Antaget', 'vacationDaysTable.assumedPrompt': 'Inget värde är registrerat för detta år — 25 dagar (lagstadgat) har antagits. Bekräfta eller ändra nedan.', @@ -134,4 +120,30 @@ export const settings = { 'settings.preschoolPlannedLabel': 'Planerade dagar', 'settings.preschoolTakenLabel': 'Uttagna dagar', 'settings.preschoolTakenPlaceholder': 'Ej angivet', + 'settings.nav.label': 'Inställningar, avsnitt', + 'settings.nav.family': 'Familj', + 'settings.nav.balances': 'Saldon', + 'settings.nav.daysOff': 'Lediga dagar', + 'settings.nav.general': 'Allmänt', + 'settings.childCardTitle': 'Barn', + 'settings.parentsCardTitle': 'Föräldrar', + 'settings.profileCardTitle': 'Din profil', + 'settings.profileHelp': 'Lönetyp och sysselsättning används för att uppskatta din SGI.', + 'settings.saveChildButton': 'Spara barnets uppgifter', + 'settings.saveProfileButton': 'Spara profil', + 'settings.saveSuccess': 'Uppgifterna sparades!', + 'settings.saveError': 'Kunde inte spara uppgifterna', + 'settings.balancesCardTitle': 'Föräldrapenningdagar', + 'settings.vacationCardTitle': 'Semesterdagar', + 'settings.vacationHelp': 'Ange semesterdagar per år. För ett tidigare år anger du hur många dagar du faktiskt hade kvar, inte hela tilldelningen.', + 'settings.preschoolCardTitle': 'Förskolans planeringsdagar', + 'settings.preschoolHelp': 'Förskolor stänger oftast ca 6 dagar per år. De reserveras i planeringen.', + 'settings.savePreschoolButton': 'Spara standardvärde', + 'settings.redDaysCardTitle': 'Röda dagar', + 'settings.takePlOnRedDaysHelp': 'Du får betalt för helger och röda dagar, men förbrukar dagar du annars hade sparat.', + 'settings.exceptionsCardTitle': 'Undantag', + 'settings.languageCardTitle': 'Språk', + 'settings.displayCardTitle': 'Visning', + 'settings.recomputeCardTitle': 'Planen', + 'settings.recomputeHelp': 'Planen räknas om automatiskt när du ändrar något. Här kan du räkna om den direkt.', } as const \ No newline at end of file diff --git a/frontend/src/pages/Settings.test.tsx b/frontend/src/pages/Settings.test.tsx index a5923d2..7257978 100644 --- a/frontend/src/pages/Settings.test.tsx +++ b/frontend/src/pages/Settings.test.tsx @@ -66,20 +66,32 @@ vi.mock('@/components/settings/PartnerInvitePanel', () => ({ }, })) -function renderSettings() { - return render() +// Sections render one at a time; tests for anything outside the default +// `Familj` section open theirs through the real tab first. +type SectionLabel = 'Familj' | 'Saldon' | 'Lediga dagar' | 'Allmänt' + +function openSection(name: SectionLabel) { + fireEvent.click(screen.getByRole('tab', { name })) +} + +function renderSettings(section?: SectionLabel) { + const result = render() + if (section) openSection(section) + return result } // The language selector tests exercise real locale switching, which needs an // actual in the tree (the other tests in this file render // Settings unwrapped and get the i18n module's non-reactive default context, // which is fine since they never touch the locale). -function renderSettingsWithLanguageProvider() { - return render( +function renderSettingsWithLanguageProvider(section?: SectionLabel) { + const result = render( , ) + if (section) openSection(section) + return result } const CURRENT_YEAR = new Date().getFullYear() @@ -145,6 +157,8 @@ describe('Settings', () => { beforeEach(() => { vi.clearAllMocks() + // The active section lives in the URL hash — start every test clean. + window.history.replaceState(null, '', '/settings') lastBalanceFlowProps = null lastCalendarExceptionsProps = null lastPartnerInvitePanelProps = null @@ -256,13 +270,116 @@ describe('Settings', () => { expect(screen.getByTestId('page-skeleton')).toBeInTheDocument() }) - it('renders the four panels as h2 headings, in order, with the family panel\'s h3 subsections', () => { + it('offers the four sections as tabs, in order, and opens Familj with its cards by default', () => { renderSettings() + const tabs = screen.getAllByRole('tab').map((tab) => tab.textContent) + expect(tabs).toEqual(['Familj', 'Saldon', 'Lediga dagar', 'Allmänt']) + expect(screen.getByRole('tab', { name: 'Familj' })).toHaveAttribute('aria-selected', 'true') + expect(screen.getByRole('tabpanel')).toHaveAttribute('aria-labelledby', 'settings-tab-familj') + const h2s = screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent) - expect(h2s).toEqual(['Barn & familj', 'Saldon', 'Kalender & språk', 'Undantag & röda dagar']) + expect(h2s).toEqual(['Familj']) const h3s = screen.getAllByRole('heading', { level: 3 }).map((h) => h.textContent) - expect(h3s).toEqual(['Din profil', 'Partner']) + expect(h3s).toEqual(['Barn', 'Föräldrar', 'Din profil']) + }) + + it.each([ + ['Saldon', ['Föräldrapenningdagar', 'Semesterdagar']], + ['Lediga dagar', ['Förskolans planeringsdagar', 'Röda dagar', 'Undantag']], + ['Allmänt', ['Språk', 'Visning', 'Planen']], + ] as const)('renders only the %s section, with its cards, once its tab is clicked', (section, cards) => { + renderSettings(section) + expect(screen.getByRole('tab', { name: section })).toHaveAttribute('aria-selected', 'true') + expect(screen.getByRole('tab', { name: 'Familj' })).toHaveAttribute('aria-selected', 'false') + expect(screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent)).toEqual([section]) + expect(screen.getAllByRole('heading', { level: 3 }).map((h) => h.textContent)).toEqual([...cards]) + }) + + describe('section navigation', () => { + it('shows the save message in an always-mounted live region, not in the page flow', async () => { + renderSettings() + fireEvent.click(screen.getByTestId('save-profile-button')) + const message = await screen.findByTestId('save-message') + expect(message.parentElement).toHaveAttribute('aria-live', 'polite') + expect(message.parentElement).toHaveClass('settings-toast') + }) + + it('writes the chosen section to the URL hash', () => { + renderSettings('Lediga dagar') + expect(window.location.hash).toBe('#ledighet') + }) + + it('deep-links to #saldon (the Dashboard\'s balance link)', () => { + window.history.replaceState(null, '', '/settings#saldon') + renderSettings() + expect(screen.getByRole('tab', { name: 'Saldon' })).toHaveAttribute('aria-selected', 'true') + expect(screen.getByTestId('balance-flow-stub')).toBeInTheDocument() + expect(screen.queryByTestId('member-list')).not.toBeInTheDocument() + }) + + it('deep-links to #ledighet', () => { + window.history.replaceState(null, '', '/settings#ledighet') + renderSettings() + expect(screen.getByRole('tab', { name: 'Lediga dagar' })).toHaveAttribute('aria-selected', 'true') + expect(screen.getByTestId('calendar-exceptions-stub')).toBeInTheDocument() + }) + + it('falls back to Familj for an unknown hash', () => { + window.history.replaceState(null, '', '/settings#nonsense') + renderSettings() + expect(screen.getByRole('tab', { name: 'Familj' })).toHaveAttribute('aria-selected', 'true') + expect(screen.getByTestId('member-list')).toBeInTheDocument() + }) + + it('follows a hash change made outside the tabs (e.g. a link to #allmant)', () => { + renderSettings() + act(() => { + window.history.replaceState(null, '', '/settings#allmant') + window.dispatchEvent(new HashChangeEvent('hashchange')) + }) + expect(screen.getByRole('tab', { name: 'Allmänt' })).toHaveAttribute('aria-selected', 'true') + expect(screen.getByTestId('recompute-plan-button')).toBeInTheDocument() + }) + + it('moves between sections with the arrow keys, focusing the new tab', () => { + renderSettings() + fireEvent.keyDown(screen.getByRole('tab', { name: 'Familj' }), { key: 'ArrowRight' }) + const saldon = screen.getByRole('tab', { name: 'Saldon' }) + expect(saldon).toHaveAttribute('aria-selected', 'true') + expect(saldon).toHaveFocus() + expect(screen.getByTestId('balance-flow-stub')).toBeInTheDocument() + + fireEvent.keyDown(saldon, { key: 'End' }) + expect(screen.getByRole('tab', { name: 'Allmänt' })).toHaveFocus() + expect(window.location.hash).toBe('#allmant') + }) + + it('scrolls the section back into view when switching after scrolling down', () => { + const scrollTo = vi.fn() + vi.stubGlobal('scrollTo', scrollTo) + const rectSpy = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue( + DOMRect.fromRect({ x: 0, y: -300, width: 800, height: 600 }), + ) + try { + renderSettings('Saldon') + expect(scrollTo).toHaveBeenCalledWith({ top: window.scrollY - 300 }) + } finally { + rectSpy.mockRestore() + vi.unstubAllGlobals() + } + }) + + it('does not scroll when the section top is already in view', () => { + const scrollTo = vi.fn() + vi.stubGlobal('scrollTo', scrollTo) + try { + renderSettings('Saldon') + expect(scrollTo).not.toHaveBeenCalled() + } finally { + vi.unstubAllGlobals() + } + }) }) it('keeps the settings-page root testid', () => { @@ -272,7 +389,7 @@ describe('Settings', () => { describe('vacation days table', () => { it('renders one row per year from the child\'s birth year through the year the child turns 4', () => { - renderSettings() + renderSettings('Saldon') for (let year = CURRENT_YEAR - 1; year <= CURRENT_YEAR + 3; year++) { expect(screen.getByTestId(`vacation-year-row-${year}`)).toBeInTheDocument() } @@ -281,7 +398,7 @@ describe('Settings', () => { }) it('marks a year before the current year as past and shows the past-year assumed prompt', () => { - renderSettings() + renderSettings('Saldon') const pastYear = CURRENT_YEAR - 1 const badge = screen.getByTestId(`vacation-assumed-badge-${pastYear}`) expect(badge.title).toBe( @@ -297,7 +414,7 @@ describe('Settings', () => { }) it('autosaves an edit to a past year under that past year, not the current one', async () => { - renderSettings() + renderSettings('Saldon') const pastYear = CURRENT_YEAR - 1 const totalInput = screen.getByTestId(`vacation-total-days-${pastYear}`) fireEvent.change(totalInput, { target: { value: '15' } }) @@ -310,7 +427,7 @@ describe('Settings', () => { }) it('prefills a year that already has saved data', () => { - renderSettings() + renderSettings('Saldon') const totalInput = screen.getByTestId(`vacation-total-days-${CURRENT_YEAR}`) as HTMLInputElement const savedInput = screen.getByTestId(`vacation-saved-days-${CURRENT_YEAR}`) as HTMLInputElement expect(totalInput.value).toBe('10') @@ -318,7 +435,7 @@ describe('Settings', () => { }) it('defaults a year with no saved data to 25 vacation days', () => { - renderSettings() + renderSettings('Saldon') const totalInput = screen.getByTestId(`vacation-total-days-${CURRENT_YEAR + 1}`) as HTMLInputElement expect(totalInput.value).toBe('25') }) @@ -350,14 +467,14 @@ describe('Settings', () => { }, }) ) - renderSettings() + renderSettings('Saldon') expect(screen.getByTestId(`vacation-at-risk-${CURRENT_YEAR}`)).toHaveTextContent('6') // A year with no matching yearBalance falls back to 0, not crashing. expect(screen.getByTestId(`vacation-at-risk-${CURRENT_YEAR + 1}`)).toHaveTextContent('0') }) it('autosaves an edited year with its total days, with no Save button', async () => { - renderSettings() + renderSettings('Saldon') const year = CURRENT_YEAR + 1 expect(screen.queryByTestId(`vacation-save-${year}`)).not.toBeInTheDocument() const totalInput = screen.getByTestId(`vacation-total-days-${year}`) @@ -374,13 +491,17 @@ describe('Settings', () => { ) }) - it('does not disable the vacation inputs while the family panel is saving', async () => { + it('does not disable the vacation inputs while a Familj card is saving', async () => { + let resolveSave: () => void = () => {} + mockUpdateChildDetails.mockReturnValue(new Promise((resolve) => { resolveSave = resolve })) renderSettings() - fireEvent.click(screen.getByTestId('save-family-button')) - // The vacation table has no shared saving flag with the family panel - // any more — each row autosaves and reports its own status. + fireEvent.click(screen.getByTestId('save-child-button')) + expect(screen.getByTestId('save-child-button')).toBeDisabled() + openSection('Saldon') + // The vacation table has no shared saving flag with the Familj cards + // — each row autosaves and reports its own status. expect(screen.getByTestId(`vacation-total-days-${CURRENT_YEAR}`)).not.toBeDisabled() - await waitFor(() => expect(screen.getByTestId('save-family-button')).not.toBeDisabled()) + await act(async () => resolveSave()) }) it('renders one table per workspace member, self first, keyed on (personalDataUuid, year)', async () => { @@ -408,7 +529,7 @@ describe('Settings', () => { upsert: mockUpsertVacationDays, }), ) - renderSettings() + renderSettings('Saldon') const selfTable = screen.getByTestId('vacation-days-table-pd-1') const partnerTable = screen.getByTestId('vacation-days-table-pd-2') @@ -432,7 +553,7 @@ describe('Settings', () => { refresh: mockRefreshMembers, }), ) - renderSettings() + renderSettings('Saldon') const partnerTable = screen.getByTestId('vacation-days-table-pd-2') fireEvent.change(within(partnerTable).getByTestId(`vacation-total-days-${CURRENT_YEAR}`), { @@ -447,53 +568,209 @@ describe('Settings', () => { }) }) - describe('family panel — child + profile (one combined Save)', () => { - it('prefills birth date and preschool planning days from the child', () => { + describe('Familj — child card and profile card, each with its own Save', () => { + it('prefills the birth date from the child', () => { renderSettings() const birthInput = screen.getByTestId('birth-date-input') as HTMLInputElement - const preschoolInput = screen.getByTestId('vacation-days-field') as HTMLInputElement expect(birthInput.value).toBe(CHILD_BIRTH_DATE) - expect(preschoolInput.value).toBe('6') }) - it('saves child details and the parent profile together with one Save button', async () => { + it('saves only the child details from the child card', async () => { renderSettings() - const preschoolInput = screen.getByTestId('vacation-days-field') - fireEvent.change(preschoolInput, { target: { value: '10' } }) - fireEvent.change(screen.getByTestId('salary-type'), { target: { value: 'monthly' } }) - fireEvent.change(screen.getByTestId('employment-status'), { target: { value: 'employed' } }) - fireEvent.click(screen.getByTestId('save-family-button')) + fireEvent.change(screen.getByTestId('birth-date-input'), { target: { value: `${CURRENT_YEAR - 1}-02-03` } }) + fireEvent.click(screen.getByTestId('save-child-button')) await waitFor(() => - expect(mockUpdateChildDetails).toHaveBeenCalledWith({ - birthDate: CHILD_BIRTH_DATE, - preschoolPlanningDaysPerYear: 10, + expect(mockUpdateChildDetails).toHaveBeenCalledWith({ birthDate: `${CURRENT_YEAR - 1}-02-03` }), + ) + expect(await screen.findByTestId('save-message')).toHaveTextContent('Uppgifterna sparades!') + expect(api.updateParentSettings).not.toHaveBeenCalled() + // Child-details saves bump via useChildSettings.update itself + // (exercised by useChildSettings.test.ts) — not a second time here. + expect(mockBumpPlanVersion).not.toHaveBeenCalled() + }) + + it('shows an error when the child save fails', async () => { + mockUpdateChildDetails.mockRejectedValue(new Error('child save failed')) + renderSettings() + fireEvent.click(screen.getByTestId('save-child-button')) + expect(await screen.findByTestId('save-message')).toHaveTextContent('child save failed') + }) + + it('falls back to a generic message when the child save rejects with a non-Error', async () => { + mockUpdateChildDetails.mockRejectedValue('nope') + renderSettings() + fireEvent.click(screen.getByTestId('save-child-button')) + expect(await screen.findByTestId('save-message')).toHaveTextContent('Kunde inte spara uppgifterna') + }) + + it('says no child is selected instead of the child form when there is none', () => { + vi.mocked(useWorkspace).mockReturnValue( + makeWorkspaceContextValue({ + workspace: { id: 'ws-1', name: 'Test Workspace', createdAt: '2026-01-01' }, + workspaceId: 'ws-1', + children: [], + selectedChildId: null, + personalDataUuid: 'pd-1', + parentName: 'Test Parent', }), ) + renderSettings() + expect(screen.getByText('Inget barn valt ännu.')).toBeInTheDocument() + expect(screen.queryByTestId('save-child-button')).not.toBeInTheDocument() + }) + + it('shows a skeleton inside the section while the child details load', () => { + vi.mocked(hooks.useChildSettings).mockReturnValue(makeChildSettingsResult({ data: null, isLoading: true })) + renderSettings() + expect(screen.getByTestId('page-skeleton')).toBeInTheDocument() + // The page shell and its tabs stay put. + expect(screen.getByRole('tab', { name: 'Familj' })).toBeInTheDocument() + }) + + it('prefills the profile from the logged-in parent\'s stored salary type and employment status', () => { + vi.mocked(hooks.useWorkspaceMembers).mockReturnValue( + makeWorkspaceMembersResult({ + data: [{ ...selfMember, salaryType: 'hourly', employmentStatus: 'jobseeking' }], + refresh: mockRefreshMembers, + }), + ) + renderSettings() + expect((screen.getByTestId('salary-type') as HTMLSelectElement).value).toBe('hourly') + expect((screen.getByTestId('employment-status') as HTMLSelectElement).value).toBe('jobseeking') + }) + + it('keeps an unsaved profile edit when the member list refreshes', () => { + const { rerender } = renderSettings() + fireEvent.change(screen.getByTestId('salary-type'), { target: { value: 'hourly' } }) + vi.mocked(hooks.useWorkspaceMembers).mockReturnValue( + makeWorkspaceMembersResult({ + data: [{ ...selfMember, employmentStatus: 'none' }], + refresh: mockRefreshMembers, + }), + ) + rerender() + expect((screen.getByTestId('salary-type') as HTMLSelectElement).value).toBe('hourly') + }) + + it('saves only the parent profile from the profile card, and re-plans', async () => { + renderSettings() + fireEvent.change(screen.getByTestId('salary-type'), { target: { value: 'hourly' } }) + fireEvent.change(screen.getByTestId('employment-status'), { target: { value: 'none' } }) + fireEvent.click(screen.getByTestId('save-profile-button')) + await waitFor(() => expect(api.updateParentSettings).toHaveBeenCalledWith('pd-1', { - salary_type: 'monthly', - employment_status: 'employed', + salary_type: 'hourly', + employment_status: 'none', }), ) // Salary type feeds the allocator — the parent-settings save must - // re-plan, not just refetch. (Child-details bumps separately via - // useChildSettings.update, exercised by useChildSettings.test.ts.) + // re-plan, not just refetch. await waitFor(() => expect(mockBumpPlanVersion).toHaveBeenCalledTimes(1)) + expect(mockUpdateChildDetails).not.toHaveBeenCalled() + expect(await screen.findByTestId('save-message')).toHaveTextContent('Uppgifterna sparades!') }) - it('shows an error when the combined family save fails', async () => { + it('sends an unset profile field as undefined when it is cleared', async () => { + renderSettings() + fireEvent.change(screen.getByTestId('salary-type'), { target: { value: '' } }) + fireEvent.change(screen.getByTestId('employment-status'), { target: { value: '' } }) + fireEvent.click(screen.getByTestId('save-profile-button')) + await waitFor(() => + expect(api.updateParentSettings).toHaveBeenCalledWith('pd-1', { + salary_type: undefined, + employment_status: undefined, + }), + ) + }) + + it('shows an error when the profile save fails', async () => { vi.mocked(api.updateParentSettings).mockRejectedValue(new Error('save failed')) renderSettings() - fireEvent.click(screen.getByTestId('save-family-button')) + fireEvent.click(screen.getByTestId('save-profile-button')) expect(await screen.findByTestId('save-message')).toHaveTextContent('save failed') expect(mockBumpPlanVersion).not.toHaveBeenCalled() }) + + it('falls back to a generic message when the profile save rejects with a non-Error', async () => { + vi.mocked(api.updateParentSettings).mockRejectedValue('nope') + renderSettings() + fireEvent.click(screen.getByTestId('save-profile-button')) + expect(await screen.findByTestId('save-message')).toHaveTextContent('Kunde inte spara uppgifterna') + }) + + it('does not disable the profile Save while the child card is saving', async () => { + let resolveSave: () => void = () => {} + mockUpdateChildDetails.mockReturnValue(new Promise((resolve) => { resolveSave = resolve })) + renderSettings() + fireEvent.click(screen.getByTestId('save-child-button')) + expect(screen.getByTestId('save-child-button')).toBeDisabled() + expect(screen.getByTestId('save-profile-button')).not.toBeDisabled() + await act(async () => resolveSave()) + expect(screen.getByTestId('save-child-button')).not.toBeDisabled() + }) + }) + + describe('Lediga dagar — preschool default card', () => { + it('prefills the preschool planning days default from the child', () => { + renderSettings('Lediga dagar') + const preschoolInput = screen.getByTestId('vacation-days-field') as HTMLInputElement + expect(preschoolInput.value).toBe('6') + }) + + it('saves only the preschool default from its own Save', async () => { + renderSettings('Lediga dagar') + fireEvent.change(screen.getByTestId('vacation-days-field'), { target: { value: '10' } }) + fireEvent.click(screen.getByTestId('save-preschool-button')) + + await waitFor(() => + expect(mockUpdateChildDetails).toHaveBeenCalledWith({ preschoolPlanningDaysPerYear: 10 }), + ) + expect(await screen.findByTestId('save-message')).toHaveTextContent('Uppgifterna sparades!') + expect(api.updateParentSettings).not.toHaveBeenCalled() + }) + + it('shows an error when the preschool save fails', async () => { + mockUpdateChildDetails.mockRejectedValue(new Error('preschool failed')) + renderSettings('Lediga dagar') + fireEvent.click(screen.getByTestId('save-preschool-button')) + expect(await screen.findByTestId('save-message')).toHaveTextContent('preschool failed') + }) + + it('falls back to a generic message when the preschool save rejects with a non-Error', async () => { + mockUpdateChildDetails.mockRejectedValue('nope') + renderSettings('Lediga dagar') + fireEvent.click(screen.getByTestId('save-preschool-button')) + expect(await screen.findByTestId('save-message')).toHaveTextContent('Kunde inte spara uppgifterna') + }) + + it('says no child is selected instead of the preschool form when there is none', () => { + vi.mocked(useWorkspace).mockReturnValue( + makeWorkspaceContextValue({ + workspace: { id: 'ws-1', name: 'Test Workspace', createdAt: '2026-01-01' }, + workspaceId: 'ws-1', + children: [], + selectedChildId: null, + personalDataUuid: 'pd-1', + parentName: 'Test Parent', + }), + ) + renderSettings('Lediga dagar') + expect(screen.getByText('Inget barn valt ännu.')).toBeInTheDocument() + expect(screen.queryByTestId('preschool-year-rows')).not.toBeInTheDocument() + }) + + it('shows a skeleton inside the section while day overrides load', () => { + vi.mocked(hooks.useDayOverrides).mockReturnValue(makeDayOverridesResult({ data: [], isLoading: true })) + renderSettings('Lediga dagar') + expect(screen.getByTestId('page-skeleton')).toBeInTheDocument() + }) }) describe('preschool planning days — per-year editing', () => { it('renders one row per year from the current year (not the vacation table\'s widened birth-year start) through the year the child turns 4, wired to usePreschoolPlanningDays', () => { - renderSettings() + renderSettings('Lediga dagar') for (let year = CURRENT_YEAR; year <= CURRENT_YEAR + 3; year++) { expect(screen.getByTestId(`preschool-year-row-${year}`)).toBeInTheDocument() } @@ -506,7 +783,7 @@ describe('Settings', () => { vi.mocked(hooks.usePreschoolPlanningDays).mockReturnValue( makePreschoolPlanningDaysResult({ setPlanned: mockSetPlanned }), ) - renderSettings() + renderSettings('Lediga dagar') fireEvent.change(screen.getByTestId(`preschool-planned-${CURRENT_YEAR}`), { target: { value: '7' } }) @@ -518,7 +795,7 @@ describe('Settings', () => { vi.mocked(hooks.usePreschoolPlanningDays).mockReturnValue( makePreschoolPlanningDaysResult({ recordTaken: mockRecordTaken }), ) - renderSettings() + renderSettings('Lediga dagar') fireEvent.change(screen.getByTestId(`preschool-taken-${CURRENT_YEAR}`), { target: { value: '4' } }) @@ -531,7 +808,7 @@ describe('Settings', () => { data: { id: 'ppd-1', childId: 'c-1', year: CURRENT_YEAR, plannedDays: 6, takenDays: null, isDefault: false, createdAt: null, updatedAt: null }, }), ) - renderSettings() + renderSettings('Lediga dagar') expect((screen.getByTestId(`preschool-taken-${CURRENT_YEAR}`) as HTMLInputElement).value).toBe('') }) }) @@ -784,7 +1061,7 @@ describe('Settings', () => { describe('Saldon panel — BalanceFlow wiring', () => { it('renders BalanceFlow in panel mode with the workspace members and selected child', () => { - renderSettings() + renderSettings('Saldon') expect(screen.getByTestId('balance-flow-stub')).toBeInTheDocument() expect(lastBalanceFlowProps).toMatchObject({ mode: 'panel', @@ -806,12 +1083,12 @@ describe('Settings', () => { parentName: 'Test Parent', }) ) - renderSettings() + renderSettings('Saldon') expect(lastBalanceFlowProps?.selectedChildId).toBe('c-9') }) it('refreshes members when BalanceFlow reports a save', () => { - renderSettings() + renderSettings('Saldon') lastBalanceFlowProps?.onSaved?.() expect(mockRefreshMembers).toHaveBeenCalled() }) @@ -821,9 +1098,19 @@ describe('Settings', () => { makeWorkspaceMembersResult({ data: [], isLoading: true, refresh: mockRefreshMembers }) ) renderSettings() - // Both the partner list (family panel) and the Saldon panel gate on - // the same `membersLoading` flag, so the message appears twice. - expect(screen.getAllByText('Laddar medlemmar…')).toHaveLength(2) + // Both the member list (Familj) and the Saldon section gate on the + // same `membersLoading` flag — each shows the message in its own + // section. + expect(screen.getAllByText('Laddar medlemmar…')).toHaveLength(1) + openSection('Saldon') + expect(screen.getAllByText('Laddar medlemmar…')).toHaveLength(1) + expect(screen.queryByTestId('balance-flow-stub')).not.toBeInTheDocument() + }) + + it('shows a skeleton inside the section while vacation days load', () => { + vi.mocked(hooks.useVacationDays).mockReturnValue(makeVacationDaysResult({ data: [], isLoading: true })) + renderSettings('Saldon') + expect(screen.getByTestId('page-skeleton')).toBeInTheDocument() expect(screen.queryByTestId('balance-flow-stub')).not.toBeInTheDocument() }) }) @@ -831,14 +1118,14 @@ describe('Settings', () => { it('toggles week numbers', () => { const setShowWeekNumbers = vi.fn() vi.mocked(hooks.useShowWeekNumbers).mockReturnValue(makeShowWeekNumbersTuple(false, setShowWeekNumbers)) - renderSettings() + renderSettings('Allmänt') fireEvent.click(screen.getByTestId('show-week-numbers-checkbox')) expect(setShowWeekNumbers).toHaveBeenCalledWith(true) }) describe('take PL on red days', () => { it('renders unchecked by default', () => { - renderSettings() + renderSettings('Lediga dagar') expect(screen.getByTestId('take-pl-on-red-days-checkbox')).not.toBeChecked() }) @@ -858,7 +1145,7 @@ describe('Settings', () => { parentName: 'Test Parent', }) ) - renderSettings() + renderSettings('Lediga dagar') expect(screen.getByTestId('take-pl-on-red-days-checkbox')).toBeChecked() }) @@ -882,7 +1169,7 @@ describe('Settings', () => { bumpPlanVersion: mockBumpPlanVersion, }) ) - renderSettings() + renderSettings('Lediga dagar') fireEvent.click(screen.getByTestId('take-pl-on-red-days-checkbox')) @@ -897,7 +1184,7 @@ describe('Settings', () => { it('shows an error message when saving fails', async () => { vi.mocked(api.updateWorkspace).mockRejectedValue(new Error('network down')) - renderSettings() + renderSettings('Lediga dagar') fireEvent.click(screen.getByTestId('take-pl-on-red-days-checkbox')) @@ -940,25 +1227,25 @@ describe('Settings', () => { }) it('is labelled understandably regardless of the active language', () => { - renderSettingsWithLanguageProvider() + renderSettingsWithLanguageProvider('Allmänt') expect(screen.getByText('Välj språk / Select language')).toBeInTheDocument() }) it('offers automatic, Swedish, and English options', () => { - renderSettingsWithLanguageProvider() + renderSettingsWithLanguageProvider('Allmänt') const select = screen.getByTestId('language-select') as HTMLSelectElement const optionValues = Array.from(select.options).map((o) => o.value) expect(optionValues).toEqual(['auto', 'sv', 'en']) }) it('defaults to "auto" when no explicit preference is stored', () => { - renderSettingsWithLanguageProvider() + renderSettingsWithLanguageProvider('Allmänt') const select = screen.getByTestId('language-select') as HTMLSelectElement expect(select.value).toBe('auto') }) it('switching to English updates the UI immediately, without a reload', () => { - renderSettingsWithLanguageProvider() + renderSettingsWithLanguageProvider('Allmänt') expect(screen.getByRole('heading', { name: 'Inställningar' })).toBeInTheDocument() fireEvent.change(screen.getByTestId('language-select'), { target: { value: 'en' } }) @@ -968,13 +1255,13 @@ describe('Settings', () => { }) it('persists the explicit choice to localStorage', () => { - renderSettingsWithLanguageProvider() + renderSettingsWithLanguageProvider('Allmänt') fireEvent.change(screen.getByTestId('language-select'), { target: { value: 'en' } }) expect(localStorage.getItem('language_preference')).toBe('en') }) it('going back to "Automatic" clears the stored preference', () => { - renderSettingsWithLanguageProvider() + renderSettingsWithLanguageProvider('Allmänt') fireEvent.change(screen.getByTestId('language-select'), { target: { value: 'en' } }) fireEvent.change(screen.getByTestId('language-select'), { target: { value: 'auto' } }) expect(localStorage.getItem('language_preference')).toBeNull() @@ -983,7 +1270,7 @@ describe('Settings', () => { describe('manual recompute button', () => { it('bumps planVersion (never calling api.syncPlan directly) and shows a spinner label while waiting', () => { - renderSettings() + renderSettings('Allmänt') fireEvent.click(screen.getByTestId('recompute-plan-button')) @@ -994,7 +1281,7 @@ describe('Settings', () => { }) it('reports success once PlanSyncProvider\'s triggered sync lands with a newer proposal', async () => { - const { rerender } = renderSettings() + const { rerender } = renderSettings('Allmänt') fireEvent.click(screen.getByTestId('recompute-plan-button')) @@ -1015,7 +1302,7 @@ describe('Settings', () => { }) it('ignores a version bump from an unrelated sync while idle', () => { - const { rerender } = renderSettings() + const { rerender } = renderSettings('Allmänt') // No recompute click happened — `latestProposalVersion` moving on its // own (e.g. a vacation-day autosave elsewhere bumped `planVersion`) @@ -1027,11 +1314,67 @@ describe('Settings', () => { expect(screen.queryByTestId('save-message')).not.toBeInTheDocument() }) + + describe('with fake timers', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('reports a timeout when no sync lands within the ceiling, and re-enables the button', () => { + renderSettings('Allmänt') + fireEvent.click(screen.getByTestId('recompute-plan-button')) + act(() => { + vi.advanceTimersByTime(10_000) + }) + expect(screen.getByTestId('save-message')).toHaveTextContent( + 'Det tog för lång tid att räkna om planen. Försök igen om en stund.', + ) + expect(screen.getByTestId('recompute-plan-button')).not.toBeDisabled() + + // The toast clears itself after a few seconds. + act(() => { + vi.advanceTimersByTime(3000) + }) + expect(screen.queryByTestId('save-message')).not.toBeInTheDocument() + }) + + it('drops the pending timeout when leaving the section mid-recompute', () => { + renderSettings('Allmänt') + fireEvent.click(screen.getByTestId('recompute-plan-button')) + openSection('Familj') + act(() => { + vi.advanceTimersByTime(10_000) + }) + expect(screen.queryByTestId('save-message')).not.toBeInTheDocument() + }) + + it('replaces an earlier toast rather than letting its timer clear the new one early', async () => { + vi.mocked(api.updateParentSettings).mockRejectedValue(new Error('first')) + renderSettings() + fireEvent.click(screen.getByTestId('save-profile-button')) + await act(async () => { + await vi.advanceTimersByTimeAsync(2000) + }) + expect(screen.getByTestId('save-message')).toHaveTextContent('first') + + vi.mocked(api.updateParentSettings).mockRejectedValue(new Error('second')) + fireEvent.click(screen.getByTestId('save-profile-button')) + await act(async () => { + await vi.advanceTimersByTimeAsync(2000) + }) + // 4s after the first toast — its timer would have cleared it by now. + expect(screen.getByTestId('save-message')).toHaveTextContent('second') + }) + }) }) describe('Undantag & röda dagar panel — CalendarExceptions wiring', () => { it('passes the fetched calendar window and override handlers through', () => { - renderSettings() + renderSettings('Lediga dagar') expect(screen.getByTestId('calendar-exceptions-stub')).toBeInTheDocument() expect(lastCalendarExceptionsProps).toMatchObject({ startYear: CURRENT_YEAR, @@ -1043,13 +1386,13 @@ describe('Settings', () => { }) it('requests the next 4-year window when paginating forward', () => { - renderSettings() + renderSettings('Lediga dagar') act(() => lastCalendarExceptionsProps?.onNextWindow()) expect(hooks.useCalendarDays).toHaveBeenLastCalledWith('ws-1', CURRENT_YEAR + 4, 4) }) it('requests the previous 4-year window when paginating backward', () => { - renderSettings() + renderSettings('Lediga dagar') act(() => lastCalendarExceptionsProps?.onPrevWindow()) expect(hooks.useCalendarDays).toHaveBeenLastCalledWith('ws-1', CURRENT_YEAR - 4, 4) }) diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 07a0ac3..09c69b3 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1,420 +1,98 @@ -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 { ReactNode, useCallback, useEffect, useRef, useState } from 'react' +import { useWorkspace } from '@/context' +import { useTranslation } 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 { SettingsNav } from '@/components/settings/SettingsNav' +import { FamilySection } from '@/components/settings/FamilySection' +import { BalancesSection } from '@/components/settings/BalancesSection' +import { DaysOffSection } from '@/components/settings/DaysOffSection' +import { GeneralSection } from '@/components/settings/GeneralSection' +import { + SETTINGS_SECTION_LABEL_KEYS, + sectionFromHash, + settingsPanelId, + settingsTabId, + type SettingsSectionId, + type ShowSettingsMessage, +} from '@/components/settings/settingsShared' 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 = { - '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 = { - 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, fallback: string): string { - if (isApiError(err)) { - const details = err.error.details as { messageKey?: string; data?: Record } | 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 -} +const MESSAGE_TIMEOUT_MS = 3000 /** - * 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. + * The active section, read from (and written back to) the URL hash, so + * `/settings#saldon` (the Dashboard's "Uppdatera saldon" link) deep-links + * straight to a section. An empty or unknown hash falls back to the + * default section. Switching uses `history.replaceState` — keeping the + * router's own history state — so tab clicks don't pile up back-button + * entries. */ -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()) +function useSectionFromHash(): [SettingsSectionId, (section: SettingsSectionId) => void] { + const [section, setSection] = useState(() => sectionFromHash(window.location.hash)) + + useEffect(() => { + const sync = () => setSection(sectionFromHash(window.location.hash)) + window.addEventListener('hashchange', sync) + window.addEventListener('popstate', sync) + return () => { + window.removeEventListener('hashchange', sync) + window.removeEventListener('popstate', sync) } - } - const years: number[] = [] - for (let year = startYear; year <= endYear; year++) { - years.push(year) - } - return years -} + }, []) -const PRESCHOOL_AUTOSAVE_DELAY_MS = 800 + const select = useCallback((next: SettingsSectionId) => { + window.history.replaceState(window.history.state, '', `#${next}`) + setSection(next) + }, []) -// 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 ( -
- - {year} - {data?.isDefault && ( - - {t('vacationDaysTable.assumedBadge')} - - )} - - - -
- ) + return [section, select] } 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 { t } = useTranslation() + const { isLoading: wsLoading } = useWorkspace() + const [activeSection, selectSection] = useSectionFromHash() 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(null) - const [renameValue, setRenameValue] = useState('') - const [removeConfirmUuid, setRemoveConfirmUuid] = useState(null) - const [memberActionError, setMemberActionError] = useState<{ uuid: string; text: string } | null>(null) + const messageTimeoutRef = useRef | null>(null) + const layoutRef = useRef(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(null) - const recomputeTimeoutRef = useRef | 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 + const clearMessage = useCallback(() => { + if (messageTimeoutRef.current !== null) { + clearTimeout(messageTimeoutRef.current) + messageTimeoutRef.current = null } - setIsRecomputing(false) - setSaveMessage({ type: 'success', text: t('settings.recomputeSuccess') }) - setTimeout(() => setSaveMessage(null), 3000) - }, [latestProposalVersion, t]) + setSaveMessage(null) + }, []) - // 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 + const showMessage: ShowSettingsMessage = useCallback((type, text) => { + if (messageTimeoutRef.current !== null) clearTimeout(messageTimeoutRef.current) + setSaveMessage({ type, text }) + messageTimeoutRef.current = setTimeout(() => { + messageTimeoutRef.current = null + setSaveMessage(null) + }, MESSAGE_TIMEOUT_MS) + }, []) - // 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], + useEffect( + () => () => { + if (messageTimeoutRef.current !== null) clearTimeout(messageTimeoutRef.current) + }, + [], ) - if (isLoading) { + const handleSelectSection = (section: SettingsSectionId) => { + selectSection(section) + // A switch made after scrolling down brings the new section's top + // (and, on narrow screens, the tab bar right above it) back into view. + const top = layoutRef.current?.getBoundingClientRect().top + if (top !== undefined && top < 0) { + window.scrollTo({ top: window.scrollY + top }) + } + } + + if (wsLoading) { return ( @@ -422,534 +100,36 @@ export function Settings(): ReactNode { ) } - 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 => { - 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 (

{t('settings.title')}

- {saveMessage && ( - - {saveMessage.text} - - )} - -
-

{t('settings.familySectionTitle')}

- {selectedChild ? ( -
-

- {t('settings.selectedChildLabel')} {selectedChild.name} -

- -
-

{t('settings.preschoolHelp')}

- -
- - {selectedChildId && ( -
-

{t('settings.preschoolPerYearTitle')}

-

{t('settings.preschoolPerYearHelp')}

-
- {preschoolYears.map((year) => ( - - ))} -
-
- )} -
- ) : ( -

{t('settings.noChildSelected')}

+
+ {saveMessage && ( + + {saveMessage.text} + )} +
-

{t('settings.profileSectionTitle')}

-

{t('settings.profileHelp')}

- - -
- -
- -

{t('settings.partnerSectionTitle')}

- {/* 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. */} -

{hasPartner ? t('invite.partnerPlaceholderIntro') : t('settings.partnerIntro')}

- - {membersLoading ? ( -

{t('settings.membersLoading')}

- ) : ( -
    - {members.map((member) => { - const isSelf = member.uuid === personalDataUuid - const isRenaming = renamingUuid === member.uuid - const isConfirmingRemove = removeConfirmUuid === member.uuid - return ( -
  • - {isRenaming ? ( - <> - setRenameValue(e.target.value)} - disabled={isSavingFamily} - className="field__control settings-panel__partner-input" - data-testid={`rename-input-${member.uuid}`} - /> - - - - ) : ( - <> - - {member.name} - {isSelf && ( - {t('settings.youBadge')} - )} - - - {!isSelf && - (isConfirmingRemove ? ( - handleConfirmRemove(member.uuid)} - onCancel={handleCancelRemove} - isBusy={isSavingFamily} - confirmTestId={`remove-confirm-${member.uuid}`} - cancelTestId={`remove-cancel-${member.uuid}`} - confirmFirst - small - /> - ) : ( - - ))} - - )} - {memberActionError?.uuid === member.uuid && ( - - {memberActionError.text} - - )} -
  • - ) - })} -
- )} - - {!hasPartner && ( -
- setNewPartnerName(e.target.value)} - disabled={isSavingFamily} - className="field__control settings-panel__partner-input" - /> - -
- )} - - -
- -
-

{t('settings.balanceSectionTitle')}

- {membersLoading ? ( -

{t('settings.membersLoading')}

- ) : ( - void refreshMembers()} - /> - )} - -

{t('settings.vacationHelp')}

- {!membersLoading && - orderedMembers.map((member, index) => ( -
- {orderedMembers.length > 1 && ( -

- {member.name} - {member.uuid === personalDataUuid && ( - {t('settings.youBadge')} - )} -

- )} - - handleSaveVacationYear(member.uuid, year, totalDays, savedDaysIn) - } - personalDataUuid={member.uuid} - /> -
- ))} -
- -
-

{t('settings.calendarLanguageSectionTitle')}

- - -

{t('settings.takePlOnRedDaysHelp')}

- - - - -

{t('settings.recomputeHelp')}

-
- -
-
- -
-

{t('settings.exceptionsSectionTitle')}

- -
+

{t(SETTINGS_SECTION_LABEL_KEYS[activeSection])}

+ {activeSection === 'familj' && } + {activeSection === 'saldon' && } + {activeSection === 'ledighet' && } + {activeSection === 'allmant' && } + +
) diff --git a/frontend/src/pages/settings.css b/frontend/src/pages/settings.css index e66ca1a..d5bd8c6 100644 --- a/frontend/src/pages/settings.css +++ b/frontend/src/pages/settings.css @@ -1,67 +1,93 @@ -/* Settings — four unboxed panels (Barn & familj / Saldon / Kalender & språk / - * Undantag & röda dagar). No cards, no boxes: a hairline between panels does - * the separating, matching the almanac's "borders replaced by whitespace" - * rule. Member/partner rows get their own thin hairline between rows only — - * never a bordered box around the whole list. */ +/* Settings — one section at a time (Familj / Saldon / Lediga dagar / + * Allmänt), picked from `SettingsNav` (settings-nav.css). Narrow screens + * stack the tab bar above the content; from 768px up responsive.css turns + * `.settings-page__layout` into two columns with a sticky nav on the left. + * Each section is a short run of cards: a hairline box, a title, one line + * of help, the controls and — where the card saves — its own Save. */ .settings-page { max-width: var(--maxw); margin: 0 auto; } -/* `.settings-page > .alert` (the save-message banner) is already styled in - * components.css — not redefined here to avoid two files competing for the - * same selector. */ - -.settings-panel { - padding: var(--sp-7) 0; - border-top: 1px solid var(--c-border); +/* The save message is a toast pinned to the bottom centre of the viewport: + * a card saved far down the page still shows its result in view, and the + * banner never pushes the layout around. The live region itself is always + * mounted (empty when idle) so screen readers announce each new message. */ +.settings-toast { + position: fixed; + left: 50%; + bottom: var(--sp-5); + z-index: var(--z-popover); + width: max-content; + max-width: calc(100vw - 2 * var(--sp-4)); + transform: translateX(-50%); + pointer-events: none; } -.settings-panel:first-of-type { - border-top: none; - padding-top: var(--sp-5); +.settings-toast__alert { + pointer-events: auto; + box-shadow: var(--shadow-float); } -.settings-panel h2 { - margin: 0 0 var(--sp-4) 0; -} - -.settings-panel h3 { - margin: var(--sp-6) 0 var(--sp-2) 0; - font-size: var(--fs-lg); -} - -.settings-panel__block { - margin-bottom: var(--sp-4); -} - -.settings-panel__field { +.settings-page__layout { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: var(--sp-5); margin-top: var(--sp-4); } -.settings-panel__help { +.settings-page__content { + display: flex; + flex-direction: column; + gap: var(--sp-5); + min-width: 0; +} + +.settings-page__section-title { + margin: 0; +} + +.settings-card { + min-width: 0; + padding: var(--sp-5); + border: 1px solid var(--c-border); + border-radius: var(--radius-lg); + background-color: var(--c-surface); +} + +.settings-card__title { + margin: 0 0 var(--sp-2) 0; + font-size: var(--fs-lg); +} + +.settings-card__subtitle { + margin: var(--sp-6) 0 var(--sp-2) 0; + font-size: var(--fs-base); +} + +.settings-card__help { font-size: var(--fs-sm); color: var(--c-text-muted); margin: 0 0 var(--sp-3) 0; max-width: 60ch; } -.settings-panel__actions { +.settings-card__actions { justify-content: flex-start; margin-top: var(--sp-4); } -.settings-panel__label { +.settings-card__label { display: block; - margin: var(--sp-5) 0 var(--sp-2) 0; + margin: 0 0 var(--sp-2) 0; font-size: var(--fs-base); } -.settings-panel__select { +.settings-card__select { max-width: 320px; } -.settings-panel__checkbox-label { +.settings-card__checkbox-label { display: flex; align-items: center; gap: var(--sp-2); @@ -69,20 +95,22 @@ padding: var(--sp-2) 0; } -.settings-panel__partner-row { +.settings-card__partner-row { display: flex; + flex-wrap: wrap; gap: var(--sp-3); - margin-top: var(--sp-3); + margin: var(--sp-3) 0 var(--sp-4) 0; } /* Box/border/focus chrome comes from `.field__control` in primitives.css — * this only adds the flex sizing that rule doesn't know about (it sits in * an inline row next to a button, not a stacked `.field`). */ -.settings-panel__partner-input { - flex: 1; +.settings-card__partner-input { + flex: 1 1 160px; + min-width: 0; } -.settings-panel__member-list { +.settings-card__member-list { list-style: none; margin: 0 0 var(--sp-2) 0; padding: 0; @@ -90,7 +118,7 @@ flex-direction: column; } -.settings-panel__member-row { +.settings-card__member-row { display: flex; flex-wrap: wrap; align-items: center; @@ -99,7 +127,17 @@ border-bottom: 1px solid var(--c-border); } -.settings-panel__remove-confirm { +.settings-card__member-row:last-child { + border-bottom: none; +} + +.settings-card__member-name { + flex: 1 1 auto; + min-width: 0; + overflow-wrap: anywhere; +} + +.settings-card__remove-confirm { display: flex; flex-wrap: wrap; align-items: center; @@ -110,11 +148,11 @@ /* One VacationDaysTable per workspace member (§3.3) — a small heading names * whose table it is whenever there's more than one. */ -.settings-panel__vacation-member { +.settings-card__vacation-member { margin-top: var(--sp-4); } -.settings-panel__vacation-member-name { +.settings-card__vacation-member-name { font-weight: 600; margin: 0 0 var(--sp-2) 0; } @@ -144,7 +182,9 @@ display: flex; align-items: center; gap: var(--sp-2); - min-width: 56px; + /* Same fixed basis as `.vacation-rows__year` — badged and unbadged rows + * keep their inputs in one column. */ + flex: 0 0 8.5rem; padding-bottom: var(--sp-2); font-weight: 600; align-self: center; diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index 3db86ab..c4d8833 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -755,10 +755,6 @@ min-height: 100px; } -.settings-page > .alert { - margin-bottom: var(--sp-4); -} - /* Fieldsets */ fieldset { border: 1px solid var(--c-border); @@ -811,3 +807,11 @@ legend { .parent-profile-input__field { max-width: 400px; } + +/* Stacked fields — the same gap as other stacked forms, so a label never + * sits tight under the select above it. */ +.parent-profile-input { + display: flex; + flex-direction: column; + gap: var(--sp-4); +} diff --git a/frontend/src/styles/responsive.css b/frontend/src/styles/responsive.css index 6347506..8ce0223 100644 --- a/frontend/src/styles/responsive.css +++ b/frontend/src/styles/responsive.css @@ -67,13 +67,17 @@ } .dashboard__section, - .settings-page__section, .calendar__section, .tips-page__section { padding-top: var(--sp-4); margin-bottom: var(--sp-4); } + /* Settings cards keep a tighter inner gutter on phones. */ + .settings-card { + padding: var(--sp-4); + } + /* Forms - full width */ .form-group input, .form-group select, @@ -214,6 +218,44 @@ } } +/* Settings — from 768px the section tabs become a sticky vertical list + * beside the content (settings-nav.css holds the narrow, horizontally + * scrolling default). `top` clears the 56px sticky header. */ +@media (min-width: 768px) { + .settings-page__layout { + grid-template-columns: 200px minmax(0, 1fr); + align-items: start; + gap: var(--sp-6); + } + + .settings-nav { + position: sticky; + top: var(--sp-8); + } + + .settings-nav__list { + flex-direction: column; + gap: var(--sp-1); + overflow-x: visible; + border-bottom: none; + padding-right: 0; + mask-image: none; + } + + .settings-nav__tab { + text-align: left; + white-space: normal; + border-bottom: none; + border-left: 2px solid transparent; + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; + } + + .settings-nav__tab--active { + border-left-color: var(--c-accent); + background-color: var(--c-accent-weak); + } +} + /* Tablet - Medium screens (641px to 1024px) */ @media (min-width: 641px) and (max-width: 1024px) { .layout__main {