31a60a296f
Clicking the month or year label only ever stepped forward (`month + 1`, `year + 1`), so the only way back was the prev-month arrow, one month at a time. Jumping back a year was impossible. Both labels are now selects, so any month or year is one click away in either direction. Selects rather than +/- steppers because the app already uses them elsewhere, and because stepping in a second direction would still not give the "quickly jump" part of the original intent. Year options are bounded to 2026-2055, the calendar cache range: outside it the backend raises UncachedYearError and returns 422, so offering those years would hand the user an error instead of a calendar. The prev/next month arrows are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
455 lines
16 KiB
TypeScript
455 lines
16 KiB
TypeScript
import { ReactNode, useMemo, useState, useEffect } from 'react'
|
||
import { useWorkspace } from '@/context'
|
||
import {
|
||
usePlannedDays,
|
||
useShowWeekNumbers,
|
||
getLastLeaveType,
|
||
setLastLeaveType,
|
||
useCalendarDays,
|
||
useDayOverrides,
|
||
} from '@/hooks'
|
||
import type { PlannedLeaveDay, CalendarDayEntry } from '@/types'
|
||
import { Layout } from '@/components/layout/Layout'
|
||
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
|
||
import { CalendarWeek } from '@/components/common/CalendarWeek'
|
||
import { PlannedDayModal } from '@/components/modals/PlannedDayModal'
|
||
import { getColorForType } from '@/utils/leaveTypes'
|
||
import { datesInRange } from '@/utils/dates'
|
||
import { useTranslation } from '@/i18n'
|
||
import './calendar.css'
|
||
|
||
const WEEKDAY_KEYS = [
|
||
'calendar.weekdayMon',
|
||
'calendar.weekdayTue',
|
||
'calendar.weekdayWed',
|
||
'calendar.weekdayThu',
|
||
'calendar.weekdayFri',
|
||
'calendar.weekdaySat',
|
||
'calendar.weekdaySun',
|
||
] as const
|
||
|
||
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 [showWeekNumbers] = useShowWeekNumbers()
|
||
const [currentMonth, setCurrentMonth] = useState(new Date())
|
||
const [selectedDates, setSelectedDates] = useState<Set<string>>(new Set())
|
||
const [dragAnchor, setDragAnchor] = useState<string | null>(null)
|
||
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
|
||
|
||
// §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
|
||
// whichever 1-2 calendar years the current month view actually touches.
|
||
const viewYear = currentMonth.getFullYear()
|
||
const viewMonth = currentMonth.getMonth()
|
||
const calendarMinYear = viewMonth === 0 ? viewYear - 1 : viewYear
|
||
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 calendarDaysByDate = useMemo(() => {
|
||
const map = new Map<string, CalendarDayEntry>()
|
||
for (const day of calendarWindow?.days ?? []) {
|
||
map.set(day.date, day)
|
||
}
|
||
return map
|
||
}, [calendarWindow])
|
||
|
||
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)
|
||
}
|
||
setIsModalOpen(true)
|
||
}
|
||
setIsDragging(false)
|
||
}
|
||
|
||
if (isDragging) {
|
||
window.addEventListener('pointerup', handlePointerUp)
|
||
return () => window.removeEventListener('pointerup', handlePointerUp)
|
||
}
|
||
}, [isDragging, dragAnchor, dragCurrent])
|
||
|
||
if (isLoading) {
|
||
return <LoadingSpinner message={t('calendar.loading')} />
|
||
}
|
||
|
||
const year = currentMonth.getFullYear()
|
||
const month = currentMonth.getMonth()
|
||
|
||
const firstDay = new Date(year, month, 1)
|
||
const lastDay = new Date(year, month + 1, 0)
|
||
const daysInMonth = lastDay.getDate()
|
||
const startOffset = (firstDay.getDay() + 6) % 7
|
||
|
||
const weeks: string[][] = []
|
||
let currentWeek: string[] = []
|
||
|
||
for (let i = 0; i < startOffset; i++) {
|
||
const prevDate = new Date(year, month, -startOffset + i + 1)
|
||
currentWeek.push(formatDate(prevDate))
|
||
}
|
||
|
||
for (let day = 1; day <= daysInMonth; day++) {
|
||
currentWeek.push(formatDate(new Date(year, month, day)))
|
||
if (currentWeek.length === 7) {
|
||
weeks.push(currentWeek)
|
||
currentWeek = []
|
||
}
|
||
}
|
||
|
||
const remaining = 7 - currentWeek.length
|
||
for (let i = 0; i < remaining; i++) {
|
||
currentWeek.push(formatDate(new Date(year, month + 1, i + 1)))
|
||
}
|
||
weeks.push(currentWeek)
|
||
|
||
const handlePrevMonth = () => {
|
||
setCurrentMonth(new Date(year, month - 1, 1))
|
||
}
|
||
|
||
const handleNextMonth = () => {
|
||
setCurrentMonth(new Date(year, month + 1, 1))
|
||
}
|
||
|
||
const handleMonthChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||
const newMonth = Number(e.target.value)
|
||
setCurrentMonth(new Date(year, newMonth, 1))
|
||
}
|
||
|
||
const handleYearChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||
const newYear = Number(e.target.value)
|
||
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)
|
||
}
|
||
}
|
||
|
||
const handleOverrideToggle = async (date: string) => {
|
||
if (!workspace?.id) return
|
||
const info = calendarDaysByDate.get(date)
|
||
if (!info) return
|
||
|
||
try {
|
||
setOverrideError(null)
|
||
if (info.isOverride) {
|
||
const existing = (dayOverrides ?? []).find((o) => o.date === date)
|
||
if (existing) {
|
||
await removeDayOverride(existing.id)
|
||
}
|
||
} else {
|
||
// Toggle the effective status: a currently-free day becomes an
|
||
// override marking it a working day, and vice versa.
|
||
await upsertDayOverride({
|
||
workspaceId: workspace.id,
|
||
date,
|
||
isWorkingDay: info.isFree,
|
||
note: null,
|
||
})
|
||
}
|
||
// The grid's shading/badges come from GET /api/calendar/days, a
|
||
// separate fetch from the override list above — without this
|
||
// 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()
|
||
} 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)
|
||
}
|
||
|
||
const handleSavePlannedDay = async (payload: {
|
||
dayType: 'sjukpenning' | 'lagsta' | 'vacation'
|
||
parentId: string
|
||
isDubbeldag: boolean
|
||
}) => {
|
||
if (selectedDates.size === 0 || !selectedChildId || !workspace?.id || !personalDataUuid) return
|
||
|
||
try {
|
||
setIsSaving(true)
|
||
setModalError(null)
|
||
|
||
setLastLeaveType(payload.dayType)
|
||
|
||
for (const date of selectedDates) {
|
||
const existingDay = (plannedDays ?? []).find(
|
||
(pd: PlannedLeaveDay) => pd.date === date && pd.parentPersonalDataUuid === personalDataUuid
|
||
)
|
||
|
||
if (existingDay) {
|
||
await update(existingDay.id, {
|
||
day_type: payload.dayType,
|
||
is_dubbeldag: payload.isDubbeldag,
|
||
})
|
||
} else {
|
||
await add({
|
||
workspace_id: workspace.id,
|
||
child_id: selectedChildId,
|
||
parent_personal_data_uuid: personalDataUuid,
|
||
date,
|
||
day_type: payload.dayType,
|
||
is_dubbeldag: payload.isDubbeldag,
|
||
})
|
||
}
|
||
}
|
||
|
||
setIsModalOpen(false)
|
||
setSelectedDates(new Set())
|
||
} catch (err) {
|
||
setModalError(err instanceof Error ? err.message : t('calendar.saveError'))
|
||
} finally {
|
||
setIsSaving(false)
|
||
}
|
||
}
|
||
|
||
const handleDeletePlannedDay = async () => {
|
||
if (selectedDates.size !== 1) return
|
||
|
||
try {
|
||
setIsSaving(true)
|
||
setModalError(null)
|
||
|
||
const dates = Array.from(selectedDates)
|
||
const date = dates[0]
|
||
if (!date) return
|
||
|
||
const existingDay = getExistingDay(date)
|
||
if (existingDay) {
|
||
await remove(existingDay.id)
|
||
}
|
||
|
||
setIsModalOpen(false)
|
||
setSelectedDates(new Set())
|
||
} catch (err) {
|
||
setModalError(err instanceof Error ? err.message : t('calendar.deleteError'))
|
||
} finally {
|
||
setIsSaving(false)
|
||
}
|
||
}
|
||
|
||
const defaultLeaveType = getLastLeaveType() ?? 'vacation'
|
||
const firstSelectedDate = Array.from(selectedDates).sort()[0]
|
||
|
||
// Dates to highlight in the grid. While dragging, show a live preview of the
|
||
// range under the cursor; otherwise show the committed selection.
|
||
const highlightedDates =
|
||
isDragging && dragAnchor && dragCurrent
|
||
? new Set(datesInRange(dragAnchor, dragCurrent))
|
||
: selectedDates
|
||
|
||
return (
|
||
<Layout>
|
||
<div data-testid="calendar" className="calendar">
|
||
<div className="calendar__header">
|
||
<button onClick={handlePrevMonth} aria-label={t('calendar.prevMonthAria')}>
|
||
←
|
||
</button>
|
||
<h2 className="calendar__title">
|
||
<select
|
||
value={month}
|
||
onChange={handleMonthChange}
|
||
className="calendar__month-select"
|
||
aria-label={t('calendar.selectMonthAria')}
|
||
>
|
||
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].map((m) => {
|
||
const date = new Date(year, m, 1)
|
||
const monthName = date.toLocaleDateString(locale === 'sv' ? 'sv-SE' : 'en-US', { month: 'long' })
|
||
return (
|
||
<option key={m} value={m}>
|
||
{monthName}
|
||
</option>
|
||
)
|
||
})}
|
||
</select>
|
||
<select
|
||
value={year}
|
||
onChange={handleYearChange}
|
||
className="calendar__year-select"
|
||
aria-label={t('calendar.selectYearAria')}
|
||
>
|
||
{Array.from({ length: 2055 - 2026 + 1 }, (_, i) => 2026 + i).map((y) => (
|
||
<option key={y} value={y}>
|
||
{y}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</h2>
|
||
<button onClick={handleNextMonth} aria-label={t('calendar.nextMonthAria')}>
|
||
→
|
||
</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>
|
||
|
||
<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>
|
||
))}
|
||
|
||
{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}
|
||
/>
|
||
)
|
||
})}
|
||
</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>
|
||
</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>
|
||
|
||
{firstSelectedDate && (
|
||
<PlannedDayModal
|
||
date={firstSelectedDate}
|
||
isOpen={isModalOpen}
|
||
existingDay={isRangeMode ? null : getExistingDay(firstSelectedDate) ?? null}
|
||
parents={[{ id: personalDataUuid ?? '', name: parentName ?? t('calendar.you') }]}
|
||
selectedParentId={personalDataUuid ?? ''}
|
||
onSave={handleSavePlannedDay}
|
||
onDelete={!isRangeMode && getExistingDay(firstSelectedDate) ? handleDeletePlannedDay : undefined}
|
||
onClose={() => {
|
||
setIsModalOpen(false)
|
||
setSelectedDates(new Set())
|
||
}}
|
||
isSaving={isSaving}
|
||
error={modalError}
|
||
isRangeMode={isRangeMode}
|
||
dayCount={selectedDates.size}
|
||
defaultDayType={defaultLeaveType}
|
||
/>
|
||
)}
|
||
</div>
|
||
</Layout>
|
||
)
|
||
}
|
||
|
||
function formatDate(date: Date): string {
|
||
const year = date.getFullYear()
|
||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||
const day = String(date.getDate()).padStart(2, '0')
|
||
return `${year}-${month}-${day}`
|
||
}
|