feat(webui): complete calendar quick access
This commit is contained in:
@@ -21,6 +21,7 @@ import { ToolbarGroup, ActionToolbar,
|
||||
type ApiSettings,
|
||||
type AuthInfo
|
||||
} from "@govoplan/core-webui";
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
import {
|
||||
createCalendar,
|
||||
createCalendarEvent,
|
||||
@@ -124,6 +125,8 @@ const modeOptions: {id: CalendarMode;label: string;}[] = [
|
||||
|
||||
|
||||
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[]>([]);
|
||||
@@ -185,6 +188,38 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
||||
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]);
|
||||
@@ -1083,6 +1118,12 @@ function calendarRemoteMoveBatchId(calendar: CalendarCollection): string {
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { CalendarDays, ExternalLink, MapPin, Plus } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import {
|
||||
Button,
|
||||
DismissibleAlert,
|
||||
LoadingFrame,
|
||||
SelectionList,
|
||||
SelectionListItem,
|
||||
SelectionListItemContent,
|
||||
hasScope,
|
||||
quickAccessLaunchState,
|
||||
useDashboardWidgetData,
|
||||
type QuickAccessToolRenderContext
|
||||
} from "@govoplan/core-webui";
|
||||
import { listCalendarEvents, type CalendarEvent } from "../../api/calendar";
|
||||
|
||||
const AGENDA_DAYS = 21;
|
||||
const AGENDA_LIMIT = 7;
|
||||
|
||||
type Props = Pick<
|
||||
QuickAccessToolRenderContext,
|
||||
"settings" | "auth" | "launchContext" | "complete" | "close"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Calendar-owned, range- and result-bounded agenda. The API applies tenant,
|
||||
* scope, and collection-visibility checks before any event reaches the rail.
|
||||
*/
|
||||
export default function CalendarQuickAccess({
|
||||
settings,
|
||||
auth,
|
||||
launchContext,
|
||||
complete,
|
||||
close
|
||||
}: Props) {
|
||||
const [selectedKey, setSelectedKey] = useState("");
|
||||
const rangeStart = useMemo(
|
||||
() => agendaStart(launchContext.temporalContext),
|
||||
[
|
||||
launchContext.temporalContext.validAt,
|
||||
launchContext.temporalContext.validityMode
|
||||
]
|
||||
);
|
||||
const rangeEnd = useMemo(() => {
|
||||
const end = new Date(rangeStart);
|
||||
end.setDate(end.getDate() + AGENDA_DAYS);
|
||||
return end;
|
||||
}, [rangeStart]);
|
||||
const load = useCallback(async () => {
|
||||
const response = await listCalendarEvents(settings, {
|
||||
start_at: rangeStart.toISOString(),
|
||||
end_at: rangeEnd.toISOString(),
|
||||
expand_recurring: true,
|
||||
limit: AGENDA_LIMIT
|
||||
});
|
||||
return response.events.filter(
|
||||
(event) => event.status.toUpperCase() !== "CANCELLED"
|
||||
);
|
||||
}, [rangeEnd, rangeStart, settings]);
|
||||
const { data: events, loading, error } = useDashboardWidgetData(load, 0);
|
||||
const items = events ?? [];
|
||||
const selected = useMemo(
|
||||
() => items.find((event) => eventKey(event) === selectedKey) ?? items[0] ?? null,
|
||||
[items, selectedKey]
|
||||
);
|
||||
const canCreate = hasScope(auth, "calendar:event:write");
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedKey && items[0]) setSelectedKey(eventKey(items[0]));
|
||||
if (selectedKey && !items.some((event) => eventKey(event) === selectedKey)) {
|
||||
setSelectedKey(items[0] ? eventKey(items[0]) : "");
|
||||
}
|
||||
}, [items, selectedKey]);
|
||||
|
||||
function selectForHost(event: CalendarEvent) {
|
||||
complete({
|
||||
contractVersion: "1",
|
||||
outcome: "completed",
|
||||
action: "selected",
|
||||
reference: {
|
||||
ownerModule: "calendar",
|
||||
kind: "event",
|
||||
objectId: eventKey(event),
|
||||
tenantId: event.tenant_id,
|
||||
label: event.summary,
|
||||
version: `${event.sequence}:${event.updated_at}`,
|
||||
path: calendarFocusPath(event.start_at)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<LoadingFrame loading={loading} label="i18n:govoplan-calendar.loading_calendar.7eb8f548">
|
||||
{error ? (
|
||||
<DismissibleAlert tone="warning" resetKey={error}>{error}</DismissibleAlert>
|
||||
) : null}
|
||||
<p className="calendar-quick-range">
|
||||
i18n:govoplan-calendar.quick_access_range: {rangeLabel(rangeStart, rangeEnd)}
|
||||
</p>
|
||||
|
||||
{items.length ? (
|
||||
<SelectionList variant="navigation" label="i18n:govoplan-calendar.agenda.891e9d6d">
|
||||
{items.map((event) => (
|
||||
<SelectionListItem
|
||||
key={eventKey(event)}
|
||||
selected={selected ? eventKey(event) === eventKey(selected) : false}
|
||||
onClick={() => setSelectedKey(eventKey(event))}
|
||||
>
|
||||
<SelectionListItemContent
|
||||
leading={<CalendarDays size={16} aria-hidden="true" />}
|
||||
title={event.summary}
|
||||
description={eventTimeLabel(event)}
|
||||
/>
|
||||
</SelectionListItem>
|
||||
))}
|
||||
</SelectionList>
|
||||
) : !loading && !error ? (
|
||||
<p className="muted">i18n:govoplan-calendar.no_events.e339ba73</p>
|
||||
) : null}
|
||||
|
||||
{selected ? (
|
||||
<section className="calendar-quick-detail" aria-label="i18n:govoplan-calendar.quick_access_event_details">
|
||||
<strong>{selected.summary}</strong>
|
||||
<span>{eventTimeLabel(selected)}</span>
|
||||
{selected.location ? (
|
||||
<span><MapPin size={13} aria-hidden="true" /> {selected.location}</span>
|
||||
) : null}
|
||||
{selected.description ? <p>{selected.description}</p> : null}
|
||||
<div className="button-row compact-actions">
|
||||
<Button variant="primary" onClick={() => selectForHost(selected)}>
|
||||
i18n:govoplan-calendar.quick_access_select_event
|
||||
</Button>
|
||||
<Link
|
||||
className="btn btn-secondary"
|
||||
to={calendarFocusPath(selected.start_at)}
|
||||
state={quickAccessLaunchState(launchContext)}
|
||||
onClick={() => selectForHost(selected)}
|
||||
>
|
||||
<ExternalLink size={15} aria-hidden="true" />
|
||||
i18n:govoplan-calendar.quick_access_open_calendar
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{canCreate ? (
|
||||
<div className="dashboard-contribution-footer">
|
||||
<Link
|
||||
className="btn btn-secondary"
|
||||
to={calendarCreatePath(rangeStart)}
|
||||
state={quickAccessLaunchState(launchContext)}
|
||||
onClick={close}
|
||||
>
|
||||
<Plus size={15} aria-hidden="true" />
|
||||
i18n:govoplan-calendar.new_event.2ef3795c
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</LoadingFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function agendaStart(
|
||||
context: QuickAccessToolRenderContext["launchContext"]["temporalContext"]
|
||||
): Date {
|
||||
if (context.validityMode === "at" && context.validAt) {
|
||||
const parsed = new Date(context.validAt);
|
||||
if (!Number.isNaN(parsed.getTime())) return parsed;
|
||||
}
|
||||
return new Date();
|
||||
}
|
||||
|
||||
function eventKey(event: CalendarEvent): string {
|
||||
return event.instance_id || event.id;
|
||||
}
|
||||
|
||||
function calendarFocusPath(startAt: string): string {
|
||||
return `/calendar?focusDate=${encodeURIComponent(startAt)}`;
|
||||
}
|
||||
|
||||
function calendarCreatePath(startAt: Date): string {
|
||||
return `/calendar?quickAction=create-event&startAt=${encodeURIComponent(startAt.toISOString())}`;
|
||||
}
|
||||
|
||||
function eventTimeLabel(event: CalendarEvent): string {
|
||||
const start = new Date(event.start_at);
|
||||
if (event.all_day) {
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format(start);
|
||||
}
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short"
|
||||
}).format(start);
|
||||
}
|
||||
|
||||
function rangeLabel(start: Date, end: Date): string {
|
||||
const formatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium" });
|
||||
return `${formatter.format(start)} – ${formatter.format(end)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user