Files
F-r-ldraledigplannerare/frontend/src/pages/Settings.tsx
T
jacobh2 0e9b20ec37 Track preschool planning days, partner invites and real vacation forfeiture
Three strands of work that grew together across the same settings screen and
API client, so they land as one change.

Per-year preschool planning days ("studiedagar"): new table, repository
(SQLite + Postgres), service, router and migration 007, plus the hooks and
Settings UI to plan and record them per child and year. Allocation now
reserves the recorded figure for a year where one exists and only falls back
to the child-level estimate otherwise, marking that fallback as assumed.

Partner invitations: workspaces issue and list invitations, registration
accepts an invite token to join the inviter's workspace instead of creating a
personal one, and a /join/:token page plus the settings invite panel drive it.

Vacation forfeiture: tips and the dashboard now distinguish days a parent
genuinely loses from days that merely age, so the warning stops firing on
balances that were never at risk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 11:22:07 +02:00

821 lines
32 KiB
TypeScript

import { ReactNode, useEffect, useMemo, useRef, useState } from 'react'
import { useWorkspace } from '@/context'
import {
useVacationDays,
useShowWeekNumbers,
useChildSettings,
useDayOverrides,
useCalendarDays,
useWorkspaceMembers,
usePreschoolPlanningDays,
useDebouncedCallback,
} from '@/hooks'
import * as api from '@/services/api'
import { isApiError } from '@/services/errors'
import { useTranslation, type Locale, type TranslationKey } from '@/i18n'
import { Layout } from '@/components/layout/Layout'
import { PageSkeleton } from '@/components/common/PageSkeleton'
import { ChildBirthdayInput } from '@/components/inputs/ChildBirthdayInput'
import { ParentProfileInput, type SalaryType, type EmploymentStatus } from '@/components/inputs/ParentProfileInput'
import { VacationDaysInput } from '@/components/inputs/VacationDaysInput'
import { VacationDaysTable, type VacationDaysTableRow } from '@/components/vacation-days/VacationDaysTable'
import { CalendarExceptions } from '@/components/calendar/CalendarExceptions'
import { BalanceFlow } from '@/components/balance/BalanceFlow'
import { PartnerInvitePanel } from '@/components/settings/PartnerInvitePanel'
import { orderSelfFirst } from '@/utils/members'
import type { VacationDays } from '@/types'
import './settings.css'
const RED_DAYS_WINDOW_YEARS = 4
const DEFAULT_VACATION_DAYS = 25
function isSalaryType(value: string): value is SalaryType {
return value === 'monthly' || value === 'hourly'
}
function isEmploymentStatus(value: string): value is EmploymentStatus {
return value === 'employed' || value === 'jobseeking' || value === 'none'
}
// Backend `message_key`s for the parent add/rename/remove endpoints (see
// workspace_service.py) mapped to real translation keys — same idiom as
// `PlanResults.tsx`'s `WARNING_BODY_KEYS` etc. A key not present here is
// intentionally never shown raw to the user (§ "Render the refusal cases
// properly") — callers fall back to a generic message instead.
const PARTNER_ERROR_KEYS: Record<string, TranslationKey> = {
'workspace.notFound': 'settings.partnerActionErrorNotFound',
'workspace.parents.notFound': 'settings.partnerActionErrorNotFound',
'workspace.parents.invalidName': 'settings.partnerNameInvalid',
'workspace.parents.remove.self': 'settings.removePartnerErrorSelf',
'workspace.parents.remove.hasLogin': 'settings.removePartnerErrorHasLogin',
}
// `hasDependentData`'s `data.blockers` is a subset of these four kinds —
// each gets its own i18n label so the refusal message can name what's
// actually blocking removal instead of a generic "can't remove".
const BLOCKER_KEYS: Record<string, TranslationKey> = {
leave_wishes: 'settings.removeBlockerLeaveWishes',
planned_leave: 'settings.removeBlockerPlannedLeave',
parental_leave: 'settings.removeBlockerParentalLeave',
vacation_days: 'settings.removeBlockerVacationDays',
}
function isBlockerList(value: unknown): value is string[] {
return Array.isArray(value) && value.every((v) => typeof v === 'string')
}
/** Turns a caught error from an add/rename/remove-partner call into a safe,
* translated message. Never surfaces a raw `message_key` — an unrecognised
* structured error falls back to `fallback`, same as a non-API error would. */
function describePartnerError(err: unknown, t: (key: TranslationKey, params?: Record<string, string | number>) => string, fallback: string): string {
if (isApiError(err)) {
const details = err.error.details as { messageKey?: string; data?: Record<string, unknown> } | undefined
const messageKey = details?.messageKey
if (messageKey === 'workspace.parents.remove.hasDependentData') {
const blockers = isBlockerList(details?.data?.blockers) ? details.data.blockers : []
const blockerLabels = blockers
.map((b) => (BLOCKER_KEYS[b] ? t(BLOCKER_KEYS[b]) : b))
.join(', ')
return t('settings.removePartnerErrorHasDependentData', { blockers: blockerLabels })
}
if (messageKey && PARTNER_ERROR_KEYS[messageKey]) {
return t(PARTNER_ERROR_KEYS[messageKey])
}
// A structured error we don't recognise — never show the raw key.
if (messageKey) return fallback
}
return err instanceof Error ? err.message : fallback
}
/** Current year through (at minimum) the year the child turns 4. */
function computeVacationYearRange(birthDate: string | undefined, currentYear: number): number[] {
let endYear = currentYear
if (birthDate) {
const fourthBirthday = new Date(birthDate)
if (!isNaN(fourthBirthday.getTime())) {
fourthBirthday.setFullYear(fourthBirthday.getFullYear() + 4)
endYear = Math.max(endYear, fourthBirthday.getFullYear())
}
}
const years: number[] = []
for (let year = currentYear; year <= endYear; year++) {
years.push(year)
}
return years
}
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` field above is only ever an estimate used
* as this row's default until a real number is entered (§ user complaint
* "how many did you take"). Its own component so each row can hold its own
* `useDebouncedCallback` instance, autosaving independently per field/year —
* same reasoning as `VacationYearRow` in `VacationDaysTable.tsx`.
*/
function PreschoolPlanningYearRow({
childId,
year,
defaultPlannedDays,
}: {
childId: string
year: number
defaultPlannedDays: number
}): ReactNode {
const { t } = useTranslation()
const { data, setPlanned, recordTaken } = usePreschoolPlanningDays(childId, year)
const [plannedDraft, setPlannedDraft] = useState(data?.plannedDays ?? defaultPlannedDays)
// `''` represents "not recorded" (`takenDays === null`) — deliberately
// distinct from `0` ("recorded, none taken"), so the input starts empty
// rather than defaulting to zero.
const [takenDraft, setTakenDraft] = useState(data?.takenDays === null || data?.takenDays === undefined ? '' : String(data.takenDays))
const plannedDirtyRef = useRef(false)
const takenDirtyRef = useRef(false)
useEffect(() => {
if (plannedDirtyRef.current) return
setPlannedDraft(data?.plannedDays ?? defaultPlannedDays)
}, [data?.plannedDays, defaultPlannedDays])
useEffect(() => {
if (takenDirtyRef.current) return
setTakenDraft(data?.takenDays === null || data?.takenDays === undefined ? '' : String(data.takenDays))
}, [data?.takenDays])
const debouncedSetPlanned = useDebouncedCallback((value: number) => {
void setPlanned(value)
.then(() => {
plannedDirtyRef.current = false
})
.catch(() => {
// Leave `plannedDirtyRef` set so a stale server value can't creep
// back in over the value still shown on screen.
})
}, PRESCHOOL_AUTOSAVE_DELAY_MS)
const debouncedRecordTaken = useDebouncedCallback((value: number) => {
void recordTaken(value)
.then(() => {
takenDirtyRef.current = false
})
.catch(() => {})
}, PRESCHOOL_AUTOSAVE_DELAY_MS)
return (
<div className="preschool-year-row" data-testid={`preschool-year-row-${year}`}>
<span className="preschool-year-row__year">
{year}
{data?.isDefault && (
<span className="badge badge--warning preschool-year-row__assumed-badge">
{t('vacationDaysTable.assumedBadge')}
</span>
)}
</span>
<label className="field preschool-year-row__field">
<span className="field__label">{t('settings.preschoolPlannedLabel')}</span>
<input
type="number"
inputMode="numeric"
min={0}
max={30}
value={plannedDraft}
onChange={(e) => {
const value = Number(e.target.value)
plannedDirtyRef.current = true
setPlannedDraft(value)
debouncedSetPlanned(value)
}}
data-testid={`preschool-planned-${year}`}
className="field__control preschool-year-row__input"
/>
</label>
<label className="field preschool-year-row__field">
<span className="field__label">{t('settings.preschoolTakenLabel')}</span>
<input
type="number"
inputMode="numeric"
min={0}
max={366}
placeholder={t('settings.preschoolTakenPlaceholder')}
value={takenDraft}
onChange={(e) => {
const raw = e.target.value
takenDirtyRef.current = true
setTakenDraft(raw)
if (raw === '') return
const value = Number(raw)
if (Number.isNaN(value)) return
debouncedRecordTaken(value)
}}
data-testid={`preschool-taken-${year}`}
className="field__control preschool-year-row__input"
/>
</label>
</div>
)
}
export function Settings(): ReactNode {
const { t, explicitLocale, setLocale } = useTranslation()
const { workspace, isLoading: wsLoading, selectedChildId, children, personalDataUuid, refreshContext } = useWorkspace()
const { data: vacDays, isLoading: vacLoading, upsert: upsertVacationDays } = useVacationDays(workspace?.id ?? '')
const {
data: members,
isLoading: membersLoading,
refresh: refreshMembers,
} = useWorkspaceMembers(workspace?.id ?? null)
const {
data: childDetails,
isLoading: childDetailsLoading,
update: updateChildDetails,
} = useChildSettings(selectedChildId)
const {
data: dayOverrides,
isLoading: dayOverridesLoading,
upsert: upsertDayOverride,
remove: removeDayOverride,
} = useDayOverrides(workspace?.id ?? '')
const [showWeekNumbers, setShowWeekNumbers] = useShowWeekNumbers()
const [redDaysStartYear, setRedDaysStartYear] = useState(() => new Date().getFullYear())
const {
data: calendarWindow,
isLoading: calendarLoading,
error: calendarError,
} = useCalendarDays(workspace?.id ?? '', redDaysStartYear, RED_DAYS_WINDOW_YEARS)
const [childBirthDate, setChildBirthDate] = useState('')
const [preschoolPlanningDays, setPreschoolPlanningDays] = useState(6)
const [parentData, setParentData] = useState<{ salaryType?: SalaryType; employmentStatus?: EmploymentStatus }>({})
const [saveMessage, setSaveMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
// Each panel owns exactly one `isSaving` flag, scoped to its own actions —
// previously a single page-wide `isSaving` meant saving a vacation year
// (Saldon panel) also disabled the "Spara" button in the family panel,
// and vice versa.
const [isSavingFamily, setIsSavingFamily] = useState(false)
const [isSavingRedDayPref, setIsSavingRedDayPref] = useState(false)
const [newPartnerName, setNewPartnerName] = useState('')
const [renamingUuid, setRenamingUuid] = useState<string | null>(null)
const [renameValue, setRenameValue] = useState('')
const [removeConfirmUuid, setRemoveConfirmUuid] = useState<string | null>(null)
const [memberActionError, setMemberActionError] = useState<{ uuid: string; text: string } | null>(null)
// 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 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],
)
// 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) =>
vacationYears.map((year) => {
const existing = vacDays.find((v: VacationDays) => v.personalDataUuid === member.uuid && v.year === year)
return {
year,
totalDays: existing?.totalDays ?? DEFAULT_VACATION_DAYS,
savedDaysIn: existing?.savedDaysIn ?? 0,
// §3.2 — no vacation_days row for this year yet: the statutory
// default is shown, not an entered value. Mirrors the backend's
// `VacationBalanceResponse.is_assumed` for the propose-time table.
isAssumed: existing === undefined,
}
}),
),
[orderedMembers, vacationYears, vacDays],
)
if (isLoading) {
return (
<Layout>
<PageSkeleton rows={4} />
</Layout>
)
}
const handleLanguageChange = (value: string) => {
setLocale(value === 'auto' ? null : (value as Locale))
}
const showMessage = (type: 'success' | 'error', text: string) => {
setSaveMessage({ type, text })
setTimeout(() => setSaveMessage(null), 3000)
}
// One combined save for the "Barn & familj" panel's editable fields (child
// birth date/preschool days, own salary/employment) — one button, one
// `isSavingFamily` flag, matching the rest of that panel's actions below.
const handleSaveFamily = async () => {
try {
setIsSavingFamily(true)
if (selectedChildId && childBirthDate) {
await updateChildDetails({
birthDate: childBirthDate,
preschoolPlanningDaysPerYear: preschoolPlanningDays,
})
}
if (personalDataUuid) {
await api.updateParentSettings(personalDataUuid, {
salary_type: parentData.salaryType,
employment_status: parentData.employmentStatus,
})
}
showMessage('success', t('settings.saveFamilySuccess'))
} catch (err) {
showMessage('error', err instanceof Error ? err.message : t('settings.saveFamilyError'))
} finally {
setIsSavingFamily(false)
}
}
const handleParentChange = (field: string, val: string) => {
setParentData((prev) => {
const next = { ...prev }
if (field === 'salaryType') {
if (isSalaryType(val)) {
next.salaryType = val
} else {
delete next.salaryType
}
} else if (field === 'employmentStatus') {
if (isEmploymentStatus(val)) {
next.employmentStatus = val
} else {
delete next.employmentStatus
}
}
return next
})
}
// Takes the owning member's uuid explicitly (§3.3) rather than always
// saving under the logged-in user's own `personalDataUuid` — each
// `VacationDaysTable` instance below binds this to its own member.
// Deliberately doesn't catch: `VacationDaysTable`'s row calls this from
// its own autosave and shows a per-row saving/saved/error indicator from
// the settled promise, so a page-wide toast here would be redundant (and
// noisy — several rows across several members can be autosaving at once).
const handleSaveVacationYear = async (
memberUuid: string,
year: number,
totalDays: number,
savedDaysIn: number,
): Promise<void> => {
if (!workspace?.id) return
await upsertVacationDays({
personalDataUuid: memberUuid,
workspaceId: workspace.id,
year,
totalDays,
savedDaysIn,
})
}
const handleRedDaysPrevWindow = () => {
setRedDaysStartYear((y) => y - RED_DAYS_WINDOW_YEARS)
}
const handleRedDaysNextWindow = () => {
setRedDaysStartYear((y) => y + RED_DAYS_WINDOW_YEARS)
}
const handleAddPartner = async () => {
if (!newPartnerName.trim() || !workspace?.id) {
showMessage('error', t('settings.partnerNameRequired'))
return
}
try {
setIsSavingFamily(true)
setSaveMessage(null)
await api.addPartner(workspace.id, newPartnerName)
await refreshMembers()
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()
}
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()
} 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()
} catch (err) {
showMessage('error', err instanceof Error ? err.message : t('settings.takePlOnRedDaysError'))
} finally {
setIsSavingRedDayPref(false)
}
}
return (
<Layout>
<div data-testid="settings-page" className="settings-page">
<h1>{t('settings.title')}</h1>
{saveMessage && (
<div
className={`alert alert--${saveMessage.type}`}
data-testid="save-message"
role="alert"
>
{saveMessage.text}
</div>
)}
<section className="settings-panel">
<h2>{t('settings.familySectionTitle')}</h2>
{selectedChild ? (
<div className="settings-panel__block">
<p>
{t('settings.selectedChildLabel')} <strong>{selectedChild.name}</strong>
</p>
<ChildBirthdayInput
birthDate={childBirthDate}
onBirthDateChange={setChildBirthDate}
label={t('settings.childBirthDateLabel')}
/>
<div className="field settings-panel__field">
<p className="settings-panel__help">{t('settings.preschoolHelp')}</p>
<VacationDaysInput
label={t('settings.preschoolPlanningDaysLabel')}
value={preschoolPlanningDays}
onChange={setPreschoolPlanningDays}
min={0}
max={30}
/>
</div>
{selectedChildId && (
<div className="settings-panel__field">
<h4>{t('settings.preschoolPerYearTitle')}</h4>
<p className="settings-panel__help">{t('settings.preschoolPerYearHelp')}</p>
<div className="preschool-year-rows" data-testid="preschool-year-rows">
{vacationYears.map((year) => (
<PreschoolPlanningYearRow
key={year}
childId={selectedChildId}
year={year}
defaultPlannedDays={preschoolPlanningDays}
/>
))}
</div>
</div>
)}
</div>
) : (
<p>{t('settings.noChildSelected')}</p>
)}
<h3>{t('settings.profileSectionTitle')}</h3>
<p className="settings-panel__help">{t('settings.profileHelp')}</p>
<ParentProfileInput parent={parentData} onChange={handleParentChange} />
<div className="actions settings-panel__actions">
<button
type="button"
onClick={() => void handleSaveFamily()}
className="btn btn--primary"
disabled={isSavingFamily}
data-testid="save-family-button"
>
{isSavingFamily ? t('common.saving') : t('settings.saveFamilyButton')}
</button>
</div>
<h3>{t('settings.partnerSectionTitle')}</h3>
{/* A member added via "Lägg till partner" (or waiting on an invite)
* is a placeholder with no login of their own — `partnerLinkedIntro`
* used to claim an account link that doesn't exist for that case.
* `invite.partnerPlaceholderIntro` describes what's actually true:
* the slot is reserved, an invite connects it to a real account. */}
<p>{hasPartner ? t('invite.partnerPlaceholderIntro') : t('settings.partnerIntro')}</p>
{membersLoading ? (
<p>{t('settings.membersLoading')}</p>
) : (
<ul className="settings-panel__member-list" data-testid="member-list">
{members.map((member) => {
const isSelf = member.uuid === personalDataUuid
const isRenaming = renamingUuid === member.uuid
const isConfirmingRemove = removeConfirmUuid === member.uuid
return (
<li
key={member.uuid}
className="settings-panel__member-row"
data-testid={`member-row-${member.uuid}`}
>
{isRenaming ? (
<>
<input
type="text"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
disabled={isSavingFamily}
className="field__control settings-panel__partner-input"
data-testid={`rename-input-${member.uuid}`}
/>
<button
onClick={() => handleSaveRename(member.uuid)}
className="btn btn--secondary btn--sm"
disabled={isSavingFamily}
data-testid={`rename-save-${member.uuid}`}
>
{t('settings.saveRenameButton')}
</button>
<button
onClick={handleCancelRename}
className="btn btn--secondary btn--sm"
disabled={isSavingFamily}
data-testid={`rename-cancel-${member.uuid}`}
>
{t('settings.cancelButton')}
</button>
</>
) : (
<>
<span data-testid={`member-name-${member.uuid}`}>
{member.name}
{isSelf && (
<span className="badge badge--muted"> {t('settings.youBadge')}</span>
)}
</span>
<button
onClick={() => handleStartRename(member)}
className="btn btn--secondary btn--sm"
disabled={isSavingFamily}
data-testid={`rename-button-${member.uuid}`}
>
{t('settings.renameButton')}
</button>
{!isSelf &&
(isConfirmingRemove ? (
<span className="settings-panel__remove-confirm">
{t('settings.removeConfirmPrompt', { name: member.name })}
<button
onClick={() => handleConfirmRemove(member.uuid)}
className="btn btn--danger btn--sm"
disabled={isSavingFamily}
data-testid={`remove-confirm-${member.uuid}`}
>
{t('settings.removeConfirmButton')}
</button>
<button
onClick={handleCancelRemove}
className="btn btn--secondary btn--sm"
disabled={isSavingFamily}
data-testid={`remove-cancel-${member.uuid}`}
>
{t('settings.cancelButton')}
</button>
</span>
) : (
<button
onClick={() => handleRequestRemove(member.uuid)}
className="btn btn--danger btn--sm"
disabled={isSavingFamily}
data-testid={`remove-button-${member.uuid}`}
>
{t('settings.removePartnerButton')}
</button>
))}
</>
)}
{memberActionError?.uuid === member.uuid && (
<p
role="alert"
className="alert alert--error"
data-testid={`member-action-error-${member.uuid}`}
>
{memberActionError.text}
</p>
)}
</li>
)
})}
</ul>
)}
{!hasPartner && (
<div className="settings-panel__partner-row">
<input
type="text"
placeholder={t('settings.partnerNamePlaceholder')}
value={newPartnerName}
onChange={(e) => setNewPartnerName(e.target.value)}
disabled={isSavingFamily}
className="field__control settings-panel__partner-input"
/>
<button
onClick={() => void handleAddPartner()}
className="btn btn--primary"
disabled={isSavingFamily}
data-testid="add-partner-button"
>
{isSavingFamily ? t('settings.addingPartner') : t('settings.addPartnerButton')}
</button>
</div>
)}
<PartnerInvitePanel workspaceId={workspace?.id ?? ''} />
</section>
<section id="saldon" className="settings-panel">
<h2>{t('settings.balanceSectionTitle')}</h2>
{membersLoading ? (
<p>{t('settings.membersLoading')}</p>
) : (
<BalanceFlow
members={members}
personalDataUuid={personalDataUuid}
workspaceId={workspace?.id ?? ''}
selectedChildId={balanceChildId}
mode="panel"
onSaved={() => void refreshMembers()}
/>
)}
<p className="settings-panel__help">{t('settings.vacationHelp')}</p>
{!membersLoading &&
orderedMembers.map((member, index) => (
<div key={member.uuid} className="settings-panel__vacation-member">
{orderedMembers.length > 1 && (
<p className="settings-panel__vacation-member-name">
{member.name}
{member.uuid === personalDataUuid && (
<span className="badge badge--muted"> {t('settings.youBadge')}</span>
)}
</p>
)}
<VacationDaysTable
rows={vacationRowsByMember[index] ?? []}
onSave={(year, totalDays, savedDaysIn) =>
handleSaveVacationYear(member.uuid, year, totalDays, savedDaysIn)
}
personalDataUuid={member.uuid}
/>
</div>
))}
</section>
<section className="settings-panel">
<h2>{t('settings.calendarLanguageSectionTitle')}</h2>
<label className="settings-panel__checkbox-label">
<input
type="checkbox"
checked={showWeekNumbers}
onChange={(e) => setShowWeekNumbers(e.target.checked)}
data-testid="show-week-numbers-checkbox"
/>
<span>{t('settings.showWeekNumbers')}</span>
</label>
<label className="settings-panel__checkbox-label">
<input
type="checkbox"
checked={workspace?.takePlOnRedDays ?? false}
onChange={(e) => void handleToggleTakePlOnRedDays(e.target.checked)}
disabled={isSavingRedDayPref}
data-testid="take-pl-on-red-days-checkbox"
/>
<span>{t('settings.takePlOnRedDays')}</span>
</label>
<p className="settings-panel__help">{t('settings.takePlOnRedDaysHelp')}</p>
<label htmlFor="language-select" className="settings-panel__label">
{t('settings.languageLabel')}
</label>
<select
id="language-select"
className="field__control settings-panel__select"
value={explicitLocale ?? 'auto'}
onChange={(e) => handleLanguageChange(e.target.value)}
data-testid="language-select"
>
<option value="auto">{t('settings.languageAutoOption')}</option>
<option value="sv">{t('settings.languageSwedishOption')}</option>
<option value="en">{t('settings.languageEnglishOption')}</option>
</select>
</section>
<section className="settings-panel">
<h2>{t('settings.exceptionsSectionTitle')}</h2>
<CalendarExceptions
days={calendarWindow?.days ?? []}
startYear={redDaysStartYear}
years={RED_DAYS_WINDOW_YEARS}
endYear={calendarWindow?.endYear}
minYear={calendarWindow?.minYear ?? null}
maxYear={calendarWindow?.maxYear ?? null}
isLoading={calendarLoading}
error={calendarError}
onPrevWindow={handleRedDaysPrevWindow}
onNextWindow={handleRedDaysNextWindow}
overrides={dayOverrides}
workspaceId={workspace?.id ?? ''}
onUpsertOverride={upsertDayOverride}
onRemoveOverride={removeDayOverride}
/>
</section>
</div>
</Layout>
)
}