Let the planner fund a hand-drawn period, and remove one at a time
Three changes to calendar editing, too interleaved in the same files to land separately. Giving the same days to the second parent rewrote the first parent's days instead of adding their own. The save looked the existing day up against whoever was logged in rather than the parent being saved for, so parent A's leave turned into parent B's vacation. The parent choice now lives with the lookup, and two parents on the same dates produce two rows and two bands. Drawing a period by hand meant naming a day type up front. It now asks who is off and lets the planner fund it, as a one-off period the sync places, with double days offered when both parents are off and priced honestly in the hint. Pinning an exact day type is still there, moved behind Advanced, and editing a pinned day goes straight back to it. Removing a period offered only the two extremes. There are now three choices: this day, this year's period, or every year. Taking one year out of a repeating period narrows it, or splits it in two when the year is in the middle; where a scope cannot be expressed it is disabled with a reason rather than doing something surprising. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
This commit is contained in:
@@ -1,19 +1,43 @@
|
||||
import { ReactNode, useState, useEffect } from 'react'
|
||||
import { ReactNode, useEffect, useState } from 'react'
|
||||
import type { PlannedLeaveDay } from '@/types'
|
||||
import { useTranslation } from '@/i18n'
|
||||
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: 'sjukpenning' | 'lagsta' | 'vacation'
|
||||
parentId: string
|
||||
isDubbeldag: boolean
|
||||
}
|
||||
|
||||
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
|
||||
onSave: (payload: {
|
||||
dayType: 'sjukpenning' | 'lagsta' | 'vacation'
|
||||
parentId: string
|
||||
isDubbeldag: boolean
|
||||
}) => Promise<void>
|
||||
onParentChange?: ((parentId: string) => void) | undefined
|
||||
onSaveWish: (payload: PlannedDayModalSaveWishPayload) => Promise<void>
|
||||
onSavePinned: (payload: PlannedDayModalSavePinnedPayload) => Promise<void>
|
||||
onDelete?: (() => Promise<void>) | undefined
|
||||
onClose: () => void
|
||||
isSaving?: boolean | undefined
|
||||
@@ -29,7 +53,9 @@ export function PlannedDayModal({
|
||||
existingDay,
|
||||
parents,
|
||||
selectedParentId,
|
||||
onSave,
|
||||
onParentChange,
|
||||
onSaveWish,
|
||||
onSavePinned,
|
||||
onDelete,
|
||||
onClose,
|
||||
isSaving = false,
|
||||
@@ -39,40 +65,79 @@ export function PlannedDayModal({
|
||||
defaultDayType,
|
||||
}: 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
|
||||
const [isAdvancedOpen, setIsAdvancedOpen] = useState(forcedAdvanced)
|
||||
const advancedOpen = forcedAdvanced || isAdvancedOpen
|
||||
|
||||
// --- 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<'sjukpenning' | 'lagsta' | 'vacation'>('vacation')
|
||||
const [parentId, setParentId] = useState(selectedParentId)
|
||||
const [isDubbeldag, setIsDubbeldag] = useState(false)
|
||||
|
||||
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(() => {
|
||||
if (existingDay) {
|
||||
setDayType(existingDay.dayType as 'sjukpenning' | 'lagsta' | 'vacation')
|
||||
setParentId(existingDay.parentPersonalDataUuid)
|
||||
setIsDubbeldag(existingDay.isDubbeldag)
|
||||
} else {
|
||||
setDayType(defaultDayType ?? 'vacation')
|
||||
setParentId(selectedParentId)
|
||||
setIsDubbeldag(false)
|
||||
}
|
||||
setLocalError(null)
|
||||
setIsConfirmingDelete(false)
|
||||
}, [isOpen, existingDay, selectedParentId, defaultDayType])
|
||||
// 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])
|
||||
|
||||
useEffect(() => {
|
||||
if (dayType === 'vacation') {
|
||||
setIsDubbeldag(false)
|
||||
if (parentMode !== 'both') {
|
||||
setWantsDubbeldag(false)
|
||||
}
|
||||
}, [dayType])
|
||||
}, [parentMode])
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setLocalError(null)
|
||||
await onSave({
|
||||
dayType,
|
||||
parentId,
|
||||
isDubbeldag,
|
||||
})
|
||||
if (advancedOpen) {
|
||||
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'))
|
||||
}
|
||||
@@ -99,6 +164,19 @@ export function PlannedDayModal({
|
||||
}
|
||||
}
|
||||
|
||||
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', {
|
||||
@@ -114,6 +192,8 @@ export function PlannedDayModal({
|
||||
? 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
|
||||
@@ -138,37 +218,21 @@ export function PlannedDayModal({
|
||||
)}
|
||||
|
||||
<div className="stack">
|
||||
<div className="field">
|
||||
<label htmlFor="day-type" className="field__label">
|
||||
{t('plannedDayModal.leaveTypeLabel')}
|
||||
</label>
|
||||
<select
|
||||
id="day-type"
|
||||
value={dayType}
|
||||
onChange={(e) => setDayType(e.target.value as 'sjukpenning' | 'lagsta' | 'vacation')}
|
||||
className="field__control"
|
||||
disabled={isSaving}
|
||||
data-testid="day-type-select"
|
||||
>
|
||||
<option value="vacation">{t('plannedDayModal.vacationOption')}</option>
|
||||
<option value="sjukpenning">{t('plannedDayModal.sjukpenningOption')}</option>
|
||||
<option value="lagsta">{t('plannedDayModal.lagstaOption')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{parents.length > 1 && (
|
||||
{!forcedAdvanced && hasMultipleParents && (
|
||||
<div className="field">
|
||||
<label htmlFor="parent-select" className="field__label">
|
||||
{t('plannedDayModal.parentLabel')}
|
||||
<label htmlFor="parent-mode-select" className="field__label">
|
||||
{t('plannedDayModal.parentModeLabel')}
|
||||
</label>
|
||||
<select
|
||||
id="parent-select"
|
||||
value={parentId}
|
||||
onChange={(e) => setParentId(e.target.value)}
|
||||
id="parent-mode-select"
|
||||
value={parentModeSelectValue}
|
||||
onChange={(e) => handleParentModeChange(e.target.value)}
|
||||
className="field__control"
|
||||
disabled={isSaving}
|
||||
data-testid="parent-select"
|
||||
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}
|
||||
@@ -178,28 +242,109 @@ export function PlannedDayModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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' && (
|
||||
{!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">
|
||||
{t('plannedDayModal.doubleDayHint')}
|
||||
{parentMode === 'both'
|
||||
? t('plannedDayModal.wantsDubbeldagHint')
|
||||
: t('plannedDayModal.wantsDubbeldagRequiresBothHint')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{existingDay?.isPinned && (
|
||||
<p className="planned-day-modal__pinned-notice" data-testid="pinned-notice">
|
||||
📌 {t('plannedDayModal.pinnedNotice')}
|
||||
</p>
|
||||
{!forcedAdvanced && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--ghost 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>
|
||||
)}
|
||||
|
||||
{advancedOpen && (
|
||||
<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 as 'sjukpenning' | 'lagsta' | 'vacation'
|
||||
setDayType(value)
|
||||
if (value === 'vacation') setIsDubbeldag(false)
|
||||
}}
|
||||
className="field__control"
|
||||
disabled={isSaving}
|
||||
data-testid="day-type-select"
|
||||
>
|
||||
<option value="vacation">{t('plannedDayModal.vacationOption')}</option>
|
||||
<option value="sjukpenning">{t('plannedDayModal.sjukpenningOption')}</option>
|
||||
<option value="lagsta">{t('plannedDayModal.lagstaOption')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
<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">
|
||||
📌 {t('plannedDayModal.pinnedNotice')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -255,7 +400,7 @@ export function PlannedDayModal({
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--primary"
|
||||
onClick={handleSave}
|
||||
onClick={() => void handleSave()}
|
||||
disabled={isSaving}
|
||||
data-testid="save-button"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user