1140 lines
44 KiB
TypeScript
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
|
|
};
|
|
}
|