Initialize GovOPlaN calendar module
This commit is contained in:
31
webui/package.json
Normal file
31
webui/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@govoplan/calendar-webui",
|
||||
"version": "0.1.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"module": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"./styles/calendar.css": "./src/styles/calendar.css"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.4",
|
||||
"lucide-react": "^0.555.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
92
webui/src/api/calendar.ts
Normal file
92
webui/src/api/calendar.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { apiFetch, type ApiSettings } from "@govoplan/core-webui";
|
||||
|
||||
export type CalendarCollection = {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
timezone: string;
|
||||
color?: string | null;
|
||||
owner_type: string;
|
||||
owner_id?: string | null;
|
||||
visibility: string;
|
||||
is_default: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type CalendarEvent = {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
calendar_id: string;
|
||||
uid: string;
|
||||
recurrence_id?: string | null;
|
||||
sequence: number;
|
||||
summary: string;
|
||||
description?: string | null;
|
||||
location?: string | null;
|
||||
status: string;
|
||||
transparency: string;
|
||||
classification: string;
|
||||
start_at: string;
|
||||
end_at?: string | null;
|
||||
duration_seconds?: number | null;
|
||||
all_day: boolean;
|
||||
timezone?: string | null;
|
||||
organizer?: Record<string, unknown> | null;
|
||||
attendees: Record<string, unknown>[];
|
||||
categories: string[];
|
||||
rrule?: Record<string, unknown> | null;
|
||||
rdate: Record<string, unknown>[];
|
||||
exdate: Record<string, unknown>[];
|
||||
reminders: Record<string, unknown>[];
|
||||
attachments: Record<string, unknown>[];
|
||||
related_to: Record<string, unknown>[];
|
||||
source_kind: string;
|
||||
source_href?: string | null;
|
||||
etag?: string | null;
|
||||
icalendar: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type CalendarCollectionListResponse = { calendars: CalendarCollection[] };
|
||||
export type CalendarEventListResponse = { events: CalendarEvent[] };
|
||||
|
||||
export type CalendarEventCreatePayload = {
|
||||
calendar_id?: string | null;
|
||||
summary: string;
|
||||
description?: string | null;
|
||||
location?: string | null;
|
||||
start_at: string;
|
||||
end_at?: string | null;
|
||||
all_day?: boolean;
|
||||
timezone?: string | null;
|
||||
status?: string;
|
||||
transparency?: string;
|
||||
classification?: string;
|
||||
categories?: string[];
|
||||
};
|
||||
|
||||
export function listCalendars(settings: ApiSettings): Promise<CalendarCollectionListResponse> {
|
||||
return apiFetch<CalendarCollectionListResponse>(settings, "/api/v1/calendar/calendars");
|
||||
}
|
||||
|
||||
export function listCalendarEvents(
|
||||
settings: ApiSettings,
|
||||
params: { calendar_id?: string; start_at?: string; end_at?: string } = {}
|
||||
): Promise<CalendarEventListResponse> {
|
||||
const search = new URLSearchParams();
|
||||
if (params.calendar_id) search.set("calendar_id", params.calendar_id);
|
||||
if (params.start_at) search.set("start_at", params.start_at);
|
||||
if (params.end_at) search.set("end_at", params.end_at);
|
||||
const suffix = search.toString() ? `?${search.toString()}` : "";
|
||||
return apiFetch<CalendarEventListResponse>(settings, `/api/v1/calendar/events${suffix}`);
|
||||
}
|
||||
|
||||
export function createCalendarEvent(settings: ApiSettings, payload: CalendarEventCreatePayload): Promise<CalendarEvent> {
|
||||
return apiFetch<CalendarEvent>(settings, "/api/v1/calendar/events", { method: "POST", body: JSON.stringify(payload) });
|
||||
}
|
||||
511
webui/src/features/calendar/CalendarPage.tsx
Normal file
511
webui/src/features/calendar/CalendarPage.tsx
Normal file
@@ -0,0 +1,511 @@
|
||||
import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
||||
import { CalendarDays, ChevronLeft, ChevronRight, Plus, RefreshCw, X } from "lucide-react";
|
||||
import { DismissibleAlert, LoadingIndicator, hasScope, type ApiSettings, type AuthInfo } from "@govoplan/core-webui";
|
||||
import { createCalendarEvent, listCalendarEvents, listCalendars, type CalendarCollection, type CalendarEvent } from "../../api/calendar";
|
||||
|
||||
type CalendarMode = "month" | "week" | "workweek" | "day" | "continuous";
|
||||
type Range = { start: Date; end: Date };
|
||||
|
||||
const modeOptions: { id: CalendarMode; label: string }[] = [
|
||||
{ id: "month", label: "Month" },
|
||||
{ id: "week", label: "Week" },
|
||||
{ id: "workweek", label: "Workweek" },
|
||||
{ id: "day", label: "Day" },
|
||||
{ id: "continuous", label: "Continuous" }
|
||||
];
|
||||
|
||||
export default function CalendarPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
|
||||
const [calendars, setCalendars] = useState<CalendarCollection[]>([]);
|
||||
const [selectedCalendarId, setSelectedCalendarId] = useState("");
|
||||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||
const [mode, setMode] = useState<CalendarMode>("month");
|
||||
const [focusDate, setFocusDate] = useState(() => startOfDay(new Date()));
|
||||
const [continuousWeeks, setContinuousWeeks] = useState({ before: 8, after: 12 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const selectedCalendar = calendars.find((calendar) => calendar.id === selectedCalendarId) ?? calendars[0] ?? null;
|
||||
const visibleRange = useMemo(() => rangeForMode(mode, focusDate, continuousWeeks), [continuousWeeks, focusDate, mode]);
|
||||
const days = useMemo(() => daysForMode(mode, focusDate, continuousWeeks), [continuousWeeks, focusDate, mode]);
|
||||
const eventsByDay = useMemo(() => groupEventsByDay(events), [events]);
|
||||
const canWrite = hasScope(auth, "calendar:event:write");
|
||||
const heading = headingForMode(mode, focusDate, visibleRange);
|
||||
|
||||
useEffect(() => {
|
||||
void loadCalendars();
|
||||
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (calendars.length) void loadEvents();
|
||||
}, [calendars.length, selectedCalendarId, visibleRange.start.getTime(), visibleRange.end.getTime()]);
|
||||
|
||||
async function loadCalendars() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await listCalendars(settings);
|
||||
setCalendars(response.calendars);
|
||||
setSelectedCalendarId((current) => current && response.calendars.some((calendar) => calendar.id === current) ? current : response.calendars[0]?.id ?? "");
|
||||
} catch (err) {
|
||||
setError(errorText(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEvents() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await listCalendarEvents(settings, {
|
||||
calendar_id: selectedCalendarId || undefined,
|
||||
start_at: visibleRange.start.toISOString(),
|
||||
end_at: visibleRange.end.toISOString()
|
||||
});
|
||||
setEvents(response.events);
|
||||
} catch (err) {
|
||||
setError(errorText(err));
|
||||
setEvents([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function moveFocus(direction: -1 | 1) {
|
||||
const daysToMove = mode === "month" ? 31 : mode === "day" ? 1 : 7;
|
||||
setFocusDate((current) => mode === "month" ? addMonths(current, direction) : addDays(current, direction * daysToMove));
|
||||
}
|
||||
|
||||
function handleContinuousScroll() {
|
||||
const node = scrollRef.current;
|
||||
if (!node || mode !== "continuous") return;
|
||||
if (node.scrollTop < 160) {
|
||||
const oldHeight = node.scrollHeight;
|
||||
setContinuousWeeks((current) => ({ ...current, before: current.before + 4 }));
|
||||
window.requestAnimationFrame(() => {
|
||||
if (scrollRef.current) scrollRef.current.scrollTop += scrollRef.current.scrollHeight - oldHeight;
|
||||
});
|
||||
}
|
||||
if (node.scrollTop + node.clientHeight > node.scrollHeight - 220) {
|
||||
setContinuousWeeks((current) => ({ ...current, after: current.after + 4 }));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate(payload: QuickCreatePayload) {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
await createCalendarEvent(settings, payload);
|
||||
setCreateOpen(false);
|
||||
await loadEvents();
|
||||
} catch (err) {
|
||||
setError(errorText(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="calendar-page">
|
||||
<header className="calendar-toolbar">
|
||||
<div className="calendar-title-block">
|
||||
<div className="calendar-title-row">
|
||||
<CalendarDays size={22} aria-hidden="true" />
|
||||
<h1>Calendar</h1>
|
||||
</div>
|
||||
<span>{heading}</span>
|
||||
</div>
|
||||
|
||||
<div className="calendar-toolbar-actions">
|
||||
<div className="calendar-icon-group" aria-label="Calendar navigation">
|
||||
<button type="button" className="calendar-icon-button" title="Previous" aria-label="Previous" onClick={() => moveFocus(-1)}>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
<button type="button" className="calendar-text-button" onClick={() => setFocusDate(startOfDay(new Date()))}>Today</button>
|
||||
<button type="button" className="calendar-icon-button" title="Next" aria-label="Next" onClick={() => moveFocus(1)}>
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="calendar-mode-switch" role="tablist" aria-label="Calendar views">
|
||||
{modeOptions.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === option.id}
|
||||
className={mode === option.id ? "is-active" : ""}
|
||||
onClick={() => setMode(option.id)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<select className="calendar-select" value={selectedCalendarId} onChange={(event) => setSelectedCalendarId(event.target.value)} aria-label="Calendar">
|
||||
{calendars.map((calendar) => <option key={calendar.id} value={calendar.id}>{calendar.name}</option>)}
|
||||
</select>
|
||||
|
||||
<button type="button" className="calendar-icon-button" title="Refresh" aria-label="Refresh" onClick={() => void loadEvents()}>
|
||||
<RefreshCw size={18} />
|
||||
</button>
|
||||
{canWrite && (
|
||||
<button type="button" className="calendar-primary-button" onClick={() => setCreateOpen(true)}>
|
||||
<Plus size={17} /> New
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
|
||||
<section className="calendar-workspace">
|
||||
<aside className="calendar-sidebar">
|
||||
<h2>Calendars</h2>
|
||||
<div className="calendar-list">
|
||||
{calendars.map((calendar) => (
|
||||
<button
|
||||
key={calendar.id}
|
||||
type="button"
|
||||
className={calendar.id === selectedCalendarId ? "is-selected" : ""}
|
||||
onClick={() => setSelectedCalendarId(calendar.id)}
|
||||
>
|
||||
<span style={{ backgroundColor: calendar.color || "#0f766e" }} />
|
||||
{calendar.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<h2>Agenda</h2>
|
||||
<div className="calendar-agenda">
|
||||
{events.slice(0, 12).map((event) => (
|
||||
<article key={event.id}>
|
||||
<strong>{event.summary}</strong>
|
||||
<span>{eventTimeLabel(event)}</span>
|
||||
</article>
|
||||
))}
|
||||
{!events.length && !loading && <p>No events</p>}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="calendar-view-shell">
|
||||
{loading && <div className="calendar-loading"><LoadingIndicator label="Loading calendar..." /></div>}
|
||||
{mode === "continuous" ? (
|
||||
<div className="calendar-continuous" ref={scrollRef} onScroll={handleContinuousScroll}>
|
||||
<CalendarWeekRows days={days} eventsByDay={eventsByDay} focusDate={focusDate} continuous />
|
||||
</div>
|
||||
) : mode === "month" ? (
|
||||
<CalendarWeekRows days={days} eventsByDay={eventsByDay} focusDate={focusDate} />
|
||||
) : (
|
||||
<CalendarTimeGrid days={days} eventsByDay={eventsByDay} />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{createOpen && selectedCalendar && (
|
||||
<QuickCreateDialog
|
||||
calendar={selectedCalendar}
|
||||
focusDate={focusDate}
|
||||
saving={saving}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onCreate={handleCreate}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarWeekRows({ days, eventsByDay, focusDate, continuous = false }: { days: Date[]; eventsByDay: Map<string, CalendarEvent[]>; focusDate: Date; continuous?: boolean }) {
|
||||
const weeks = chunk(days, 7);
|
||||
return (
|
||||
<div className={continuous ? "calendar-week-rows is-continuous" : "calendar-week-rows"}>
|
||||
<div className="calendar-weekday-header">
|
||||
{["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].map((label) => <span key={label}>{label}</span>)}
|
||||
</div>
|
||||
{weeks.map((week) => (
|
||||
<div className="calendar-week-row" key={dayKey(week[0])}>
|
||||
{week.map((day) => {
|
||||
const key = dayKey(day);
|
||||
const dayEvents = eventsByDay.get(key) ?? [];
|
||||
return (
|
||||
<section key={key} className={`calendar-day-cell ${sameMonth(day, focusDate) ? "" : "is-muted"} ${sameDay(day, new Date()) ? "is-today" : ""}`}>
|
||||
<header>
|
||||
<span>{day.getDate()}</span>
|
||||
{continuous && day.getDate() <= 7 && <small>{monthLabel(day)}</small>}
|
||||
</header>
|
||||
<div className="calendar-event-stack">
|
||||
{dayEvents.slice(0, 4).map((event) => <CalendarEventChip key={event.id} event={event} />)}
|
||||
{dayEvents.length > 4 && <span className="calendar-more-events">+{dayEvents.length - 4}</span>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarTimeGrid({ days, eventsByDay }: { days: Date[]; eventsByDay: Map<string, CalendarEvent[]> }) {
|
||||
const hours = Array.from({ length: 12 }, (_item, index) => index + 7);
|
||||
return (
|
||||
<div className="calendar-time-grid" style={{ gridTemplateColumns: `72px repeat(${days.length}, minmax(132px, 1fr))` }}>
|
||||
<div className="calendar-time-corner" />
|
||||
{days.map((day) => (
|
||||
<header key={dayKey(day)} className={sameDay(day, new Date()) ? "is-today" : ""}>
|
||||
<strong>{weekdayLong(day)}</strong>
|
||||
<span>{dayMonthLabel(day)}</span>
|
||||
</header>
|
||||
))}
|
||||
{hours.map((hour) => (
|
||||
<TimeRow key={hour} hour={hour} days={days} eventsByDay={eventsByDay} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimeRow({ hour, days, eventsByDay }: { hour: number; days: Date[]; eventsByDay: Map<string, CalendarEvent[]> }) {
|
||||
return (
|
||||
<>
|
||||
<div className="calendar-hour-label">{String(hour).padStart(2, "0")}:00</div>
|
||||
{days.map((day) => {
|
||||
const events = (eventsByDay.get(dayKey(day)) ?? []).filter((event) => event.all_day || new Date(event.start_at).getHours() === hour);
|
||||
return (
|
||||
<div key={`${dayKey(day)}-${hour}`} className="calendar-hour-cell">
|
||||
{events.map((event) => <CalendarEventChip key={event.id} event={event} compact />)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarEventChip({ event, compact = false }: { event: CalendarEvent; compact?: boolean }) {
|
||||
return (
|
||||
<article className={compact ? "calendar-event-chip is-compact" : "calendar-event-chip"}>
|
||||
<strong>{event.summary}</strong>
|
||||
{!compact && <span>{eventTimeLabel(event)}</span>}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
type QuickCreatePayload = {
|
||||
calendar_id: string;
|
||||
summary: string;
|
||||
location?: string | null;
|
||||
start_at: string;
|
||||
end_at?: string | null;
|
||||
all_day: boolean;
|
||||
};
|
||||
|
||||
function QuickCreateDialog({
|
||||
calendar,
|
||||
focusDate,
|
||||
saving,
|
||||
onCancel,
|
||||
onCreate
|
||||
}: {
|
||||
calendar: CalendarCollection;
|
||||
focusDate: Date;
|
||||
saving: boolean;
|
||||
onCancel: () => void;
|
||||
onCreate: (payload: QuickCreatePayload) => Promise<void>;
|
||||
}) {
|
||||
const [summary, setSummary] = useState("");
|
||||
const [location, setLocation] = useState("");
|
||||
const [date, setDate] = useState(toInputDate(focusDate));
|
||||
const [start, setStart] = useState("09:00");
|
||||
const [end, setEnd] = useState("10:00");
|
||||
const [allDay, setAllDay] = useState(false);
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const startAt = allDay ? localDateTime(date, "00:00") : localDateTime(date, start);
|
||||
const endAt = allDay ? addDays(startAt, 1) : localDateTime(date, end);
|
||||
await onCreate({
|
||||
calendar_id: calendar.id,
|
||||
summary,
|
||||
location: location || null,
|
||||
start_at: startAt.toISOString(),
|
||||
end_at: endAt.toISOString(),
|
||||
all_day: allDay
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="calendar-dialog-backdrop" role="presentation">
|
||||
<form className="calendar-dialog" onSubmit={submit}>
|
||||
<header>
|
||||
<h2>New event</h2>
|
||||
<button type="button" className="calendar-icon-button" title="Close" aria-label="Close" onClick={onCancel}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</header>
|
||||
<label>
|
||||
<span>Title</span>
|
||||
<input value={summary} onChange={(event) => setSummary(event.target.value)} required maxLength={500} autoFocus />
|
||||
</label>
|
||||
<label>
|
||||
<span>Location</span>
|
||||
<input value={location} onChange={(event) => setLocation(event.target.value)} maxLength={500} />
|
||||
</label>
|
||||
<label>
|
||||
<span>Date</span>
|
||||
<input type="date" value={date} onChange={(event) => setDate(event.target.value)} required />
|
||||
</label>
|
||||
<div className="calendar-dialog-row">
|
||||
<label>
|
||||
<span>Start</span>
|
||||
<input type="time" value={start} onChange={(event) => setStart(event.target.value)} disabled={allDay} />
|
||||
</label>
|
||||
<label>
|
||||
<span>End</span>
|
||||
<input type="time" value={end} onChange={(event) => setEnd(event.target.value)} disabled={allDay} />
|
||||
</label>
|
||||
</div>
|
||||
<label className="calendar-checkbox">
|
||||
<input type="checkbox" checked={allDay} onChange={(event) => setAllDay(event.target.checked)} />
|
||||
<span>All day</span>
|
||||
</label>
|
||||
<footer>
|
||||
<button type="button" className="calendar-text-button" onClick={onCancel}>Cancel</button>
|
||||
<button type="submit" className="calendar-primary-button" disabled={saving}>{saving ? "Saving..." : "Create"}</button>
|
||||
</footer>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function rangeForMode(mode: CalendarMode, focusDate: Date, continuousWeeks: { before: number; after: number }): Range {
|
||||
if (mode === "month") {
|
||||
const start = startOfWeek(startOfMonth(focusDate));
|
||||
const end = addDays(startOfWeek(addMonths(startOfMonth(focusDate), 1)), 7);
|
||||
return { start, end };
|
||||
}
|
||||
if (mode === "day") return { start: startOfDay(focusDate), end: addDays(startOfDay(focusDate), 1) };
|
||||
if (mode === "continuous") {
|
||||
const start = addDays(startOfWeek(focusDate), -continuousWeeks.before * 7);
|
||||
const end = addDays(startOfWeek(focusDate), continuousWeeks.after * 7);
|
||||
return { start, end };
|
||||
}
|
||||
const start = startOfWeek(focusDate);
|
||||
return { start, end: addDays(start, mode === "workweek" ? 5 : 7) };
|
||||
}
|
||||
|
||||
function daysForMode(mode: CalendarMode, focusDate: Date, continuousWeeks: { before: number; after: number }): Date[] {
|
||||
const range = rangeForMode(mode, focusDate, continuousWeeks);
|
||||
return daysBetween(range.start, range.end);
|
||||
}
|
||||
|
||||
function groupEventsByDay(events: CalendarEvent[]): Map<string, CalendarEvent[]> {
|
||||
const grouped = new Map<string, CalendarEvent[]>();
|
||||
for (const event of events) {
|
||||
const start = new Date(event.start_at);
|
||||
const end = event.end_at ? new Date(event.end_at) : start;
|
||||
let cursor = startOfDay(start);
|
||||
const last = startOfDay(end);
|
||||
while (cursor <= last) {
|
||||
const key = dayKey(cursor);
|
||||
grouped.set(key, [...(grouped.get(key) ?? []), event]);
|
||||
cursor = addDays(cursor, 1);
|
||||
}
|
||||
}
|
||||
for (const [key, items] of grouped.entries()) {
|
||||
grouped.set(key, items.sort((left, right) => left.start_at.localeCompare(right.start_at)));
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
function eventTimeLabel(event: CalendarEvent): string {
|
||||
if (event.all_day) return "All day";
|
||||
const start = new Date(event.start_at);
|
||||
const end = event.end_at ? new Date(event.end_at) : null;
|
||||
return end ? `${timeLabel(start)}-${timeLabel(end)}` : timeLabel(start);
|
||||
}
|
||||
|
||||
function headingForMode(mode: CalendarMode, focusDate: Date, range: Range): string {
|
||||
if (mode === "day") return dayMonthLabel(focusDate);
|
||||
if (mode === "month") return `${monthLabel(focusDate)} ${focusDate.getFullYear()}`;
|
||||
if (mode === "continuous") return `${dayMonthLabel(range.start)} - ${dayMonthLabel(addDays(range.end, -1))}`;
|
||||
return `${dayMonthLabel(range.start)} - ${dayMonthLabel(addDays(range.end, -1))}`;
|
||||
}
|
||||
|
||||
function chunk<T>(items: T[], size: number): T[][] {
|
||||
const rows: T[][] = [];
|
||||
for (let index = 0; index < items.length; index += size) rows.push(items.slice(index, index + size));
|
||||
return rows;
|
||||
}
|
||||
|
||||
function startOfDay(value: Date): Date {
|
||||
return new Date(value.getFullYear(), value.getMonth(), value.getDate());
|
||||
}
|
||||
|
||||
function startOfWeek(value: Date): Date {
|
||||
const day = value.getDay() || 7;
|
||||
return addDays(startOfDay(value), 1 - day);
|
||||
}
|
||||
|
||||
function startOfMonth(value: Date): Date {
|
||||
return new Date(value.getFullYear(), value.getMonth(), 1);
|
||||
}
|
||||
|
||||
function addDays(value: Date, days: number): Date {
|
||||
const next = new Date(value);
|
||||
next.setDate(next.getDate() + days);
|
||||
return next;
|
||||
}
|
||||
|
||||
function addMonths(value: Date, months: number): Date {
|
||||
const next = new Date(value);
|
||||
next.setMonth(next.getMonth() + months);
|
||||
return next;
|
||||
}
|
||||
|
||||
function daysBetween(start: Date, end: Date): Date[] {
|
||||
const days: Date[] = [];
|
||||
for (let cursor = startOfDay(start); cursor < end; cursor = addDays(cursor, 1)) days.push(cursor);
|
||||
return days;
|
||||
}
|
||||
|
||||
function dayKey(value: Date): string {
|
||||
return toInputDate(value);
|
||||
}
|
||||
|
||||
function sameDay(left: Date, right: Date): boolean {
|
||||
return left.getFullYear() === right.getFullYear() && left.getMonth() === right.getMonth() && left.getDate() === right.getDate();
|
||||
}
|
||||
|
||||
function sameMonth(left: Date, right: Date): boolean {
|
||||
return left.getFullYear() === right.getFullYear() && left.getMonth() === right.getMonth();
|
||||
}
|
||||
|
||||
function toInputDate(value: Date): string {
|
||||
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}-${String(value.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function localDateTime(date: string, time: string): Date {
|
||||
return new Date(`${date}T${time || "00:00"}:00`);
|
||||
}
|
||||
|
||||
function monthLabel(value: Date): string {
|
||||
return new Intl.DateTimeFormat(undefined, { month: "long" }).format(value);
|
||||
}
|
||||
|
||||
function dayMonthLabel(value: Date): string {
|
||||
return new Intl.DateTimeFormat(undefined, { day: "2-digit", month: "short" }).format(value);
|
||||
}
|
||||
|
||||
function weekdayLong(value: Date): string {
|
||||
return new Intl.DateTimeFormat(undefined, { weekday: "short" }).format(value);
|
||||
}
|
||||
|
||||
function timeLabel(value: Date): string {
|
||||
return new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit" }).format(value);
|
||||
}
|
||||
|
||||
function errorText(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
return "Calendar request failed.";
|
||||
}
|
||||
2
webui/src/index.ts
Normal file
2
webui/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { calendarModule as default, calendarModule } from "./module";
|
||||
export * from "./api/calendar";
|
||||
21
webui/src/module.ts
Normal file
21
webui/src/module.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createElement, lazy } from "react";
|
||||
import type { PlatformWebModule } from "@govoplan/core-webui";
|
||||
import "./styles/calendar.css";
|
||||
|
||||
const CalendarPage = lazy(() => import("./features/calendar/CalendarPage"));
|
||||
|
||||
const eventRead = ["calendar:event:read"];
|
||||
|
||||
export const calendarModule: PlatformWebModule = {
|
||||
id: "calendar",
|
||||
label: "Calendar",
|
||||
version: "1.0.0",
|
||||
dependencies: ["access"],
|
||||
optionalDependencies: ["mail", "tasks", "scheduling", "appointments", "workflow", "notifications", "dms", "connectors"],
|
||||
navItems: [{ to: "/calendar", label: "Calendar", iconName: "calendar", anyOf: eventRead, order: 55 }],
|
||||
routes: [
|
||||
{ path: "/calendar", anyOf: eventRead, order: 55, render: ({ settings, auth }) => createElement(CalendarPage, { settings, auth }) }
|
||||
]
|
||||
};
|
||||
|
||||
export default calendarModule;
|
||||
445
webui/src/styles/calendar.css
Normal file
445
webui/src/styles/calendar.css
Normal file
@@ -0,0 +1,445 @@
|
||||
.calendar-page {
|
||||
min-height: 100%;
|
||||
padding: 24px;
|
||||
color: #172026;
|
||||
background: #f5f7f8;
|
||||
}
|
||||
|
||||
.calendar-toolbar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.calendar-title-block {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.calendar-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.calendar-title-row h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.calendar-title-block span {
|
||||
color: #5b6770;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.calendar-toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.calendar-icon-group,
|
||||
.calendar-mode-switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px;
|
||||
border: 1px solid #d6dde1;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.calendar-mode-switch button,
|
||||
.calendar-text-button,
|
||||
.calendar-primary-button,
|
||||
.calendar-icon-button,
|
||||
.calendar-select {
|
||||
border: 1px solid #cfd8dc;
|
||||
background: #ffffff;
|
||||
color: #172026;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.calendar-mode-switch button {
|
||||
min-height: 32px;
|
||||
padding: 0 10px;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.calendar-mode-switch button.is-active {
|
||||
background: #0f766e;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.calendar-icon-button {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.calendar-text-button,
|
||||
.calendar-primary-button {
|
||||
min-height: 34px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.calendar-primary-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
border-color: #0f766e;
|
||||
background: #0f766e;
|
||||
color: #ffffff;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.calendar-primary-button:disabled {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.calendar-select {
|
||||
min-height: 36px;
|
||||
max-width: 240px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.calendar-workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(210px, 260px) minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
min-height: calc(100vh - 150px);
|
||||
}
|
||||
|
||||
.calendar-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
border-right: 1px solid #dbe2e6;
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
.calendar-sidebar h2 {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
color: #4d5a61;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.calendar-list,
|
||||
.calendar-agenda {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.calendar-list button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 34px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.calendar-list button.is-selected {
|
||||
border-color: #b8c8cc;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.calendar-list button span {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.calendar-agenda article {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #e1e6e8;
|
||||
}
|
||||
|
||||
.calendar-agenda strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.calendar-agenda span,
|
||||
.calendar-agenda p {
|
||||
margin: 0;
|
||||
color: #5f6f78;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.calendar-view-shell {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid #d7e0e4;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.calendar-loading {
|
||||
position: absolute;
|
||||
inset: 0 auto auto 0;
|
||||
z-index: 2;
|
||||
padding: 10px;
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
}
|
||||
|
||||
.calendar-week-rows {
|
||||
min-width: 760px;
|
||||
}
|
||||
|
||||
.calendar-weekday-header,
|
||||
.calendar-week-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(104px, 1fr));
|
||||
}
|
||||
|
||||
.calendar-weekday-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
border-bottom: 1px solid #d7e0e4;
|
||||
background: #edf3f3;
|
||||
}
|
||||
|
||||
.calendar-weekday-header span {
|
||||
min-height: 34px;
|
||||
padding: 9px 10px;
|
||||
color: #506169;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.calendar-week-row {
|
||||
min-height: 132px;
|
||||
border-bottom: 1px solid #d7e0e4;
|
||||
}
|
||||
|
||||
.calendar-day-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-height: 132px;
|
||||
padding: 8px;
|
||||
border-right: 1px solid #e1e7ea;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.calendar-day-cell.is-muted {
|
||||
background: #f7f9fa;
|
||||
color: #738089;
|
||||
}
|
||||
|
||||
.calendar-day-cell.is-today {
|
||||
box-shadow: inset 0 0 0 2px #0f766e;
|
||||
}
|
||||
|
||||
.calendar-day-cell header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.calendar-day-cell header span {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.calendar-day-cell header small {
|
||||
color: #63747d;
|
||||
}
|
||||
|
||||
.calendar-event-stack {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.calendar-event-chip {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-height: 34px;
|
||||
padding: 5px 7px;
|
||||
border-left: 3px solid #0f766e;
|
||||
background: #e7f4f1;
|
||||
color: #16332f;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.calendar-event-chip.is-compact {
|
||||
min-height: 28px;
|
||||
margin: 3px 4px;
|
||||
background: #e7f4f1;
|
||||
}
|
||||
|
||||
.calendar-event-chip strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.calendar-event-chip span {
|
||||
color: #4d6764;
|
||||
}
|
||||
|
||||
.calendar-more-events {
|
||||
color: #596970;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.calendar-continuous {
|
||||
max-height: calc(100vh - 172px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.calendar-week-rows.is-continuous .calendar-week-row {
|
||||
min-height: 82px;
|
||||
}
|
||||
|
||||
.calendar-week-rows.is-continuous .calendar-day-cell {
|
||||
min-height: 82px;
|
||||
}
|
||||
|
||||
.calendar-time-grid {
|
||||
display: grid;
|
||||
min-width: 720px;
|
||||
}
|
||||
|
||||
.calendar-time-grid > header,
|
||||
.calendar-time-corner {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
align-content: center;
|
||||
min-height: 54px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid #d7e0e4;
|
||||
border-right: 1px solid #d7e0e4;
|
||||
background: #edf3f3;
|
||||
}
|
||||
|
||||
.calendar-time-grid > header.is-today {
|
||||
background: #dbeeea;
|
||||
}
|
||||
|
||||
.calendar-time-grid > header span {
|
||||
color: #52646d;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.calendar-hour-label,
|
||||
.calendar-hour-cell {
|
||||
min-height: 64px;
|
||||
border-bottom: 1px solid #e3e9ec;
|
||||
border-right: 1px solid #e3e9ec;
|
||||
}
|
||||
|
||||
.calendar-hour-label {
|
||||
padding: 8px;
|
||||
color: #62737c;
|
||||
font-size: 12px;
|
||||
background: #f7f9fa;
|
||||
}
|
||||
|
||||
.calendar-hour-cell {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.calendar-dialog-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 20px;
|
||||
background: rgba(15, 23, 32, 0.38);
|
||||
}
|
||||
|
||||
.calendar-dialog {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
width: min(460px, 100%);
|
||||
padding: 18px;
|
||||
border: 1px solid #cbd5d9;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 18px 50px rgba(21, 33, 42, 0.22);
|
||||
}
|
||||
|
||||
.calendar-dialog header,
|
||||
.calendar-dialog footer,
|
||||
.calendar-dialog-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.calendar-dialog h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.calendar-dialog label {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
font-size: 13px;
|
||||
color: #4b5b63;
|
||||
}
|
||||
|
||||
.calendar-dialog input {
|
||||
min-height: 36px;
|
||||
border: 1px solid #c9d3d8;
|
||||
padding: 0 9px;
|
||||
color: #172026;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.calendar-dialog-row label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.calendar-checkbox {
|
||||
display: flex !important;
|
||||
grid-template-columns: none !important;
|
||||
align-items: center;
|
||||
gap: 8px !important;
|
||||
}
|
||||
|
||||
.calendar-checkbox input {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.calendar-page {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.calendar-toolbar {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.calendar-toolbar-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.calendar-workspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.calendar-sidebar {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid #dbe2e6;
|
||||
padding: 0 0 14px;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user