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