Files
govoplan-calendar/webui/src/features/calendar/CalendarPage.tsx
T

1140 lines
44 KiB
TypeScript

import {
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type DragEvent as ReactDragEvent,
type WheelEvent as ReactWheelEvent } from
"react";
import { CalendarDays, ChevronLeft, ChevronRight, Pencil, Plus, RefreshCw } from "lucide-react";
import { ToolbarGroup, ActionToolbar,
AdminIconButton,
Button,
DismissibleAlert,
DocumentationHelpLink,
LoadingFrame,
SegmentedControl,
TableActionGroup,
hasScope,
i18nMessage,
type ApiSettings,
type AuthInfo
} from "@govoplan/core-webui";
import { useLocation, useNavigate } from "react-router";
import {
createCalendar,
createCalendarEvent,
createSyncSource,
deleteCalendar,
deleteCalendarEvent,
deleteCalendarEventOccurrence,
discoverCalDavCalendars,
getCalendarEvent,
getCalendarViewPreferences,
listCalendarEvents,
listCalendars,
listSyncSources,
syncSyncSource,
updateCalendar,
updateCalendarEvent,
updateCalendarEventOccurrence,
updateSyncSource,
type CalendarCalDavDiscoveryPayload,
type CalendarCollection,
type CalendarCollectionDeletePayload,
type CalendarEvent,
type CalendarEventCreatePayload,
type CalendarSyncSource,
type CalendarViewPreferences as CalendarViewPreferencesResponse,
} from
"../../api/calendar";
import {
CalendarCollectionDeleteDialog,
CalendarCollectionDialog,
syncSourceConnectionUpdatePayload,
syncSourceCreatePayload,
type CalendarCollectionDialogState,
type CalendarCollectionFormPayload,
type CalendarDeleteDialogState,
} from "./CalendarCollectionDialogs";
import { CalendarEventDialog } from "./CalendarEventDialog";
import { CalendarOutboxDialog } from "./CalendarOutboxDialog";
import { CalendarMigrationDialog } from "./CalendarMigrationDialog";
import {
CalendarTimeGrid,
CalendarWeekRows,
EventInlineLabel,
} from "./CalendarViews";
import {
CONTINUOUS_WEEK_ROW_HEIGHT,
DEFAULT_CALENDAR_COLOR,
addDays,
addMonths,
agendaDateLabel,
agendaGroupsForDays,
calendarEventColorStyle,
calendarEventInstanceId,
continuousEventWindow,
daysBetweenCount,
daysForMode,
dayKey,
dropSlotMinuteOfDay,
errorText,
groupEventsByDay,
headingForMode,
loadCalendarMode,
loadCalendarViewPreferences,
moveEventToDay,
moveEventToTime,
normalizeHexColor,
rangeForMode,
resizeEventToTime,
saveCalendarMode,
startOfDay,
startOfWeek,
type CalendarDragAction,
type CalendarDropTarget,
type CalendarMode,
type CalendarResizeEdge,
type CalendarViewPreferences,
type ContinuousViewport,
} from "./calendarViewModel";
import {
CALENDAR_DOCUMENTATION,
CALENDAR_I18N,
} from "./interfacePatterns";
type EventEditScope = "occurrence" | "series";
type EventDialogState =
| { kind: "create" }
| {
kind: "edit";
occurrence: CalendarEvent;
seriesEvent: CalendarEvent | null;
editScope: EventEditScope;
};
const INITIAL_CONTINUOUS_WEEKS = { before: 4, after: 6 };
const modeOptions: {id: CalendarMode;label: string;}[] = [
{ id: "continuous", label: "i18n:govoplan-calendar.continuous.04f2ccda" },
{ id: "month", label: "i18n:govoplan-calendar.month.082bc378" },
{ id: "week", label: "i18n:govoplan-calendar.week.f82be68a" },
{ id: "workweek", label: "i18n:govoplan-calendar.workweek.2fef6ea4" },
{ id: "day", label: "i18n:govoplan-calendar.day.987b9ced" }];
export default function CalendarPage({ settings, auth }: {settings: ApiSettings;auth: AuthInfo;}) {
const location = useLocation();
const navigate = useNavigate();
const [calendars, setCalendars] = useState<CalendarCollection[]>([]);
const [syncSources, setSyncSources] = useState<CalendarSyncSource[]>([]);
const [visibleCalendarIds, setVisibleCalendarIds] = useState<string[]>([]);
const [eventCalendarId, setEventCalendarId] = useState("");
const [events, setEvents] = useState<CalendarEvent[]>([]);
const eventRequestRef = useRef(0);
const [mode, setMode] = useState<CalendarMode>(() => loadCalendarMode());
const [focusDate, setFocusDate] = useState(() => startOfDay(new Date()));
const [continuousWeeks, setContinuousWeeks] = useState(INITIAL_CONTINUOUS_WEEKS);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [eventDialog, setEventDialog] = useState<EventDialogState | null>(null);
const [calendarDialog, setCalendarDialog] = useState<CalendarCollectionDialogState | null>(null);
const [calendarDeleteDialog, setCalendarDeleteDialog] = useState<CalendarDeleteDialogState | null>(null);
const [outboxDialog, setOutboxDialog] = useState<{ calendar: CalendarCollection; source: CalendarSyncSource } | null>(null);
const [migrationBatchId, setMigrationBatchId] = useState("");
const [syncingSourceId, setSyncingSourceId] = useState("");
const [continuousViewport, setContinuousViewport] = useState<ContinuousViewport>({ scrollTop: 0, height: 0 });
const [draggingEventId, setDraggingEventId] = useState("");
const [hoveredEventId, setHoveredEventId] = useState("");
const [dropTarget, setDropTarget] = useState<CalendarDropTarget>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const continuousPrependPendingRef = useRef(false);
const continuousAppendPendingRef = useRef(false);
const pendingContinuousScrollDayRef = useRef<Date | null>(null);
const dragActionRef = useRef<CalendarDragAction | null>(null);
const [viewPreferences, setViewPreferences] = useState<CalendarViewPreferences>(
() => loadCalendarViewPreferences()
);
const visibleCalendarIdSet = useMemo(() => new Set(visibleCalendarIds), [visibleCalendarIds]);
const visibleEvents = useMemo(() => events.filter((event) => visibleCalendarIdSet.has(event.calendar_id)), [events, visibleCalendarIdSet]);
const syncSourceByCalendarId = useMemo(() => new Map(syncSources.map((source) => [source.calendar_id, source])), [syncSources]);
const calendarColorById = useMemo(() => new Map(calendars.map((calendar) => [calendar.id, calendar.color || DEFAULT_CALENDAR_COLOR])), [calendars]);
const targetCalendarId = eventCalendarId || calendars[0]?.id || "";
const visibleRange = useMemo(() => rangeForMode(mode, focusDate, continuousWeeks), [continuousWeeks, focusDate, mode]);
const days = useMemo(() => daysForMode(mode, focusDate, continuousWeeks), [continuousWeeks, focusDate, mode]);
const eventWindow = useMemo(
() => mode === "continuous" ? continuousEventWindow(days, continuousViewport) : visibleRange,
[continuousViewport.height, continuousViewport.scrollTop, days, mode, visibleRange]
);
const eventsByDay = useMemo(() => groupEventsByDay(visibleEvents), [visibleEvents]);
const agendaGroups = useMemo(() => agendaGroupsForDays(days, eventsByDay), [days, eventsByDay]);
const canWrite = hasScope(auth, "calendar:event:write");
const canDelete = canWrite || hasScope(auth, "calendar:event:delete");
const canSyncCalendars = hasScope(auth, "calendar:event:import");
const canManageCalendars = hasScope(auth, "calendar:calendar:write");
const canDeleteCalendars = hasScope(auth, "calendar:calendar:admin");
const heading = headingForMode(mode, focusDate, visibleRange);
const dialogEvent =
eventDialog?.kind === "edit"
? eventDialog.editScope === "series"
? eventDialog.seriesEvent
: eventDialog.occurrence
: null;
useEffect(() => {
void loadCalendars();
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
useEffect(() => {
const parameters = new URLSearchParams(location.search);
const focusParameter = parameters.get("focusDate");
const requestedFocus = validCalendarLaunchDate(focusParameter);
if (requestedFocus) setFocusDate(requestedFocus);
if (parameters.get("quickAction") !== "create-event" || loading) return;
const requestedStart = validCalendarLaunchDate(parameters.get("startAt"));
if (canWrite && calendars.length > 0 && targetCalendarId) {
setFocusDate(requestedStart ?? requestedFocus ?? new Date());
setEventDialog({ kind: "create" });
}
parameters.delete("quickAction");
parameters.delete("startAt");
if (requestedStart) parameters.set("focusDate", requestedStart.toISOString());
const search = parameters.toString();
navigate(
{ pathname: location.pathname, search: search ? `?${search}` : "" },
{ replace: true, state: location.state }
);
}, [
calendars.length,
canWrite,
loading,
location.pathname,
location.search,
location.state,
navigate,
targetCalendarId
]);
useEffect(() => {
saveCalendarMode(mode);
}, [mode]);
useEffect(() => {
let active = true;
const load = async () => {
try {
const preferences = await getCalendarViewPreferences(settings);
if (active) setViewPreferences(calendarViewPreferences(preferences));
} catch (err) {
if (active) setError(errorText(err));
}
};
const handlePreferenceChange = () => void load();
void load();
window.addEventListener(
"govoplan:calendar-preferences-changed",
handlePreferenceChange
);
return () => {
active = false;
window.removeEventListener(
"govoplan:calendar-preferences-changed",
handlePreferenceChange
);
};
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
useEffect(() => {
if (calendars.length) {
void loadEvents();
} else {
setEvents([]);
}
}, [calendars.length, eventWindow.start.getTime(), eventWindow.end.getTime()]);
useEffect(() => {
if (mode !== "continuous") return undefined;
const frame = window.requestAnimationFrame(() => {
const node = scrollRef.current;
if (!node || node.scrollTop > 0) return;
const firstWeek = node.querySelector<HTMLElement>(".calendar-week-row");
const rowHeight = firstWeek?.offsetHeight || 92;
node.scrollTop = Math.max(0, continuousWeeks.before * rowHeight - node.clientHeight / 3);
updateContinuousViewport(node);
});
return () => window.cancelAnimationFrame(frame);
}, [focusDate, mode]);
useEffect(() => {
if (mode !== "continuous" || !pendingContinuousScrollDayRef.current) return;
const targetDay = pendingContinuousScrollDayRef.current;
pendingContinuousScrollDayRef.current = null;
const frame = window.requestAnimationFrame(() => {
scrollContinuousDayIntoView(targetDay, "smooth");
});
return () => window.cancelAnimationFrame(frame);
}, [days, mode]);
async function loadCalendars(): Promise<CalendarCollection[]> {
setLoading(true);
setError("");
try {
const [response, sourceResponse] = await Promise.all([
listCalendars(settings),
listSyncSources(settings)
]);
setCalendars(response.calendars);
setSyncSources(sourceResponse.sources);
const ids = response.calendars.map((calendar) => calendar.id);
setVisibleCalendarIds((current) => {
const next = current.filter((id) => ids.includes(id));
return next.length > 0 ? next : ids;
});
setEventCalendarId((current) => current && ids.includes(current) ? current : ids[0] ?? "");
return response.calendars;
} catch (err) {
setError(errorText(err));
return [];
} finally {
setLoading(false);
}
}
async function loadEvents() {
const requestId = ++eventRequestRef.current;
setLoading(true);
setError("");
const start = eventWindow.start.toISOString();
const end = eventWindow.end.toISOString();
try {
const response = await listCalendarEvents(settings, {
start_at: start,
end_at: end,
expand_recurring: true
});
if (requestId !== eventRequestRef.current) return;
setEvents(response.events);
} catch (err) {
if (requestId !== eventRequestRef.current) return;
setError(errorText(err));
setEvents([]);
} finally {
if (requestId === eventRequestRef.current) setLoading(false);
}
}
function toggleCalendarVisibility(calendarId: string) {
setVisibleCalendarIds((current) => {
const next = current.includes(calendarId) ? current.filter((id) => id !== calendarId) : [...current, calendarId];
if (next.length > 0 && !next.includes(eventCalendarId)) setEventCalendarId(next[0]);
return next;
});
setEventCalendarId((current) => current || calendarId);
}
function openCalendarEditor(calendar: CalendarCollection) {
setCalendarDialog({
kind: "edit",
calendar,
eventCount: null,
loadingEventCount: true
});
void listCalendarEvents(settings, { calendar_id: calendar.id }).
then((response) => {
setCalendarDialog((current) => current?.kind === "edit" && current.calendar.id === calendar.id ?
{ ...current, eventCount: response.events.length, loadingEventCount: false } :
current);
}).
catch((err) => {
setCalendarDialog((current) => current?.kind === "edit" && current.calendar.id === calendar.id ?
{ ...current, eventCount: null, loadingEventCount: false } :
current);
setError(errorText(err));
});
}
async function handleCalendarSave(payload: CalendarCollectionFormPayload): Promise<boolean> {
const currentDialog = calendarDialog;
const name = payload.name.trim();
if (!name || !canManageCalendars) return false;
setSaving(true);
setError("");
try {
let reloadSources = false;
if (currentDialog?.kind === "edit") {
const source = syncSourceByCalendarId.get(currentDialog.calendar.id);
const updated = await updateCalendar(settings, currentDialog.calendar.id, {
name,
color: normalizeHexColor(payload.color) || DEFAULT_CALENDAR_COLOR
});
if (source && canDeleteCalendars) {
const updatedSource = await updateSyncSource(
settings,
source.id,
syncSourceConnectionUpdatePayload(
payload.sourceMode,
payload.caldav,
source.metadata,
),
);
setSyncSources((current) => current.map((item) => item.id === source.id ? updatedSource : item));
reloadSources = true;
}
setCalendars((current) => current.map((item) => item.id === currentDialog.calendar.id ? updated : item));
} else {
const calendar = await createCalendar(settings, {
name,
color: normalizeHexColor(payload.color) || DEFAULT_CALENDAR_COLOR,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
owner_type: payload.sourceMode === "open_xchange" && payload.caldav.resource_calendar_ref.trim() ? "resource" : "tenant",
owner_id: payload.sourceMode === "open_xchange" ? payload.caldav.resource_calendar_ref.trim() || null : null
});
let createdSource: CalendarSyncSource | null = null;
if (payload.sourceMode !== "local") {
try {
createdSource = await createSyncSource(settings, {
...syncSourceCreatePayload(payload.sourceMode, payload.caldav),
calendar_id: calendar.id
});
reloadSources = true;
} catch (sourceError) {
await deleteCalendar(settings, calendar.id, { event_action: "delete" }).catch(() => undefined);
throw sourceError;
}
}
setCalendars((current) => [...current, calendar].sort(compareCalendars));
setVisibleCalendarIds((current) => current.includes(calendar.id) ? current : [...current, calendar.id]);
setEventCalendarId(calendar.id);
if (createdSource) setSyncSources((current) => [...current, createdSource]);
}
setCalendarDialog(null);
if (reloadSources) await loadCalendars();
return true;
} catch (err) {
setError(errorText(err));
return false;
} finally {
setSaving(false);
}
}
async function handleCalendarDelete(calendar: CalendarCollection, payload: CalendarCollectionDeletePayload) {
if (!canDeleteCalendars) return;
setSaving(true);
setError("");
try {
await deleteCalendar(settings, calendar.id, payload);
setCalendarDialog(null);
setCalendarDeleteDialog(null);
const remoteMove = payload.external_action === "remote_move";
if (!remoteMove) {
setCalendars((current) => current.filter((item) => item.id !== calendar.id));
setSyncSources((current) => current.filter((item) => item.calendar_id !== calendar.id));
setVisibleCalendarIds((current) => current.filter((id) => id !== calendar.id));
setEventCalendarId((current) => current === calendar.id ? "" : current);
}
const refreshedCalendars = await loadCalendars();
if (remoteMove) {
const refreshedSource = refreshedCalendars.find((item) => item.id === calendar.id);
const batchId = refreshedSource ? calendarRemoteMoveBatchId(refreshedSource) : "";
if (batchId) setMigrationBatchId(batchId);
}
if (calendars.length > 1 || payload.event_action === "move") {
await loadEvents();
} else {
setEvents([]);
}
} catch (err) {
setError(errorText(err));
} finally
{
setSaving(false);
}
}
function openCalendarDelete(calendar: CalendarCollection, eventCount: number | null = null, loadingEventCount = true) {
setCalendarDeleteDialog({ calendar, eventCount, loadingEventCount });
if (!loadingEventCount) return;
void listCalendarEvents(settings, { calendar_id: calendar.id }).
then((response) => {
setCalendarDeleteDialog((current) => current?.calendar.id === calendar.id ?
{ ...current, eventCount: response.events.length, loadingEventCount: false } :
current);
}).
catch((err) => {
setCalendarDeleteDialog((current) => current?.calendar.id === calendar.id ?
{ ...current, eventCount: null, loadingEventCount: false } :
current);
setError(errorText(err));
});
}
async function handleSyncSource(source: CalendarSyncSource, payload: {password?: string | null;bearer_token?: string | null;force_full?: boolean;} = {}) {
setSyncingSourceId(source.id);
setError("");
try {
const response = await syncSyncSource(settings, source.id, payload);
setSyncSources((current) => current.map((item) => item.id === source.id ? response.source : item));
await loadEvents();
} catch (err) {
setError(errorText(err));
} finally {
setSyncingSourceId("");
}
}
async function handleCalDavDiscovery(payload: CalendarCalDavDiscoveryPayload) {
return discoverCalDavCalendars(settings, payload);
}
function moveFocus(direction: -1 | 1) {
if (mode === "continuous") return;
if (mode === "month") {
setFocusDate((current) => addMonths(current, direction));
return;
}
const daysToMove = mode === "day" ? 1 : 7;
setFocusDate((current) => addDays(current, direction * daysToMove));
}
function handleToday() {
const today = startOfDay(new Date());
if (mode !== "continuous") {
setFocusDate(today);
return;
}
pendingContinuousScrollDayRef.current = today;
setContinuousWeeks(INITIAL_CONTINUOUS_WEEKS);
setFocusDate(today);
window.requestAnimationFrame(() => {
if (pendingContinuousScrollDayRef.current) {
pendingContinuousScrollDayRef.current = null;
scrollContinuousDayIntoView(today, "smooth");
}
});
}
function handleContinuousScroll() {
const node = scrollRef.current;
if (!node || mode !== "continuous") return;
updateContinuousViewport(node);
if (node.scrollTop < 160) prependContinuousWeeks();
if (node.scrollTop + node.clientHeight > node.scrollHeight - 220) appendContinuousWeeks();
}
function handleContinuousWheel(event: ReactWheelEvent<HTMLDivElement>) {
const node = scrollRef.current;
if (!node || mode !== "continuous") return;
if (event.deltaY < 0 && node.scrollTop <= 0) prependContinuousWeeks();
}
function prependContinuousWeeks() {
const node = scrollRef.current;
if (!node || continuousPrependPendingRef.current) return;
continuousPrependPendingRef.current = true;
const oldHeight = node.scrollHeight;
setContinuousWeeks((current) => ({ ...current, before: current.before + 4 }));
window.requestAnimationFrame(() => {
const nextNode = scrollRef.current;
if (nextNode) nextNode.scrollTop += nextNode.scrollHeight - oldHeight;
continuousPrependPendingRef.current = false;
});
}
function updateContinuousViewport(node: HTMLDivElement) {
const next = { scrollTop: node.scrollTop, height: node.clientHeight };
setContinuousViewport((current) =>
Math.abs(current.scrollTop - next.scrollTop) < 1 && Math.abs(current.height - next.height) < 1 ? current : next
);
}
function scrollContinuousDayIntoView(day: Date, behavior: ScrollBehavior = "auto") {
const node = scrollRef.current;
if (!node || days.length === 0) return;
const firstWeek = startOfWeek(days[0]);
const targetWeek = startOfWeek(day);
const weekIndex = Math.max(0, Math.floor(daysBetweenCount(firstWeek, targetWeek) / 7));
node.scrollTo({ top: weekIndex * CONTINUOUS_WEEK_ROW_HEIGHT, behavior });
window.requestAnimationFrame(() => updateContinuousViewport(node));
}
function openEventEditor(event: CalendarEvent) {
const recurringOccurrence = Boolean(
event.is_occurrence &&
event.series_event_id &&
event.recurrence_id
);
setEventDialog({
kind: "edit",
occurrence: event,
seriesEvent: recurringOccurrence ? null : event,
editScope: recurringOccurrence ? "occurrence" : "series"
});
if (!recurringOccurrence || !event.series_event_id) return;
const instanceId = calendarEventInstanceId(event);
void getCalendarEvent(settings, event.series_event_id)
.then((seriesEvent) => {
setEventDialog((current) =>
current?.kind === "edit" &&
calendarEventInstanceId(current.occurrence) === instanceId
? { ...current, seriesEvent }
: current
);
})
.catch((err) => setError(errorText(err)));
}
function setEventEditScope(scope: EventEditScope) {
setEventDialog((current) =>
current?.kind === "edit" &&
(scope === "occurrence" || current.seriesEvent)
? { ...current, editScope: scope }
: current
);
}
function beginEventDrag(dragEvent: ReactDragEvent<HTMLElement>, action: CalendarDragAction) {
if (!canWrite) {
dragEvent.preventDefault();
return;
}
dragActionRef.current = action;
setDraggingEventId(calendarEventInstanceId(action.event));
dragEvent.dataTransfer.effectAllowed = "move";
dragEvent.dataTransfer.setData("text/plain", calendarEventInstanceId(action.event));
}
function handleEventDragStart(dragEvent: ReactDragEvent<HTMLElement>, calendarEvent: CalendarEvent) {
beginEventDrag(dragEvent, { kind: "move", event: calendarEvent });
}
function handleEventResizeDragStart(dragEvent: ReactDragEvent<HTMLElement>, calendarEvent: CalendarEvent, edge: CalendarResizeEdge) {
dragEvent.stopPropagation();
if (calendarEvent.all_day) {
dragEvent.preventDefault();
return;
}
beginEventDrag(dragEvent, { kind: edge === "start" ? "resize-start" : "resize-end", event: calendarEvent });
}
function handleEventDragEnd() {
dragActionRef.current = null;
setDraggingEventId("");
setDropTarget(null);
}
function allowEventDrop(dragEvent: ReactDragEvent<HTMLElement>, canDrop: (action: CalendarDragAction) => boolean): boolean {
const action = dragActionRef.current;
if (!canWrite || !action || !canDrop(action)) return false;
dragEvent.preventDefault();
dragEvent.dataTransfer.dropEffect = "move";
return true;
}
function handleEventDragOverDay(dragEvent: ReactDragEvent<HTMLElement>, day: Date) {
if (!allowEventDrop(dragEvent, (action) => action.kind === "move")) return;
const key = dayKey(day);
setDropTarget((current) => current?.kind === "day" && current.key === key ? current : { kind: "day", key });
}
function handleEventDragOverTime(dragEvent: ReactDragEvent<HTMLElement>, day: Date) {
if (!allowEventDrop(dragEvent, (action) => action.kind === "move" || action.kind === "resize-start" || action.kind === "resize-end")) return;
const key = dayKey(day);
const minuteOfDay = dropSlotMinuteOfDay(dragEvent);
setDropTarget((current) =>
current?.kind === "time" && current.key === key && current.minuteOfDay === minuteOfDay ?
current :
{ kind: "time", key, minuteOfDay }
);
}
function handleDropTargetLeave(dragEvent: ReactDragEvent<HTMLElement>) {
const related = dragEvent.relatedTarget;
if (related instanceof Node && dragEvent.currentTarget.contains(related)) return;
setDropTarget(null);
}
function handleEventDropOnDay(dropEvent: ReactDragEvent<HTMLElement>, day: Date) {
dropEvent.preventDefault();
const action = dragActionRef.current;
if (!action || action.kind !== "move" || !canWrite) return;
setDropTarget(null);
void moveEvent(action.event, moveEventToDay(action.event, day));
}
function handleEventDropOnTime(dropEvent: ReactDragEvent<HTMLElement>, day: Date, minuteOfDay: number) {
dropEvent.preventDefault();
const action = dragActionRef.current;
if (!action || !canWrite) return;
setDropTarget(null);
const next = action.kind === "move" ?
moveEventToTime(action.event, day, minuteOfDay) :
resizeEventToTime(action.event, action.kind === "resize-start" ? "start" : "end", day, minuteOfDay);
void moveEvent(action.event, next);
}
async function moveEvent(calendarEvent: CalendarEvent, next: {startAt: Date;endAt: Date | null;allDay: boolean;}) {
setSaving(true);
setError("");
try {
const payload = {
start_at: next.startAt.toISOString(),
end_at: next.endAt ? next.endAt.toISOString() : null,
all_day: next.allDay
};
if (
calendarEvent.is_occurrence &&
calendarEvent.series_event_id &&
calendarEvent.recurrence_id
) {
await updateCalendarEventOccurrence(
settings,
calendarEvent.series_event_id,
calendarEvent.recurrence_id,
payload
);
} else {
await updateCalendarEvent(settings, calendarEvent.id, payload);
}
await loadEvents();
} catch (err) {
setError(errorText(err));
} finally {
handleEventDragEnd();
setSaving(false);
}
}
function appendContinuousWeeks() {
if (continuousAppendPendingRef.current) return;
continuousAppendPendingRef.current = true;
setContinuousWeeks((current) => ({ ...current, after: current.after + 4 }));
window.requestAnimationFrame(() => {
continuousAppendPendingRef.current = false;
});
}
async function handleSave(
payload: CalendarEventCreatePayload,
event: CalendarEvent | null,
editScope: EventEditScope
): Promise<boolean> {
setSaving(true);
setError("");
try {
const currentDialog = eventDialog;
if (
event &&
currentDialog?.kind === "edit" &&
editScope === "occurrence" &&
currentDialog.occurrence.series_event_id &&
currentDialog.occurrence.recurrence_id
) {
await updateCalendarEventOccurrence(
settings,
currentDialog.occurrence.series_event_id,
currentDialog.occurrence.recurrence_id,
payload
);
} else if (event) {
await updateCalendarEvent(settings, event.id, payload);
} else {
await createCalendarEvent(settings, payload);
}
if (payload.calendar_id) {
setVisibleCalendarIds((current) => current.includes(payload.calendar_id as string) ? current : [...current, payload.calendar_id as string]);
setEventCalendarId(payload.calendar_id);
}
setEventDialog(null);
await loadEvents();
return true;
} catch (err) {
setError(errorText(err));
return false;
} finally {
setSaving(false);
}
}
async function handleDelete(
event: CalendarEvent,
editScope: EventEditScope
) {
setSaving(true);
setError("");
try {
const currentDialog = eventDialog;
if (
currentDialog?.kind === "edit" &&
editScope === "occurrence" &&
currentDialog.occurrence.series_event_id &&
currentDialog.occurrence.recurrence_id
) {
await deleteCalendarEventOccurrence(
settings,
currentDialog.occurrence.series_event_id,
currentDialog.occurrence.recurrence_id
);
} else {
await deleteCalendarEvent(settings, event.id);
}
setEventDialog(null);
await loadEvents();
} catch (err) {
setError(errorText(err));
} finally {
setSaving(false);
}
}
return (
<div className="workspace-data-page module-entry-page calendar-page calendar-fullscreen">
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
<LoadingFrame loading={loading} label="i18n:govoplan-calendar.loading_calendar.7eb8f548" className="calendar-loading-frame">
<div className="calendar-shell">
<aside className="calendar-sidebar">
<div className="calendar-sidebar-heading">i18n:govoplan-calendar.calendars.94445018</div>
<div className="calendar-list">
{calendars.map((calendar) => {
const source = syncSourceByCalendarId.get(calendar.id) ?? null;
const syncing = source ? syncingSourceId === source.id : false;
return (
<div
key={calendar.id}
className={[
"calendar-list-row",
calendar.id === targetCalendarId ? "is-target" : "",
syncing ? "is-syncing" : ""].
filter(Boolean).join(" ")}
aria-busy={syncing || undefined}>
<button
type="button"
className={visibleCalendarIds.includes(calendar.id) ? "calendar-visibility-switch is-on" : "calendar-visibility-switch"}
role="switch"
aria-checked={visibleCalendarIds.includes(calendar.id)}
aria-label={i18nMessage("i18n:govoplan-calendar.show_value.60e2ce8e", { value0: calendar.name })}
title={i18nMessage("i18n:govoplan-calendar.show_value.60e2ce8e", { value0: calendar.name })}
style={{ "--calendar-list-color": normalizeHexColor(calendar.color) || DEFAULT_CALENDAR_COLOR } as CSSProperties}
onClick={() => toggleCalendarVisibility(calendar.id)}>
<span />
</button>
<button type="button" className="calendar-list-name" onClick={() => setEventCalendarId(calendar.id)}>
{calendar.name}
</button>
<TableActionGroup
className="calendar-list-actions"
actions={[
source && canSyncCalendars && {
id: "sync",
label: syncing ? i18nMessage("i18n:govoplan-calendar.syncing_value.ca80b487", { value0: calendar.name }) : i18nMessage("i18n:govoplan-calendar.sync_value.72e4ba66", { value0: calendar.name }),
icon: <RefreshCw size={15} className={syncing ? "calendar-sync-spin" : undefined} />,
onClick: () => void handleSyncSource(source),
disabled: saving || syncing,
disabledReason: saving || syncing ? CALENDAR_I18N.saving : undefined
},
(canManageCalendars || canDeleteCalendars) && {
id: "edit",
label: i18nMessage("i18n:govoplan-calendar.edit_value.fad75899", { value0: calendar.name }),
icon: <Pencil size={15} />,
onClick: () => openCalendarEditor(calendar)
}
]}
/>
</div>);
})}
{!calendars.length && !loading &&
<p className="calendar-empty-note">i18n:govoplan-calendar.no_calendars.3a7e4a7a</p>
}
{canManageCalendars &&
<div className="calendar-create-action">
<Button type="button" onClick={() => setCalendarDialog({ kind: "create" })} disabled={saving} disabledReason={saving ? CALENDAR_I18N.saving : undefined}>
<Plus size={16} /> i18n:govoplan-calendar.add_calendar.124c55eb
</Button>
</div>
}
</div>
<div className="calendar-sidebar-heading is-secondary">i18n:govoplan-calendar.agenda.891e9d6d</div>
<div className="calendar-agenda">
{agendaGroups.map((group) =>
<section key={group.key} className="calendar-agenda-group">
<h3>{agendaDateLabel(group.date)}</h3>
{group.events.map((event) =>
<button
key={`${group.key}-${calendarEventInstanceId(event)}`}
type="button"
className={["calendar-agenda-item", hoveredEventId === calendarEventInstanceId(event) ? "is-linked-hover" : ""].filter(Boolean).join(" ")}
style={calendarEventColorStyle(calendarColorById.get(event.calendar_id))}
onMouseEnter={() => setHoveredEventId(calendarEventInstanceId(event))}
onMouseLeave={() => setHoveredEventId((current) => current === calendarEventInstanceId(event) ? "" : current)}
onFocus={() => setHoveredEventId(calendarEventInstanceId(event))}
onBlur={() => setHoveredEventId((current) => current === calendarEventInstanceId(event) ? "" : current)}
onClick={() => openEventEditor(event)}>
<EventInlineLabel event={event} />
</button>
)}
</section>
)}
{!agendaGroups.length && !loading && <p>i18n:govoplan-calendar.no_events.e339ba73</p>}
</div>
</aside>
<section className="calendar-main-panel" aria-label="i18n:govoplan-calendar.calendar.adab5090">
<ActionToolbar className="calendar-view-toolbar" aria-label="i18n:govoplan-calendar.calendar_controls.974f4fa1">
<ToolbarGroup grow className="calendar-toolbar-left">
<SegmentedControl
className="calendar-mode-switch"
size="equal"
ariaLabel="i18n:govoplan-calendar.calendar_views.9e6b9c2b"
value={mode}
onChange={(nextMode) => {
if (nextMode === "continuous") {
setContinuousWeeks(INITIAL_CONTINUOUS_WEEKS);
setContinuousViewport({ scrollTop: 0, height: 0 });
}
setMode(nextMode);
}}
options={modeOptions}
/>
</ToolbarGroup>
<ToolbarGroup align="center" className="calendar-toolbar-center">
<div className="calendar-icon-group" aria-label="i18n:govoplan-calendar.calendar_navigation.7ba43cd2">
<AdminIconButton
label="i18n:govoplan-calendar.previous.50f94286"
icon={<ChevronLeft size={18} />}
onClick={() => moveFocus(-1)}
disabled={mode === "continuous"}
/>
<Button type="button" onClick={handleToday}>i18n:govoplan-calendar.today.24345a14</Button>
<AdminIconButton
label="i18n:govoplan-calendar.next.bc981983"
icon={<ChevronRight size={18} />}
onClick={() => moveFocus(1)}
disabled={mode === "continuous"}
/>
<div className="calendar-range-label">
<CalendarDays size={18} aria-hidden="true" />
<strong>{heading}</strong>
</div>
</div>
</ToolbarGroup>
<ToolbarGroup align="end" className="calendar-toolbar-right">
<DocumentationHelpLink reference={CALENDAR_DOCUMENTATION} />
<AdminIconButton
label="i18n:govoplan-calendar.refresh.56e3badc"
icon={<RefreshCw size={18} />}
onClick={() => void loadEvents()}
/>
{canWrite &&
<Button type="button" variant="primary" onClick={() => setEventDialog({ kind: "create" })} disabled={!targetCalendarId} disabledReason={!targetCalendarId ? CALENDAR_I18N.targetCalendarRequired : undefined}>
<Plus size={17} /> i18n:govoplan-calendar.new.6403f2b7
</Button>
}
</ToolbarGroup>
</ActionToolbar>
<div className={`calendar-view-shell is-${mode}`}>
{mode === "continuous" ?
<div className="calendar-continuous" ref={scrollRef} onScroll={handleContinuousScroll} onWheel={handleContinuousWheel}>
<CalendarWeekRows
days={days}
eventsByDay={eventsByDay}
calendarColorById={calendarColorById}
focusDate={focusDate}
variant="continuous"
preferences={viewPreferences}
canWrite={canWrite}
draggingEventId={draggingEventId}
hoveredEventId={hoveredEventId}
dropTarget={dropTarget}
viewport={continuousViewport}
onEventSelect={openEventEditor}
onEventHover={setHoveredEventId}
onEventDragStart={handleEventDragStart}
onEventDragEnd={handleEventDragEnd}
onEventDragOverDay={handleEventDragOverDay}
onDropTargetLeave={handleDropTargetLeave}
onEventDropOnDay={handleEventDropOnDay} />
</div> :
mode === "month" ?
<CalendarWeekRows
days={days}
eventsByDay={eventsByDay}
calendarColorById={calendarColorById}
focusDate={focusDate}
variant="month"
preferences={viewPreferences}
canWrite={canWrite}
draggingEventId={draggingEventId}
hoveredEventId={hoveredEventId}
dropTarget={dropTarget}
onEventSelect={openEventEditor}
onEventHover={setHoveredEventId}
onEventDragStart={handleEventDragStart}
onEventDragEnd={handleEventDragEnd}
onEventDragOverDay={handleEventDragOverDay}
onDropTargetLeave={handleDropTargetLeave}
onEventDropOnDay={handleEventDropOnDay} /> :
<CalendarTimeGrid
days={days}
eventsByDay={eventsByDay}
calendarColorById={calendarColorById}
mode={mode}
preferences={viewPreferences}
canWrite={canWrite}
draggingEventId={draggingEventId}
hoveredEventId={hoveredEventId}
dropTarget={dropTarget}
onEventSelect={openEventEditor}
onEventHover={setHoveredEventId}
onEventDragStart={handleEventDragStart}
onEventResizeDragStart={handleEventResizeDragStart}
onEventDragEnd={handleEventDragEnd}
onEventDragOverDay={handleEventDragOverDay}
onEventDragOverTime={handleEventDragOverTime}
onDropTargetLeave={handleDropTargetLeave}
onEventDropOnDay={handleEventDropOnDay}
onEventDropOnTime={handleEventDropOnTime} />
}
</div>
</section>
</div>
</LoadingFrame>
{eventDialog && calendars.length > 0 &&
<CalendarEventDialog
key={
eventDialog.kind === "edit"
? `${calendarEventInstanceId(eventDialog.occurrence)}:${eventDialog.editScope}`
: "new-event"
}
calendars={calendars}
defaultCalendarId={targetCalendarId}
event={dialogEvent}
editScope={eventDialog.kind === "edit" ? eventDialog.editScope : "series"}
canChooseSeries={
eventDialog.kind === "edit" &&
Boolean(eventDialog.occurrence.is_occurrence)
}
seriesLoaded={
eventDialog.kind !== "edit" ||
eventDialog.seriesEvent !== null
}
focusDate={focusDate}
saving={saving}
canWrite={canWrite}
canDelete={canDelete}
onEditScopeChange={setEventEditScope}
onCancel={() => setEventDialog(null)}
onSave={handleSave}
onDelete={handleDelete} />
}
{calendarDialog &&
<CalendarCollectionDialog
key={calendarDialog.kind === "edit" ? calendarDialog.calendar.id : "new-calendar"}
state={calendarDialog}
settings={settings}
source={calendarDialog.kind === "edit" ? syncSourceByCalendarId.get(calendarDialog.calendar.id) ?? null : null}
saving={saving}
syncingSourceId={syncingSourceId}
canWrite={canManageCalendars}
canDelete={canDeleteCalendars}
canManageSources={canDeleteCalendars}
canSyncSources={canSyncCalendars}
onCancel={() => setCalendarDialog(null)}
onSave={handleCalendarSave}
onRequestDelete={(calendar, eventCount, loadingEventCount) => openCalendarDelete(calendar, eventCount, loadingEventCount)}
onSync={handleSyncSource}
onOpenOutbox={(source) => {
if (calendarDialog.kind === "edit") {
setOutboxDialog({ calendar: calendarDialog.calendar, source });
}
}}
onOpenMigration={(batchId) => {
setCalendarDialog(null);
setMigrationBatchId(batchId);
}}
onDiscover={handleCalDavDiscovery} />
}
{outboxDialog &&
<CalendarOutboxDialog
settings={settings}
calendar={outboxDialog.calendar}
source={outboxDialog.source}
onClose={() => setOutboxDialog(null)} />
}
{migrationBatchId &&
<CalendarMigrationDialog
settings={settings}
batchId={migrationBatchId}
onClose={() => setMigrationBatchId("")}
onChanged={() => {
void loadCalendars();
void loadEvents();
}} />
}
{calendarDeleteDialog &&
<CalendarCollectionDeleteDialog
state={calendarDeleteDialog}
calendars={calendars}
syncSources={syncSources}
saving={saving}
onCancel={() => setCalendarDeleteDialog(null)}
onDelete={handleCalendarDelete} />
}
</div>);
}
function compareCalendars(left: CalendarCollection, right: CalendarCollection): number {
if (left.is_default !== right.is_default) return left.is_default ? -1 : 1;
return left.name.localeCompare(right.name);
}
function calendarRemoteMoveBatchId(calendar: CalendarCollection): string {
const remoteMove = calendar.metadata?.remote_move;
if (!remoteMove || typeof remoteMove !== "object" || Array.isArray(remoteMove)) return "";
const batchId = (remoteMove as Record<string, unknown>).batch_id;
return typeof batchId === "string" ? batchId : "";
}
function validCalendarLaunchDate(value: string | null): Date | null {
if (!value) return null;
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
function calendarViewPreferences(
response: CalendarViewPreferencesResponse
): CalendarViewPreferences {
return {
dimWeekends: response.dim_weekends,
dimOffHours: response.dim_off_hours,
workdayStartHour: response.workday_start_hour,
workdayEndHour: response.workday_end_hour,
continuousVirtualization: response.continuous_virtualization,
continuousOverscanWeeks: response.continuous_overscan_weeks,
alternateContinuousMonths: response.alternate_continuous_months
};
}