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 onSavePinned: (payload: PlannedDayModalSavePinnedPayload) => Promise onDelete?: (() => Promise) | 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) | 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(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(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 ` handleParentModeChange(e.target.value)} className="field__control" disabled={isSaving} data-testid="parent-mode-select" > {parents.map((p) => ( ))} )} {!forcedAdvanced && hasMultipleParents && (

{parentMode === 'both' ? t('plannedDayModal.wantsDubbeldagHint') : t('plannedDayModal.wantsDubbeldagRequiresBothHint')}

)} {/* 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 && (

{t('plannedDayModal.freeDaysStayOffNotice')}

)} {!forcedAdvanced && hasMultipleParents && ( )} {showPinnedControls && (
{freeDaysWillStayOff && (

{t('plannedDayModal.freeDaysStayOffNotice')}

)} {showPinExclusionNotice && (

{t('plannedDayModal.pinExcludesFromSyncNotice')}

)} {hasMultipleParents && (
)} {dayType !== 'auto' && (
{dayType === 'vacation' && (

{t('plannedDayModal.doubleDayHint')}

)}
)} {existingDay?.isPinned && (

{t('plannedDayModal.pinnedNotice')}

)}
)}
{!isRangeMode && existingDay && onDelete && isConfirmingDelete && (
{t('plannedDayModal.confirmDelete')}
)} {!isConfirmingDelete && ( <> {!isRangeMode && existingDay && onDelete && ( )} )}
) }