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>
This commit is contained in:
2026-09-20 11:22:07 +02:00
parent 6904961bb4
commit 0e9b20ec37
88 changed files with 5301 additions and 348 deletions
+209 -36
View File
@@ -1,4 +1,4 @@
import { ReactNode, useEffect, useMemo, useState } from 'react'
import { ReactNode, useEffect, useMemo, useRef, useState } from 'react'
import { useWorkspace } from '@/context'
import {
useVacationDays,
@@ -7,6 +7,8 @@ import {
useDayOverrides,
useCalendarDays,
useWorkspaceMembers,
usePreschoolPlanningDays,
useDebouncedCallback,
} from '@/hooks'
import * as api from '@/services/api'
import { isApiError } from '@/services/errors'
@@ -19,6 +21,9 @@ 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
@@ -100,6 +105,120 @@ function computeVacationYearRange(birthDate: string | undefined, currentYear: nu
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()
@@ -137,7 +256,6 @@ export function Settings(): ReactNode {
// (Saldon panel) also disabled the "Spara" button in the family panel,
// and vice versa.
const [isSavingFamily, setIsSavingFamily] = useState(false)
const [isSavingVacation, setIsSavingVacation] = useState(false)
const [isSavingRedDayPref, setIsSavingRedDayPref] = useState(false)
const [newPartnerName, setNewPartnerName] = useState('')
const [renamingUuid, setRenamingUuid] = useState<string | null>(null)
@@ -170,27 +288,36 @@ export function Settings(): ReactNode {
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],
)
const vacationRows: VacationDaysTableRow[] = useMemo(
// 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(
() =>
vacationYears.map((year) => {
const existing = vacDays.find((v) => 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,
}
}),
[vacationYears, vacDays],
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) {
@@ -256,24 +383,27 @@ export function Settings(): ReactNode {
})
}
const handleSaveVacationYear = async (year: number, totalDays: number, savedDaysIn: number) => {
try {
if (workspace?.id && personalDataUuid) {
setIsSavingVacation(true)
await upsertVacationDays({
personalDataUuid,
workspaceId: workspace.id,
year,
totalDays,
savedDaysIn,
})
showMessage('success', t('settings.saveVacationSuccess', { year }))
}
} catch (err) {
showMessage('error', err instanceof Error ? err.message : t('settings.saveVacationError'))
} finally {
setIsSavingVacation(false)
}
// 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 = () => {
@@ -413,6 +543,23 @@ export function Settings(): ReactNode {
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>
@@ -435,7 +582,12 @@ export function Settings(): ReactNode {
</div>
<h3>{t('settings.partnerSectionTitle')}</h3>
<p>{hasPartner ? t('settings.partnerLinkedIntro') : t('settings.partnerIntro')}</p>
{/* 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>
@@ -562,6 +714,8 @@ export function Settings(): ReactNode {
</button>
</div>
)}
<PartnerInvitePanel workspaceId={workspace?.id ?? ''} />
</section>
<section id="saldon" className="settings-panel">
@@ -580,7 +734,26 @@ export function Settings(): ReactNode {
)}
<p className="settings-panel__help">{t('settings.vacationHelp')}</p>
<VacationDaysTable rows={vacationRows} onSave={handleSaveVacationYear} isSaving={isSavingVacation} />
{!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">