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:
2026-09-19 15:14:24 +02:00
parent 11f2a17b43
commit 35e479a005
17 changed files with 1514 additions and 750 deletions
@@ -41,33 +41,47 @@ describe('PeriodPopover', () => {
expect(screen.queryByTestId('period-save-button')).not.toBeInTheDocument()
})
it('shows the ad-hoc delete label', () => {
it('shows the ad-hoc "this period" label and disables "every year" (nothing recurring to remove)', () => {
render(<PeriodPopover {...defaultProps} />)
expect(screen.getByTestId('period-delete-button')).toHaveTextContent('Ta bort dessa dagar')
expect(screen.getByTestId('period-delete-year-button')).toHaveTextContent('Ta bort dessa dagar')
expect(screen.getByTestId('period-delete-day-button')).not.toBeDisabled()
expect(screen.getByTestId('period-delete-all-years-button')).toBeDisabled()
expect(screen.getByTestId('period-delete-all-years-button')).toHaveAttribute(
'title',
'Den här perioden är inte kopplad till en återkommande plan.',
)
})
it('shows an inline confirmation instead of deleting immediately', () => {
render(<PeriodPopover {...defaultProps} />)
fireEvent.click(screen.getByTestId('period-delete-button'))
fireEvent.click(screen.getByTestId('period-delete-year-button'))
expect(mockOnDelete).not.toHaveBeenCalled()
expect(screen.getByTestId('period-delete-confirm')).toBeInTheDocument()
})
it('calls onDelete after confirming inline', async () => {
it('calls onDelete with the "year" scope after confirming inline', async () => {
mockOnDelete.mockResolvedValue(undefined)
render(<PeriodPopover {...defaultProps} />)
fireEvent.click(screen.getByTestId('period-delete-button'))
fireEvent.click(screen.getByTestId('period-delete-year-button'))
fireEvent.click(screen.getByTestId('period-delete-confirm-button'))
await waitFor(() => expect(mockOnDelete).toHaveBeenCalled())
await waitFor(() => expect(mockOnDelete).toHaveBeenCalledWith('year'))
})
it('calls onDelete with the "day" scope for the day button', async () => {
mockOnDelete.mockResolvedValue(undefined)
render(<PeriodPopover {...defaultProps} />)
fireEvent.click(screen.getByTestId('period-delete-day-button'))
fireEvent.click(screen.getByTestId('period-delete-confirm-button'))
await waitFor(() => expect(mockOnDelete).toHaveBeenCalledWith('day'))
})
it('does not call onDelete when the inline confirmation is cancelled', () => {
render(<PeriodPopover {...defaultProps} />)
fireEvent.click(screen.getByTestId('period-delete-button'))
fireEvent.click(screen.getByTestId('period-delete-year-button'))
fireEvent.click(screen.getByTestId('period-delete-cancel-button'))
expect(mockOnDelete).not.toHaveBeenCalled()
expect(screen.queryByTestId('period-delete-confirm')).not.toBeInTheDocument()
expect(screen.getByTestId('period-delete-button')).toBeInTheDocument()
expect(screen.getByTestId('period-delete-year-button')).toBeInTheDocument()
})
})
@@ -223,6 +237,7 @@ describe('PeriodPopover', () => {
parentPersonalDataUuid: null,
recurrence: 'yearly',
lastYear: null,
wantsDubbeldag: false,
})
})
})
@@ -280,27 +295,88 @@ describe('PeriodPopover', () => {
})
})
describe('dubbeldagar checkbox', () => {
it('is enabled only when parentMode is "both"', () => {
render(<PeriodPopover {...defaultProps} wish={makeLeaveWish({ ...wish, parentMode: 'both' })} />)
expect(screen.getByTestId('period-wants-dubbeldag-checkbox')).not.toBeDisabled()
})
it('is disabled for a non-"both" parentMode', () => {
render(<PeriodPopover {...defaultProps} wish={wish} />)
expect(screen.getByTestId('period-wants-dubbeldag-checkbox')).toBeDisabled()
})
it('sends wantsDubbeldag: true only when checked in "both" mode', async () => {
mockOnSave.mockResolvedValue(undefined)
render(<PeriodPopover {...defaultProps} wish={makeLeaveWish({ ...wish, parentMode: 'both' })} />)
fireEvent.click(screen.getByTestId('period-wants-dubbeldag-checkbox'))
fireEvent.click(screen.getByTestId('period-save-button'))
await waitFor(() => expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ wantsDubbeldag: true })))
})
it('never sends wantsDubbeldag: true outside "both" mode, even if it was checked before switching', async () => {
mockOnSave.mockResolvedValue(undefined)
render(<PeriodPopover {...defaultProps} wish={makeLeaveWish({ ...wish, parentMode: 'both', wantsDubbeldag: true })} />)
fireEvent.change(screen.getByTestId('period-parent-select'), { target: { value: 'either' } })
fireEvent.click(screen.getByTestId('period-save-button'))
await waitFor(() => expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ wantsDubbeldag: false })))
})
})
it('disables "this day" and shows why, for a wish-linked band (would just reappear on the next sync)', () => {
render(<PeriodPopover {...defaultProps} wish={wish} />)
expect(screen.getByTestId('period-delete-day-button')).toBeDisabled()
expect(screen.getByTestId('period-delete-day-button')).toHaveAttribute(
'title',
"Går inte att ta bort en enskild dag från en återkommande plan — planeraren lägger tillbaka den vid nästa synk. Ta bort perioden istället.",
)
expect(screen.getByTestId('period-delete-year-button')).not.toBeDisabled()
expect(screen.getByTestId('period-delete-all-years-button')).not.toBeDisabled()
})
it('displays a generic local error when onDelete rejects with a non-Error value', async () => {
mockOnDelete.mockRejectedValue('boom')
render(<PeriodPopover {...defaultProps} wish={wish} />)
fireEvent.click(screen.getByTestId('period-delete-button'))
fireEvent.click(screen.getByTestId('period-delete-year-button'))
fireEvent.click(screen.getByTestId('period-delete-confirm-button'))
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('Det gick inte att skapa perioden')
})
})
it('calls onDelete for a wish-linked band after confirmation', async () => {
it('calls onDelete with the "allYears" scope for a wish-linked band after confirmation', async () => {
mockOnDelete.mockResolvedValue(undefined)
render(<PeriodPopover {...defaultProps} wish={wish} />)
fireEvent.click(screen.getByTestId('period-delete-button'))
fireEvent.click(screen.getByTestId('period-delete-all-years-button'))
fireEvent.click(screen.getByTestId('period-delete-confirm-button'))
await waitFor(() => expect(mockOnDelete).toHaveBeenCalled())
await waitFor(() => expect(mockOnDelete).toHaveBeenCalledWith('allYears'))
})
it('shows the wish-linked delete label', () => {
it('calls onDelete with the "year" scope for a wish-linked band', async () => {
mockOnDelete.mockResolvedValue(undefined)
render(<PeriodPopover {...defaultProps} wish={wish} />)
expect(screen.getByTestId('period-delete-button')).toHaveTextContent('Ta bort period')
fireEvent.click(screen.getByTestId('period-delete-year-button'))
fireEvent.click(screen.getByTestId('period-delete-confirm-button'))
await waitFor(() => expect(mockOnDelete).toHaveBeenCalledWith('year'))
})
it('shows the wish-linked "this period" and "every year" labels', () => {
render(<PeriodPopover {...defaultProps} wish={wish} />)
expect(screen.getByTestId('period-delete-year-button')).toHaveTextContent('Den här perioden')
expect(screen.getByTestId('period-delete-all-years-button')).toHaveTextContent('Den här perioden, varje år')
})
it('shows a scope-specific confirmation message for each delete button', () => {
render(<PeriodPopover {...defaultProps} wish={wish} />)
fireEvent.click(screen.getByTestId('period-delete-year-button'))
expect(screen.getByTestId('period-delete-confirm')).toHaveTextContent(
'Är du säker på att du vill ta bort den här perioden (bara det här året)?',
)
fireEvent.click(screen.getByTestId('period-delete-cancel-button'))
fireEvent.click(screen.getByTestId('period-delete-all-years-button'))
expect(screen.getByTestId('period-delete-confirm')).toHaveTextContent(
'Är du säker på att du vill ta bort perioden — alla år?',
)
})
it('displays a local error when onSave rejects', async () => {
@@ -19,8 +19,20 @@ export interface PeriodPopoverSavePayload {
// Meaningful only when `recurrence === 'yearly'`. `null` = "until the
// child turns 4" (no explicit end year).
lastYear: number | null
// §Decision A (co-leave, opt-in dubbeldagar) — only meaningful/sent when
// `parentMode === 'both'`. See `LeaveWish.wantsDubbeldag` and
// dubbeldagar.md at the repo root.
wantsDubbeldag: boolean
}
/** todo.md #9 — the three-way remove. `'day'` removes just the clicked
* occurrence's first date (ad-hoc bands only — see Calendar.tsx's
* `handleDeletePeriod`); `'year'` removes this occurrence/year only (for a
* yearly wish, narrows or splits `firstYear`/`lastYear`; for an ad-hoc band,
* removes the whole contiguous range, same as today); `'allYears'` removes
* every occurrence (today's only wish-branch behaviour, unchanged). */
export type PeriodDeleteScope = 'day' | 'year' | 'allYears'
export interface PeriodPopoverProps {
isOpen: boolean
/** The `LeaveWish` behind the clicked band, or `null` for an ad-hoc band
@@ -35,7 +47,7 @@ export interface PeriodPopoverProps {
isSaving?: boolean | undefined
error?: string | null | undefined
onSave: (payload: PeriodPopoverSavePayload) => Promise<void>
onDelete: () => Promise<void>
onDelete: (scope: PeriodDeleteScope) => Promise<void>
onClose: () => void
}
@@ -47,6 +59,7 @@ interface FormState {
parentPersonalDataUuid: string | null
recurrence: 'yearly' | 'once'
lastYear: number | null
wantsDubbeldag: boolean
}
/** Mirrors `LeaveWish.window_wraps()` (backend/app/entities/leave_wish.py):
@@ -89,6 +102,7 @@ function formStateFromWish(
parentPersonalDataUuid: wish.parentPersonalDataUuid,
recurrence: wish.recurrence,
lastYear: wish.lastYear,
wantsDubbeldag: wish.wantsDubbeldag,
}
}
@@ -113,9 +127,10 @@ export function PeriodPopover({
parentPersonalDataUuid: null,
recurrence: 'once',
lastYear: null,
wantsDubbeldag: false,
})
const [localError, setLocalError] = useState<string | null>(null)
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false)
const [deleteScope, setDeleteScope] = useState<PeriodDeleteScope | null>(null)
useEffect(() => {
if (wish) {
@@ -129,10 +144,11 @@ export function PeriodPopover({
parentPersonalDataUuid: null,
recurrence: 'once',
lastYear: null,
wantsDubbeldag: false,
})
}
setLocalError(null)
setIsConfirmingDelete(false)
setDeleteScope(null)
}, [isOpen, wish, startDate, endDate, t])
if (!isOpen) return null
@@ -151,31 +167,39 @@ export function PeriodPopover({
parentPersonalDataUuid: form.parentMode === 'specific' ? form.parentPersonalDataUuid : null,
recurrence: form.recurrence,
lastYear: form.recurrence === 'yearly' ? form.lastYear : null,
wantsDubbeldag: form.parentMode === 'both' ? form.wantsDubbeldag : false,
})
} catch (err) {
setLocalError(err instanceof Error ? err.message : t('calendar.periodCreateError'))
}
}
const handleDeleteClick = () => {
setIsConfirmingDelete(true)
const handleDeleteClick = (scope: PeriodDeleteScope) => {
setDeleteScope(scope)
}
const handleCancelDelete = () => {
setIsConfirmingDelete(false)
setDeleteScope(null)
}
const handleConfirmDelete = async () => {
if (!deleteScope) return
try {
setLocalError(null)
await onDelete()
await onDelete(deleteScope)
} catch (err) {
setLocalError(err instanceof Error ? err.message : t('calendar.periodCreateError'))
} finally {
setIsConfirmingDelete(false)
setDeleteScope(null)
}
}
const deleteConfirmMessage: Record<PeriodDeleteScope, string> = {
day: t('calendar.periodDeleteConfirmDay'),
year: t('calendar.periodDeleteConfirmYear'),
allYears: t('calendar.periodDeleteConfirmAllYears'),
}
const title = wish ? resolveWishName(wish, t) : t('calendar.periodAdHocTitle')
return (
@@ -297,6 +321,24 @@ export function PeriodPopover({
</select>
</div>
<div className="field">
<label className="period-popover__checkbox-label">
<input
type="checkbox"
checked={form.wantsDubbeldag}
onChange={(e) => setForm((prev) => ({ ...prev, wantsDubbeldag: e.target.checked }))}
disabled={isSaving || form.parentMode !== 'both'}
data-testid="period-wants-dubbeldag-checkbox"
/>
{' '}{t('calendar.wantsDubbeldagLabel')}
</label>
<p className="period-popover__hint">
{form.parentMode === 'both'
? t('calendar.wantsDubbeldagHint')
: t('calendar.wantsDubbeldagRequiresBothHint')}
</p>
</div>
<fieldset className="period-popover__fieldset" data-testid="period-repeat-fieldset">
<legend className="field__label">{t('calendar.periodRepeatLabel')}</legend>
<label className="period-popover__radio-label">
@@ -365,11 +407,9 @@ export function PeriodPopover({
</div>
<div className="modal__footer">
{isConfirmingDelete ? (
{deleteScope ? (
<div className="modal__delete-confirm" data-testid="period-delete-confirm">
<span className="modal__delete-confirm-text">
{t('calendar.periodDeleteConfirm')}
</span>
<span className="modal__delete-confirm-text">{deleteConfirmMessage[deleteScope]}</span>
<button
type="button"
className="btn btn--secondary"
@@ -391,19 +431,37 @@ export function PeriodPopover({
</div>
) : (
<>
<button
type="button"
className="btn btn--danger btn--start"
onClick={handleDeleteClick}
disabled={isSaving}
data-testid="period-delete-button"
>
{isSaving
? t('calendar.periodDeletingButton')
: wish
? t('calendar.periodDeleteButton')
: t('calendar.periodDeleteAdHocButton')}
</button>
<div className="period-popover__delete-group btn--start" data-testid="period-delete-group">
<button
type="button"
className="btn btn--danger"
onClick={() => handleDeleteClick('day')}
disabled={isSaving || wish !== null}
title={wish !== null ? t('calendar.periodDeleteDayDisabledHint') : undefined}
data-testid="period-delete-day-button"
>
{t('calendar.periodDeleteDayButton')}
</button>
<button
type="button"
className="btn btn--danger"
onClick={() => handleDeleteClick('year')}
disabled={isSaving}
data-testid="period-delete-year-button"
>
{wish ? t('calendar.periodDeleteYearButton') : t('calendar.periodDeleteAdHocButton')}
</button>
<button
type="button"
className="btn btn--danger"
onClick={() => handleDeleteClick('allYears')}
disabled={isSaving || wish === null}
title={wish === null ? t('calendar.periodDeleteAllYearsDisabledHint') : undefined}
data-testid="period-delete-all-years-button"
>
{t('calendar.periodDeleteAllYearsButton')}
</button>
</div>
<button
type="button"
className="btn btn--secondary"
@@ -32,6 +32,24 @@
cursor: pointer;
}
.period-popover__checkbox-label input:disabled {
cursor: not-allowed;
}
.period-popover__hint {
font-size: var(--fs-xs);
color: var(--c-text-faint);
margin: calc(var(--sp-2) * -1) 0 var(--sp-2) var(--sp-6);
font-style: italic;
}
/* todo.md #9 — the three-way remove: the delete-scope buttons sit side by
* side, same row as the other footer buttons. */
.period-popover__delete-group {
display: flex;
gap: var(--sp-2);
}
/* Footer, buttons and the inline delete-confirmation row are shared with
* the other modals via .modal__footer / .btn* / .modal__delete-confirm* in
* src/styles/primitives.css — see that file for the button formula. */
File diff suppressed because it is too large Load Diff
@@ -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"
>
@@ -46,6 +46,19 @@
margin-bottom: 0;
}
.planned-day-modal__advanced-toggle {
align-self: flex-start;
padding-left: 0;
}
.planned-day-modal__advanced {
display: flex;
flex-direction: column;
gap: var(--sp-4);
padding-top: var(--sp-2);
border-top: 1px solid var(--c-border);
}
/* Footer, buttons and the inline delete-confirmation row are shared with
* the other modals via .modal__footer / .btn* / .modal__delete-confirm* in
* src/styles/primitives.css — see that file for the button formula. */