Author leave on the calendar itself
Planning used to mean filling in a wish list on one tab, pressing a button to see a suggestion, then pressing another to accept it — with nothing on screen telling you whether a plan was active. Dragging a range on the calendar now creates the period directly, clicking a band opens it for editing, and each change is recomputed and saved in one step. A period repeats never or yearly, matching what the planner can actually express. Clicking a day edits that day: its funding type, which pins it so the planner leaves it alone, and whether the day counts as worked or off. That switch used to be a mode covering the whole grid, which is what made the calendar confusing. The season presets move to a rail beside the grid. Picking one lets the planner choose the placement, then jumps there and flashes it, rather than asking the user to guess dates the engine already knows. A sync that funds nothing now says so, naming the period and how many days it fell short, instead of leaving the screen unchanged and looking broken.
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { PeriodPopover } from './PeriodPopover'
|
||||
import { makeLeaveWish } from '../../../tests/fixtures'
|
||||
|
||||
describe('PeriodPopover', () => {
|
||||
const mockOnClose = vi.fn()
|
||||
const mockOnSave = vi.fn()
|
||||
const mockOnDelete = vi.fn()
|
||||
|
||||
const defaultProps = {
|
||||
isOpen: true,
|
||||
wish: null,
|
||||
startDate: '2028-06-15',
|
||||
endDate: '2028-06-20',
|
||||
parents: [{ id: 'p1', name: 'Parent 1' }],
|
||||
onSave: mockOnSave,
|
||||
onDelete: mockOnDelete,
|
||||
onClose: mockOnClose,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns null when not open', () => {
|
||||
const { container } = render(<PeriodPopover {...defaultProps} isOpen={false} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
describe('ad-hoc band (no wish)', () => {
|
||||
it('shows the ad-hoc notice and date range instead of editable fields', () => {
|
||||
render(<PeriodPopover {...defaultProps} />)
|
||||
expect(screen.getByTestId('period-adhoc-notice')).toBeInTheDocument()
|
||||
expect(screen.getByText('2028-06-15 – 2028-06-20')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('period-name-input')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render a save button', () => {
|
||||
render(<PeriodPopover {...defaultProps} />)
|
||||
expect(screen.queryByTestId('period-save-button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the ad-hoc delete label', () => {
|
||||
render(<PeriodPopover {...defaultProps} />)
|
||||
expect(screen.getByTestId('period-delete-button')).toHaveTextContent('Ta bort dessa dagar')
|
||||
})
|
||||
|
||||
it('calls onDelete after confirmation', async () => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
mockOnDelete.mockResolvedValue(undefined)
|
||||
render(<PeriodPopover {...defaultProps} />)
|
||||
fireEvent.click(screen.getByTestId('period-delete-button'))
|
||||
await waitFor(() => expect(mockOnDelete).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('does not call onDelete when confirmation is cancelled', () => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||
render(<PeriodPopover {...defaultProps} />)
|
||||
fireEvent.click(screen.getByTestId('period-delete-button'))
|
||||
expect(mockOnDelete).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('wish-linked band', () => {
|
||||
const wish = makeLeaveWish({
|
||||
id: 'lw-1',
|
||||
name: 'Sommar',
|
||||
windowStartMonth: 6,
|
||||
windowStartDay: 15,
|
||||
windowEndMonth: 7,
|
||||
windowEndDay: 26,
|
||||
firstYear: 2028,
|
||||
lastYear: null,
|
||||
recurrence: 'yearly',
|
||||
parentMode: 'either',
|
||||
})
|
||||
|
||||
it('prefills name/dates/parent/repeat from the wish', () => {
|
||||
render(<PeriodPopover {...defaultProps} wish={wish} />)
|
||||
expect(screen.getByTestId('period-name-input')).toHaveValue('Sommar')
|
||||
expect(screen.getByTestId('period-start-input')).toHaveValue('2028-06-15')
|
||||
expect(screen.getByTestId('period-end-input')).toHaveValue('2028-07-26')
|
||||
expect(screen.getByTestId('period-parent-select')).toHaveValue('either')
|
||||
expect(screen.getByTestId('period-repeat-yearly')).toBeChecked()
|
||||
})
|
||||
|
||||
it('shows the wrapping window correctly for a Jul-style wish (end year = firstYear + 1)', () => {
|
||||
const jul = makeLeaveWish({
|
||||
windowStartMonth: 12,
|
||||
windowStartDay: 20,
|
||||
windowEndMonth: 1,
|
||||
windowEndDay: 16,
|
||||
firstYear: 2028,
|
||||
})
|
||||
render(<PeriodPopover {...defaultProps} wish={jul} />)
|
||||
expect(screen.getByTestId('period-start-input')).toHaveValue('2028-12-20')
|
||||
expect(screen.getByTestId('period-end-input')).toHaveValue('2029-01-16')
|
||||
})
|
||||
|
||||
it('shows "until age 4" checked when lastYear is null', () => {
|
||||
render(<PeriodPopover {...defaultProps} wish={wish} />)
|
||||
expect(screen.getByTestId('period-until-age4')).toBeChecked()
|
||||
expect(screen.queryByTestId('period-last-year-input')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows an explicit last-year input when lastYear is set', () => {
|
||||
render(<PeriodPopover {...defaultProps} wish={makeLeaveWish({ ...wish, lastYear: 2030 })} />)
|
||||
expect(screen.getByTestId('period-until-age4')).not.toBeChecked()
|
||||
expect(screen.getByTestId('period-last-year-input')).toHaveValue(2030)
|
||||
})
|
||||
|
||||
it('unchecking "until age 4" reveals a last-year input seeded from the start date\'s year', () => {
|
||||
render(<PeriodPopover {...defaultProps} wish={wish} />)
|
||||
expect(screen.getByTestId('period-until-age4')).toBeChecked()
|
||||
fireEvent.click(screen.getByTestId('period-until-age4'))
|
||||
expect(screen.getByTestId('period-until-age4')).not.toBeChecked()
|
||||
expect(screen.getByTestId('period-last-year-input')).toHaveValue(2028)
|
||||
})
|
||||
|
||||
it('re-checking "until age 4" hides the last-year input and clears it on save', async () => {
|
||||
mockOnSave.mockResolvedValue(undefined)
|
||||
render(<PeriodPopover {...defaultProps} wish={makeLeaveWish({ ...wish, lastYear: 2030 })} />)
|
||||
fireEvent.click(screen.getByTestId('period-until-age4'))
|
||||
expect(screen.queryByTestId('period-last-year-input')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByTestId('period-save-button'))
|
||||
await waitFor(() => expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ lastYear: null })))
|
||||
})
|
||||
|
||||
it('editing the last-year input updates the value sent on save', async () => {
|
||||
mockOnSave.mockResolvedValue(undefined)
|
||||
render(<PeriodPopover {...defaultProps} wish={makeLeaveWish({ ...wish, lastYear: 2030 })} />)
|
||||
fireEvent.change(screen.getByTestId('period-last-year-input'), { target: { value: '2032' } })
|
||||
expect(screen.getByTestId('period-last-year-input')).toHaveValue(2032)
|
||||
fireEvent.click(screen.getByTestId('period-save-button'))
|
||||
await waitFor(() => expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ lastYear: 2032 })))
|
||||
})
|
||||
|
||||
it('toggles to "Never" repeat and hides the yearly options', () => {
|
||||
render(<PeriodPopover {...defaultProps} wish={wish} />)
|
||||
fireEvent.click(screen.getByTestId('period-repeat-never'))
|
||||
expect(screen.queryByTestId('period-yearly-options')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onSave with the edited name, dates and recurrence', async () => {
|
||||
mockOnSave.mockResolvedValue(undefined)
|
||||
render(<PeriodPopover {...defaultProps} wish={wish} />)
|
||||
|
||||
fireEvent.change(screen.getByTestId('period-name-input'), { target: { value: 'Sommarledighet' } })
|
||||
fireEvent.change(screen.getByTestId('period-end-input'), { target: { value: '2028-08-02' } })
|
||||
fireEvent.click(screen.getByTestId('period-save-button'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSave).toHaveBeenCalledWith({
|
||||
name: 'Sommarledighet',
|
||||
startDate: '2028-06-15',
|
||||
endDate: '2028-08-02',
|
||||
parentMode: 'either',
|
||||
parentPersonalDataUuid: null,
|
||||
recurrence: 'yearly',
|
||||
lastYear: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('sends lastYear: null when switched to Never even if a last year was set', async () => {
|
||||
mockOnSave.mockResolvedValue(undefined)
|
||||
render(<PeriodPopover {...defaultProps} wish={makeLeaveWish({ ...wish, lastYear: 2030 })} />)
|
||||
fireEvent.click(screen.getByTestId('period-repeat-never'))
|
||||
fireEvent.click(screen.getByTestId('period-save-button'))
|
||||
await waitFor(() => {
|
||||
expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({ recurrence: 'once', lastYear: null }))
|
||||
})
|
||||
})
|
||||
|
||||
it('switches parentMode to specific when a member is selected', async () => {
|
||||
mockOnSave.mockResolvedValue(undefined)
|
||||
render(
|
||||
<PeriodPopover
|
||||
{...defaultProps}
|
||||
wish={wish}
|
||||
parents={[
|
||||
{ id: 'p1', name: 'Alex' },
|
||||
{ id: 'p2', name: 'Sam' },
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
fireEvent.change(screen.getByTestId('period-parent-select'), { target: { value: 'p2' } })
|
||||
fireEvent.click(screen.getByTestId('period-save-button'))
|
||||
await waitFor(() => {
|
||||
expect(mockOnSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ parentMode: 'specific', parentPersonalDataUuid: 'p2' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('switches parentMode back to "both" after picking a specific member', async () => {
|
||||
mockOnSave.mockResolvedValue(undefined)
|
||||
render(
|
||||
<PeriodPopover
|
||||
{...defaultProps}
|
||||
wish={wish}
|
||||
parents={[
|
||||
{ id: 'p1', name: 'Alex' },
|
||||
{ id: 'p2', name: 'Sam' },
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
fireEvent.change(screen.getByTestId('period-parent-select'), { target: { value: 'p2' } })
|
||||
fireEvent.change(screen.getByTestId('period-parent-select'), { target: { value: 'both' } })
|
||||
fireEvent.click(screen.getByTestId('period-save-button'))
|
||||
await waitFor(() => {
|
||||
expect(mockOnSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ parentMode: 'both', parentPersonalDataUuid: null }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('displays a generic local error when onDelete rejects with a non-Error value', async () => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
mockOnDelete.mockRejectedValue('boom')
|
||||
render(<PeriodPopover {...defaultProps} wish={wish} />)
|
||||
fireEvent.click(screen.getByTestId('period-delete-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 () => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
mockOnDelete.mockResolvedValue(undefined)
|
||||
render(<PeriodPopover {...defaultProps} wish={wish} />)
|
||||
fireEvent.click(screen.getByTestId('period-delete-button'))
|
||||
await waitFor(() => expect(mockOnDelete).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('shows the wish-linked delete label', () => {
|
||||
render(<PeriodPopover {...defaultProps} wish={wish} />)
|
||||
expect(screen.getByTestId('period-delete-button')).toHaveTextContent('Ta bort period')
|
||||
})
|
||||
|
||||
it('displays a local error when onSave rejects', async () => {
|
||||
mockOnSave.mockRejectedValue(new Error('save exploded'))
|
||||
render(<PeriodPopover {...defaultProps} wish={wish} />)
|
||||
fireEvent.click(screen.getByTestId('period-save-button'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('save exploded')
|
||||
})
|
||||
})
|
||||
|
||||
it('displays a generic error when onSave rejects with a non-Error value', async () => {
|
||||
mockOnSave.mockRejectedValue('boom')
|
||||
render(<PeriodPopover {...defaultProps} wish={wish} />)
|
||||
fireEvent.click(screen.getByTestId('period-save-button'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).not.toHaveTextContent('boom')
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Det gick inte att skapa perioden')
|
||||
})
|
||||
})
|
||||
|
||||
it('displays the error prop directly', () => {
|
||||
render(<PeriodPopover {...defaultProps} wish={wish} error="server said no" />)
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('server said no')
|
||||
})
|
||||
|
||||
it('shows the loading state while saving', () => {
|
||||
render(<PeriodPopover {...defaultProps} wish={wish} isSaving />)
|
||||
expect(screen.getByTestId('period-save-button')).toBeDisabled()
|
||||
expect(screen.getByTestId('period-save-button')).toHaveTextContent('Sparar…')
|
||||
})
|
||||
|
||||
it('resolves a preset-created title through its message key, not the stale stored name', () => {
|
||||
const presetWish = makeLeaveWish({ name: 'Stale Cached Name', nameMessageKey: 'wishEditor.seasonSommar' })
|
||||
render(<PeriodPopover {...defaultProps} wish={presetWish} />)
|
||||
expect(screen.getByTestId('period-popover-title')).toHaveTextContent('Sommar')
|
||||
})
|
||||
})
|
||||
|
||||
describe('close behaviour', () => {
|
||||
it('calls onClose on the close button', () => {
|
||||
render(<PeriodPopover {...defaultProps} />)
|
||||
fireEvent.click(screen.getByLabelText('Stäng'))
|
||||
expect(mockOnClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onClose on overlay click but not on content click', () => {
|
||||
render(<PeriodPopover {...defaultProps} />)
|
||||
fireEvent.click(screen.getByTestId('period-popover__content'))
|
||||
expect(mockOnClose).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByTestId('period-popover__overlay'))
|
||||
expect(mockOnClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onClose on the cancel button', () => {
|
||||
render(<PeriodPopover {...defaultProps} />)
|
||||
fireEvent.click(screen.getByTestId('period-cancel-button'))
|
||||
expect(mockOnClose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,376 @@
|
||||
import { ReactNode, useEffect, useState } from 'react'
|
||||
import type { LeaveWish } from '@/types'
|
||||
import { useTranslation, type TranslationKey } from '@/i18n'
|
||||
import { resolveWishName } from '@/utils/wishName'
|
||||
import './period-popover.css'
|
||||
|
||||
export interface PeriodPopoverSavePayload {
|
||||
name: string
|
||||
// ISO-8601 dates for the period's first (or only) occurrence. Month/day
|
||||
// become the wish's recurring anchor; the start date's year becomes
|
||||
// `firstYear`. See `LeaveWish.window_for_year()` (backend) for how a
|
||||
// wrapping window (end sorts before start) is inferred from month/day
|
||||
// alone, not from the actual year gap between these two dates.
|
||||
startDate: string
|
||||
endDate: string
|
||||
parentMode: 'either' | 'both' | 'specific'
|
||||
parentPersonalDataUuid: string | null
|
||||
recurrence: 'yearly' | 'once'
|
||||
// Meaningful only when `recurrence === 'yearly'`. `null` = "until the
|
||||
// child turns 4" (no explicit end year).
|
||||
lastYear: number | null
|
||||
}
|
||||
|
||||
export interface PeriodPopoverProps {
|
||||
isOpen: boolean
|
||||
/** The `LeaveWish` behind the clicked band, or `null` for an ad-hoc band
|
||||
* (planned days with no `wishId`) — those can only be deleted, not edited,
|
||||
* since there is no wish to persist name/parent/repeat changes against. */
|
||||
wish: LeaveWish | null
|
||||
/** The clicked band's actual placed dates — always shown, and the only
|
||||
* source of truth for an ad-hoc band. */
|
||||
startDate: string
|
||||
endDate: string
|
||||
parents: Array<{ id: string; name: string }>
|
||||
isSaving?: boolean | undefined
|
||||
error?: string | null | undefined
|
||||
onSave: (payload: PeriodPopoverSavePayload) => Promise<void>
|
||||
onDelete: () => Promise<void>
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
interface FormState {
|
||||
name: string
|
||||
startDate: string
|
||||
endDate: string
|
||||
parentMode: 'either' | 'both' | 'specific'
|
||||
parentPersonalDataUuid: string | null
|
||||
recurrence: 'yearly' | 'once'
|
||||
lastYear: number | null
|
||||
}
|
||||
|
||||
/** Mirrors `LeaveWish.window_wraps()` (backend/app/entities/leave_wish.py):
|
||||
* whether (end_month, end_day) sorts before (start_month, start_day). */
|
||||
function windowWraps(startMonth: number, startDay: number, endMonth: number, endDay: number): boolean {
|
||||
return endMonth < startMonth || (endMonth === startMonth && endDay < startDay)
|
||||
}
|
||||
|
||||
function formatISO(year: number, month: number, day: number): string {
|
||||
return `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function parseISO(date: string): { year: number; month: number; day: number } {
|
||||
const [year, month, day] = date.split('-').map(Number)
|
||||
return { year: year ?? 1970, month: month ?? 1, day: day ?? 1 }
|
||||
}
|
||||
|
||||
function formStateFromWish(wish: LeaveWish, t: (key: TranslationKey) => string): FormState {
|
||||
const wraps = windowWraps(wish.windowStartMonth, wish.windowStartDay, wish.windowEndMonth, wish.windowEndDay)
|
||||
const endYear = wraps ? wish.firstYear + 1 : wish.firstYear
|
||||
return {
|
||||
name: resolveWishName(wish, t),
|
||||
startDate: formatISO(wish.firstYear, wish.windowStartMonth, wish.windowStartDay),
|
||||
endDate: formatISO(endYear, wish.windowEndMonth, wish.windowEndDay),
|
||||
parentMode: wish.parentMode,
|
||||
parentPersonalDataUuid: wish.parentPersonalDataUuid,
|
||||
recurrence: wish.recurrence,
|
||||
lastYear: wish.lastYear,
|
||||
}
|
||||
}
|
||||
|
||||
export function PeriodPopover({
|
||||
isOpen,
|
||||
wish,
|
||||
startDate,
|
||||
endDate,
|
||||
parents,
|
||||
isSaving = false,
|
||||
error = null,
|
||||
onSave,
|
||||
onDelete,
|
||||
onClose,
|
||||
}: PeriodPopoverProps): ReactNode {
|
||||
const { t } = useTranslation()
|
||||
const [form, setForm] = useState<FormState>({
|
||||
name: '',
|
||||
startDate,
|
||||
endDate,
|
||||
parentMode: 'either',
|
||||
parentPersonalDataUuid: null,
|
||||
recurrence: 'once',
|
||||
lastYear: null,
|
||||
})
|
||||
const [localError, setLocalError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (wish) {
|
||||
setForm(formStateFromWish(wish, t))
|
||||
} else {
|
||||
setForm({
|
||||
name: '',
|
||||
startDate,
|
||||
endDate,
|
||||
parentMode: 'either',
|
||||
parentPersonalDataUuid: null,
|
||||
recurrence: 'once',
|
||||
lastYear: null,
|
||||
})
|
||||
}
|
||||
setLocalError(null)
|
||||
}, [isOpen, wish, startDate, endDate, t])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const parentSelectValue = form.parentMode === 'specific' ? (form.parentPersonalDataUuid ?? '') : form.parentMode
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!wish) return
|
||||
try {
|
||||
setLocalError(null)
|
||||
await onSave({
|
||||
name: form.name,
|
||||
startDate: form.startDate,
|
||||
endDate: form.endDate,
|
||||
parentMode: form.parentMode,
|
||||
parentPersonalDataUuid: form.parentMode === 'specific' ? form.parentPersonalDataUuid : null,
|
||||
recurrence: form.recurrence,
|
||||
lastYear: form.recurrence === 'yearly' ? form.lastYear : null,
|
||||
})
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : t('calendar.periodCreateError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (confirm(t('calendar.periodDeleteConfirm'))) {
|
||||
try {
|
||||
setLocalError(null)
|
||||
await onDelete()
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : t('calendar.periodCreateError'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const title = wish ? resolveWishName(wish, t) : t('calendar.periodAdHocTitle')
|
||||
|
||||
return (
|
||||
<div
|
||||
className="period-popover__overlay"
|
||||
onClick={onClose}
|
||||
data-testid="period-popover__overlay"
|
||||
role="dialog"
|
||||
>
|
||||
<div
|
||||
className="period-popover__content"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
data-testid="period-popover__content"
|
||||
>
|
||||
<div className="period-popover__header">
|
||||
<h2 data-testid="period-popover-title">{title}</h2>
|
||||
<button
|
||||
className="period-popover__close"
|
||||
onClick={onClose}
|
||||
aria-label={t('calendar.periodCloseAria')}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="period-popover__body">
|
||||
{(error || localError) && (
|
||||
<div className="period-popover__error" role="alert">
|
||||
{error || localError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!wish && (
|
||||
<>
|
||||
<p className="period-popover__adhoc-notice" data-testid="period-adhoc-notice">
|
||||
{t('calendar.periodAdHocNotice')}
|
||||
</p>
|
||||
<p className="period-popover__date-range">
|
||||
{startDate} – {endDate}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{wish && (
|
||||
<>
|
||||
<div className="period-popover__field">
|
||||
<label htmlFor="period-name" className="period-popover__label">
|
||||
{t('calendar.periodNameLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="period-name"
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
className="period-popover__input"
|
||||
disabled={isSaving}
|
||||
data-testid="period-name-input"
|
||||
aria-label={t('calendar.periodNameAria')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="period-popover__field">
|
||||
<label htmlFor="period-start" className="period-popover__label">
|
||||
{t('calendar.periodStartLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="period-start"
|
||||
type="date"
|
||||
value={form.startDate}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, startDate: e.target.value }))}
|
||||
className="period-popover__input"
|
||||
disabled={isSaving}
|
||||
data-testid="period-start-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="period-popover__field">
|
||||
<label htmlFor="period-end" className="period-popover__label">
|
||||
{t('calendar.periodEndLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="period-end"
|
||||
type="date"
|
||||
value={form.endDate}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, endDate: e.target.value }))}
|
||||
className="period-popover__input"
|
||||
disabled={isSaving}
|
||||
data-testid="period-end-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="period-popover__field">
|
||||
<label htmlFor="period-parent" className="period-popover__label">
|
||||
{t('wishEditor.parentLabel')}
|
||||
</label>
|
||||
<select
|
||||
id="period-parent"
|
||||
value={parentSelectValue}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
if (value === 'either' || value === 'both') {
|
||||
setForm((prev) => ({ ...prev, parentMode: value, parentPersonalDataUuid: null }))
|
||||
} else {
|
||||
setForm((prev) => ({ ...prev, parentMode: 'specific', parentPersonalDataUuid: value }))
|
||||
}
|
||||
}}
|
||||
className="period-popover__select"
|
||||
disabled={isSaving}
|
||||
data-testid="period-parent-select"
|
||||
>
|
||||
<option value="either">{t('wishEditor.eitherParent')}</option>
|
||||
<option value="both">{t('wishEditor.bothParents')}</option>
|
||||
{parents.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<fieldset className="period-popover__fieldset" data-testid="period-repeat-fieldset">
|
||||
<legend className="period-popover__label">{t('calendar.periodRepeatLabel')}</legend>
|
||||
<label className="period-popover__radio-label">
|
||||
<input
|
||||
type="radio"
|
||||
name="period-repeat"
|
||||
checked={form.recurrence === 'once'}
|
||||
onChange={() => setForm((prev) => ({ ...prev, recurrence: 'once' }))}
|
||||
disabled={isSaving}
|
||||
data-testid="period-repeat-never"
|
||||
/>
|
||||
{' '}{t('calendar.periodRepeatNever')}
|
||||
</label>
|
||||
<label className="period-popover__radio-label">
|
||||
<input
|
||||
type="radio"
|
||||
name="period-repeat"
|
||||
checked={form.recurrence === 'yearly'}
|
||||
onChange={() => setForm((prev) => ({ ...prev, recurrence: 'yearly' }))}
|
||||
disabled={isSaving}
|
||||
data-testid="period-repeat-yearly"
|
||||
/>
|
||||
{' '}{t('calendar.periodRepeatYearly')}
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
{form.recurrence === 'yearly' && (
|
||||
<div className="period-popover__field" data-testid="period-yearly-options">
|
||||
<label className="period-popover__checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.lastYear === null}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
lastYear: e.target.checked ? null : parseISO(prev.startDate).year,
|
||||
}))
|
||||
}
|
||||
disabled={isSaving}
|
||||
data-testid="period-until-age4"
|
||||
/>
|
||||
{' '}{t('wishEditor.untilAge4')}
|
||||
</label>
|
||||
|
||||
{form.lastYear !== null && (
|
||||
<label className="period-popover__field">
|
||||
{t('calendar.periodLastYearLabel')}
|
||||
<input
|
||||
type="number"
|
||||
min={2000}
|
||||
max={2200}
|
||||
value={form.lastYear}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, lastYear: Number(e.target.value) }))
|
||||
}
|
||||
disabled={isSaving}
|
||||
data-testid="period-last-year-input"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="period-popover__footer">
|
||||
<button
|
||||
className="period-popover__button period-popover__button--danger"
|
||||
onClick={() => void handleDelete()}
|
||||
disabled={isSaving}
|
||||
data-testid="period-delete-button"
|
||||
>
|
||||
{isSaving
|
||||
? t('calendar.periodDeletingButton')
|
||||
: wish
|
||||
? t('calendar.periodDeleteButton')
|
||||
: t('calendar.periodDeleteAdHocButton')}
|
||||
</button>
|
||||
<button
|
||||
className="period-popover__button period-popover__button--secondary"
|
||||
onClick={onClose}
|
||||
disabled={isSaving}
|
||||
data-testid="period-cancel-button"
|
||||
>
|
||||
{t('calendar.periodCancelButton')}
|
||||
</button>
|
||||
{wish && (
|
||||
<button
|
||||
className="period-popover__button period-popover__button--primary"
|
||||
onClick={() => void handleSave()}
|
||||
disabled={isSaving}
|
||||
data-testid="period-save-button"
|
||||
>
|
||||
{isSaving ? t('calendar.periodSavingButton') : t('calendar.periodSaveButton')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { PresetRail, SEASON_PRESETS } from './PresetRail'
|
||||
|
||||
describe('PresetRail', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders one button per season preset', () => {
|
||||
render(<PresetRail onSelectPreset={vi.fn()} />)
|
||||
for (const preset of SEASON_PRESETS) {
|
||||
expect(screen.getByTestId(`preset-rail-${preset.key}`)).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
it('renders the heading and hint', () => {
|
||||
render(<PresetRail onSelectPreset={vi.fn()} />)
|
||||
expect(screen.getByTestId('preset-rail')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onSelectPreset with the clicked preset', () => {
|
||||
const onSelectPreset = vi.fn()
|
||||
render(<PresetRail onSelectPreset={onSelectPreset} />)
|
||||
fireEvent.click(screen.getByTestId('preset-rail-season-sommar'))
|
||||
expect(onSelectPreset).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ key: 'season-sommar', defaultWeeks: 6 }),
|
||||
)
|
||||
})
|
||||
|
||||
it('every preset is flexible (window search, not a fixed placement)', () => {
|
||||
// Season presets seed a "när det passar bäst" wish — the allocator
|
||||
// resolves the placement. A fixed period is what dragging produces.
|
||||
for (const preset of SEASON_PRESETS) {
|
||||
expect(preset.startMonth).toBeGreaterThanOrEqual(1)
|
||||
expect(preset.endMonth).toBeGreaterThanOrEqual(1)
|
||||
}
|
||||
expect(SEASON_PRESETS).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('disables every button when isBusy is true', () => {
|
||||
render(<PresetRail isBusy onSelectPreset={vi.fn()} />)
|
||||
for (const preset of SEASON_PRESETS) {
|
||||
expect(screen.getByTestId(`preset-rail-${preset.key}`)).toBeDisabled()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not call onSelectPreset for a disabled button click', () => {
|
||||
const onSelectPreset = vi.fn()
|
||||
render(<PresetRail isBusy onSelectPreset={onSelectPreset} />)
|
||||
fireEvent.click(screen.getByTestId('preset-rail-season-var'))
|
||||
expect(onSelectPreset).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the week count and the flexible badge for each preset', () => {
|
||||
render(<PresetRail onSelectPreset={vi.fn()} />)
|
||||
const summerButton = screen.getByTestId('preset-rail-season-sommar')
|
||||
expect(summerButton).toHaveTextContent('6')
|
||||
expect(summerButton).toHaveTextContent('när det passar bäst')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { useTranslation, type TranslationKey } from '@/i18n'
|
||||
import './preset-rail.css'
|
||||
|
||||
export interface SeasonPreset {
|
||||
key: string
|
||||
labelKey: TranslationKey
|
||||
startMonth: number
|
||||
startDay: number
|
||||
endMonth: number
|
||||
endDay: number
|
||||
defaultWeeks: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The four season quick-picks, copied from `SEASON_PRESETS` in
|
||||
* `WishEditor.tsx` (PLANNING_ENGINE_SPEC.md §10.7) — deliberately NOT
|
||||
* imported from there since that component is owned by another task.
|
||||
* Every one of these is a `flexible` wish: the dates below are the bounding
|
||||
* range the allocator searches within for the best-costing placement, not
|
||||
* the final dates. A fixed (exact-dates) period is what dragging on the
|
||||
* calendar produces instead — see Calendar.tsx's drag-select handler.
|
||||
*/
|
||||
export const SEASON_PRESETS: SeasonPreset[] = [
|
||||
{ key: 'season-var', labelKey: 'wishEditor.seasonVar', startMonth: 3, startDay: 1, endMonth: 5, endDay: 31, defaultWeeks: 1 },
|
||||
{ key: 'season-sommar', labelKey: 'wishEditor.seasonSommar', startMonth: 6, startDay: 1, endMonth: 8, endDay: 31, defaultWeeks: 6 },
|
||||
{ key: 'season-host', labelKey: 'wishEditor.seasonHost', startMonth: 9, startDay: 1, endMonth: 10, endDay: 31, defaultWeeks: 1 },
|
||||
{ key: 'season-jul', labelKey: 'wishEditor.seasonJul', startMonth: 12, startDay: 1, endMonth: 1, endDay: 31, defaultWeeks: 4 },
|
||||
]
|
||||
|
||||
export interface PresetRailProps {
|
||||
/** Disables every button while a preset's create+sync is in flight. */
|
||||
isBusy?: boolean | undefined
|
||||
onSelectPreset: (preset: SeasonPreset) => void | Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* A side rail of one-click season presets. Clicking one creates a flexible
|
||||
* `LeaveWish` and syncs the plan — the allocator picks the best placement,
|
||||
* and the calendar scrolls to it and flashes the resulting band (see
|
||||
* Calendar.tsx's `handleSelectPreset`). This rail only renders the choices
|
||||
* and reports which one was picked; it owns no request/scroll logic itself.
|
||||
*/
|
||||
export function PresetRail({ isBusy = false, onSelectPreset }: PresetRailProps): ReactNode {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<aside className="preset-rail" data-testid="preset-rail" aria-label={t('calendar.railHeading')}>
|
||||
<h3 className="preset-rail__heading">{t('calendar.railHeading')}</h3>
|
||||
<p className="preset-rail__hint">{t('calendar.railHint')}</p>
|
||||
<div className="preset-rail__buttons">
|
||||
{SEASON_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.key}
|
||||
type="button"
|
||||
className="preset-rail__button"
|
||||
onClick={() => void onSelectPreset(preset)}
|
||||
disabled={isBusy}
|
||||
data-testid={`preset-rail-${preset.key}`}
|
||||
aria-label={t('calendar.presetAddAria', { name: t(preset.labelKey) })}
|
||||
>
|
||||
<span className="preset-rail__button-name">{t(preset.labelKey)}</span>
|
||||
<span className="preset-rail__button-detail">
|
||||
{preset.defaultWeeks} {t(preset.defaultWeeks === 1 ? 'wishEditor.weekUnit' : 'wishEditor.weeksUnit')}
|
||||
{' · '}
|
||||
{t('wishEditor.flexBadge')}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
.period-popover__overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.period-popover__content {
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
max-width: 480px;
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.period-popover__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.period-popover__header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.period-popover__close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 28px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
color: #666;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.period-popover__close:hover {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.period-popover__body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.period-popover__error {
|
||||
background-color: #ffebee;
|
||||
color: #c62828;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.period-popover__adhoc-notice {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.period-popover__date-range {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.period-popover__field {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.period-popover__label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 6px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.period-popover__input,
|
||||
.period-popover__select {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #d0d0d0;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.period-popover__input:disabled,
|
||||
.period-popover__select:disabled {
|
||||
background-color: #f5f5f5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.period-popover__fieldset {
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
|
||||
.period-popover__radio-label,
|
||||
.period-popover__checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.period-popover__radio-label input,
|
||||
.period-popover__checkbox-label input {
|
||||
margin-right: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.period-popover__footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.period-popover__button {
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.period-popover__button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.period-popover__button--primary {
|
||||
background-color: #0066cc;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.period-popover__button--primary:hover:not(:disabled) {
|
||||
background-color: #0052a3;
|
||||
}
|
||||
|
||||
.period-popover__button--secondary {
|
||||
background-color: #e0e0e0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.period-popover__button--secondary:hover:not(:disabled) {
|
||||
background-color: #d0d0d0;
|
||||
}
|
||||
|
||||
.period-popover__button--danger {
|
||||
background-color: #d32f2f;
|
||||
color: #fff;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.period-popover__button--danger:hover:not(:disabled) {
|
||||
background-color: #b71c1c;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
.preset-rail {
|
||||
width: 200px;
|
||||
flex-shrink: 0;
|
||||
padding: 16px;
|
||||
background-color: #f9f9f9;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.preset-rail__heading {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.preset-rail__hint {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.preset-rail__buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.preset-rail__button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #d0d0d0;
|
||||
border-radius: 4px;
|
||||
background-color: #fff;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background-color 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.preset-rail__button:hover:not(:disabled) {
|
||||
background-color: #f0f8ff;
|
||||
border-color: #0066cc;
|
||||
}
|
||||
|
||||
.preset-rail__button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.preset-rail__button-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.preset-rail__button-detail {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
}
|
||||
@@ -456,6 +456,26 @@ describe('CalendarWeek', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('§Task7 flashWishId', () => {
|
||||
it('adds the flash class to the band matching flashWishId', () => {
|
||||
const plannedDays = [pd({ id: '1', date: '2026-01-05', wishId: 'wish-1' })]
|
||||
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} flashWishId="wish-1" />)
|
||||
expect(screen.getAllByTestId(/^calendar-band-/)[0]!).toHaveClass('calendar-band--flash')
|
||||
})
|
||||
|
||||
it('does not flash a band for a different wishId', () => {
|
||||
const plannedDays = [pd({ id: '1', date: '2026-01-05', wishId: 'wish-1' })]
|
||||
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} flashWishId="wish-2" />)
|
||||
expect(screen.getAllByTestId(/^calendar-band-/)[0]!).not.toHaveClass('calendar-band--flash')
|
||||
})
|
||||
|
||||
it('never flashes an ad-hoc (wishId null) band even if flashWishId is set oddly', () => {
|
||||
const plannedDays = [pd({ id: '1', date: '2026-01-05', wishId: null })]
|
||||
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} flashWishId={null} />)
|
||||
expect(screen.getAllByTestId(/^calendar-band-/)[0]!).not.toHaveClass('calendar-band--flash')
|
||||
})
|
||||
})
|
||||
|
||||
describe('lanes: overlapping bands for two parents do not collide', () => {
|
||||
it('assigns overlapping bands to different lanes (both render, neither is dropped)', () => {
|
||||
const plannedDays = [
|
||||
|
||||
@@ -29,8 +29,12 @@ export interface CalendarWeekProps {
|
||||
onPointerEnter: (date: string) => void
|
||||
/** §3.4/§3.6 — calendar metadata (röda dagar/klämdagar/overrides) keyed by ISO date. */
|
||||
calendarDaysByDate?: Map<string, CalendarDayEntry> | undefined
|
||||
/** Optional for now — a later task wires this to a period editor. */
|
||||
/** Opens the period editor for the clicked band's underlying period. */
|
||||
onBandClick?: ((info: BandClickInfo) => void) | undefined
|
||||
/** §Task7 — the wishId of a just-created/just-edited period to briefly
|
||||
* highlight (see Calendar.tsx's preset rail: "scrolls to the resolved
|
||||
* placement and flashes the band"). `null`/omitted highlights nothing. */
|
||||
flashWishId?: string | null | undefined
|
||||
}
|
||||
|
||||
/** One day's slot inside a band segment: either a real planned_leave_day
|
||||
@@ -241,6 +245,7 @@ export function CalendarWeek({
|
||||
onPointerEnter,
|
||||
calendarDaysByDate,
|
||||
onBandClick,
|
||||
flashWishId,
|
||||
}: CalendarWeekProps): ReactNode {
|
||||
const { t } = useTranslation()
|
||||
const bandSegments = useMemo(
|
||||
@@ -323,6 +328,7 @@ export function CalendarWeek({
|
||||
'calendar-band',
|
||||
segment.isBandStart ? 'calendar-band--start' : '',
|
||||
segment.isBandEnd ? 'calendar-band--end' : '',
|
||||
segment.wishId && segment.wishId === flashWishId ? 'calendar-band--flash' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { PlannedDayModal } from './PlannedDayModal'
|
||||
import { makePlannedLeaveDay } from '@/../tests/fixtures'
|
||||
import { makePlannedLeaveDay, makeCalendarDayEntry } from '@/../tests/fixtures'
|
||||
|
||||
describe('PlannedDayModal', () => {
|
||||
const mockOnClose = vi.fn()
|
||||
@@ -827,4 +827,136 @@ describe('PlannedDayModal', () => {
|
||||
expect(screen.getByTestId('delete-button')).toHaveTextContent('Tar bort…')
|
||||
})
|
||||
})
|
||||
|
||||
describe('§Task7 pinned notice', () => {
|
||||
it('shows a pinned notice when the existing day is pinned', () => {
|
||||
const existingDay = makePlannedLeaveDay({ id: 'day-1', parentPersonalDataUuid: 'p1', isPinned: true })
|
||||
render(<PlannedDayModal {...defaultProps} isOpen={true} existingDay={existingDay} />)
|
||||
expect(screen.getByTestId('pinned-notice')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show a pinned notice for an unpinned day', () => {
|
||||
const existingDay = makePlannedLeaveDay({ id: 'day-1', parentPersonalDataUuid: 'p1', isPinned: false })
|
||||
render(<PlannedDayModal {...defaultProps} isOpen={true} existingDay={existingDay} />)
|
||||
expect(screen.queryByTestId('pinned-notice')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show a pinned notice for a brand-new day', () => {
|
||||
render(<PlannedDayModal {...defaultProps} isOpen={true} />)
|
||||
expect(screen.queryByTestId('pinned-notice')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('§Task7 day status switch (arbetsdag/ledig)', () => {
|
||||
const onToggleOverride = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
onToggleOverride.mockReset()
|
||||
})
|
||||
|
||||
it('is hidden when dayInfo is not provided', () => {
|
||||
render(<PlannedDayModal {...defaultProps} isOpen={true} onToggleOverride={onToggleOverride} />)
|
||||
expect(screen.queryByTestId('day-status-switch')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('is hidden when onToggleOverride is not provided', () => {
|
||||
render(<PlannedDayModal {...defaultProps} isOpen={true} dayInfo={makeCalendarDayEntry()} />)
|
||||
expect(screen.queryByTestId('day-status-switch')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('is hidden in range mode even with dayInfo and onToggleOverride provided', () => {
|
||||
render(
|
||||
<PlannedDayModal
|
||||
{...defaultProps}
|
||||
isOpen={true}
|
||||
isRangeMode={true}
|
||||
dayCount={3}
|
||||
dayInfo={makeCalendarDayEntry()}
|
||||
onToggleOverride={onToggleOverride}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByTestId('day-status-switch')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('selects "Ledig" when the day is currently free', () => {
|
||||
render(
|
||||
<PlannedDayModal
|
||||
{...defaultProps}
|
||||
isOpen={true}
|
||||
dayInfo={makeCalendarDayEntry({ isFree: true })}
|
||||
onToggleOverride={onToggleOverride}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByTestId('day-status-off')).toBeChecked()
|
||||
expect(screen.getByTestId('day-status-workday')).not.toBeChecked()
|
||||
})
|
||||
|
||||
it('selects "Arbetsdag" when the day is currently a working day', () => {
|
||||
render(
|
||||
<PlannedDayModal
|
||||
{...defaultProps}
|
||||
isOpen={true}
|
||||
dayInfo={makeCalendarDayEntry({ isFree: false })}
|
||||
onToggleOverride={onToggleOverride}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByTestId('day-status-workday')).toBeChecked()
|
||||
expect(screen.getByTestId('day-status-off')).not.toBeChecked()
|
||||
})
|
||||
|
||||
it('calls onToggleOverride when picking the option that differs from the current status', async () => {
|
||||
onToggleOverride.mockResolvedValue(undefined)
|
||||
render(
|
||||
<PlannedDayModal
|
||||
{...defaultProps}
|
||||
isOpen={true}
|
||||
dayInfo={makeCalendarDayEntry({ isFree: false })}
|
||||
onToggleOverride={onToggleOverride}
|
||||
/>
|
||||
)
|
||||
await userEvent.click(screen.getByTestId('day-status-off'))
|
||||
expect(onToggleOverride).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not call onToggleOverride when clicking the already-selected option', async () => {
|
||||
onToggleOverride.mockResolvedValue(undefined)
|
||||
render(
|
||||
<PlannedDayModal
|
||||
{...defaultProps}
|
||||
isOpen={true}
|
||||
dayInfo={makeCalendarDayEntry({ isFree: false })}
|
||||
onToggleOverride={onToggleOverride}
|
||||
/>
|
||||
)
|
||||
await userEvent.click(screen.getByTestId('day-status-workday'))
|
||||
expect(onToggleOverride).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the override error when provided', () => {
|
||||
render(
|
||||
<PlannedDayModal
|
||||
{...defaultProps}
|
||||
isOpen={true}
|
||||
dayInfo={makeCalendarDayEntry()}
|
||||
onToggleOverride={onToggleOverride}
|
||||
overrideError="Det gick inte att uppdatera undantaget"
|
||||
/>
|
||||
)
|
||||
expect(screen.getByTestId('day-status-error')).toHaveTextContent('Det gick inte att uppdatera undantaget')
|
||||
})
|
||||
|
||||
it('disables the switch while saving', () => {
|
||||
render(
|
||||
<PlannedDayModal
|
||||
{...defaultProps}
|
||||
isOpen={true}
|
||||
isSaving={true}
|
||||
dayInfo={makeCalendarDayEntry()}
|
||||
onToggleOverride={onToggleOverride}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByTestId('day-status-workday')).toBeDisabled()
|
||||
expect(screen.getByTestId('day-status-off')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ReactNode, useState, useEffect } from 'react'
|
||||
import type { PlannedLeaveDay } from '@/types'
|
||||
import type { CalendarDayEntry, PlannedLeaveDay } from '@/types'
|
||||
import { useTranslation } from '@/i18n'
|
||||
import './planned-day-modal.css'
|
||||
|
||||
@@ -21,6 +21,14 @@ export interface PlannedDayModalProps {
|
||||
isRangeMode?: boolean | undefined
|
||||
dayCount?: number | undefined
|
||||
defaultDayType?: 'sjukpenning' | 'lagsta' | 'vacation' | undefined
|
||||
// §Task7 day popover — this date's calendar metadata (röda dagar/weekend/
|
||||
// overrides), driving the "arbetsdag/ledig" switch below. `undefined`
|
||||
// while the calendar window hasn't loaded metadata for this date yet;
|
||||
// `null`/absent `onToggleOverride` hides the switch entirely (e.g. in
|
||||
// range mode, where there is no single day to toggle).
|
||||
dayInfo?: CalendarDayEntry | null | undefined
|
||||
onToggleOverride?: (() => Promise<void>) | undefined
|
||||
overrideError?: string | null | undefined
|
||||
}
|
||||
|
||||
export function PlannedDayModal({
|
||||
@@ -37,12 +45,16 @@ export function PlannedDayModal({
|
||||
isRangeMode = false,
|
||||
dayCount = 1,
|
||||
defaultDayType,
|
||||
dayInfo,
|
||||
onToggleOverride,
|
||||
overrideError = null,
|
||||
}: PlannedDayModalProps): ReactNode {
|
||||
const { t, locale } = useTranslation()
|
||||
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 [isTogglingOverride, setIsTogglingOverride] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (existingDay) {
|
||||
@@ -88,6 +100,22 @@ export function PlannedDayModal({
|
||||
}
|
||||
}
|
||||
|
||||
// §Task7 — the "Den här dagen är: arbetsdag / ledig" switch. There are
|
||||
// only two effective states, so flipping to whichever option ISN'T
|
||||
// currently selected is exactly the toggle `onToggleOverride` performs —
|
||||
// no separate "set to X" call is needed. A no-op click on the
|
||||
// already-selected option is ignored.
|
||||
const handleSelectDayStatus = async (wantFree: boolean) => {
|
||||
if (!onToggleOverride || !dayInfo) return
|
||||
if (dayInfo.isFree === wantFree) return
|
||||
try {
|
||||
setIsTogglingOverride(true)
|
||||
await onToggleOverride()
|
||||
} finally {
|
||||
setIsTogglingOverride(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const displayDate = new Date(date + 'T00:00:00Z').toLocaleDateString(locale === 'sv' ? 'sv-SE' : 'en-US', {
|
||||
@@ -179,6 +207,52 @@ export function PlannedDayModal({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{existingDay?.isPinned && (
|
||||
<p className="planned-day-modal__pinned-notice" data-testid="pinned-notice">
|
||||
📌 {t('plannedDayModal.pinnedNotice')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isRangeMode && dayInfo && onToggleOverride && (
|
||||
<div className="planned-day-modal__field">
|
||||
<span className="planned-day-modal__label">{t('plannedDayModal.dayStatusLabel')}</span>
|
||||
<div
|
||||
className="planned-day-modal__override-switch"
|
||||
role="radiogroup"
|
||||
aria-label={t('plannedDayModal.dayStatusLabel')}
|
||||
data-testid="day-status-switch"
|
||||
>
|
||||
<label className="planned-day-modal__checkbox-label">
|
||||
<input
|
||||
type="radio"
|
||||
name="day-status"
|
||||
checked={!dayInfo.isFree}
|
||||
onChange={() => void handleSelectDayStatus(false)}
|
||||
disabled={isSaving || isTogglingOverride}
|
||||
data-testid="day-status-workday"
|
||||
/>
|
||||
{' '}{t('plannedDayModal.workdayOption')}
|
||||
</label>
|
||||
<label className="planned-day-modal__checkbox-label">
|
||||
<input
|
||||
type="radio"
|
||||
name="day-status"
|
||||
checked={dayInfo.isFree}
|
||||
onChange={() => void handleSelectDayStatus(true)}
|
||||
disabled={isSaving || isTogglingOverride}
|
||||
data-testid="day-status-off"
|
||||
/>
|
||||
{' '}{t('plannedDayModal.dayOffOption')}
|
||||
</label>
|
||||
</div>
|
||||
{overrideError && (
|
||||
<p className="planned-day-modal__error" role="alert" data-testid="day-status-error">
|
||||
{overrideError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="planned-day-modal__footer">
|
||||
|
||||
@@ -121,6 +121,22 @@
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.planned-day-modal__pinned-notice {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin: -8px 0 16px 0;
|
||||
}
|
||||
|
||||
.planned-day-modal__override-switch {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.planned-day-modal__override-switch .planned-day-modal__checkbox-label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.planned-day-modal__footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
@@ -2,6 +2,7 @@ export { useAnalytics } from './useAnalytics'
|
||||
export { useLeaveWishes } from './useLeaveWishes'
|
||||
export { usePlanProposal } from './usePlanProposal'
|
||||
export { usePlanApply } from './usePlanApply'
|
||||
export { usePlanSync } from './usePlanSync'
|
||||
export { useWorkspaceMembers } from './useWorkspaceMembers'
|
||||
export { useShowWeekNumbers, getLastLeaveType, setLastLeaveType } from './useCalendarPreferences'
|
||||
export { useChildSettings } from './useChildSettings'
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { usePlanSync } from './usePlanSync'
|
||||
import * as api from '@/services/api'
|
||||
import { makePlanSyncResponse, makePlanChange } from '../../tests/fixtures'
|
||||
|
||||
vi.mock('@/services/api', () => ({
|
||||
syncPlan: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('usePlanSync', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('starts idle with no error and no changes', () => {
|
||||
const { result } = renderHook(() => usePlanSync())
|
||||
expect(result.current.isSyncing).toBe(false)
|
||||
expect(result.current.error).toBeNull()
|
||||
expect(result.current.lastChanges).toBeNull()
|
||||
})
|
||||
|
||||
it('calls api.syncPlan with the workspace id and stores the changes', async () => {
|
||||
const response = makePlanSyncResponse({ changes: [makePlanChange({ kind: 'retyped' })] })
|
||||
vi.mocked(api.syncPlan).mockResolvedValue(response)
|
||||
|
||||
const { result } = renderHook(() => usePlanSync())
|
||||
|
||||
let returned: Awaited<ReturnType<typeof result.current.sync>> | undefined
|
||||
await act(async () => {
|
||||
returned = await result.current.sync('ws-1')
|
||||
})
|
||||
|
||||
expect(api.syncPlan).toHaveBeenCalledWith('ws-1')
|
||||
expect(returned).toEqual(response)
|
||||
expect(result.current.lastChanges).toEqual(response.changes)
|
||||
expect(result.current.isSyncing).toBe(false)
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('flips isSyncing while the call is in flight', async () => {
|
||||
let resolvePromise: (value: ReturnType<typeof makePlanSyncResponse>) => void = () => {}
|
||||
vi.mocked(api.syncPlan).mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolvePromise = resolve
|
||||
}),
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => usePlanSync())
|
||||
|
||||
let syncPromise: Promise<unknown> = Promise.resolve()
|
||||
act(() => {
|
||||
syncPromise = result.current.sync('ws-1')
|
||||
})
|
||||
|
||||
await waitFor(() => expect(result.current.isSyncing).toBe(true))
|
||||
|
||||
resolvePromise(makePlanSyncResponse())
|
||||
await act(async () => {
|
||||
await syncPromise
|
||||
})
|
||||
|
||||
expect(result.current.isSyncing).toBe(false)
|
||||
})
|
||||
|
||||
it('clears a previous changes list at the start of a new call', async () => {
|
||||
vi.mocked(api.syncPlan).mockResolvedValueOnce(
|
||||
makePlanSyncResponse({ changes: [makePlanChange()] }),
|
||||
)
|
||||
const { result } = renderHook(() => usePlanSync())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.sync('ws-1')
|
||||
})
|
||||
expect(result.current.lastChanges).toHaveLength(1)
|
||||
|
||||
let resolveSecond: (value: ReturnType<typeof makePlanSyncResponse>) => void = () => {}
|
||||
vi.mocked(api.syncPlan).mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveSecond = resolve
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
void result.current.sync('ws-1')
|
||||
})
|
||||
|
||||
await waitFor(() => expect(result.current.lastChanges).toBeNull())
|
||||
resolveSecond(makePlanSyncResponse())
|
||||
})
|
||||
|
||||
it('sets a readable error and rethrows when the request rejects with an Error', async () => {
|
||||
vi.mocked(api.syncPlan).mockRejectedValue(new Error('workspace not found'))
|
||||
const { result } = renderHook(() => usePlanSync())
|
||||
|
||||
await act(async () => {
|
||||
await expect(result.current.sync('ws-1')).rejects.toThrow('workspace not found')
|
||||
})
|
||||
|
||||
expect(result.current.error).toBe('workspace not found')
|
||||
expect(result.current.isSyncing).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to a generic error message for a non-Error rejection', async () => {
|
||||
vi.mocked(api.syncPlan).mockRejectedValue('network exploded')
|
||||
const { result } = renderHook(() => usePlanSync())
|
||||
|
||||
await act(async () => {
|
||||
await expect(result.current.sync('ws-1')).rejects.toBe('network exploded')
|
||||
})
|
||||
|
||||
expect(result.current.error).toBe('Failed to sync the plan')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import * as api from '@/services/api'
|
||||
import type { PlanSyncResponse } from '@/types'
|
||||
|
||||
interface UsePlanSyncResult {
|
||||
/** True while a sync call is in flight. */
|
||||
isSyncing: boolean
|
||||
error: string | null
|
||||
/** The most recent sync's `changes` — cleared to `null` at the start of
|
||||
* every new call so a stale diff from a previous sync never lingers. */
|
||||
lastChanges: PlanSyncResponse['changes'] | null
|
||||
sync: (workspaceId: string) => Promise<PlanSyncResponse>
|
||||
}
|
||||
|
||||
/**
|
||||
* `POST /api/plan/sync` — the direct-manipulation calendar's one call:
|
||||
* recompute the plan from the workspace's `LeaveWish`es and persist it,
|
||||
* never retyping/overwriting a pinned day, and reporting exactly what
|
||||
* changed. Callers (Calendar.tsx) drive their own toast/undo UI off the
|
||||
* returned `changes`; this hook only owns the request's loading/error state.
|
||||
*/
|
||||
export function usePlanSync(): UsePlanSyncResult {
|
||||
const [isSyncing, setIsSyncing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [lastChanges, setLastChanges] = useState<PlanSyncResponse['changes'] | null>(null)
|
||||
|
||||
const sync = useCallback(async (workspaceId: string): Promise<PlanSyncResponse> => {
|
||||
setIsSyncing(true)
|
||||
setError(null)
|
||||
setLastChanges(null)
|
||||
try {
|
||||
const result = await api.syncPlan(workspaceId)
|
||||
setLastChanges(result.changes)
|
||||
return result
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to sync the plan')
|
||||
throw err
|
||||
} finally {
|
||||
setIsSyncing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { isSyncing, error, lastChanges, sync }
|
||||
}
|
||||
@@ -49,6 +49,10 @@ export const calendar = {
|
||||
'plannedDayModal.leaveTypeLabel': 'Leave Type',
|
||||
'plannedDayModal.newTitle': 'Plan a Day',
|
||||
'plannedDayModal.parentLabel': 'Parent',
|
||||
'plannedDayModal.pinnedNotice': 'This day is pinned by you — the planner will not change it automatically.',
|
||||
'plannedDayModal.dayStatusLabel': 'This day is:',
|
||||
'plannedDayModal.workdayOption': 'Working day',
|
||||
'plannedDayModal.dayOffOption': 'Day off',
|
||||
'plannedDayModal.rangeTitle': 'Plan {count} days',
|
||||
'plannedDayModal.saveButton': 'Save',
|
||||
'plannedDayModal.saveError': 'Failed to save planned day',
|
||||
@@ -63,4 +67,44 @@ export const calendar = {
|
||||
'redDaysList.overrideFree': 'Exception: day off',
|
||||
'redDaysList.overrideWorking': 'Exception: working day',
|
||||
'redDaysList.prevWindow': 'Previous 4 years',
|
||||
|
||||
// §Task7 — the calendar becomes the plan: drag-select, period popover,
|
||||
// preset rail, and the sync toast/undo.
|
||||
'calendar.newPeriodName': 'New period',
|
||||
'calendar.periodCreateError': 'Failed to create the period',
|
||||
'calendar.syncError': 'Failed to sync the plan',
|
||||
'calendar.syncingLabel': 'Syncing…',
|
||||
'calendar.toastChangedDays': '{name}: {count} day(s) changed',
|
||||
'calendar.toastGenericPeriod': 'The period',
|
||||
'calendar.toastShortfallNone':
|
||||
'{name} ({start}–{end}): none of the {weeksWished} week(s) requested could be funded — {shortfallDays} day(s) short',
|
||||
'calendar.toastShortfallPartial':
|
||||
'{name} ({start}–{end}): only {weeksFunded} of {weeksWished} week(s) funded — {shortfallDays} day(s) short',
|
||||
'calendar.undoButton': 'Undo',
|
||||
'calendar.undoError': 'Failed to undo the change',
|
||||
'calendar.dismissToastAria': 'Dismiss the message',
|
||||
|
||||
'calendar.railHeading': 'Add leave',
|
||||
'calendar.railHint': 'Click to add — the calendar jumps to wherever it fits best.',
|
||||
'calendar.presetAddAria': 'Add {name}',
|
||||
|
||||
'calendar.periodPopoverTitle': 'Edit period',
|
||||
'calendar.periodAdHocTitle': 'Period',
|
||||
'calendar.periodAdHocNotice': 'This period is not linked to a saved wish.',
|
||||
'calendar.periodNameLabel': 'Name',
|
||||
'calendar.periodNameAria': "The period's name",
|
||||
'calendar.periodStartLabel': 'Start date',
|
||||
'calendar.periodEndLabel': 'End date',
|
||||
'calendar.periodRepeatLabel': 'Repeat',
|
||||
'calendar.periodRepeatNever': 'Never',
|
||||
'calendar.periodRepeatYearly': 'Yearly',
|
||||
'calendar.periodLastYearLabel': 'Last year',
|
||||
'calendar.periodSaveButton': 'Save period',
|
||||
'calendar.periodSavingButton': 'Saving…',
|
||||
'calendar.periodDeleteButton': 'Delete period',
|
||||
'calendar.periodDeletingButton': 'Deleting…',
|
||||
'calendar.periodDeleteConfirm': 'Are you sure you want to delete this period?',
|
||||
'calendar.periodDeleteAdHocButton': 'Delete these days',
|
||||
'calendar.periodCancelButton': 'Cancel',
|
||||
'calendar.periodCloseAria': 'Close',
|
||||
} as const
|
||||
|
||||
@@ -49,6 +49,10 @@ export const calendar = {
|
||||
'plannedDayModal.leaveTypeLabel': 'Typ av ledighet',
|
||||
'plannedDayModal.newTitle': 'Planera en dag',
|
||||
'plannedDayModal.parentLabel': 'Förälder',
|
||||
'plannedDayModal.pinnedNotice': 'Den här dagen är låst av dig — planeraren ändrar den inte automatiskt.',
|
||||
'plannedDayModal.dayStatusLabel': 'Den här dagen är:',
|
||||
'plannedDayModal.workdayOption': 'Arbetsdag',
|
||||
'plannedDayModal.dayOffOption': 'Ledig',
|
||||
'plannedDayModal.rangeTitle': 'Planera {count} dagar',
|
||||
'plannedDayModal.saveButton': 'Spara',
|
||||
'plannedDayModal.saveError': 'Det gick inte att spara den planerade dagen',
|
||||
@@ -63,4 +67,44 @@ export const calendar = {
|
||||
'redDaysList.overrideFree': 'Undantag: ledig dag',
|
||||
'redDaysList.overrideWorking': 'Undantag: arbetsdag',
|
||||
'redDaysList.prevWindow': 'Föregående 4 år',
|
||||
|
||||
// §Task7 — the calendar becomes the plan: drag-select, period popover,
|
||||
// preset rail, and the sync toast/undo.
|
||||
'calendar.newPeriodName': 'Ny period',
|
||||
'calendar.periodCreateError': 'Det gick inte att skapa perioden',
|
||||
'calendar.syncError': 'Det gick inte att synka planen',
|
||||
'calendar.syncingLabel': 'Synkroniserar…',
|
||||
'calendar.toastChangedDays': '{name}: {count} dag(ar) ändrades',
|
||||
'calendar.toastGenericPeriod': 'Perioden',
|
||||
'calendar.toastShortfallNone':
|
||||
'{name} ({start}–{end}): inget av de {weeksWished} önskade veckorna kunde finansieras — {shortfallDays} dag(ar) saknas',
|
||||
'calendar.toastShortfallPartial':
|
||||
'{name} ({start}–{end}): endast {weeksFunded} av {weeksWished} veckor finansierades — {shortfallDays} dag(ar) saknas',
|
||||
'calendar.undoButton': 'Ångra',
|
||||
'calendar.undoError': 'Det gick inte att ångra ändringen',
|
||||
'calendar.dismissToastAria': 'Stäng meddelandet',
|
||||
|
||||
'calendar.railHeading': 'Lägg till ledighet',
|
||||
'calendar.railHint': 'Klicka för att lägga till — kalendern hoppar till där den passar bäst.',
|
||||
'calendar.presetAddAria': 'Lägg till {name}',
|
||||
|
||||
'calendar.periodPopoverTitle': 'Redigera period',
|
||||
'calendar.periodAdHocTitle': 'Period',
|
||||
'calendar.periodAdHocNotice': 'Den här perioden är inte kopplad till en sparad önskan.',
|
||||
'calendar.periodNameLabel': 'Namn',
|
||||
'calendar.periodNameAria': 'Periodens namn',
|
||||
'calendar.periodStartLabel': 'Startdatum',
|
||||
'calendar.periodEndLabel': 'Slutdatum',
|
||||
'calendar.periodRepeatLabel': 'Upprepning',
|
||||
'calendar.periodRepeatNever': 'Aldrig',
|
||||
'calendar.periodRepeatYearly': 'Varje år',
|
||||
'calendar.periodLastYearLabel': 'Sista året',
|
||||
'calendar.periodSaveButton': 'Spara period',
|
||||
'calendar.periodSavingButton': 'Sparar…',
|
||||
'calendar.periodDeleteButton': 'Ta bort period',
|
||||
'calendar.periodDeletingButton': 'Tar bort…',
|
||||
'calendar.periodDeleteConfirm': 'Är du säker på att du vill ta bort den här perioden?',
|
||||
'calendar.periodDeleteAdHocButton': 'Ta bort dessa dagar',
|
||||
'calendar.periodCancelButton': 'Avbryt',
|
||||
'calendar.periodCloseAria': 'Stäng',
|
||||
} as const
|
||||
+1200
-574
File diff suppressed because it is too large
Load Diff
+626
-145
@@ -1,4 +1,4 @@
|
||||
import { ReactNode, useMemo, useState, useEffect } from 'react'
|
||||
import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useWorkspace } from '@/context'
|
||||
import {
|
||||
usePlannedDays,
|
||||
@@ -7,14 +7,31 @@ import {
|
||||
setLastLeaveType,
|
||||
useCalendarDays,
|
||||
useDayOverrides,
|
||||
useLeaveWishes,
|
||||
useWorkspaceMembers,
|
||||
usePlanSync,
|
||||
} from '@/hooks'
|
||||
import type { PlannedLeaveDay, CalendarDayEntry } from '@/types'
|
||||
import type {
|
||||
PlannedLeaveDay,
|
||||
CalendarDayEntry,
|
||||
LeaveWish,
|
||||
LeaveWishCreateRequest,
|
||||
LeaveWishUpdateRequest,
|
||||
PersonalData,
|
||||
PlanChange,
|
||||
PlanSyncResponse,
|
||||
WindowOutcome,
|
||||
} from '@/types'
|
||||
import { Layout } from '@/components/layout/Layout'
|
||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
|
||||
import { CalendarWeek, type BandClickInfo } from '@/components/common/CalendarWeek'
|
||||
import { PlannedDayModal } from '@/components/modals/PlannedDayModal'
|
||||
import { PeriodPopover, type PeriodPopoverSavePayload } from '@/components/calendar/PeriodPopover'
|
||||
import { PresetRail, type SeasonPreset } from '@/components/calendar/PresetRail'
|
||||
import { getColorForType } from '@/utils/leaveTypes'
|
||||
import { datesInRange } from '@/utils/dates'
|
||||
import { resolveWishName, resolveWishNameFromData } from '@/utils/wishName'
|
||||
import { datesInRange, formatDateForDisplay } from '@/utils/dates'
|
||||
import { renderRejection, type Translate } from '@/utils/rejectionMessages'
|
||||
import { useTranslation } from '@/i18n'
|
||||
import './calendar.css'
|
||||
|
||||
@@ -28,10 +45,137 @@ const WEEKDAY_KEYS = [
|
||||
'calendar.weekdaySun',
|
||||
] as const
|
||||
|
||||
/** Small "what changed" toast + optional undo — see Calendar.tsx's usage. */
|
||||
interface CalendarToast {
|
||||
message: string
|
||||
undo: { label: string; run: () => Promise<void> } | null
|
||||
}
|
||||
|
||||
function splitISODate(date: string): { year: number; month: number; day: number } {
|
||||
const [year, month, day] = date.split('-').map(Number)
|
||||
return { year: year ?? 1970, month: month ?? 1, day: day ?? 1 }
|
||||
}
|
||||
|
||||
/** Builds the full `LeaveWishCreateRequest` a wish's current fields would
|
||||
* recreate — used both to "undo" a period delete (recreate the wish) and,
|
||||
* minus `workspaceId`/`childId`, as the patch that "undoes" a period edit
|
||||
* (restore every field to its pre-edit value; §Task7 "undo reverts the
|
||||
* wish edit, not the days"). */
|
||||
function createRequestFromWish(w: LeaveWish): LeaveWishCreateRequest {
|
||||
return {
|
||||
workspaceId: w.workspaceId,
|
||||
childId: w.childId,
|
||||
name: w.name,
|
||||
nameMessageKey: w.nameMessageKey,
|
||||
windowStartMonth: w.windowStartMonth,
|
||||
windowStartDay: w.windowStartDay,
|
||||
windowEndMonth: w.windowEndMonth,
|
||||
windowEndDay: w.windowEndDay,
|
||||
targetWeeks: w.targetWeeks,
|
||||
targetDays: w.targetDays ?? null,
|
||||
recurrence: w.recurrence,
|
||||
firstYear: w.firstYear,
|
||||
lastYear: w.lastYear,
|
||||
priority: w.priority,
|
||||
parentMode: w.parentMode,
|
||||
parentPersonalDataUuid: w.parentPersonalDataUuid,
|
||||
windowMode: w.windowMode,
|
||||
}
|
||||
}
|
||||
|
||||
function patchFromWish(w: LeaveWish): LeaveWishUpdateRequest {
|
||||
return {
|
||||
name: w.name,
|
||||
nameMessageKey: w.nameMessageKey,
|
||||
windowStartMonth: w.windowStartMonth,
|
||||
windowStartDay: w.windowStartDay,
|
||||
windowEndMonth: w.windowEndMonth,
|
||||
windowEndDay: w.windowEndDay,
|
||||
targetWeeks: w.targetWeeks,
|
||||
targetDays: w.targetDays ?? null,
|
||||
recurrence: w.recurrence,
|
||||
firstYear: w.firstYear,
|
||||
lastYear: w.lastYear,
|
||||
priority: w.priority,
|
||||
parentMode: w.parentMode,
|
||||
parentPersonalDataUuid: w.parentPersonalDataUuid,
|
||||
windowMode: w.windowMode,
|
||||
}
|
||||
}
|
||||
|
||||
/** Groups a sync's `changes` by the wish (or "adhoc") they belong to and
|
||||
* renders one "{name}: N dagar ändrades" fragment per group — house style
|
||||
* is structured data in, composed copy out; the backend never sends prose. */
|
||||
function summarizeChanges(changes: PlanChange[], wishes: LeaveWish[], t: Translate): string | null {
|
||||
if (changes.length === 0) return null
|
||||
const counts = new Map<string, number>()
|
||||
for (const change of changes) {
|
||||
const key = change.newWishId ?? change.oldWishId ?? 'adhoc'
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1)
|
||||
}
|
||||
const parts: string[] = []
|
||||
for (const [wishId, count] of counts) {
|
||||
const wish = wishes.find((w) => w.id === wishId)
|
||||
const name = wish ? resolveWishName(wish, t) : t('calendar.toastGenericPeriod')
|
||||
parts.push(t('calendar.toastChangedDays', { name, count }))
|
||||
}
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
/** Renders a `WindowOutcome`'s shortfall for the touched wish, from
|
||||
* `outcome.messageKey`/`data` (today always `planOutcomes.windowExplanation`,
|
||||
* carrying `wish_name`/`wish_name_message_key`) — falling back to the plain
|
||||
* `shortfallDays` count when there's nothing richer to say. `funded`
|
||||
* distinguishes "nothing could be placed" from "only part of it fit", the
|
||||
* two phrasings the "sync that changes nothing must say why" fix needs. */
|
||||
function summarizeOutcomeShortfall(outcome: WindowOutcome, funded: boolean, t: Translate): string {
|
||||
const name = resolveWishNameFromData(outcome.data, t) || t('calendar.toastGenericPeriod')
|
||||
const params = {
|
||||
name,
|
||||
start: formatDateForDisplay(outcome.startDate),
|
||||
end: formatDateForDisplay(outcome.endDate),
|
||||
weeksWished: outcome.weeksWished,
|
||||
weeksFunded: outcome.weeksFunded,
|
||||
shortfallDays: outcome.shortfallDays,
|
||||
}
|
||||
return t(funded ? 'calendar.toastShortfallPartial' : 'calendar.toastShortfallNone', params)
|
||||
}
|
||||
|
||||
/** Builds the full sync toast message: what changed (existing summary), why
|
||||
* the wish just touched (if any) fell short of what was wished, and any
|
||||
* rejections the apply step returned — a genuine no-op sync (nothing to
|
||||
* report on any of the three) still renders `null`, so it stays silent. */
|
||||
function buildSyncToastMessage(
|
||||
result: PlanSyncResponse,
|
||||
wishes: LeaveWish[],
|
||||
members: PersonalData[],
|
||||
watchWishId: string | undefined,
|
||||
t: Translate,
|
||||
): string | null {
|
||||
const parts: string[] = []
|
||||
|
||||
const changesMessage = summarizeChanges(result.changes, wishes, t)
|
||||
if (changesMessage) parts.push(changesMessage)
|
||||
|
||||
if (watchWishId) {
|
||||
const outcome = result.proposal.outcomes.find((o) => o.wishId === watchWishId)
|
||||
if (outcome && outcome.shortfallDays > 0) {
|
||||
const funded = result.changes.some((c) => c.newWishId === watchWishId || c.oldWishId === watchWishId)
|
||||
parts.push(summarizeOutcomeShortfall(outcome, funded, t))
|
||||
}
|
||||
}
|
||||
|
||||
for (const rejection of result.applyResult.rejections) {
|
||||
parts.push(renderRejection(rejection, t, members))
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join(' · ') : null
|
||||
}
|
||||
|
||||
export function Calendar(): ReactNode {
|
||||
const { t, locale } = useTranslation()
|
||||
const { isLoading: wsLoading, selectedChildId, workspace, personalDataUuid, parentName } = useWorkspace()
|
||||
const { data: plannedDays, isLoading: pdLoading, add, update, remove } = usePlannedDays(selectedChildId ?? undefined)
|
||||
const plannedDaysHook = usePlannedDays(selectedChildId ?? undefined)
|
||||
const [showWeekNumbers] = useShowWeekNumbers()
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date())
|
||||
const [selectedDates, setSelectedDates] = useState<Set<string>>(new Set())
|
||||
@@ -39,13 +183,20 @@ export function Calendar(): ReactNode {
|
||||
const [dragCurrent, setDragCurrent] = useState<string | null>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [isRangeMode, setIsRangeMode] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [modalError, setModalError] = useState<string | null>(null)
|
||||
const [isOverrideMode, setIsOverrideMode] = useState(false)
|
||||
const [overrideError, setOverrideError] = useState<string | null>(null)
|
||||
|
||||
const isLoading = wsLoading || pdLoading
|
||||
const [activeBand, setActiveBand] = useState<BandClickInfo | null>(null)
|
||||
const [isPeriodOpen, setIsPeriodOpen] = useState(false)
|
||||
const [isPeriodSaving, setIsPeriodSaving] = useState(false)
|
||||
const [periodError, setPeriodError] = useState<string | null>(null)
|
||||
const [isCreatingPeriod, setIsCreatingPeriod] = useState(false)
|
||||
const [flashWishId, setFlashWishId] = useState<string | null>(null)
|
||||
const [toast, setToast] = useState<CalendarToast | null>(null)
|
||||
const [isUndoing, setIsUndoing] = useState(false)
|
||||
|
||||
const isLoading = wsLoading || plannedDaysHook.isLoading
|
||||
|
||||
// §3.4/§3.6 — the visible grid can spill into the previous year (January's
|
||||
// leading days) or the next year (December's trailing days), so fetch
|
||||
@@ -56,44 +207,155 @@ export function Calendar(): ReactNode {
|
||||
const calendarMaxYear = viewMonth === 11 ? viewYear + 1 : viewYear
|
||||
const calendarYearsSpan = calendarMaxYear - calendarMinYear + 1
|
||||
|
||||
const { data: calendarWindow, refetch: refetchCalendarWindow } = useCalendarDays(
|
||||
workspace?.id ?? '',
|
||||
calendarMinYear,
|
||||
calendarYearsSpan,
|
||||
)
|
||||
const { data: dayOverrides, upsert: upsertDayOverride, remove: removeDayOverride } = useDayOverrides(
|
||||
workspace?.id ?? '',
|
||||
)
|
||||
const calendarHook = useCalendarDays(workspace?.id ?? '', calendarMinYear, calendarYearsSpan)
|
||||
const dayOverridesHook = useDayOverrides(workspace?.id ?? '')
|
||||
const wishesHook = useLeaveWishes(workspace?.id ?? null)
|
||||
const membersHook = useWorkspaceMembers(workspace?.id ?? null)
|
||||
const syncHook = usePlanSync()
|
||||
|
||||
const calendarDaysByDate = useMemo(() => {
|
||||
const map = new Map<string, CalendarDayEntry>()
|
||||
for (const day of calendarWindow?.days ?? []) {
|
||||
for (const day of calendarHook.data?.days ?? []) {
|
||||
map.set(day.date, day)
|
||||
}
|
||||
return map
|
||||
}, [calendarWindow])
|
||||
}, [calendarHook.data])
|
||||
|
||||
const showToast = useCallback((message: string, undo: CalendarToast['undo']) => {
|
||||
setToast({ message, undo })
|
||||
}, [])
|
||||
|
||||
// Auto-dismiss the toast after a while so it never lingers indefinitely.
|
||||
useEffect(() => {
|
||||
if (!toast) return
|
||||
const timer = setTimeout(() => setToast(null), 8000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [toast])
|
||||
|
||||
// Clear a flashed band's highlight shortly after it's applied.
|
||||
useEffect(() => {
|
||||
if (!flashWishId) return
|
||||
const timer = setTimeout(() => setFlashWishId(null), 1600)
|
||||
return () => clearTimeout(timer)
|
||||
}, [flashWishId])
|
||||
|
||||
// §Task7 — the calendar IS the plan: recompute + persist from the
|
||||
// workspace's LeaveWishes in one call, then refresh the two views that
|
||||
// depend on the result (planned days for the bands, calendar metadata for
|
||||
// shading). `silent` suppresses the "what changed" toast (used for the
|
||||
// initial load and for undo's re-sync) without suppressing error
|
||||
// reporting — a broken sync should always be visible.
|
||||
const runSync = useCallback(
|
||||
async (
|
||||
workspaceId: string,
|
||||
options?: { silent?: boolean; undo?: CalendarToast['undo']; watchWishId?: string },
|
||||
): Promise<PlanSyncResponse | undefined> => {
|
||||
try {
|
||||
const result = await syncHook.sync(workspaceId)
|
||||
await Promise.all([plannedDaysHook.refresh(), calendarHook.refetch()])
|
||||
if (!options?.silent) {
|
||||
const message = buildSyncToastMessage(
|
||||
result,
|
||||
wishesHook.data,
|
||||
membersHook.data,
|
||||
options?.watchWishId,
|
||||
t,
|
||||
)
|
||||
if (message) showToast(message, options?.undo ?? null)
|
||||
}
|
||||
return result
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : t('calendar.syncError'), null)
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
[syncHook, plannedDaysHook, calendarHook, wishesHook.data, membersHook.data, t, showToast],
|
||||
)
|
||||
|
||||
// Sync once per workspace as soon as it's known, so wishes seeded/created
|
||||
// elsewhere show up as bands without requiring a manual edit first.
|
||||
const didAutoSyncRef = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (!workspace?.id) return
|
||||
if (didAutoSyncRef.current === workspace.id) return
|
||||
didAutoSyncRef.current = workspace.id
|
||||
void runSync(workspace.id, { silent: true })
|
||||
}, [workspace?.id, runSync])
|
||||
|
||||
const handleCreatePeriodFromDrag = useCallback(
|
||||
async (dates: string[]) => {
|
||||
setSelectedDates(new Set())
|
||||
if (!workspace?.id || !selectedChildId || !personalDataUuid) return
|
||||
const sorted = [...dates].sort()
|
||||
const start = sorted[0]
|
||||
const end = sorted[sorted.length - 1]
|
||||
if (!start || !end) return
|
||||
|
||||
const { year: startYear, month: startMonth, day: startDay } = splitISODate(start)
|
||||
const { month: endMonth, day: endDay } = splitISODate(end)
|
||||
const dayCount = sorted.length
|
||||
const targetWeeks = Math.min(52, Math.max(1, Math.ceil(dayCount / 7)))
|
||||
|
||||
try {
|
||||
setIsCreatingPeriod(true)
|
||||
const created = await wishesHook.add({
|
||||
workspaceId: workspace.id,
|
||||
childId: selectedChildId,
|
||||
name: t('calendar.newPeriodName'),
|
||||
nameMessageKey: 'calendar.newPeriodName',
|
||||
windowStartMonth: startMonth,
|
||||
windowStartDay: startDay,
|
||||
windowEndMonth: endMonth,
|
||||
windowEndDay: endDay,
|
||||
targetWeeks,
|
||||
targetDays: dayCount,
|
||||
recurrence: 'once',
|
||||
firstYear: startYear,
|
||||
priority: wishesHook.data.length + 1,
|
||||
parentMode: 'specific',
|
||||
parentPersonalDataUuid: personalDataUuid,
|
||||
windowMode: 'fixed',
|
||||
})
|
||||
await runSync(workspace.id, {
|
||||
watchWishId: created.id,
|
||||
undo: {
|
||||
label: t('calendar.undoButton'),
|
||||
run: async () => {
|
||||
await wishesHook.remove(created.id)
|
||||
await runSync(workspace.id, { silent: true })
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : t('calendar.periodCreateError'), null)
|
||||
} finally {
|
||||
setIsCreatingPeriod(false)
|
||||
}
|
||||
},
|
||||
[workspace?.id, selectedChildId, personalDataUuid, wishesHook, t, runSync, showToast],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const handlePointerUp = () => {
|
||||
if (isDragging && dragAnchor && dragCurrent) {
|
||||
const dates = datesInRange(dragAnchor, dragCurrent)
|
||||
setSelectedDates(new Set(dates))
|
||||
|
||||
if (dates.length === 1) {
|
||||
setIsRangeMode(false)
|
||||
} else {
|
||||
setIsRangeMode(true)
|
||||
setSelectedDates(new Set(dates))
|
||||
setIsModalOpen(true)
|
||||
} else if (dates.length > 1) {
|
||||
void handleCreatePeriodFromDrag(dates)
|
||||
}
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
setIsDragging(false)
|
||||
setDragAnchor(null)
|
||||
setDragCurrent(null)
|
||||
}
|
||||
|
||||
if (isDragging) {
|
||||
window.addEventListener('pointerup', handlePointerUp)
|
||||
return () => window.removeEventListener('pointerup', handlePointerUp)
|
||||
}
|
||||
}, [isDragging, dragAnchor, dragCurrent])
|
||||
}, [isDragging, dragAnchor, dragCurrent, handleCreatePeriodFromDrag])
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingSpinner message={t('calendar.loading')} />
|
||||
@@ -106,8 +368,8 @@ export function Calendar(): ReactNode {
|
||||
// `CalendarWindow.minYear`/`maxYear`, not a hardcoded literal), while
|
||||
// always including the currently-selected year even before the window has
|
||||
// loaded (or if it somehow falls outside the reported bounds).
|
||||
const yearRangeMin = Math.min(calendarWindow?.minYear ?? year, year)
|
||||
const yearRangeMax = Math.max(calendarWindow?.maxYear ?? year, year)
|
||||
const yearRangeMin = Math.min(calendarHook.data?.minYear ?? year, year)
|
||||
const yearRangeMax = Math.max(calendarHook.data?.maxYear ?? year, year)
|
||||
const yearOptions = Array.from(
|
||||
{ length: yearRangeMax - yearRangeMin + 1 },
|
||||
(_, i) => yearRangeMin + i,
|
||||
@@ -158,21 +420,13 @@ export function Calendar(): ReactNode {
|
||||
setCurrentMonth(new Date(newYear, month, 1))
|
||||
}
|
||||
|
||||
// §3.6 — an explicit mode toggle (rather than a modifier-click or context
|
||||
// menu) keeps day-override editing unambiguous and fully decoupled from
|
||||
// the pointer-drag multi-select gesture below: modifier keys don't exist
|
||||
// on touch, and a context menu needs its own a11y/keyboard path. While the
|
||||
// mode is active, pointer-drag is disabled entirely so a click always
|
||||
// means "toggle this day's override", never "start a range selection".
|
||||
const handlePointerDown = (date: string) => {
|
||||
if (isOverrideMode) return
|
||||
setDragAnchor(date)
|
||||
setDragCurrent(date)
|
||||
setIsDragging(true)
|
||||
}
|
||||
|
||||
const handlePointerEnter = (date: string) => {
|
||||
if (isOverrideMode) return
|
||||
if (isDragging) {
|
||||
setDragCurrent(date)
|
||||
}
|
||||
@@ -186,14 +440,14 @@ export function Calendar(): ReactNode {
|
||||
try {
|
||||
setOverrideError(null)
|
||||
if (info.isOverride) {
|
||||
const existing = (dayOverrides ?? []).find((o) => o.date === date)
|
||||
const existing = (dayOverridesHook.data ?? []).find((o) => o.date === date)
|
||||
if (existing) {
|
||||
await removeDayOverride(existing.id)
|
||||
await dayOverridesHook.remove(existing.id)
|
||||
}
|
||||
} else {
|
||||
// Toggle the effective status: a currently-free day becomes an
|
||||
// override marking it a working day, and vice versa.
|
||||
await upsertDayOverride({
|
||||
await dayOverridesHook.upsert({
|
||||
workspaceId: workspace.id,
|
||||
date,
|
||||
isWorkingDay: info.isFree,
|
||||
@@ -205,24 +459,21 @@ export function Calendar(): ReactNode {
|
||||
// explicit refetch the day-overrides list would be up to date but the
|
||||
// calendar cell would keep showing the pre-mutation state until a
|
||||
// full page reload.
|
||||
await refetchCalendarWindow()
|
||||
await calendarHook.refetch()
|
||||
} catch (err) {
|
||||
setOverrideError(err instanceof Error ? err.message : t('calendar.overrideError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDayClick = (date: string) => {
|
||||
if (isOverrideMode) {
|
||||
void handleOverrideToggle(date)
|
||||
return
|
||||
}
|
||||
setSelectedDates(new Set([date]))
|
||||
setIsRangeMode(false)
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
const getExistingDay = (date: string): PlannedLeaveDay | undefined => {
|
||||
return (plannedDays ?? []).find((pd: PlannedLeaveDay) => pd.date === date && pd.parentPersonalDataUuid === personalDataUuid)
|
||||
return (plannedDaysHook.data ?? []).find(
|
||||
(pd: PlannedLeaveDay) => pd.date === date && pd.parentPersonalDataUuid === personalDataUuid,
|
||||
)
|
||||
}
|
||||
|
||||
const handleSavePlannedDay = async (payload: {
|
||||
@@ -239,23 +490,27 @@ export function Calendar(): ReactNode {
|
||||
setLastLeaveType(payload.dayType)
|
||||
|
||||
for (const date of selectedDates) {
|
||||
const existingDay = (plannedDays ?? []).find(
|
||||
(pd: PlannedLeaveDay) => pd.date === date && pd.parentPersonalDataUuid === personalDataUuid
|
||||
const existingDay = (plannedDaysHook.data ?? []).find(
|
||||
(pd: PlannedLeaveDay) => pd.date === date && pd.parentPersonalDataUuid === personalDataUuid,
|
||||
)
|
||||
|
||||
if (existingDay) {
|
||||
await update(existingDay.id, {
|
||||
// §Task7 — a hand-edit through the day popover pins the day so a
|
||||
// later `POST /api/plan/sync` never silently retypes it.
|
||||
await plannedDaysHook.update(existingDay.id, {
|
||||
day_type: payload.dayType,
|
||||
is_dubbeldag: payload.isDubbeldag,
|
||||
is_pinned: true,
|
||||
})
|
||||
} else {
|
||||
await add({
|
||||
await plannedDaysHook.add({
|
||||
workspace_id: workspace.id,
|
||||
child_id: selectedChildId,
|
||||
parent_personal_data_uuid: personalDataUuid,
|
||||
parent_personal_data_uuid: payload.parentId,
|
||||
date,
|
||||
day_type: payload.dayType,
|
||||
is_dubbeldag: payload.isDubbeldag,
|
||||
is_pinned: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -282,7 +537,7 @@ export function Calendar(): ReactNode {
|
||||
|
||||
const existingDay = getExistingDay(date)
|
||||
if (existingDay) {
|
||||
await remove(existingDay.id)
|
||||
await plannedDaysHook.remove(existingDay.id)
|
||||
}
|
||||
|
||||
setIsModalOpen(false)
|
||||
@@ -294,11 +549,191 @@ export function Calendar(): ReactNode {
|
||||
}
|
||||
}
|
||||
|
||||
// Exposed cleanly for a later task: clicking a band (a planned period,
|
||||
// possibly spanning several weeks) will open a period editor. That editor
|
||||
// doesn't exist yet, so this is intentionally a no-op for now.
|
||||
const handleBandClick = (_info: BandClickInfo): void => {
|
||||
// TODO: open a period editor for this band once it exists.
|
||||
const handleBandClick = (info: BandClickInfo): void => {
|
||||
setActiveBand(info)
|
||||
setIsPeriodOpen(true)
|
||||
setPeriodError(null)
|
||||
}
|
||||
|
||||
const closePeriodPopover = () => {
|
||||
setIsPeriodOpen(false)
|
||||
setActiveBand(null)
|
||||
setPeriodError(null)
|
||||
}
|
||||
|
||||
const handleSavePeriod = async (payload: PeriodPopoverSavePayload) => {
|
||||
if (!activeBand?.wishId || !workspace?.id) return
|
||||
const previousWish = wishesHook.data.find((w) => w.id === activeBand.wishId)
|
||||
if (!previousWish) return
|
||||
|
||||
const start = splitISODate(payload.startDate)
|
||||
const end = splitISODate(payload.endDate)
|
||||
|
||||
try {
|
||||
setIsPeriodSaving(true)
|
||||
setPeriodError(null)
|
||||
await wishesHook.update(previousWish.id, {
|
||||
name: payload.name,
|
||||
nameMessageKey: null,
|
||||
windowStartMonth: start.month,
|
||||
windowStartDay: start.day,
|
||||
windowEndMonth: end.month,
|
||||
windowEndDay: end.day,
|
||||
firstYear: start.year,
|
||||
recurrence: payload.recurrence,
|
||||
lastYear: payload.recurrence === 'yearly' ? payload.lastYear : null,
|
||||
parentMode: payload.parentMode,
|
||||
parentPersonalDataUuid: payload.parentMode === 'specific' ? payload.parentPersonalDataUuid : null,
|
||||
})
|
||||
closePeriodPopover()
|
||||
await runSync(workspace.id, {
|
||||
watchWishId: previousWish.id,
|
||||
undo: {
|
||||
label: t('calendar.undoButton'),
|
||||
run: async () => {
|
||||
await wishesHook.update(previousWish.id, patchFromWish(previousWish))
|
||||
await runSync(workspace.id, { silent: true })
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
setPeriodError(err instanceof Error ? err.message : t('calendar.periodCreateError'))
|
||||
} finally {
|
||||
setIsPeriodSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeletePeriod = async () => {
|
||||
if (!workspace?.id || !activeBand) return
|
||||
|
||||
try {
|
||||
setIsPeriodSaving(true)
|
||||
setPeriodError(null)
|
||||
|
||||
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,
|
||||
})
|
||||
} 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,
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setPeriodError(err instanceof Error ? err.message : t('calendar.periodCreateError'))
|
||||
} finally {
|
||||
setIsPeriodSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectPreset = async (preset: SeasonPreset) => {
|
||||
if (!workspace?.id || !selectedChildId) return
|
||||
|
||||
try {
|
||||
setIsCreatingPeriod(true)
|
||||
const created = await wishesHook.add({
|
||||
workspaceId: workspace.id,
|
||||
childId: selectedChildId,
|
||||
name: t(preset.labelKey),
|
||||
nameMessageKey: preset.labelKey,
|
||||
windowStartMonth: preset.startMonth,
|
||||
windowStartDay: preset.startDay,
|
||||
windowEndMonth: preset.endMonth,
|
||||
windowEndDay: preset.endDay,
|
||||
targetWeeks: preset.defaultWeeks,
|
||||
recurrence: 'yearly',
|
||||
firstYear: new Date().getFullYear(),
|
||||
priority: wishesHook.data.length + 1,
|
||||
parentMode: 'either',
|
||||
windowMode: 'flexible',
|
||||
})
|
||||
|
||||
const result = await runSync(workspace.id, {
|
||||
watchWishId: created.id,
|
||||
undo: {
|
||||
label: t('calendar.undoButton'),
|
||||
run: async () => {
|
||||
await wishesHook.remove(created.id)
|
||||
await runSync(workspace.id, { silent: true })
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (result) {
|
||||
const placedDates = result.changes
|
||||
.filter((c) => c.newWishId === created.id)
|
||||
.map((c) => c.date)
|
||||
.sort()
|
||||
const earliest = placedDates[0]
|
||||
if (earliest) {
|
||||
const { year: placedYear, month: placedMonth } = splitISODate(earliest)
|
||||
setCurrentMonth(new Date(placedYear, placedMonth - 1, 1))
|
||||
setFlashWishId(created.id)
|
||||
const calendarEl = document.querySelector('[data-testid="calendar"]')
|
||||
if (calendarEl && typeof calendarEl.scrollIntoView === 'function') {
|
||||
calendarEl.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : t('calendar.periodCreateError'), null)
|
||||
} finally {
|
||||
setIsCreatingPeriod(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUndoClick = async () => {
|
||||
const undo = toast?.undo
|
||||
if (!undo) return
|
||||
try {
|
||||
setIsUndoing(true)
|
||||
await undo.run()
|
||||
setToast(null)
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : t('calendar.undoError'), null)
|
||||
} finally {
|
||||
setIsUndoing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const defaultLeaveType = getLastLeaveType() ?? 'vacation'
|
||||
@@ -311,6 +746,19 @@ export function Calendar(): ReactNode {
|
||||
? new Set(datesInRange(dragAnchor, dragCurrent))
|
||||
: selectedDates
|
||||
|
||||
// §Task7 — feed every workspace member, not just the logged-in parent, so
|
||||
// couple mode can plan for either parent from the calendar's popovers.
|
||||
// Falls back to the logged-in parent alone while members haven't loaded
|
||||
// yet (or in solo mode, where there is only one).
|
||||
const parentsForModals =
|
||||
membersHook.data.length > 0
|
||||
? membersHook.data.map((m) => ({ id: m.uuid, name: m.name }))
|
||||
: [{ id: personalDataUuid ?? '', name: parentName ?? t('calendar.you') }]
|
||||
|
||||
const activeWish = activeBand?.wishId
|
||||
? wishesHook.data.find((w) => w.id === activeBand.wishId) ?? null
|
||||
: null
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div data-testid="calendar" className="calendar">
|
||||
@@ -353,117 +801,150 @@ export function Calendar(): ReactNode {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="calendar__toolbar">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOverrideMode((prev) => !prev)}
|
||||
aria-pressed={isOverrideMode}
|
||||
className={`calendar__override-toggle${isOverrideMode ? ' calendar__override-toggle--active' : ''}`}
|
||||
data-testid="calendar-override-toggle"
|
||||
>
|
||||
{isOverrideMode ? t('calendar.overrideModeActiveLabel') : t('calendar.overrideModeLabel')}
|
||||
</button>
|
||||
{isOverrideMode && (
|
||||
<p className="calendar__toolbar-hint" data-testid="calendar-override-hint">
|
||||
{t('calendar.overrideModeHint')}
|
||||
</p>
|
||||
)}
|
||||
{overrideError && (
|
||||
<p className="calendar__toolbar-error" role="alert" data-testid="calendar-override-error">
|
||||
{overrideError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{(syncHook.isSyncing || isCreatingPeriod) && (
|
||||
<p className="calendar__sync-indicator" data-testid="calendar-sync-indicator">
|
||||
{t('calendar.syncingLabel')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className={`calendar__grid${showWeekNumbers ? ' calendar__grid--with-week-numbers' : ''}`}>
|
||||
{showWeekNumbers && <div className="calendar__week-number-header" />}
|
||||
{WEEKDAY_KEYS.map((key) => (
|
||||
<div key={key} className="calendar__weekday">
|
||||
{t(key)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{toast && (
|
||||
<div className="calendar__toast" role="status" data-testid="calendar-toast">
|
||||
<span className="calendar__toast-message">{toast.message}</span>
|
||||
{toast.undo && (
|
||||
<button
|
||||
type="button"
|
||||
className="calendar__toast-undo"
|
||||
onClick={() => void handleUndoClick()}
|
||||
disabled={isUndoing}
|
||||
data-testid="calendar-toast-undo"
|
||||
>
|
||||
{toast.undo.label}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="calendar__toast-dismiss"
|
||||
aria-label={t('calendar.dismissToastAria')}
|
||||
onClick={() => setToast(null)}
|
||||
data-testid="calendar-toast-dismiss"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Each week is its own small CSS Grid (see CalendarWeek.tsx/
|
||||
calendar.css) so a multi-day band can span several day columns as
|
||||
one element — that only works if each week is a separate grid,
|
||||
not a flat row of the header grid above. */}
|
||||
<div className="calendar__weeks">
|
||||
{weeks.map((week, weekIdx) => {
|
||||
const firstDate = week[0]
|
||||
const weekMonth = firstDate ? new Date(firstDate + 'T00:00:00Z').getMonth() : month
|
||||
return (
|
||||
<CalendarWeek
|
||||
key={weekIdx}
|
||||
weekDates={week}
|
||||
plannedDays={plannedDays ?? []}
|
||||
selectedDates={highlightedDates}
|
||||
parentName={parentName ?? t('calendar.you')}
|
||||
personalDataUuid={personalDataUuid ?? ''}
|
||||
isCurrentMonth={weekMonth === month}
|
||||
showWeekNumbers={showWeekNumbers}
|
||||
onDayClick={handleDayClick}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
calendarDaysByDate={calendarDaysByDate}
|
||||
onBandClick={handleBandClick}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="calendar__layout">
|
||||
<div className="calendar__main">
|
||||
<div className={`calendar__grid${showWeekNumbers ? ' calendar__grid--with-week-numbers' : ''}`}>
|
||||
{showWeekNumbers && <div className="calendar__week-number-header" />}
|
||||
{WEEKDAY_KEYS.map((key) => (
|
||||
<div key={key} className="calendar__weekday">
|
||||
{t(key)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="calendar__legend">
|
||||
<h3>{t('calendar.legendTitle')}</h3>
|
||||
<div className="calendar__legend-items">
|
||||
<div className="calendar__legend-item">
|
||||
<span className="calendar__legend-swatch" style={{ backgroundColor: getColorForType('vacation') }} />
|
||||
<span>{t('calendar.legendVacation')}</span>
|
||||
{/* Each week is its own small CSS Grid (see CalendarWeek.tsx/
|
||||
calendar.css) so a multi-day band can span several day columns as
|
||||
one element — that only works if each week is a separate grid,
|
||||
not a flat row of the header grid above. */}
|
||||
<div className="calendar__weeks">
|
||||
{weeks.map((week, weekIdx) => {
|
||||
const firstDate = week[0]
|
||||
const weekMonth = firstDate ? new Date(firstDate + 'T00:00:00Z').getMonth() : month
|
||||
return (
|
||||
<CalendarWeek
|
||||
key={weekIdx}
|
||||
weekDates={week}
|
||||
plannedDays={plannedDaysHook.data ?? []}
|
||||
selectedDates={highlightedDates}
|
||||
parentName={parentName ?? t('calendar.you')}
|
||||
personalDataUuid={personalDataUuid ?? ''}
|
||||
isCurrentMonth={weekMonth === month}
|
||||
showWeekNumbers={showWeekNumbers}
|
||||
onDayClick={handleDayClick}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
calendarDaysByDate={calendarDaysByDate}
|
||||
onBandClick={handleBandClick}
|
||||
flashWishId={flashWishId}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="calendar__legend-item">
|
||||
<span className="calendar__legend-swatch" style={{ backgroundColor: getColorForType('sjukpenning') }} />
|
||||
<span>{t('calendar.legendIncomeBased')}</span>
|
||||
</div>
|
||||
<div className="calendar__legend-item">
|
||||
<span className="calendar__legend-swatch" style={{ backgroundColor: getColorForType('lagsta') }} />
|
||||
<span>{t('calendar.legendMinimumRate')}</span>
|
||||
</div>
|
||||
<div className="calendar__legend-item">
|
||||
<span className="calendar__legend-swatch calendar__legend-swatch--red-day" />
|
||||
<span>{t('calendar.legendRedDay')}</span>
|
||||
</div>
|
||||
<div className="calendar__legend-item">
|
||||
<span className="calendar__legend-swatch calendar__legend-swatch--klamdag" />
|
||||
<span>{t('calendar.legendKlamdag')}</span>
|
||||
</div>
|
||||
<div className="calendar__legend-item">
|
||||
<span className="calendar__legend-swatch calendar__legend-swatch--override" />
|
||||
<span>{t('calendar.legendOverride')}</span>
|
||||
</div>
|
||||
<div className="calendar__legend-item">
|
||||
<span className="calendar__legend-swatch calendar__legend-swatch--dubbeldag">2×</span>
|
||||
<span>{t('calendar.legendDoubleDay')}</span>
|
||||
|
||||
<div className="calendar__legend">
|
||||
<h3>{t('calendar.legendTitle')}</h3>
|
||||
<div className="calendar__legend-items">
|
||||
<div className="calendar__legend-item">
|
||||
<span className="calendar__legend-swatch" style={{ backgroundColor: getColorForType('vacation') }} />
|
||||
<span>{t('calendar.legendVacation')}</span>
|
||||
</div>
|
||||
<div className="calendar__legend-item">
|
||||
<span className="calendar__legend-swatch" style={{ backgroundColor: getColorForType('sjukpenning') }} />
|
||||
<span>{t('calendar.legendIncomeBased')}</span>
|
||||
</div>
|
||||
<div className="calendar__legend-item">
|
||||
<span className="calendar__legend-swatch" style={{ backgroundColor: getColorForType('lagsta') }} />
|
||||
<span>{t('calendar.legendMinimumRate')}</span>
|
||||
</div>
|
||||
<div className="calendar__legend-item">
|
||||
<span className="calendar__legend-swatch calendar__legend-swatch--red-day" />
|
||||
<span>{t('calendar.legendRedDay')}</span>
|
||||
</div>
|
||||
<div className="calendar__legend-item">
|
||||
<span className="calendar__legend-swatch calendar__legend-swatch--klamdag" />
|
||||
<span>{t('calendar.legendKlamdag')}</span>
|
||||
</div>
|
||||
<div className="calendar__legend-item">
|
||||
<span className="calendar__legend-swatch calendar__legend-swatch--override" />
|
||||
<span>{t('calendar.legendOverride')}</span>
|
||||
</div>
|
||||
<div className="calendar__legend-item">
|
||||
<span className="calendar__legend-swatch calendar__legend-swatch--dubbeldag">2×</span>
|
||||
<span>{t('calendar.legendDoubleDay')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PresetRail isBusy={isCreatingPeriod || syncHook.isSyncing} onSelectPreset={handleSelectPreset} />
|
||||
</div>
|
||||
|
||||
{firstSelectedDate && (
|
||||
<PlannedDayModal
|
||||
date={firstSelectedDate}
|
||||
isOpen={isModalOpen}
|
||||
existingDay={isRangeMode ? null : getExistingDay(firstSelectedDate) ?? null}
|
||||
parents={[{ id: personalDataUuid ?? '', name: parentName ?? t('calendar.you') }]}
|
||||
existingDay={getExistingDay(firstSelectedDate) ?? null}
|
||||
parents={parentsForModals}
|
||||
selectedParentId={personalDataUuid ?? ''}
|
||||
onSave={handleSavePlannedDay}
|
||||
onDelete={!isRangeMode && getExistingDay(firstSelectedDate) ? handleDeletePlannedDay : undefined}
|
||||
onDelete={getExistingDay(firstSelectedDate) ? handleDeletePlannedDay : undefined}
|
||||
onClose={() => {
|
||||
setIsModalOpen(false)
|
||||
setSelectedDates(new Set())
|
||||
}}
|
||||
isSaving={isSaving}
|
||||
error={modalError}
|
||||
isRangeMode={isRangeMode}
|
||||
dayCount={selectedDates.size}
|
||||
defaultDayType={defaultLeaveType}
|
||||
dayInfo={calendarDaysByDate.get(firstSelectedDate) ?? null}
|
||||
onToggleOverride={() => handleOverrideToggle(firstSelectedDate)}
|
||||
overrideError={overrideError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeBand && (
|
||||
<PeriodPopover
|
||||
isOpen={isPeriodOpen}
|
||||
wish={activeWish}
|
||||
startDate={activeBand.startDate}
|
||||
endDate={activeBand.endDate}
|
||||
parents={parentsForModals}
|
||||
isSaving={isPeriodSaving}
|
||||
error={periodError}
|
||||
onSave={handleSavePeriod}
|
||||
onDelete={handleDeletePeriod}
|
||||
onClose={closePeriodPopover}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -319,6 +319,21 @@
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* §Task7 — briefly highlights the band the preset rail just placed, after
|
||||
the calendar has scrolled/navigated to it. */
|
||||
.calendar-band--flash {
|
||||
animation: calendar-band-flash 1.4s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes calendar-band-flash {
|
||||
0%, 100% {
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
30%, 70% {
|
||||
box-shadow: 0 0 0 3px #ffd54f, 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-band__label {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -462,46 +477,74 @@
|
||||
border: 2px dashed #666;
|
||||
}
|
||||
|
||||
/* §3.6 — override-mode toolbar */
|
||||
.calendar__toolbar {
|
||||
/* §Task7 — the calendar becomes the plan: a side rail of presets next to
|
||||
the month grid, a sync-in-progress indicator, and a "what changed" toast
|
||||
with optional undo. The global "mark working/day off" toolbar mode this
|
||||
used to hold was deleted — the same capability now lives in the day
|
||||
popover (see PlannedDayModal's "Den här dagen är" switch). */
|
||||
.calendar__layout {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.calendar__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.calendar__sync-indicator {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.calendar__toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
background-color: #eef6ff;
|
||||
border: 1px solid #b8d9ff;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
color: #003a70;
|
||||
}
|
||||
|
||||
.calendar__override-toggle {
|
||||
padding: 8px 14px;
|
||||
border: 1px solid #d0d0d0;
|
||||
.calendar__toast-message {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.calendar__toast-undo {
|
||||
padding: 4px 10px;
|
||||
border: 1px solid #0066cc;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: var(--c-text);
|
||||
color: #0066cc;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: background-color 0.2s, border-color 0.2s, color 0.2s;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.calendar__override-toggle:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.calendar__override-toggle--active {
|
||||
.calendar__toast-undo:hover:not(:disabled) {
|
||||
background-color: #0066cc;
|
||||
border-color: #0066cc;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.calendar__toolbar-hint {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin: 0;
|
||||
.calendar__toast-undo:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.calendar__toolbar-error {
|
||||
font-size: 12px;
|
||||
color: #b3261e;
|
||||
margin: 0;
|
||||
.calendar__toast-dismiss {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
color: #003a70;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.calendar__grid--with-week-numbers {
|
||||
|
||||
Reference in New Issue
Block a user