201 lines
6.5 KiB
TypeScript
201 lines
6.5 KiB
TypeScript
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)}`;
|
||
}
|