f2b3920cbc
Two components each carried the same four lines turning a date into an ISO string from local parts. They now share one helper, deliberately separate from the one that formats in UTC: substituting one for the other shifts the day by one anywhere east of Greenwich, Sweden included, so a test pins the difference rather than a comment asking nicely. Creating a period ran its own copy of the sync-and-report sequence purely so it could name a wish the lookup could not yet see; it now passes that wish to the ordinary path instead. Two comments still explained their relationship to components deleted some time ago, which would send the next reader looking for files that are not there, and some styling outlived the markup it described.
1016 lines
37 KiB
TypeScript
1016 lines
37 KiB
TypeScript
import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||
import { useWorkspace } from '@/context'
|
||
import {
|
||
usePlannedDays,
|
||
useShowWeekNumbers,
|
||
getLastLeaveType,
|
||
setLastLeaveType,
|
||
useCalendarDays,
|
||
useDayOverrides,
|
||
useLeaveWishes,
|
||
useWorkspaceMembers,
|
||
usePlanSync,
|
||
} from '@/hooks'
|
||
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 { resolveWishName, resolveWishNameFromData } from '@/utils/wishName'
|
||
import { datesInRange, formatDateForDisplay, formatDateLocalISO } from '@/utils/dates'
|
||
import { renderRejection, type Translate } from '@/utils/rejectionMessages'
|
||
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
|
||
|
||
/** 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.
|
||
*
|
||
* `wish` is the touched wish itself (not just the outcome), because the
|
||
* outcome only ever carries `weeksWished`/`weeksFunded` — the backend's
|
||
* week-rounded view. A calendar-drawn period sets `targetDays` (an exact
|
||
* day count); `targetWeeks` on that same wish is just `Math.ceil(days / 7)`,
|
||
* a leftover the API still requires, never what the user actually asked
|
||
* for. Reporting `weeksWished` for those wishes says "3 week(s)" for a
|
||
* 7-day drag. When `targetDays` is set, report days instead — the figure
|
||
* the user actually asked for — and only fall back to weeks for wishes that
|
||
* never had a day-granular target (e.g. the preset rail, which only ever
|
||
* sends `targetWeeks`). */
|
||
function summarizeOutcomeShortfall(
|
||
outcome: WindowOutcome,
|
||
funded: boolean,
|
||
wish: LeaveWish | undefined,
|
||
t: Translate,
|
||
): string {
|
||
const name = resolveWishNameFromData(outcome.data, t) || t('calendar.toastGenericPeriod')
|
||
const base = {
|
||
name,
|
||
start: formatDateForDisplay(outcome.startDate),
|
||
end: formatDateForDisplay(outcome.endDate),
|
||
shortfallDays: outcome.shortfallDays,
|
||
}
|
||
if (wish?.targetDays != null) {
|
||
const params = {
|
||
...base,
|
||
daysWished: wish.targetDays,
|
||
daysFunded: wish.targetDays - outcome.shortfallDays,
|
||
}
|
||
return t(funded ? 'calendar.toastShortfallPartialDays' : 'calendar.toastShortfallNoneDays', params)
|
||
}
|
||
const params = {
|
||
...base,
|
||
weeksWished: outcome.weeksWished,
|
||
weeksFunded: outcome.weeksFunded,
|
||
}
|
||
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.
|
||
*
|
||
* `watchWish`, when given, is used in place of looking `watchWishId` up in
|
||
* `wishes` — needed right after creating a wish, where `wishes` is whatever
|
||
* `wishesHook.data` was as of this closure's own render (captured before
|
||
* the create's background refetch lands) and so never actually contains a
|
||
* wish created moments ago. Passing the just-created wish object directly
|
||
* sidesteps that; every other caller (editing/re-syncing an *existing*
|
||
* wish) can leave it out and rely on the `wishes` lookup as before. */
|
||
function buildSyncToastMessage(
|
||
result: PlanSyncResponse,
|
||
wishes: LeaveWish[],
|
||
members: PersonalData[],
|
||
watchWishId: string | undefined,
|
||
t: Translate,
|
||
watchWish?: LeaveWish,
|
||
): 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)
|
||
const wish = watchWish ?? wishes.find((w) => w.id === watchWishId)
|
||
parts.push(summarizeOutcomeShortfall(outcome, funded, wish, 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 plannedDaysHook = 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 [isSaving, setIsSaving] = useState(false)
|
||
const [modalError, setModalError] = useState<string | null>(null)
|
||
const [overrideError, setOverrideError] = useState<string | null>(null)
|
||
|
||
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
|
||
// 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 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 calendarHook.data?.days ?? []) {
|
||
map.set(day.date, day)
|
||
}
|
||
return map
|
||
}, [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
|
||
// Used right after creating a wish, where `wishesHook.data` is
|
||
// whatever it was as of this closure's own render (captured before
|
||
// the create's background refetch lands) and so never actually
|
||
// contains a wish created moments ago. Passing the just-created
|
||
// wish object directly sidesteps that — see `buildSyncToastMessage`.
|
||
watchWish?: LeaveWish
|
||
},
|
||
): 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,
|
||
options?.watchWish,
|
||
)
|
||
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',
|
||
})
|
||
// Undo is just "delete the wish, re-sync": the backend deletes every
|
||
// `planned_leave_day` row the wish produced as part of deleting the
|
||
// wish (`LeaveWishService.delete()`), so there is nothing left here
|
||
// for the frontend to individually track or clean up.
|
||
await runSync(workspace.id, {
|
||
watchWishId: created.id,
|
||
watchWish: created,
|
||
undo: {
|
||
label: t('calendar.undoButton'),
|
||
run: async () => {
|
||
await wishesHook.remove(created.id)
|
||
await runSync(workspace.id, { silent: true })
|
||
},
|
||
},
|
||
})
|
||
} catch (err) {
|
||
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)
|
||
if (dates.length === 1) {
|
||
setSelectedDates(new Set(dates))
|
||
setIsModalOpen(true)
|
||
} else if (dates.length > 1) {
|
||
void handleCreatePeriodFromDrag(dates)
|
||
}
|
||
}
|
||
setIsDragging(false)
|
||
setDragAnchor(null)
|
||
setDragCurrent(null)
|
||
}
|
||
|
||
if (isDragging) {
|
||
window.addEventListener('pointerup', handlePointerUp)
|
||
return () => window.removeEventListener('pointerup', handlePointerUp)
|
||
}
|
||
}, [isDragging, dragAnchor, dragCurrent, handleCreatePeriodFromDrag])
|
||
|
||
if (isLoading) {
|
||
return <LoadingSpinner message={t('calendar.loading')} />
|
||
}
|
||
|
||
const year = currentMonth.getFullYear()
|
||
const month = currentMonth.getMonth()
|
||
|
||
// The year <select> must cover the API's real supported range (from
|
||
// `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(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,
|
||
)
|
||
|
||
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(formatDateLocalISO(prevDate))
|
||
}
|
||
|
||
for (let day = 1; day <= daysInMonth; day++) {
|
||
currentWeek.push(formatDateLocalISO(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(formatDateLocalISO(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))
|
||
}
|
||
|
||
const handlePointerDown = (date: string) => {
|
||
setDragAnchor(date)
|
||
setDragCurrent(date)
|
||
setIsDragging(true)
|
||
}
|
||
|
||
const handlePointerEnter = (date: string) => {
|
||
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 = (dayOverridesHook.data ?? []).find((o) => o.date === date)
|
||
if (existing) {
|
||
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 dayOverridesHook.upsert({
|
||
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 calendarHook.refetch()
|
||
} catch (err) {
|
||
setOverrideError(err instanceof Error ? err.message : t('calendar.overrideError'))
|
||
}
|
||
}
|
||
|
||
const handleDayClick = (date: string) => {
|
||
setSelectedDates(new Set([date]))
|
||
setIsModalOpen(true)
|
||
}
|
||
|
||
const getExistingDay = (date: string): PlannedLeaveDay | undefined => {
|
||
return (plannedDaysHook.data ?? []).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 = (plannedDaysHook.data ?? []).find(
|
||
(pd: PlannedLeaveDay) => pd.date === date && pd.parentPersonalDataUuid === personalDataUuid,
|
||
)
|
||
|
||
if (existingDay) {
|
||
// §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 plannedDaysHook.add({
|
||
workspace_id: workspace.id,
|
||
child_id: selectedChildId,
|
||
parent_personal_data_uuid: payload.parentId,
|
||
date,
|
||
day_type: payload.dayType,
|
||
is_dubbeldag: payload.isDubbeldag,
|
||
is_pinned: true,
|
||
})
|
||
}
|
||
}
|
||
|
||
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 plannedDaysHook.remove(existingDay.id)
|
||
}
|
||
|
||
setIsModalOpen(false)
|
||
setSelectedDates(new Set())
|
||
} catch (err) {
|
||
setModalError(err instanceof Error ? err.message : t('calendar.deleteError'))
|
||
} finally {
|
||
setIsSaving(false)
|
||
}
|
||
}
|
||
|
||
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,
|
||
watchWish: created,
|
||
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'
|
||
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
|
||
|
||
// §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">
|
||
<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')}
|
||
>
|
||
{yearOptions.map((y) => (
|
||
<option key={y} value={y}>
|
||
{y}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</h2>
|
||
<button onClick={handleNextMonth} aria-label={t('calendar.nextMonthAria')}>
|
||
→
|
||
</button>
|
||
</div>
|
||
|
||
{(syncHook.isSyncing || isCreatingPeriod) && (
|
||
<p className="calendar__sync-indicator" data-testid="calendar-sync-indicator">
|
||
{t('calendar.syncingLabel')}
|
||
</p>
|
||
)}
|
||
|
||
{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>
|
||
)}
|
||
|
||
<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>
|
||
|
||
{/* 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">
|
||
<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={getExistingDay(firstSelectedDate) ?? null}
|
||
parents={parentsForModals}
|
||
selectedParentId={personalDataUuid ?? ''}
|
||
onSave={handleSavePlannedDay}
|
||
onDelete={getExistingDay(firstSelectedDate) ? handleDeletePlannedDay : undefined}
|
||
onClose={() => {
|
||
setIsModalOpen(false)
|
||
setSelectedDates(new Set())
|
||
}}
|
||
isSaving={isSaving}
|
||
error={modalError}
|
||
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>
|
||
</Layout>
|
||
)
|
||
}
|