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:
+209
-36
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user