Files
F-r-ldraledigplannerare/frontend/src/components/modals/PlannedDayModal.tsx
T
jacobh2 06592df87c Say something when planning a free day changes nothing
Planning a day through the default "Planera en dag" dialog on a weekend or
red day accepted the save, returned 200, and did nothing visible: the
allocator marks a free day as 'free' before a type is chosen, the shortfall
count excludes it, and planning_service strips it before persisting — so the
sync came back accepted with an empty batch and the toast builder had nothing
to say. The click looked broken.

The modal already had the right words for this; they were only wired to the
advanced/pinned path. They now show in the default path too, so the user is
told before clicking. And an accepted sync that changed nothing now says so
instead of staying silent — opt-in at the one call site that needs it, since
the preset rail deliberately stays quiet on a genuine no-op.

No change to the allocator, to planning_service, or to the policy that free
days stay off.

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

538 lines
22 KiB
TypeScript

import { ReactNode, useEffect, useState } from 'react'
import type { PlannedLeaveDay } from '@/types'
import { useTranslation } from '@/i18n'
import { Icon } from '@/components/common/Icon'
import './planned-day-modal.css'
/** The default path — todo.md #10/#11: the planner decides which day type
* funds each date; the user only says who it's for (and, when both parents
* are involved, whether dubbeldagar are allowed). Builds a fixed, one-off
* `LeaveWish` spanning the selected dates — see `Calendar.tsx`'s
* `handleSaveWish`. */
export interface PlannedDayModalSaveWishPayload {
parentMode: 'either' | 'both' | 'specific'
parentPersonalDataUuid: string | null
wantsDubbeldag: boolean
}
/** The escape hatch (today's pre-existing behaviour, kept verbatim) — pins
* an exact day type for an exact parent, writing `planned_leave_day` rows
* directly. */
export interface PlannedDayModalSavePinnedPayload {
dayType: PinnedDayType
parentId: string
isDubbeldag: boolean
}
type PinnedDayType = 'sjukpenning' | 'lagsta' | 'vacation'
/** What the type select can hold: a concrete type to pin, or — for a solo
* household, where the select is the only question left — `'auto'`, the
* planner-decides default. */
type DayTypeChoice = PinnedDayType | 'auto'
function isPinnedDayType(value: string): value is PinnedDayType {
return value === 'sjukpenning' || value === 'lagsta' || value === 'vacation'
}
/** One day the user removed by hand (a `DayAssertion` with `dayType: 'none'`),
* ready to be offered back. */
export interface DayExclusionEntry {
id: string
parentName: string
}
export interface PlannedDayModalProps {
date: string
isOpen: boolean
existingDay?: PlannedLeaveDay | null | undefined
parents: Array<{ id: string; name: string }>
/** The parent the "Advanced" section's pinned edit currently targets.
* Lifted into Calendar.tsx (not local state here) so a save/delete keyed
* on this same value never fights a resolution keyed on the logged-in
* parent instead — see todo.md #14's fix. Changing the advanced parent
* select calls `onParentChange`, which re-resolves `existingDay` for the
* newly selected parent in the caller and flows back down here. */
selectedParentId: string
onParentChange?: ((parentId: string) => void) | undefined
onSaveWish: (payload: PlannedDayModalSaveWishPayload) => Promise<void>
onSavePinned: (payload: PlannedDayModalSavePinnedPayload) => Promise<void>
onDelete?: (() => Promise<void>) | undefined
/** Removals recorded for THIS date (see `DayExclusionEntry`), one per
* parent. Shown with a way back: a day the user can't restore is a trap,
* and the undo toast is gone as soon as it times out. */
excludedEntries?: DayExclusionEntry[] | undefined
onRestoreExcluded?: ((exclusionId: string) => Promise<void>) | undefined
onClose: () => void
isSaving?: boolean | undefined
error?: string | null | undefined
isRangeMode?: boolean | undefined
dayCount?: number | undefined
defaultDayType?: 'sjukpenning' | 'lagsta' | 'vacation' | undefined
/** Dates in the current selection that are free days (weekend/red day,
* per workspace `DayOverride`) — a `vacation` pin always leaves these off,
* and a `sjukpenning`/`lagsta` pin does too unless `takePlOnRedDays`.
* Drives the "will stay off" notice below the type select. */
freeDatesInSelection?: string[] | undefined
/** Mirrors `Workspace.takePlOnRedDays` (see Settings) — whether this
* workspace opted in to funding free days with parental leave. */
takePlOnRedDays?: boolean | undefined
}
export function PlannedDayModal({
date,
isOpen,
existingDay,
parents,
selectedParentId,
onParentChange,
onSaveWish,
onSavePinned,
onDelete,
excludedEntries = [],
onRestoreExcluded,
onClose,
isSaving = false,
error = null,
isRangeMode = false,
dayCount = 1,
defaultDayType,
freeDatesInSelection = [],
takePlOnRedDays = false,
}: PlannedDayModalProps): ReactNode {
const { t, locale } = useTranslation()
const hasMultipleParents = parents.length > 1
// Editing an existing pinned day always reopens the advanced/pinned path —
// it must never be silently reinterpreted as a new planner-funded wish.
const forcedAdvanced = !isRangeMode && !!existingDay
// A solo household has nobody to choose between, so the "Advanced"
// disclosure would hide exactly one control. The type select is shown
// directly instead, with the planner-decides choice as its default.
const soloDirect = !hasMultipleParents && !forcedAdvanced
const [isAdvancedOpen, setIsAdvancedOpen] = useState(forcedAdvanced)
// --- Default ("planner decides") path state -----------------------------
const [parentMode, setParentMode] = useState<'either' | 'both' | 'specific'>(
hasMultipleParents ? 'both' : 'either',
)
const [specificParentId, setSpecificParentId] = useState(selectedParentId)
const [wantsDubbeldag, setWantsDubbeldag] = useState(false)
// --- Advanced ("pin exactly") path state ---------------------------------
const [dayType, setDayType] = useState<DayTypeChoice>(soloDirect ? 'auto' : 'vacation')
const [isDubbeldag, setIsDubbeldag] = useState(false)
// Whether saving pins an exact type (the escape hatch) or hands the dates
// to the planner as a wish. Couples opt in through the disclosure; a solo
// household opts in by picking a concrete type in the select.
const pinnedPath = forcedAdvanced || (soloDirect ? dayType !== 'auto' : isAdvancedOpen)
const showPinnedControls = forcedAdvanced || soloDirect || isAdvancedOpen
// A concrete type is about to be pinned (not the 'auto' planner-decides
// choice) — the pin-exclusion notice below only makes sense once that's
// true.
const isPinningConcreteType = pinnedPath && dayType !== 'auto'
// Would every free day (weekend/red day) in the selection stay OFF under
// the save path this dialog is currently set up to take? Pinning a
// concrete type: always for `vacation`, and for a PL type unless the
// workspace opted in via `takePlOnRedDays` — mirrors `Calendar.tsx`'s
// `handleSavePinnedDay` filter exactly. The default/auto (wish) path:
// `allocation_service.py`'s `is_free_day` branch fires BEFORE any type is
// chosen and marks the day 'free' unless `takePlOnRedDays`, regardless of
// parentMode/dubbeldag — so free days stay off there too, always, unless
// that same opt-in is on. Shown regardless of `isPinningConcreteType` so
// the default path warns just as loudly as the advanced one (see the
// "silent no-op" fix: planning a free day through the default dialog used
// to accept with nothing changed and no explanation).
const freeDaysWillStayOff =
freeDatesInSelection.length > 0 &&
(pinnedPath ? dayType === 'vacation' || !takePlOnRedDays : !takePlOnRedDays)
// Pinning excludes the day from `sync_plan`'s automatic re-planning —
// worth saying prospectively (about to pin) even when the day isn't
// already pinned; `existingDay?.isPinned` below already covers the
// already-pinned case, so this doesn't repeat there.
const showPinExclusionNotice = isPinningConcreteType && !existingDay?.isPinned
const [localError, setLocalError] = useState<string | null>(null)
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false)
useEffect(() => {
setIsAdvancedOpen(forcedAdvanced)
setParentMode(hasMultipleParents ? 'both' : 'either')
setSpecificParentId(selectedParentId)
setWantsDubbeldag(false)
setLocalError(null)
setIsConfirmingDelete(false)
// Deliberately keyed on `isOpen`/`date` (a fresh "session"), not on
// `selectedParentId` — that prop changes live while the advanced parent
// select is used (see `onParentChange`), and re-running this on every
// such change would stomp the parent-mode selector back to its default.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen, date, isRangeMode])
useEffect(() => {
// A range is never an edit of its first date's day, so that day's type
// must not leak into the select as if it were being edited.
if (existingDay && !isRangeMode) {
setDayType(isPinnedDayType(existingDay.dayType) ? existingDay.dayType : 'vacation')
setIsDubbeldag(existingDay.isDubbeldag)
} else {
setDayType(soloDirect ? 'auto' : (defaultDayType ?? 'vacation'))
setIsDubbeldag(false)
}
// Deliberately NOT a separate "if dayType === 'vacation', clear
// isDubbeldag" effect keyed on `[dayType]` — a second effect racing this
// one on the same mount/update (both scheduled from the same commit,
// each still closing over ITS OWN render's pre-update `dayType`) can
// clobber the `isDubbeldag: true` this effect just set, if the initial
// `dayType` state happened to be 'vacation' the moment this fires. The
// vacation/dubbeldag rule is instead enforced right where the user
// actually changes the type — see the advanced day-type `<select>`'s
// `onChange` below.
}, [isOpen, existingDay, defaultDayType, soloDirect, isRangeMode])
useEffect(() => {
if (parentMode !== 'both') {
setWantsDubbeldag(false)
}
}, [parentMode])
const handleSave = async () => {
try {
setLocalError(null)
if (pinnedPath && dayType !== 'auto') {
await onSavePinned({ dayType, parentId: selectedParentId, isDubbeldag })
} else {
await onSaveWish({
parentMode,
parentPersonalDataUuid: parentMode === 'specific' ? specificParentId : null,
wantsDubbeldag,
})
}
} catch (err) {
setLocalError(err instanceof Error ? err.message : t('plannedDayModal.saveError'))
}
}
const handleDeleteClick = () => {
if (!onDelete) return
setIsConfirmingDelete(true)
}
const handleCancelDelete = () => {
setIsConfirmingDelete(false)
}
const handleConfirmDelete = async () => {
if (!onDelete) return
try {
setLocalError(null)
await onDelete()
} catch (err) {
setLocalError(err instanceof Error ? err.message : t('plannedDayModal.deleteError'))
} finally {
setIsConfirmingDelete(false)
}
}
const handleParentModeChange = (value: string) => {
if (value === 'either' || value === 'both') {
setParentMode(value)
} else {
setParentMode('specific')
setSpecificParentId(value)
}
}
const handleAdvancedParentChange = (parentId: string) => {
onParentChange?.(parentId)
}
if (!isOpen) return null
const displayDate = new Date(date + 'T00:00:00Z').toLocaleDateString(locale === 'sv' ? 'sv-SE' : 'en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})
const modalTitle = isRangeMode
? t('plannedDayModal.rangeTitle', { count: dayCount })
: existingDay
? t('plannedDayModal.editTitle')
: t('plannedDayModal.newTitle')
const parentModeSelectValue = parentMode === 'specific' ? specificParentId : parentMode
return (
<div className="modal__overlay" onClick={onClose} data-testid="planned-day-modal__overlay" role="dialog">
<div
className="modal__content modal__content--sm"
onClick={(e) => e.stopPropagation()}
data-testid="planned-day-modal__content"
>
<div className="modal__header">
<h2 data-testid="modal-title">{modalTitle}</h2>
<button type="button" className="btn btn--icon" onClick={onClose} aria-label={t('plannedDayModal.closeAria')}>
<Icon name="close" size={20} />
</button>
</div>
<div className="modal__body">
{!isRangeMode && <p className="planned-day-modal__date">{displayDate}</p>}
{(error || localError) && (
<div className="alert alert--error" role="alert">
{error || localError}
</div>
)}
{!isRangeMode &&
excludedEntries.map((entry) => (
<div
key={entry.id}
className="planned-day-modal__excluded-notice"
data-testid="excluded-notice"
>
<span>{t('plannedDayModal.excludedNotice', { parent: entry.parentName })}</span>
{onRestoreExcluded && (
<button
type="button"
className="btn btn--secondary btn--sm"
onClick={() => void onRestoreExcluded(entry.id)}
disabled={isSaving}
data-testid={`restore-excluded-${entry.id}`}
>
{t('plannedDayModal.restoreExcludedButton')}
</button>
)}
</div>
))}
<div className="stack">
{!forcedAdvanced && hasMultipleParents && (
<div className="field">
<label htmlFor="parent-mode-select" className="field__label">
{t('plannedDayModal.parentModeLabel')}
</label>
<select
id="parent-mode-select"
value={parentModeSelectValue}
onChange={(e) => handleParentModeChange(e.target.value)}
className="field__control"
disabled={isSaving}
data-testid="parent-mode-select"
>
<option value="either">{t('wishEditor.eitherParent')}</option>
<option value="both">{t('wishEditor.bothParents')}</option>
{parents.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
)}
{!forcedAdvanced && hasMultipleParents && (
<div className="field">
<label className="planned-day-modal__checkbox-label">
<input
type="checkbox"
checked={wantsDubbeldag}
onChange={(e) => setWantsDubbeldag(e.target.checked)}
disabled={isSaving || parentMode !== 'both'}
data-testid="wants-dubbeldag-checkbox"
/>
{' '}{t('plannedDayModal.wantsDubbeldagLabel')}
</label>
<p className="planned-day-modal__hint">
{parentMode === 'both'
? t('plannedDayModal.wantsDubbeldagHint')
: t('plannedDayModal.wantsDubbeldagRequiresBothHint')}
</p>
</div>
)}
{/* The default path's own copy of the "will stay off" notice —
* `showPinnedControls` is false here (Advanced isn't open), so
* the copy inside that section below never renders for this
* case. Gated on `!showPinnedControls` rather than re-deriving
* "default path" so the two copies can never both render. */}
{!forcedAdvanced && hasMultipleParents && !showPinnedControls && freeDaysWillStayOff && (
<p className="planned-day-modal__hint" data-testid="free-days-stay-off-notice">
{t('plannedDayModal.freeDaysStayOffNotice')}
</p>
)}
{!forcedAdvanced && hasMultipleParents && (
<button
type="button"
className="btn btn--link planned-day-modal__advanced-toggle"
onClick={() => setIsAdvancedOpen((open) => !open)}
disabled={isSaving}
data-testid="advanced-toggle"
aria-expanded={isAdvancedOpen}
>
{isAdvancedOpen ? t('plannedDayModal.hideAdvanced') : t('plannedDayModal.showAdvanced')}
</button>
)}
{showPinnedControls && (
<div className="planned-day-modal__advanced" data-testid="advanced-section">
<div className="field">
<label htmlFor="day-type" className="field__label">
{t('plannedDayModal.leaveTypeLabel')}
</label>
<select
id="day-type"
value={dayType}
onChange={(e) => {
const value = e.target.value
const next: DayTypeChoice = isPinnedDayType(value) ? value : 'auto'
setDayType(next)
if (next === 'vacation' || next === 'auto') setIsDubbeldag(false)
}}
className="field__control"
disabled={isSaving}
data-testid="day-type-select"
>
{soloDirect && <option value="auto">{t('plannedDayModal.autoOption')}</option>}
<option value="vacation">{t('plannedDayModal.vacationOption')}</option>
<option value="sjukpenning">{t('plannedDayModal.sjukpenningOption')}</option>
<option value="lagsta">{t('plannedDayModal.lagstaOption')}</option>
</select>
</div>
{freeDaysWillStayOff && (
<p className="planned-day-modal__hint" data-testid="free-days-stay-off-notice">
{t('plannedDayModal.freeDaysStayOffNotice')}
</p>
)}
{showPinExclusionNotice && (
<p className="planned-day-modal__hint" data-testid="pin-exclusion-notice">
{t('plannedDayModal.pinExcludesFromSyncNotice')}
</p>
)}
{hasMultipleParents && (
<div className="field">
<label htmlFor="parent-select" className="field__label">
{t('plannedDayModal.parentLabel')}
</label>
<select
id="parent-select"
value={selectedParentId}
onChange={(e) => handleAdvancedParentChange(e.target.value)}
className="field__control"
disabled={isSaving}
data-testid="parent-select"
>
{parents.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
)}
{dayType !== 'auto' && (
<div className="field">
<label className="planned-day-modal__checkbox-label">
<input
type="checkbox"
checked={isDubbeldag}
onChange={(e) => setIsDubbeldag(e.target.checked)}
disabled={isSaving || dayType === 'vacation'}
data-testid="dubbeldag-checkbox"
/>
{' '}{t('plannedDayModal.doubleDayLabel')}
</label>
{dayType === 'vacation' && (
<p className="planned-day-modal__hint">
{t('plannedDayModal.doubleDayHint')}
</p>
)}
</div>
)}
{existingDay?.isPinned && (
<p className="planned-day-modal__pinned-notice" data-testid="pinned-notice">
<Icon name="pin" size={16} />
<span>{t('plannedDayModal.pinnedNotice')}</span>
</p>
)}
</div>
)}
</div>
</div>
<div className="modal__footer">
{!isRangeMode && existingDay && onDelete && isConfirmingDelete && (
<div className="modal__delete-confirm" data-testid="delete-confirm">
<span className="modal__delete-confirm-text">
{t('plannedDayModal.confirmDelete')}
</span>
<button
type="button"
className="btn btn--secondary"
onClick={handleCancelDelete}
disabled={isSaving}
data-testid="delete-cancel-button"
>
{t('plannedDayModal.cancelButton')}
</button>
<button
type="button"
className="btn btn--danger"
onClick={() => void handleConfirmDelete()}
disabled={isSaving}
data-testid="delete-confirm-button"
>
{isSaving ? t('plannedDayModal.deletingButton') : t('plannedDayModal.deleteConfirmButton')}
</button>
</div>
)}
{!isConfirmingDelete && (
<>
{!isRangeMode && existingDay && onDelete && (
<button
type="button"
className="btn btn--danger btn--start"
onClick={handleDeleteClick}
disabled={isSaving}
data-testid="delete-button"
>
{isSaving ? t('plannedDayModal.deletingButton') : t('plannedDayModal.deleteButton')}
</button>
)}
<button
type="button"
className="btn btn--secondary"
onClick={onClose}
disabled={isSaving}
data-testid="cancel-button"
>
{t('plannedDayModal.cancelButton')}
</button>
<button
type="button"
className="btn btn--primary"
onClick={() => void handleSave()}
disabled={isSaving}
data-testid="save-button"
>
{isSaving ? t('plannedDayModal.savingButton') : t('plannedDayModal.saveButton')}
</button>
</>
)}
</div>
</div>
</div>
)
}