06592df87c
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>
538 lines
22 KiB
TypeScript
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>
|
|
)
|
|
}
|