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:
@@ -5,10 +5,14 @@
|
||||
* specs only check rendering (bands, red days, age-4 figures) of state the
|
||||
* API seeded directly.
|
||||
*
|
||||
* A drag no longer creates a `LeaveWish`/synced period on its own — it opens
|
||||
* the day popover pre-selected on every dragged date, and saving writes
|
||||
* exactly those days (ad-hoc `planned_leave_day` rows, no wish). See
|
||||
* `Calendar.tsx`'s pointerup handler and `handleSavePlannedDay`.
|
||||
* A drag opens the day popover pre-selected on every dragged date, same as
|
||||
* before. What that popover does on save changed (todo.md #10/#11): by
|
||||
* default it now creates a fixed, one-off `LeaveWish` and lets the planner
|
||||
* fund it (a `calendar-band-wish:` band, not `adhoc:`) — see
|
||||
* `Calendar.tsx`'s `handleSaveWish`. The old "write exact ad-hoc
|
||||
* `planned_leave_day` rows" behaviour still exists, behind the modal's
|
||||
* "Advanced" disclosure (`handleSavePinnedDay`) — the first test below
|
||||
* opens it explicitly to keep exercising that path.
|
||||
*
|
||||
* The drag deliberately crosses a month boundary (the last two days of one
|
||||
* month into the first day of the next) — a regression guard for the
|
||||
@@ -53,7 +57,7 @@ function targetRange(): { month: Date; dates: [string, string, string] } {
|
||||
return { month, dates }
|
||||
}
|
||||
|
||||
test('dragging a range across a month boundary plans every day, and deleting it removes the days for good', async ({
|
||||
test('dragging a range and pinning it through Advanced plans every day exactly, and deleting it removes the days for good', async ({
|
||||
page,
|
||||
}) => {
|
||||
await registerAndReachOnboarding(page, uniqueEmail('drag-delete'), 'Robin Testsson')
|
||||
@@ -75,6 +79,10 @@ test('dragging a range across a month boundary plans every day, and deleting it
|
||||
// A multi-day drag opens the day popover pre-selected on every dragged
|
||||
// date — it does not create a period/wish by itself.
|
||||
await expect(page.getByTestId('modal-title')).toHaveText('Planera 3 dagar')
|
||||
// The default save path would create a fixed wish instead (see the next
|
||||
// test) — opening "Advanced" keeps this test on the exact-pinned-days
|
||||
// path todo.md #10/#11 kept as an escape hatch.
|
||||
await page.getByTestId('advanced-toggle').click()
|
||||
await page.getByTestId('save-button').click()
|
||||
await expect(page.getByTestId('planned-day-modal__content')).not.toBeVisible()
|
||||
|
||||
@@ -104,7 +112,11 @@ test('dragging a range across a month boundary plans every day, and deleting it
|
||||
await expect(page.getByTestId('period-adhoc-notice')).toBeVisible()
|
||||
await expect(page.getByTestId('period-popover__content')).toContainText(`${firstDate} – ${lastDate}`)
|
||||
|
||||
await page.getByTestId('period-delete-button').click()
|
||||
// todo.md #9 — an ad-hoc band has no recurring plan, so "every year" is
|
||||
// disabled; "this day"/"this period" are the only meaningful choices, and
|
||||
// "this period" removes the whole ad-hoc range (today's only behaviour).
|
||||
await expect(page.getByTestId('period-delete-all-years-button')).toBeDisabled()
|
||||
await page.getByTestId('period-delete-year-button').click()
|
||||
await expect(page.getByTestId('period-delete-confirm')).toBeVisible()
|
||||
|
||||
// Regression guard for the "Yes, delete" footer bug: the confirm button's
|
||||
@@ -137,3 +149,47 @@ test('dragging a range across a month boundary plans every day, and deleting it
|
||||
await calendarDayCell(page, dates[1]).click()
|
||||
await expect(page.getByTestId('modal-title')).toHaveText('Planera en dag')
|
||||
})
|
||||
|
||||
test('a drag saved without opening Advanced creates a planner-funded wish, and the three-way remove deletes it', async ({
|
||||
page,
|
||||
}) => {
|
||||
// todo.md #10/#11 — the default path no longer writes pinned days
|
||||
// directly: it creates a fixed, one-off `LeaveWish` and lets the sync
|
||||
// fund it, so the resulting band is wish-linked (`calendar-band-wish:`),
|
||||
// not `adhoc:`.
|
||||
await registerAndReachOnboarding(page, uniqueEmail('drag-wish'), 'Robin Testsson')
|
||||
await fillChildStep(page, 'Nova')
|
||||
await continueSoloAndSkipBalances(page)
|
||||
|
||||
const { month, dates } = targetRange()
|
||||
|
||||
await goToCalendarMonth(page, month)
|
||||
|
||||
const wishBand = page.locator('[data-testid^="calendar-band-wish:"]')
|
||||
await expect(wishBand).toHaveCount(0)
|
||||
|
||||
await dragSelectDates(page, dates)
|
||||
await expect(page.getByTestId('modal-title')).toHaveText('Planera 3 dagar')
|
||||
// A solo account has no parent-mode selector to touch — save directly.
|
||||
await page.getByTestId('save-button').click()
|
||||
await expect(page.getByTestId('planned-day-modal__content')).not.toBeVisible()
|
||||
|
||||
await expect(wishBand.first()).toBeVisible()
|
||||
|
||||
// Open the wish-linked band. Its own three-way remove: "this day" cannot
|
||||
// be expressed for a wish (it would just reappear on the next sync) and
|
||||
// is disabled; "this period" on a `recurrence: 'once'` wish (there is
|
||||
// only one occurrence) removes the whole thing, same as "every year".
|
||||
await wishBand.first().click()
|
||||
await expect(page.getByTestId('period-popover__content')).toBeVisible()
|
||||
await expect(page.getByTestId('period-delete-day-button')).toBeDisabled()
|
||||
|
||||
await page.getByTestId('period-delete-year-button').click()
|
||||
await expect(page.getByTestId('period-delete-confirm')).toBeVisible()
|
||||
await page.getByTestId('period-delete-confirm-button').click()
|
||||
await expect(page.getByTestId('period-popover__overlay')).not.toBeVisible()
|
||||
|
||||
await page.reload()
|
||||
await goToCalendarMonth(page, month)
|
||||
await expect(page.locator('[data-testid^="calendar-band-wish:"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -54,6 +54,13 @@ export const calendar = {
|
||||
'plannedDayModal.pinnedNotice': 'This day is pinned by you — the planner will not change it automatically.',
|
||||
'plannedDayModal.dayStatusLabel': 'This day is:',
|
||||
'plannedDayModal.rangeTitle': 'Plan {count} days',
|
||||
'plannedDayModal.parentModeLabel': 'Who is this for?',
|
||||
'plannedDayModal.wantsDubbeldagLabel': 'Allow double days (dubbeldagar)',
|
||||
'plannedDayModal.wantsDubbeldagHint':
|
||||
"Both parents are off the same day and each spend one of their own parental-benefit days (no vacation day is used) — only available in the child's first 15 months, capped at 60 dates per child, shared between you.",
|
||||
'plannedDayModal.wantsDubbeldagRequiresBothHint': 'Requires both parents to be selected above.',
|
||||
'plannedDayModal.showAdvanced': 'Advanced: pick type and parent manually',
|
||||
'plannedDayModal.hideAdvanced': 'Hide advanced',
|
||||
'plannedDayModal.saveButton': 'Save',
|
||||
'plannedDayModal.saveError': 'Failed to save planned day',
|
||||
'plannedDayModal.savingButton': 'Saving…',
|
||||
@@ -115,4 +122,20 @@ export const calendar = {
|
||||
'calendar.periodDeleteAdHocButton': 'Delete these days',
|
||||
'calendar.periodCancelButton': 'Cancel',
|
||||
'calendar.periodCloseAria': 'Close',
|
||||
|
||||
// todo.md #9 — three-way remove: this day / this period (this year) / this
|
||||
// period every year.
|
||||
'calendar.periodDeleteDayButton': 'This day',
|
||||
'calendar.periodDeleteYearButton': 'This period',
|
||||
'calendar.periodDeleteAllYearsButton': 'This period, every year',
|
||||
'calendar.periodDeleteDayDisabledHint':
|
||||
"Can't remove a single day from a recurring plan — the planner will add it back on the next sync. Delete the period instead.",
|
||||
'calendar.periodDeleteAllYearsDisabledHint': 'This period is not linked to a recurring plan.',
|
||||
'calendar.periodDeleteConfirmDay': 'Are you sure you want to remove this day?',
|
||||
'calendar.periodDeleteConfirmYear': 'Are you sure you want to remove this period (this year only)?',
|
||||
'calendar.periodDeleteConfirmAllYears': 'Are you sure you want to remove the period — every year?',
|
||||
'calendar.wantsDubbeldagLabel': 'Allow double days (dubbeldagar)',
|
||||
'calendar.wantsDubbeldagHint':
|
||||
"Both parents are off the same day and each spend one of their own parental-benefit days (no vacation day is used) — only available in the child's first 15 months, capped at 60 dates per child, shared between you.",
|
||||
'calendar.wantsDubbeldagRequiresBothHint': 'Requires the period to apply to both parents.',
|
||||
} as const
|
||||
|
||||
@@ -7,6 +7,7 @@ export const wishEditor = {
|
||||
'wishEditor.bothParents': 'Both parents',
|
||||
'wishEditor.eitherParent': 'Either parent',
|
||||
'wishEditor.flexBadge': 'whenever it fits best',
|
||||
'wishEditor.manualPeriodName': 'Manually added period',
|
||||
'wishEditor.parentLabel': 'Parent',
|
||||
'wishEditor.seasonHost': 'Autumn',
|
||||
'wishEditor.seasonJul': 'Christmas',
|
||||
|
||||
@@ -54,6 +54,13 @@ export const calendar = {
|
||||
'plannedDayModal.pinnedNotice': 'Den här dagen är låst av dig — planeraren ändrar den inte automatiskt.',
|
||||
'plannedDayModal.dayStatusLabel': 'Den här dagen är:',
|
||||
'plannedDayModal.rangeTitle': 'Planera {count} dagar',
|
||||
'plannedDayModal.parentModeLabel': 'Vem gäller det?',
|
||||
'plannedDayModal.wantsDubbeldagLabel': 'Tillåt dubbeldagar',
|
||||
'plannedDayModal.wantsDubbeldagHint':
|
||||
'Båda föräldrarna är lediga samma dag och tar var sin egen föräldrapenningdag (ingen semesterdag går åt) — gäller bara de första 15 månaderna och max 60 dagar per barn, delat mellan er.',
|
||||
'plannedDayModal.wantsDubbeldagRequiresBothHint': 'Kräver att båda föräldrarna är valda ovan.',
|
||||
'plannedDayModal.showAdvanced': 'Avancerat: välj typ och förälder manuellt',
|
||||
'plannedDayModal.hideAdvanced': 'Dölj avancerat',
|
||||
'plannedDayModal.saveButton': 'Spara',
|
||||
'plannedDayModal.saveError': 'Det gick inte att spara den planerade dagen',
|
||||
'plannedDayModal.savingButton': 'Sparar…',
|
||||
@@ -115,4 +122,20 @@ export const calendar = {
|
||||
'calendar.periodDeleteAdHocButton': 'Ta bort dessa dagar',
|
||||
'calendar.periodCancelButton': 'Avbryt',
|
||||
'calendar.periodCloseAria': 'Stäng',
|
||||
|
||||
// todo.md #9 — three-way remove: this day / this period (this year) / this
|
||||
// period every year.
|
||||
'calendar.periodDeleteDayButton': 'Den här dagen',
|
||||
'calendar.periodDeleteYearButton': 'Den här perioden',
|
||||
'calendar.periodDeleteAllYearsButton': 'Den här perioden, varje år',
|
||||
'calendar.periodDeleteDayDisabledHint':
|
||||
'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.',
|
||||
'calendar.periodDeleteAllYearsDisabledHint': 'Den här perioden är inte kopplad till en återkommande plan.',
|
||||
'calendar.periodDeleteConfirmDay': 'Är du säker på att du vill ta bort den här dagen?',
|
||||
'calendar.periodDeleteConfirmYear': 'Är du säker på att du vill ta bort den här perioden (bara det här året)?',
|
||||
'calendar.periodDeleteConfirmAllYears': 'Är du säker på att du vill ta bort perioden — alla år?',
|
||||
'calendar.wantsDubbeldagLabel': 'Tillåt dubbeldagar',
|
||||
'calendar.wantsDubbeldagHint':
|
||||
'Båda föräldrarna är lediga samma dag och tar var sin egen föräldrapenningdag (ingen semesterdag går åt) — gäller bara de första 15 månaderna och max 60 dagar per barn, delat mellan er.',
|
||||
'calendar.wantsDubbeldagRequiresBothHint': 'Kräver att perioden gäller båda föräldrarna.',
|
||||
} as const
|
||||
@@ -7,6 +7,7 @@ export const wishEditor = {
|
||||
'wishEditor.bothParents': 'Båda föräldrarna',
|
||||
'wishEditor.eitherParent': 'Endera förälder',
|
||||
'wishEditor.flexBadge': 'när det passar bäst',
|
||||
'wishEditor.manualPeriodName': 'Manuellt tillagd period',
|
||||
'wishEditor.parentLabel': 'Förälder',
|
||||
'wishEditor.seasonHost': 'Höst',
|
||||
'wishEditor.seasonJul': 'Jul',
|
||||
|
||||
@@ -89,12 +89,60 @@ vi.mock('@/components/modals/PlannedDayModal', () => ({
|
||||
data-parents-count={props.parents.length}
|
||||
data-is-range-mode={props.isRangeMode ? 'yes' : 'no'}
|
||||
data-day-count={props.dayCount ?? ''}
|
||||
data-selected-parent-id={props.selectedParentId}
|
||||
>
|
||||
{/* §todo10/11 default path — the planner decides funding. */}
|
||||
<button
|
||||
data-testid="modal-save"
|
||||
onClick={() => void props.onSave({ dayType: 'vacation', parentId: props.selectedParentId, isDubbeldag: false })}
|
||||
data-testid="modal-save-wish-either"
|
||||
onClick={() => void props.onSaveWish({ parentMode: 'either', parentPersonalDataUuid: null, wantsDubbeldag: false })}
|
||||
>
|
||||
save
|
||||
save wish (either)
|
||||
</button>
|
||||
<button
|
||||
data-testid="modal-save-wish-both"
|
||||
onClick={() => void props.onSaveWish({ parentMode: 'both', parentPersonalDataUuid: null, wantsDubbeldag: false })}
|
||||
>
|
||||
save wish (both)
|
||||
</button>
|
||||
<button
|
||||
data-testid="modal-save-wish-both-dubbeldag"
|
||||
onClick={() => void props.onSaveWish({ parentMode: 'both', parentPersonalDataUuid: null, wantsDubbeldag: true })}
|
||||
>
|
||||
save wish (both, dubbeldag)
|
||||
</button>
|
||||
<button
|
||||
data-testid="modal-save-wish-specific"
|
||||
onClick={() => void props.onSaveWish({ parentMode: 'specific', parentPersonalDataUuid: 'pd-b', wantsDubbeldag: false })}
|
||||
>
|
||||
save wish (specific)
|
||||
</button>
|
||||
{/* Deliberately sends `wantsDubbeldag: true` alongside `parentMode:
|
||||
'specific'` — the real modal itself disables the checkbox outside
|
||||
'both' mode, but Calendar.tsx's `handleSaveWish` must not trust
|
||||
that alone; it re-gates on `parentMode` before sending the wish. */}
|
||||
<button
|
||||
data-testid="modal-save-wish-specific-dubbeldag-attempt"
|
||||
onClick={() => void props.onSaveWish({ parentMode: 'specific', parentPersonalDataUuid: 'pd-b', wantsDubbeldag: true })}
|
||||
>
|
||||
save wish (specific, dubbeldag attempt)
|
||||
</button>
|
||||
{/* The "Advanced" escape hatch — pins an exact day type for whichever
|
||||
parent `selectedParentId` currently names (Calendar.tsx's lifted
|
||||
`advancedParentId`, updated live via `onParentChange`). */}
|
||||
<button
|
||||
data-testid="modal-save-pinned"
|
||||
onClick={() => void props.onSavePinned({ dayType: 'vacation', parentId: props.selectedParentId, isDubbeldag: false })}
|
||||
>
|
||||
save pinned (vacation)
|
||||
</button>
|
||||
<button
|
||||
data-testid="modal-save-pinned-sjukpenning"
|
||||
onClick={() => void props.onSavePinned({ dayType: 'sjukpenning', parentId: props.selectedParentId, isDubbeldag: false })}
|
||||
>
|
||||
save pinned (sjukpenning)
|
||||
</button>
|
||||
<button data-testid="modal-change-parent-b" onClick={() => props.onParentChange?.('pd-b')}>
|
||||
switch advanced parent to B
|
||||
</button>
|
||||
{props.onDelete && (
|
||||
<button data-testid="modal-delete" onClick={() => void props.onDelete?.()}>
|
||||
@@ -129,6 +177,7 @@ vi.mock('@/components/calendar/PeriodPopover', () => ({
|
||||
parentPersonalDataUuid: null,
|
||||
recurrence: 'yearly',
|
||||
lastYear: null,
|
||||
wantsDubbeldag: false,
|
||||
} satisfies PeriodPopoverSavePayload)
|
||||
}
|
||||
>
|
||||
@@ -145,13 +194,21 @@ vi.mock('@/components/calendar/PeriodPopover', () => ({
|
||||
parentPersonalDataUuid: null,
|
||||
recurrence: 'once',
|
||||
lastYear: null,
|
||||
wantsDubbeldag: false,
|
||||
} satisfies PeriodPopoverSavePayload)
|
||||
}
|
||||
>
|
||||
save once
|
||||
</button>
|
||||
<button data-testid="period-delete" onClick={() => void props.onDelete()}>
|
||||
delete
|
||||
{/* §todo9 — the three-way remove. */}
|
||||
<button data-testid="period-delete-day" onClick={() => void props.onDelete('day')}>
|
||||
delete (day)
|
||||
</button>
|
||||
<button data-testid="period-delete-year" onClick={() => void props.onDelete('year')}>
|
||||
delete (year)
|
||||
</button>
|
||||
<button data-testid="period-delete-all-years" onClick={() => void props.onDelete('allYears')}>
|
||||
delete (all years)
|
||||
</button>
|
||||
<button data-testid="period-close" onClick={props.onClose}>
|
||||
close
|
||||
@@ -450,7 +507,7 @@ describe('Calendar', () => {
|
||||
const calendarDays = screen.getAllByTestId('calendar-day')
|
||||
const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))
|
||||
fireEvent.click(currentMonthDay!)
|
||||
fireEvent.click(screen.getByTestId('modal-save'))
|
||||
fireEvent.click(screen.getByTestId('modal-save-pinned'))
|
||||
|
||||
await waitFor(() => expect(add).toHaveBeenCalledWith(expect.objectContaining({ is_pinned: true })))
|
||||
})
|
||||
@@ -469,7 +526,7 @@ describe('Calendar', () => {
|
||||
const calendarDays = screen.getAllByTestId('calendar-day')
|
||||
const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today))
|
||||
fireEvent.click(todayCell!)
|
||||
fireEvent.click(screen.getByTestId('modal-save'))
|
||||
fireEvent.click(screen.getByTestId('modal-save-pinned'))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(update).toHaveBeenCalledWith('pd-1', expect.objectContaining({ is_pinned: true })),
|
||||
@@ -484,7 +541,7 @@ describe('Calendar', () => {
|
||||
const calendarDays = screen.getAllByTestId('calendar-day')
|
||||
const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))
|
||||
fireEvent.click(currentMonthDay!)
|
||||
fireEvent.click(screen.getByTestId('modal-save'))
|
||||
fireEvent.click(screen.getByTestId('modal-save-pinned'))
|
||||
|
||||
// The mocked modal doesn't surface `error`, but the save must not
|
||||
// crash and must leave the modal open with the day still selected.
|
||||
@@ -740,7 +797,7 @@ describe('Calendar', () => {
|
||||
|
||||
render(<Calendar />)
|
||||
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
|
||||
fireEvent.click(screen.getByTestId('period-delete'))
|
||||
fireEvent.click(screen.getByTestId('period-delete-all-years'))
|
||||
|
||||
await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-1'))
|
||||
await waitFor(() => expect(sync).toHaveBeenCalled())
|
||||
@@ -762,7 +819,7 @@ describe('Calendar', () => {
|
||||
|
||||
render(<Calendar />)
|
||||
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
|
||||
fireEvent.click(screen.getByTestId('period-delete'))
|
||||
fireEvent.click(screen.getByTestId('period-delete-all-years'))
|
||||
|
||||
const undoButton = await screen.findByTestId('calendar-toast-undo')
|
||||
fireEvent.click(undoButton)
|
||||
@@ -789,7 +846,7 @@ describe('Calendar', () => {
|
||||
|
||||
render(<Calendar />)
|
||||
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
|
||||
fireEvent.click(screen.getByTestId('period-delete'))
|
||||
fireEvent.click(screen.getByTestId('period-delete-all-years'))
|
||||
|
||||
await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-1'))
|
||||
await waitFor(() => expect(screen.getByTestId('calendar-toast')).toBeInTheDocument())
|
||||
@@ -810,7 +867,7 @@ describe('Calendar', () => {
|
||||
|
||||
render(<Calendar />)
|
||||
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
|
||||
fireEvent.click(screen.getByTestId('period-delete'))
|
||||
fireEvent.click(screen.getByTestId('period-delete-year'))
|
||||
|
||||
await waitFor(() => expect(remove).toHaveBeenCalledWith('pd-1'))
|
||||
// Sync was still called once, from the initial auto-sync on mount —
|
||||
@@ -1220,7 +1277,7 @@ describe('Calendar', () => {
|
||||
|
||||
render(<Calendar />)
|
||||
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
|
||||
fireEvent.click(screen.getByTestId('period-delete'))
|
||||
fireEvent.click(screen.getByTestId('period-delete-year'))
|
||||
|
||||
const undoButton = await screen.findByTestId('calendar-toast-undo')
|
||||
fireEvent.click(undoButton)
|
||||
@@ -1460,7 +1517,7 @@ describe('Calendar', () => {
|
||||
|
||||
render(<Calendar />)
|
||||
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
|
||||
fireEvent.click(screen.getByTestId('period-delete'))
|
||||
fireEvent.click(screen.getByTestId('period-delete-all-years'))
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('period-popover')).toHaveAttribute('data-error', 'cannot delete'))
|
||||
})
|
||||
@@ -1530,7 +1587,7 @@ describe('Calendar', () => {
|
||||
// requires a re-render for the new hook return value to take effect.
|
||||
vi.mocked(usePlannedDays).mockReturnValue(makePlannedDaysResult({ data: [] }))
|
||||
rerender(<Calendar />)
|
||||
fireEvent.click(screen.getByTestId('period-delete'))
|
||||
fireEvent.click(screen.getByTestId('period-delete-year'))
|
||||
|
||||
expect(screen.queryByTestId('calendar-toast')).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -1585,7 +1642,7 @@ describe('Calendar', () => {
|
||||
|
||||
const modal = await screen.findByTestId('modal')
|
||||
expect(modal).toHaveAttribute('data-day-count', '3')
|
||||
fireEvent.click(screen.getByTestId('modal-save'))
|
||||
fireEvent.click(screen.getByTestId('modal-save-pinned'))
|
||||
|
||||
// The pre-existing day is updated (pinned), the other two are added
|
||||
// (also pinned) — never a LeaveWish, never a sync.
|
||||
@@ -1676,4 +1733,312 @@ describe('Calendar', () => {
|
||||
expect(reservedHeights[0]).not.toBe('0px')
|
||||
})
|
||||
})
|
||||
|
||||
describe('todo.md #14 — two parents cannot both be given the same days', () => {
|
||||
it('saving the pinned/advanced path targets the modal-selected parent, never the logged-in one', async () => {
|
||||
const today = todayDateString()
|
||||
const add = vi.fn().mockResolvedValue(makePlannedLeaveDay())
|
||||
const update = vi.fn().mockResolvedValue(makePlannedLeaveDay())
|
||||
vi.mocked(useWorkspaceMembers).mockReturnValue(
|
||||
makeWorkspaceMembersResult({
|
||||
data: [makePersonalData({ uuid: 'person-1', name: 'Förälder A' }), makePersonalData({ uuid: 'pd-b', name: 'Förälder B' })],
|
||||
}),
|
||||
)
|
||||
// Parent A already has a pinned sjukpenning day on `today` — this is
|
||||
// exactly the state the bug report described after "parent A takes 2
|
||||
// days on PL".
|
||||
vi.mocked(usePlannedDays).mockReturnValue(
|
||||
makePlannedDaysResult({
|
||||
data: [
|
||||
makePlannedLeaveDay({
|
||||
id: 'pd-a-day',
|
||||
date: today,
|
||||
parentPersonalDataUuid: 'person-1',
|
||||
dayType: 'sjukpenning',
|
||||
isPinned: true,
|
||||
}),
|
||||
],
|
||||
add,
|
||||
update,
|
||||
}),
|
||||
)
|
||||
|
||||
render(<Calendar />)
|
||||
const calendarDays = screen.getAllByTestId('calendar-day')
|
||||
const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today))!
|
||||
fireEvent.click(todayCell)
|
||||
|
||||
// Opens defaulted to A (the logged-in parent) — A's existing day shows.
|
||||
expect(screen.getByTestId('modal')).toHaveAttribute('data-existing-day', 'yes')
|
||||
expect(screen.getByTestId('modal')).toHaveAttribute('data-selected-parent-id', 'person-1')
|
||||
|
||||
// Switch the advanced parent to B — B has nothing here yet.
|
||||
fireEvent.click(screen.getByTestId('modal-change-parent-b'))
|
||||
expect(screen.getByTestId('modal')).toHaveAttribute('data-existing-day', 'no')
|
||||
expect(screen.getByTestId('modal')).toHaveAttribute('data-selected-parent-id', 'pd-b')
|
||||
|
||||
// Now select vacation for B and save through the advanced/pinned path.
|
||||
fireEvent.click(screen.getByTestId('modal-save-pinned'))
|
||||
|
||||
// A's row must never be touched — the old bug called `update` on it.
|
||||
await waitFor(() => expect(add).toHaveBeenCalledWith(expect.objectContaining({ parent_personal_data_uuid: 'pd-b', day_type: 'vacation' })))
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('deleting through the advanced path removes the currently-selected parent\'s day, not the other parent\'s', async () => {
|
||||
const today = todayDateString()
|
||||
const remove = vi.fn().mockResolvedValue(undefined)
|
||||
vi.mocked(useWorkspaceMembers).mockReturnValue(
|
||||
makeWorkspaceMembersResult({
|
||||
data: [makePersonalData({ uuid: 'person-1', name: 'Förälder A' }), makePersonalData({ uuid: 'pd-b', name: 'Förälder B' })],
|
||||
}),
|
||||
)
|
||||
vi.mocked(usePlannedDays).mockReturnValue(
|
||||
makePlannedDaysResult({
|
||||
data: [
|
||||
makePlannedLeaveDay({ id: 'pd-a-day', date: today, parentPersonalDataUuid: 'person-1', dayType: 'sjukpenning' }),
|
||||
makePlannedLeaveDay({ id: 'pd-b-day', date: today, parentPersonalDataUuid: 'pd-b', dayType: 'vacation' }),
|
||||
],
|
||||
remove,
|
||||
}),
|
||||
)
|
||||
|
||||
render(<Calendar />)
|
||||
const calendarDays = screen.getAllByTestId('calendar-day')
|
||||
const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today))!
|
||||
fireEvent.click(todayCell)
|
||||
fireEvent.click(screen.getByTestId('modal-change-parent-b'))
|
||||
fireEvent.click(screen.getByTestId('modal-delete'))
|
||||
|
||||
await waitFor(() => expect(remove).toHaveBeenCalledWith('pd-b-day'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('todo.md #10/#11 — the manual-add modal defaults to a planner-funded wish', () => {
|
||||
it('a click/drag by default creates a fixed, once-off wish (not a pinned day) for the chosen parent mode', async () => {
|
||||
const created = makeLeaveWish({ id: 'lw-manual' })
|
||||
const add = vi.fn().mockResolvedValue(created)
|
||||
const addDay = vi.fn()
|
||||
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
|
||||
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
|
||||
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
|
||||
vi.mocked(usePlannedDays).mockReturnValue(makePlannedDaysResult({ add: addDay }))
|
||||
|
||||
render(<Calendar />)
|
||||
const calendarDays = screen.getAllByTestId('calendar-day')
|
||||
const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))!
|
||||
fireEvent.click(currentMonthDay)
|
||||
fireEvent.click(screen.getByTestId('modal-save-wish-both'))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ windowMode: 'fixed', recurrence: 'once', parentMode: 'both', wantsDubbeldag: false }),
|
||||
),
|
||||
)
|
||||
await waitFor(() => expect(sync).toHaveBeenCalled())
|
||||
// The default path never touches planned days directly.
|
||||
expect(addDay).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forwards wantsDubbeldag only when the wish targets both parents', async () => {
|
||||
const created = makeLeaveWish({ id: 'lw-manual' })
|
||||
const add = vi.fn().mockResolvedValue(created)
|
||||
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
|
||||
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
|
||||
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
|
||||
|
||||
render(<Calendar />)
|
||||
const calendarDays = screen.getAllByTestId('calendar-day')
|
||||
const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))!
|
||||
fireEvent.click(currentMonthDay)
|
||||
fireEvent.click(screen.getByTestId('modal-save-wish-both-dubbeldag'))
|
||||
|
||||
await waitFor(() => expect(add).toHaveBeenCalledWith(expect.objectContaining({ wantsDubbeldag: true })))
|
||||
})
|
||||
|
||||
it('never sends wantsDubbeldag for a specific-parent wish, even if the modal tried to', async () => {
|
||||
const created = makeLeaveWish({ id: 'lw-manual' })
|
||||
const add = vi.fn().mockResolvedValue(created)
|
||||
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
|
||||
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ add, data: [] }))
|
||||
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
|
||||
|
||||
render(<Calendar />)
|
||||
const calendarDays = screen.getAllByTestId('calendar-day')
|
||||
const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))!
|
||||
fireEvent.click(currentMonthDay)
|
||||
fireEvent.click(screen.getByTestId('modal-save-wish-specific-dubbeldag-attempt'))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ parentMode: 'specific', parentPersonalDataUuid: 'pd-b', wantsDubbeldag: false }),
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('todo.md #9 — the three-way remove', () => {
|
||||
it('"day" scope is a no-op for a wish-linked band (disabled in the UI; would just reappear on the next sync)', async () => {
|
||||
const today = todayDateString()
|
||||
const wish = makeLeaveWish({ id: 'lw-1' })
|
||||
const remove = vi.fn().mockResolvedValue(undefined)
|
||||
const update = vi.fn().mockResolvedValue(wish)
|
||||
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], remove, update }))
|
||||
vi.mocked(usePlannedDays).mockReturnValue(
|
||||
makePlannedDaysResult({
|
||||
data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })],
|
||||
}),
|
||||
)
|
||||
|
||||
render(<Calendar />)
|
||||
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
|
||||
fireEvent.click(screen.getByTestId('period-delete-day'))
|
||||
|
||||
await waitFor(() => expect(screen.queryByTestId('period-popover')).not.toBeInTheDocument())
|
||||
expect(remove).not.toHaveBeenCalled()
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('"every year" scope is a no-op for an ad-hoc band (disabled in the UI; nothing recurring to remove)', async () => {
|
||||
const today = todayDateString()
|
||||
const removeDay = vi.fn()
|
||||
vi.mocked(usePlannedDays).mockReturnValue(
|
||||
makePlannedDaysResult({
|
||||
data: [makePlannedLeaveDay({ id: 'pd-1', date: today, wishId: null, parentPersonalDataUuid: 'person-1' })],
|
||||
remove: removeDay,
|
||||
}),
|
||||
)
|
||||
|
||||
render(<Calendar />)
|
||||
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
|
||||
fireEvent.click(screen.getByTestId('period-delete-all-years'))
|
||||
|
||||
await waitFor(() => expect(screen.queryByTestId('period-popover')).not.toBeInTheDocument())
|
||||
expect(removeDay).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('"day" scope on an ad-hoc band removes only the clicked occurrence\'s first date', async () => {
|
||||
const removeDay = vi.fn().mockResolvedValue(undefined)
|
||||
vi.mocked(usePlannedDays).mockReturnValue(
|
||||
makePlannedDaysResult({
|
||||
data: [
|
||||
makePlannedLeaveDay({ id: 'pd-1', date: '2027-03-10', wishId: null, parentPersonalDataUuid: 'person-1' }),
|
||||
makePlannedLeaveDay({ id: 'pd-2', date: '2027-03-11', wishId: null, parentPersonalDataUuid: 'person-1' }),
|
||||
],
|
||||
remove: removeDay,
|
||||
}),
|
||||
)
|
||||
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams('date=2027-03-10'), mockSetSearchParams])
|
||||
|
||||
render(<Calendar />)
|
||||
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
|
||||
fireEvent.click(screen.getByTestId('period-delete-day'))
|
||||
|
||||
await waitFor(() => expect(removeDay).toHaveBeenCalledWith('pd-1'))
|
||||
expect(removeDay).not.toHaveBeenCalledWith('pd-2')
|
||||
})
|
||||
|
||||
describe('"year" scope on a recurring wish — narrows or splits firstYear/lastYear', () => {
|
||||
it('removing the first year of an open-ended wish bumps firstYear forward', async () => {
|
||||
const wish = makeLeaveWish({ id: 'lw-1', recurrence: 'yearly', firstYear: 2026, lastYear: null })
|
||||
const update = vi.fn().mockResolvedValue(wish)
|
||||
const remove = vi.fn()
|
||||
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], update, remove }))
|
||||
vi.mocked(usePlannedDays).mockReturnValue(
|
||||
makePlannedDaysResult({
|
||||
data: [makePlannedLeaveDay({ id: 'pd-1', date: '2026-06-15', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })],
|
||||
}),
|
||||
)
|
||||
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams('date=2026-06-15'), mockSetSearchParams])
|
||||
|
||||
render(<Calendar />)
|
||||
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
|
||||
fireEvent.click(screen.getByTestId('period-delete-year'))
|
||||
|
||||
await waitFor(() => expect(update).toHaveBeenCalledWith('lw-1', expect.objectContaining({ firstYear: 2027 })))
|
||||
expect(remove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removing the last year of a bounded wish decrements lastYear', async () => {
|
||||
const wish = makeLeaveWish({ id: 'lw-1', recurrence: 'yearly', firstYear: 2026, lastYear: 2029 })
|
||||
const update = vi.fn().mockResolvedValue(wish)
|
||||
const remove = vi.fn()
|
||||
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], update, remove }))
|
||||
vi.mocked(usePlannedDays).mockReturnValue(
|
||||
makePlannedDaysResult({
|
||||
data: [makePlannedLeaveDay({ id: 'pd-1', date: '2029-06-15', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })],
|
||||
}),
|
||||
)
|
||||
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams('date=2029-06-15'), mockSetSearchParams])
|
||||
|
||||
render(<Calendar />)
|
||||
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
|
||||
fireEvent.click(screen.getByTestId('period-delete-year'))
|
||||
|
||||
await waitFor(() => expect(update).toHaveBeenCalledWith('lw-1', expect.objectContaining({ lastYear: 2028 })))
|
||||
expect(remove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removing a middle year splits the wish into two, excluding just that year', async () => {
|
||||
const wish = makeLeaveWish({ id: 'lw-1', recurrence: 'yearly', firstYear: 2026, lastYear: 2030 })
|
||||
const update = vi.fn().mockResolvedValue(wish)
|
||||
const add = vi.fn().mockResolvedValue(makeLeaveWish({ id: 'lw-1-split' }))
|
||||
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], update, add }))
|
||||
vi.mocked(usePlannedDays).mockReturnValue(
|
||||
makePlannedDaysResult({
|
||||
data: [makePlannedLeaveDay({ id: 'pd-1', date: '2028-06-15', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })],
|
||||
}),
|
||||
)
|
||||
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams('date=2028-06-15'), mockSetSearchParams])
|
||||
|
||||
render(<Calendar />)
|
||||
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
|
||||
fireEvent.click(screen.getByTestId('period-delete-year'))
|
||||
|
||||
// The original wish keeps 2026-2027; a new wish covers 2029-2030.
|
||||
await waitFor(() => expect(update).toHaveBeenCalledWith('lw-1', expect.objectContaining({ lastYear: 2027 })))
|
||||
expect(add).toHaveBeenCalledWith(expect.objectContaining({ firstYear: 2029, lastYear: 2030 }))
|
||||
})
|
||||
|
||||
it('removing the only year of a single-year wish deletes it entirely instead of leaving an empty range', async () => {
|
||||
const wish = makeLeaveWish({ id: 'lw-1', recurrence: 'yearly', firstYear: 2026, lastYear: 2026 })
|
||||
const remove = vi.fn().mockResolvedValue(undefined)
|
||||
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
|
||||
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], remove }))
|
||||
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
|
||||
vi.mocked(usePlannedDays).mockReturnValue(
|
||||
makePlannedDaysResult({
|
||||
data: [makePlannedLeaveDay({ id: 'pd-1', date: '2026-06-15', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })],
|
||||
}),
|
||||
)
|
||||
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams('date=2026-06-15'), mockSetSearchParams])
|
||||
|
||||
render(<Calendar />)
|
||||
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
|
||||
fireEvent.click(screen.getByTestId('period-delete-year'))
|
||||
|
||||
await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-1'))
|
||||
})
|
||||
|
||||
it('a once-off wish treats "this year" the same as "every year" (there is only one occurrence)', async () => {
|
||||
const wish = makeLeaveWish({ id: 'lw-1', recurrence: 'once', firstYear: 2026 })
|
||||
const remove = vi.fn().mockResolvedValue(undefined)
|
||||
const sync = vi.fn().mockResolvedValue(makePlanSyncResponse())
|
||||
vi.mocked(useLeaveWishes).mockReturnValue(makeLeaveWishesResult({ data: [wish], remove }))
|
||||
vi.mocked(usePlanSync).mockReturnValue(makePlanSyncResult({ sync }))
|
||||
vi.mocked(usePlannedDays).mockReturnValue(
|
||||
makePlannedDaysResult({
|
||||
data: [makePlannedLeaveDay({ id: 'pd-1', date: '2026-06-15', wishId: 'lw-1', parentPersonalDataUuid: 'person-1' })],
|
||||
}),
|
||||
)
|
||||
vi.mocked(useSearchParams).mockReturnValue([new URLSearchParams('date=2026-06-15'), mockSetSearchParams])
|
||||
|
||||
render(<Calendar />)
|
||||
fireEvent.click(screen.getAllByTestId(/^calendar-band-/)[0]!)
|
||||
fireEvent.click(screen.getByTestId('period-delete-year'))
|
||||
|
||||
await waitFor(() => expect(remove).toHaveBeenCalledWith('lw-1'))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+254
-62
@@ -26,7 +26,7 @@ import { Layout } from '@/components/layout/Layout'
|
||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
|
||||
import { CalendarWeek, computeWeekLaneCount, type BandClickInfo } from '@/components/common/CalendarWeek'
|
||||
import { PlannedDayModal } from '@/components/modals/PlannedDayModal'
|
||||
import { PeriodPopover, type PeriodPopoverSavePayload } from '@/components/calendar/PeriodPopover'
|
||||
import { PeriodPopover, type PeriodPopoverSavePayload, type PeriodDeleteScope } from '@/components/calendar/PeriodPopover'
|
||||
import { PresetRail, type SeasonPreset } from '@/components/calendar/PresetRail'
|
||||
import { getColorForType } from '@/utils/leaveTypes'
|
||||
import { resolveWishName, resolveWishNameFromData } from '@/utils/wishName'
|
||||
@@ -101,6 +101,7 @@ function createRequestFromWish(w: LeaveWish): LeaveWishCreateRequest {
|
||||
parentMode: w.parentMode,
|
||||
parentPersonalDataUuid: w.parentPersonalDataUuid,
|
||||
windowMode: w.windowMode,
|
||||
wantsDubbeldag: w.wantsDubbeldag,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +122,7 @@ function patchFromWish(w: LeaveWish): LeaveWishUpdateRequest {
|
||||
parentMode: w.parentMode,
|
||||
parentPersonalDataUuid: w.parentPersonalDataUuid,
|
||||
windowMode: w.windowMode,
|
||||
wantsDubbeldag: w.wantsDubbeldag,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,6 +253,14 @@ export function Calendar(): ReactNode {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [modalError, setModalError] = useState<string | null>(null)
|
||||
// §todo14 — which parent the day popover's "Advanced" (pin exactly) path
|
||||
// targets. Lifted here (rather than kept as PlannedDayModal-local state)
|
||||
// so `existingDay`/save/delete all resolve against the SAME parent the
|
||||
// modal is currently showing — see `getExistingDay`'s doc comment for the
|
||||
// bug this fixes. Reset to the logged-in parent whenever a fresh
|
||||
// date/range is selected; updated live via the modal's `onParentChange`
|
||||
// while it stays open.
|
||||
const [advancedParentId, setAdvancedParentId] = useState(personalDataUuid ?? '')
|
||||
|
||||
const [activeBand, setActiveBand] = useState<BandClickInfo | null>(null)
|
||||
const [isPeriodOpen, setIsPeriodOpen] = useState(false)
|
||||
@@ -364,9 +374,12 @@ export function Calendar(): ReactNode {
|
||||
if (dates.length > 0) {
|
||||
// §Task7 revision — a drag (single- or multi-day) always opens the
|
||||
// day popover on the exact dragged dates; it never creates a
|
||||
// LeaveWish/synced period on its own. The user decides the leave
|
||||
// type/parent for every one of those days in the popover.
|
||||
// LeaveWish/synced period on its own by itself — the default path
|
||||
// now creates a fixed one-off wish and lets the planner fund it
|
||||
// (§todo10/11); the "Advanced" disclosure still writes exact
|
||||
// pinned days, same as before.
|
||||
setSelectedDates(new Set(dates))
|
||||
setAdvancedParentId(personalDataUuid ?? '')
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
}
|
||||
@@ -379,7 +392,7 @@ export function Calendar(): ReactNode {
|
||||
window.addEventListener('pointerup', handlePointerUp)
|
||||
return () => window.removeEventListener('pointerup', handlePointerUp)
|
||||
}
|
||||
}, [isDragging, dragAnchor, dragCurrent])
|
||||
}, [isDragging, dragAnchor, dragCurrent, personalDataUuid])
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingSpinner message={t('calendar.loading')} />
|
||||
@@ -489,21 +502,31 @@ export function Calendar(): ReactNode {
|
||||
|
||||
const handleDayClick = (date: string) => {
|
||||
setSelectedDates(new Set([date]))
|
||||
setAdvancedParentId(personalDataUuid ?? '')
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
const getExistingDay = (date: string): PlannedLeaveDay | undefined => {
|
||||
// §todo14 fix — takes the parent explicitly rather than assuming the
|
||||
// logged-in one. The old version always resolved against
|
||||
// `personalDataUuid`, so saving parent B's day on a date where parent A
|
||||
// already had one found (and silently overwrote) A's row instead of
|
||||
// creating B's. Every caller below now passes the parent the save/delete
|
||||
// is actually FOR — the modal-selected one, never the logged-in default.
|
||||
const getExistingDay = (date: string, parentId: string): PlannedLeaveDay | undefined => {
|
||||
return (plannedDaysHook.data ?? []).find(
|
||||
(pd: PlannedLeaveDay) => pd.date === date && pd.parentPersonalDataUuid === personalDataUuid,
|
||||
(pd: PlannedLeaveDay) => pd.date === date && pd.parentPersonalDataUuid === parentId,
|
||||
)
|
||||
}
|
||||
|
||||
const handleSavePlannedDay = async (payload: {
|
||||
// The "Advanced" escape hatch — pins an exact day type for an exact
|
||||
// parent, writing `planned_leave_day` rows directly (today's pre-existing
|
||||
// behaviour, unchanged except for the §todo14 lookup fix above).
|
||||
const handleSavePinnedDay = async (payload: {
|
||||
dayType: 'sjukpenning' | 'lagsta' | 'vacation'
|
||||
parentId: string
|
||||
isDubbeldag: boolean
|
||||
}) => {
|
||||
if (selectedDates.size === 0 || !selectedChildId || !workspace?.id || !personalDataUuid) return
|
||||
if (selectedDates.size === 0 || !selectedChildId || !workspace?.id) return
|
||||
|
||||
try {
|
||||
setIsSaving(true)
|
||||
@@ -512,9 +535,7 @@ export function Calendar(): ReactNode {
|
||||
setLastLeaveType(payload.dayType)
|
||||
|
||||
for (const date of selectedDates) {
|
||||
const existingDay = (plannedDaysHook.data ?? []).find(
|
||||
(pd: PlannedLeaveDay) => pd.date === date && pd.parentPersonalDataUuid === personalDataUuid,
|
||||
)
|
||||
const existingDay = getExistingDay(date, payload.parentId)
|
||||
|
||||
if (existingDay) {
|
||||
// §Task7 — a hand-edit through the day popover pins the day so a
|
||||
@@ -546,6 +567,70 @@ export function Calendar(): ReactNode {
|
||||
}
|
||||
}
|
||||
|
||||
// §todo10/11 default path — the planner decides funding. Builds a fixed,
|
||||
// one-off `LeaveWish` spanning every selected date and lets the existing
|
||||
// sync machinery place it, exactly like `handleSelectPreset` below (same
|
||||
// add-then-sync-then-undo shape).
|
||||
const handleSaveWish = async (payload: {
|
||||
parentMode: 'either' | 'both' | 'specific'
|
||||
parentPersonalDataUuid: string | null
|
||||
wantsDubbeldag: boolean
|
||||
}) => {
|
||||
if (selectedDates.size === 0 || !selectedChildId || !workspace?.id) return
|
||||
|
||||
const orderedDates = Array.from(selectedDates).sort()
|
||||
const first = orderedDates[0]
|
||||
const last = orderedDates[orderedDates.length - 1]
|
||||
if (!first || !last) return
|
||||
const start = splitISODate(first)
|
||||
const end = splitISODate(last)
|
||||
const dayCount = orderedDates.length
|
||||
|
||||
try {
|
||||
setIsSaving(true)
|
||||
setModalError(null)
|
||||
|
||||
const created = await wishesHook.add({
|
||||
workspaceId: workspace.id,
|
||||
childId: selectedChildId,
|
||||
name: t('wishEditor.manualPeriodName'),
|
||||
nameMessageKey: 'wishEditor.manualPeriodName',
|
||||
windowStartMonth: start.month,
|
||||
windowStartDay: start.day,
|
||||
windowEndMonth: end.month,
|
||||
windowEndDay: end.day,
|
||||
targetWeeks: Math.max(1, Math.ceil(dayCount / 7)),
|
||||
targetDays: dayCount,
|
||||
recurrence: 'once',
|
||||
firstYear: start.year,
|
||||
priority: wishesHook.data.length + 1,
|
||||
parentMode: payload.parentMode,
|
||||
parentPersonalDataUuid: payload.parentMode === 'specific' ? payload.parentPersonalDataUuid : null,
|
||||
windowMode: 'fixed',
|
||||
wantsDubbeldag: payload.parentMode === 'both' ? payload.wantsDubbeldag : false,
|
||||
})
|
||||
|
||||
setIsModalOpen(false)
|
||||
setSelectedDates(new Set())
|
||||
|
||||
await runSync(workspace.id, {
|
||||
watchWishId: created.id,
|
||||
watchWish: created,
|
||||
undo: {
|
||||
label: t('calendar.undoButton'),
|
||||
run: async () => {
|
||||
await wishesHook.remove(created.id)
|
||||
await runSync(workspace.id, { silent: true })
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
setModalError(err instanceof Error ? err.message : t('calendar.saveError'))
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeletePlannedDay = async () => {
|
||||
if (selectedDates.size !== 1) return
|
||||
|
||||
@@ -557,7 +642,7 @@ export function Calendar(): ReactNode {
|
||||
const date = dates[0]
|
||||
if (!date) return
|
||||
|
||||
const existingDay = getExistingDay(date)
|
||||
const existingDay = getExistingDay(date, advancedParentId)
|
||||
if (existingDay) {
|
||||
await plannedDaysHook.remove(existingDay.id)
|
||||
}
|
||||
@@ -619,6 +704,7 @@ export function Calendar(): ReactNode {
|
||||
lastYear: payload.recurrence === 'yearly' ? payload.lastYear : null,
|
||||
parentMode: payload.parentMode,
|
||||
parentPersonalDataUuid: payload.parentMode === 'specific' ? payload.parentPersonalDataUuid : null,
|
||||
wantsDubbeldag: payload.wantsDubbeldag,
|
||||
})
|
||||
closePeriodPopover()
|
||||
await runSync(workspace.id, {
|
||||
@@ -638,61 +724,165 @@ export function Calendar(): ReactNode {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeletePeriod = async () => {
|
||||
// §todo9 — the whole-wish delete (unchanged behaviour), factored out so
|
||||
// both the "every year" scope and the "narrow down to nothing" edges of
|
||||
// the "this year" scope (below) can fall back to it.
|
||||
const deleteWholeWish = async (wishId: string, workspaceId: string) => {
|
||||
const previousWish = wishesHook.data.find((w) => w.id === wishId)
|
||||
await wishesHook.remove(wishId)
|
||||
closePeriodPopover()
|
||||
await runSync(workspaceId, {
|
||||
undo: previousWish
|
||||
? {
|
||||
label: t('calendar.undoButton'),
|
||||
run: async () => {
|
||||
await wishesHook.add(createRequestFromWish(previousWish))
|
||||
await runSync(workspaceId, { silent: true })
|
||||
},
|
||||
}
|
||||
: null,
|
||||
})
|
||||
}
|
||||
|
||||
// §todo9 — the ad-hoc "this period" delete (unchanged behaviour, and also
|
||||
// what ad-hoc's "this day" falls back to for a single-day band).
|
||||
const deleteAdhocRange = async (start: string, end: string, parentUuid: string) => {
|
||||
const bandDates = new Set(datesInRange(start, end))
|
||||
const toDelete = (plannedDaysHook.data ?? []).filter(
|
||||
(pd) => pd.parentPersonalDataUuid === parentUuid && pd.wishId === null && bandDates.has(pd.date),
|
||||
)
|
||||
const snapshot = toDelete.map((pd) => ({ ...pd }))
|
||||
for (const pd of toDelete) {
|
||||
await plannedDaysHook.remove(pd.id)
|
||||
}
|
||||
closePeriodPopover()
|
||||
if (snapshot.length > 0) {
|
||||
showToast(t('calendar.toastChangedDays', { name: t('calendar.toastGenericPeriod'), count: snapshot.length }), {
|
||||
label: t('calendar.undoButton'),
|
||||
run: async () => {
|
||||
for (const pd of snapshot) {
|
||||
await plannedDaysHook.add({
|
||||
workspace_id: pd.workspaceId,
|
||||
child_id: pd.childId,
|
||||
parent_personal_data_uuid: pd.parentPersonalDataUuid,
|
||||
date: pd.date,
|
||||
day_type: pd.dayType,
|
||||
is_dubbeldag: pd.isDubbeldag,
|
||||
is_pinned: pd.isPinned,
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// §todo9 "this period" (this year only) on a recurring wish — the option
|
||||
// with no existing mechanism. Narrows `firstYear`/`lastYear` to exclude
|
||||
// just the clicked occurrence's year, or splits the wish in two around a
|
||||
// middle year, using only fields the wish already has (no backend
|
||||
// change). Degenerates to the whole-wish delete when narrowing would
|
||||
// leave no valid year. A `recurrence: 'once'` wish has exactly one
|
||||
// occurrence, so "this year" and "every year" coincide — handled by the
|
||||
// same fallback.
|
||||
const narrowWishByOneYear = async (wish: LeaveWish, clickedYear: number, workspaceId: string) => {
|
||||
if (wish.recurrence === 'once') {
|
||||
await deleteWholeWish(wish.id, workspaceId)
|
||||
return
|
||||
}
|
||||
|
||||
const first = wish.firstYear
|
||||
const last = wish.lastYear
|
||||
|
||||
const finishWithUpdate = async (patch: LeaveWishUpdateRequest) => {
|
||||
await wishesHook.update(wish.id, patch)
|
||||
closePeriodPopover()
|
||||
await runSync(workspaceId, {
|
||||
watchWishId: wish.id,
|
||||
undo: {
|
||||
label: t('calendar.undoButton'),
|
||||
run: async () => {
|
||||
await wishesHook.update(wish.id, patchFromWish(wish))
|
||||
await runSync(workspaceId, { silent: true })
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (clickedYear === first) {
|
||||
const newFirstYear = first + 1
|
||||
if (last !== null && newFirstYear > last) {
|
||||
await deleteWholeWish(wish.id, workspaceId)
|
||||
return
|
||||
}
|
||||
await finishWithUpdate({ firstYear: newFirstYear })
|
||||
return
|
||||
}
|
||||
|
||||
if (last !== null && clickedYear === last) {
|
||||
const newLastYear = last - 1
|
||||
if (newLastYear < first) {
|
||||
await deleteWholeWish(wish.id, workspaceId)
|
||||
return
|
||||
}
|
||||
await finishWithUpdate({ lastYear: newLastYear })
|
||||
return
|
||||
}
|
||||
|
||||
// A middle year (strictly between `first` and a defined `last`, or past
|
||||
// `first` with `last` still open-ended): split into two wishes around
|
||||
// the excluded year, each keeping the original's other fields.
|
||||
const created = await wishesHook.add({
|
||||
...createRequestFromWish(wish),
|
||||
firstYear: clickedYear + 1,
|
||||
lastYear: wish.lastYear,
|
||||
})
|
||||
await wishesHook.update(wish.id, { lastYear: clickedYear - 1 })
|
||||
closePeriodPopover()
|
||||
await runSync(workspaceId, {
|
||||
undo: {
|
||||
label: t('calendar.undoButton'),
|
||||
run: async () => {
|
||||
await wishesHook.update(wish.id, patchFromWish(wish))
|
||||
await wishesHook.remove(created.id)
|
||||
await runSync(workspaceId, { silent: true })
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleDeletePeriod = async (scope: PeriodDeleteScope) => {
|
||||
if (!workspace?.id || !activeBand) return
|
||||
|
||||
try {
|
||||
setIsPeriodSaving(true)
|
||||
setPeriodError(null)
|
||||
|
||||
if (scope === 'allYears') {
|
||||
if (activeBand.wishId) await deleteWholeWish(activeBand.wishId, workspace.id)
|
||||
else closePeriodPopover() // disabled in the UI for ad-hoc bands
|
||||
return
|
||||
}
|
||||
|
||||
if (scope === 'day') {
|
||||
if (activeBand.wishId) {
|
||||
// Disabled in the UI (would just reappear on the next sync).
|
||||
closePeriodPopover()
|
||||
return
|
||||
}
|
||||
await deleteAdhocRange(activeBand.startDate, activeBand.startDate, activeBand.parentPersonalDataUuid)
|
||||
return
|
||||
}
|
||||
|
||||
// scope === 'year'
|
||||
if (activeBand.wishId) {
|
||||
const previousWish = wishesHook.data.find((w) => w.id === activeBand.wishId)
|
||||
await wishesHook.remove(activeBand.wishId)
|
||||
closePeriodPopover()
|
||||
await runSync(workspace.id, {
|
||||
undo: previousWish
|
||||
? {
|
||||
label: t('calendar.undoButton'),
|
||||
run: async () => {
|
||||
await wishesHook.add(createRequestFromWish(previousWish))
|
||||
await runSync(workspace.id, { silent: true })
|
||||
},
|
||||
}
|
||||
: null,
|
||||
})
|
||||
if (!previousWish) {
|
||||
closePeriodPopover()
|
||||
return
|
||||
}
|
||||
await narrowWishByOneYear(previousWish, splitISODate(activeBand.startDate).year, workspace.id)
|
||||
} else {
|
||||
// Ad-hoc band (no wishId): delete the underlying planned days
|
||||
// directly — there is no wish to resync from.
|
||||
const bandDates = new Set(datesInRange(activeBand.startDate, activeBand.endDate))
|
||||
const toDelete = (plannedDaysHook.data ?? []).filter(
|
||||
(pd) =>
|
||||
pd.parentPersonalDataUuid === activeBand.parentPersonalDataUuid &&
|
||||
pd.wishId === null &&
|
||||
bandDates.has(pd.date),
|
||||
)
|
||||
const snapshot = toDelete.map((pd) => ({ ...pd }))
|
||||
for (const pd of toDelete) {
|
||||
await plannedDaysHook.remove(pd.id)
|
||||
}
|
||||
closePeriodPopover()
|
||||
if (snapshot.length > 0) {
|
||||
showToast(t('calendar.toastChangedDays', { name: t('calendar.toastGenericPeriod'), count: snapshot.length }), {
|
||||
label: t('calendar.undoButton'),
|
||||
run: async () => {
|
||||
for (const pd of snapshot) {
|
||||
await plannedDaysHook.add({
|
||||
workspace_id: pd.workspaceId,
|
||||
child_id: pd.childId,
|
||||
parent_personal_data_uuid: pd.parentPersonalDataUuid,
|
||||
date: pd.date,
|
||||
day_type: pd.dayType,
|
||||
is_dubbeldag: pd.isDubbeldag,
|
||||
is_pinned: pd.isPinned,
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
await deleteAdhocRange(activeBand.startDate, activeBand.endDate, activeBand.parentPersonalDataUuid)
|
||||
}
|
||||
} catch (err) {
|
||||
setPeriodError(err instanceof Error ? err.message : t('calendar.periodCreateError'))
|
||||
@@ -971,11 +1161,13 @@ export function Calendar(): ReactNode {
|
||||
<PlannedDayModal
|
||||
date={firstSelectedDate}
|
||||
isOpen={isModalOpen}
|
||||
existingDay={getExistingDay(firstSelectedDate) ?? null}
|
||||
existingDay={getExistingDay(firstSelectedDate, advancedParentId) ?? null}
|
||||
parents={parentsForModals}
|
||||
selectedParentId={personalDataUuid ?? ''}
|
||||
onSave={handleSavePlannedDay}
|
||||
onDelete={getExistingDay(firstSelectedDate) ? handleDeletePlannedDay : undefined}
|
||||
selectedParentId={advancedParentId}
|
||||
onParentChange={setAdvancedParentId}
|
||||
onSaveWish={handleSaveWish}
|
||||
onSavePinned={handleSavePinnedDay}
|
||||
onDelete={getExistingDay(firstSelectedDate, advancedParentId) ? handleDeletePlannedDay : undefined}
|
||||
onClose={() => {
|
||||
setIsModalOpen(false)
|
||||
setSelectedDates(new Set())
|
||||
|
||||
@@ -248,6 +248,7 @@ interface RawLeaveWish {
|
||||
parent_mode: 'either' | 'both' | 'specific'
|
||||
parent_personal_data_uuid: string | null
|
||||
window_mode: 'fixed' | 'flexible'
|
||||
wants_dubbeldag?: boolean
|
||||
created_at: string | null
|
||||
updated_at: string | null
|
||||
}
|
||||
@@ -272,6 +273,7 @@ function toLeaveWish(raw: RawLeaveWish): LeaveWish {
|
||||
parentMode: raw.parent_mode,
|
||||
parentPersonalDataUuid: raw.parent_personal_data_uuid,
|
||||
windowMode: raw.window_mode ?? 'fixed',
|
||||
wantsDubbeldag: raw.wants_dubbeldag ?? false,
|
||||
createdAt: raw.created_at,
|
||||
updatedAt: raw.updated_at,
|
||||
}
|
||||
@@ -862,6 +864,7 @@ export async function createLeaveWish(payload: LeaveWishCreateRequest): Promise<
|
||||
parent_mode: payload.parentMode ?? 'either',
|
||||
parent_personal_data_uuid: payload.parentPersonalDataUuid ?? null,
|
||||
window_mode: payload.windowMode ?? 'fixed',
|
||||
wants_dubbeldag: payload.wantsDubbeldag ?? false,
|
||||
}),
|
||||
})) as RawLeaveWish
|
||||
return toLeaveWish(raw)
|
||||
@@ -890,6 +893,7 @@ export async function updateLeaveWish(
|
||||
body.parent_personal_data_uuid = payload.parentPersonalDataUuid
|
||||
}
|
||||
if (payload.windowMode !== undefined) body.window_mode = payload.windowMode
|
||||
if (payload.wantsDubbeldag !== undefined) body.wants_dubbeldag = payload.wantsDubbeldag
|
||||
|
||||
const raw = (await apiFetch(`/leave-wishes/${id}`, {
|
||||
method: 'PUT',
|
||||
|
||||
@@ -104,6 +104,11 @@ export interface LeaveWishCreateRequest {
|
||||
|
||||
// PLANNING_ENGINE_SPEC.md §10.1 — defaults to 'fixed' server-side when omitted.
|
||||
windowMode?: 'fixed' | 'flexible'
|
||||
|
||||
// §Decision A (co-leave, opt-in dubbeldagar) — only meaningful when
|
||||
// parentMode is 'both'; the backend rejects `true` otherwise. Omitted
|
||||
// defaults to `false` server-side. See `LeaveWish.wantsDubbeldag`.
|
||||
wantsDubbeldag?: boolean
|
||||
}
|
||||
|
||||
/** Partial update — only supplied fields are changed. */
|
||||
|
||||
@@ -67,6 +67,13 @@ export interface LeaveWish {
|
||||
// exact placement. 'flexible': the same fields describe the bounding range the
|
||||
// allocator searches within for the best-costing placement.
|
||||
windowMode: 'fixed' | 'flexible'
|
||||
// §Decision A (co-leave, opt-in dubbeldagar) — see backend/app/entities/
|
||||
// leave_wish.py and dubbeldagar.md at the repo root. Only meaningful when
|
||||
// `parentMode === 'both'`; the backend rejects `true` otherwise. Newly
|
||||
// wired to the frontend by the manual-add modal's "allow double days"
|
||||
// checkbox (PlannedDayModal.tsx/PeriodPopover.tsx) — until now this field
|
||||
// existed on the wire but had no UI.
|
||||
wantsDubbeldag: boolean
|
||||
createdAt: string | null
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -148,6 +148,7 @@ export function makeLeaveWish(overrides: Partial<LeaveWish> = {}): LeaveWish {
|
||||
parentMode: 'either',
|
||||
parentPersonalDataUuid: null,
|
||||
windowMode: 'fixed',
|
||||
wantsDubbeldag: false,
|
||||
createdAt: '2026-01-01T00:00:00',
|
||||
updatedAt: '2026-01-01T00:00:00',
|
||||
...overrides,
|
||||
|
||||
Reference in New Issue
Block a user