350 lines
16 KiB
TypeScript
350 lines
16 KiB
TypeScript
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
|
import { Bell, CalendarCheck, Check, Clock, Plus, RefreshCw, Send, XCircle } from "lucide-react";
|
|
import { Button, hasScope, type ApiSettings, type AuthInfo } from "@govoplan/core-webui";
|
|
import {
|
|
closeSchedulingRequest,
|
|
createSchedulingCalendarEvent,
|
|
createSchedulingHolds,
|
|
createSchedulingNotifications,
|
|
createSchedulingRequest,
|
|
decideSchedulingRequest,
|
|
evaluateSchedulingFreeBusy,
|
|
listSchedulingNotifications,
|
|
listSchedulingRequests,
|
|
openSchedulingRequest,
|
|
schedulingSummary,
|
|
type SchedulingNotification,
|
|
type SchedulingRequest,
|
|
type SchedulingSummaryResponse
|
|
} from "../../api/scheduling";
|
|
|
|
type SlotDraft = { label: string; start_at: string; end_at: string };
|
|
type ParticipantDraft = { display_name: string; email: string; required: boolean };
|
|
|
|
const now = new Date();
|
|
const defaultStart = new Date(now.getTime() + 24 * 60 * 60 * 1000);
|
|
defaultStart.setMinutes(0, 0, 0);
|
|
const defaultEnd = new Date(defaultStart.getTime() + 60 * 60 * 1000);
|
|
|
|
function localValue(date: Date): string {
|
|
const pad = (value: number) => value.toString().padStart(2, "0");
|
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
}
|
|
|
|
function isoFromLocal(value: string): string {
|
|
return new Date(value).toISOString();
|
|
}
|
|
|
|
export default function SchedulingPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
|
|
const [requests, setRequests] = useState<SchedulingRequest[]>([]);
|
|
const [selectedId, setSelectedId] = useState("");
|
|
const [summary, setSummary] = useState<SchedulingSummaryResponse | null>(null);
|
|
const [notifications, setNotifications] = useState<SchedulingNotification[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState("");
|
|
const [title, setTitle] = useState("Scheduling poll");
|
|
const [location, setLocation] = useState("");
|
|
const [calendarId, setCalendarId] = useState("");
|
|
const [calendarEnabled, setCalendarEnabled] = useState(false);
|
|
const [slots, setSlots] = useState<SlotDraft[]>([
|
|
{ label: "Option 1", start_at: localValue(defaultStart), end_at: localValue(defaultEnd) }
|
|
]);
|
|
const [participants, setParticipants] = useState<ParticipantDraft[]>([{ display_name: "", email: "", required: true }]);
|
|
|
|
const canWrite = hasScope(auth, "scheduling:schedule:write");
|
|
const selected = useMemo(() => requests.find((item) => item.id === selectedId) || requests[0] || null, [requests, selectedId]);
|
|
const optionResultById = useMemo(() => {
|
|
const map = new Map<string, SchedulingSummaryResponse["poll_summary"]["option_results"][number]>();
|
|
for (const result of summary?.poll_summary.option_results || []) map.set(result.option_id, result);
|
|
return map;
|
|
}, [summary]);
|
|
|
|
useEffect(() => {
|
|
void loadRequests();
|
|
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
|
|
|
useEffect(() => {
|
|
if (!selected?.id) {
|
|
setSummary(null);
|
|
setNotifications([]);
|
|
return;
|
|
}
|
|
void loadDetails(selected.id);
|
|
}, [selected?.id]);
|
|
|
|
async function loadRequests() {
|
|
setLoading(true);
|
|
setError("");
|
|
try {
|
|
const response = await listSchedulingRequests(settings);
|
|
setRequests(response.requests);
|
|
if (!selectedId && response.requests[0]) setSelectedId(response.requests[0].id);
|
|
} catch (err) {
|
|
setError(errorMessage(err));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function loadDetails(requestId: string) {
|
|
try {
|
|
const [summaryResponse, notificationResponse] = await Promise.all([
|
|
schedulingSummary(settings, requestId).catch(() => null),
|
|
listSchedulingNotifications(settings, requestId).catch(() => ({ notifications: [] }))
|
|
]);
|
|
setSummary(summaryResponse);
|
|
setNotifications(notificationResponse.notifications);
|
|
} catch (err) {
|
|
setError(errorMessage(err));
|
|
}
|
|
}
|
|
|
|
async function createRequest(event: FormEvent) {
|
|
event.preventDefault();
|
|
if (!canWrite) return;
|
|
setSaving(true);
|
|
setError("");
|
|
try {
|
|
const request = await createSchedulingRequest(settings, {
|
|
title,
|
|
location: location || null,
|
|
status: "collecting",
|
|
calendar: {
|
|
enabled: calendarEnabled,
|
|
calendar_id: calendarId || null,
|
|
freebusy_enabled: calendarEnabled,
|
|
tentative_holds_enabled: calendarEnabled,
|
|
create_event_on_decision: calendarEnabled
|
|
},
|
|
slots: slots.map((slot) => ({
|
|
label: slot.label || null,
|
|
start_at: isoFromLocal(slot.start_at),
|
|
end_at: isoFromLocal(slot.end_at),
|
|
location: location || null
|
|
})),
|
|
participants: participants
|
|
.filter((participant) => participant.email || participant.display_name)
|
|
.map((participant) => ({ ...participant, participant_type: "external" }))
|
|
});
|
|
setRequests((items) => [request, ...items]);
|
|
setSelectedId(request.id);
|
|
} catch (err) {
|
|
setError(errorMessage(err));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
async function runAction(action: () => Promise<{ request?: SchedulingRequest } | SchedulingSummaryResponse | unknown>) {
|
|
if (!selected || !canWrite) return;
|
|
setSaving(true);
|
|
setError("");
|
|
try {
|
|
const response = await action();
|
|
const updated = isRequestEnvelope(response) ? response.request : null;
|
|
if (updated) {
|
|
setRequests((items) => items.map((item) => (item.id === updated.id ? updated : item)));
|
|
}
|
|
await loadDetails(selected.id);
|
|
} catch (err) {
|
|
setError(errorMessage(err));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<main className="scheduling-page">
|
|
<div className="scheduling-shell">
|
|
<aside className="scheduling-sidebar">
|
|
<div className="scheduling-sidebar-bar">
|
|
<strong>Scheduling</strong>
|
|
<Button className="scheduling-icon-button" onClick={() => void loadRequests()} title="Refresh">
|
|
<RefreshCw size={16} />
|
|
</Button>
|
|
</div>
|
|
<div className="scheduling-list">
|
|
{loading ? <div className="scheduling-note">Loading</div> : null}
|
|
{requests.map((request) => (
|
|
<button
|
|
key={request.id}
|
|
className={`scheduling-list-item ${selected?.id === request.id ? "is-selected" : ""}`}
|
|
type="button"
|
|
onClick={() => setSelectedId(request.id)}
|
|
>
|
|
<span>{request.title}</span>
|
|
<small>{request.status}</small>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</aside>
|
|
<section className="scheduling-workspace">
|
|
<div className="scheduling-topbar">
|
|
<div className="scheduling-title-line">
|
|
<CalendarCheck size={18} />
|
|
<strong>{selected?.title || "Scheduling"}</strong>
|
|
</div>
|
|
<div className="scheduling-actions">
|
|
<Button disabled={!selected || saving || !canWrite} onClick={() => void runAction(() => openSchedulingRequest(settings, selected!.id))}>
|
|
<Send size={16} /> Open
|
|
</Button>
|
|
<Button disabled={!selected || saving || !canWrite} onClick={() => void runAction(() => closeSchedulingRequest(settings, selected!.id))}>
|
|
<XCircle size={16} /> Close
|
|
</Button>
|
|
<Button disabled={!selected || saving || !canWrite} onClick={() => void runAction(() => evaluateSchedulingFreeBusy(settings, selected!.id))}>
|
|
<RefreshCw size={16} /> Free/busy
|
|
</Button>
|
|
<Button disabled={!selected || saving || !canWrite} onClick={() => void runAction(() => createSchedulingHolds(settings, selected!.id))}>
|
|
<Clock size={16} /> Holds
|
|
</Button>
|
|
<Button disabled={!selected || saving || !canWrite} onClick={() => void runAction(() => createSchedulingCalendarEvent(settings, selected!.id))}>
|
|
<CalendarCheck size={16} /> Event
|
|
</Button>
|
|
<Button disabled={!selected || saving || !canWrite} onClick={() => void runAction(() => createSchedulingNotifications(settings, selected!.id, "reminder"))}>
|
|
<Bell size={16} /> Reminder
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{error ? <div className="scheduling-alert">{error}</div> : null}
|
|
|
|
<div className="scheduling-content">
|
|
<form className="scheduling-create-panel" onSubmit={(event) => void createRequest(event)}>
|
|
<div className="scheduling-panel-heading">
|
|
<Plus size={16} />
|
|
<strong>New poll</strong>
|
|
</div>
|
|
<label>
|
|
<span>Title</span>
|
|
<input value={title} onChange={(event) => setTitle(event.target.value)} />
|
|
</label>
|
|
<label>
|
|
<span>Location</span>
|
|
<input value={location} onChange={(event) => setLocation(event.target.value)} />
|
|
</label>
|
|
<label className="scheduling-checkbox">
|
|
<input checked={calendarEnabled} type="checkbox" onChange={(event) => setCalendarEnabled(event.target.checked)} />
|
|
<span>Calendar</span>
|
|
</label>
|
|
{calendarEnabled ? (
|
|
<label>
|
|
<span>Calendar ID</span>
|
|
<input value={calendarId} onChange={(event) => setCalendarId(event.target.value)} />
|
|
</label>
|
|
) : null}
|
|
<div className="scheduling-form-section">
|
|
<div className="scheduling-mini-heading">
|
|
<span>Slots</span>
|
|
<Button type="button" onClick={() => setSlots((items) => [...items, { label: `Option ${items.length + 1}`, start_at: localValue(defaultStart), end_at: localValue(defaultEnd) }])}>
|
|
<Plus size={14} />
|
|
</Button>
|
|
</div>
|
|
{slots.map((slot, index) => (
|
|
<div className="scheduling-slot-draft" key={index}>
|
|
<input value={slot.label} onChange={(event) => updateSlot(index, "label", event.target.value)} />
|
|
<input type="datetime-local" value={slot.start_at} onChange={(event) => updateSlot(index, "start_at", event.target.value)} />
|
|
<input type="datetime-local" value={slot.end_at} onChange={(event) => updateSlot(index, "end_at", event.target.value)} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="scheduling-form-section">
|
|
<div className="scheduling-mini-heading">
|
|
<span>Participants</span>
|
|
<Button type="button" onClick={() => setParticipants((items) => [...items, { display_name: "", email: "", required: true }])}>
|
|
<Plus size={14} />
|
|
</Button>
|
|
</div>
|
|
{participants.map((participant, index) => (
|
|
<div className="scheduling-participant-draft" key={index}>
|
|
<input placeholder="Name" value={participant.display_name} onChange={(event) => updateParticipant(index, "display_name", event.target.value)} />
|
|
<input placeholder="Email" value={participant.email} onChange={(event) => updateParticipant(index, "email", event.target.value)} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
<Button disabled={saving || !canWrite} type="submit" variant="primary">
|
|
<Plus size={16} /> Create
|
|
</Button>
|
|
</form>
|
|
|
|
<div className="scheduling-detail">
|
|
{selected ? (
|
|
<>
|
|
<div className="scheduling-status-row">
|
|
<span className={`scheduling-status scheduling-status-${selected.status}`}>{selected.status}</span>
|
|
<span>{selected.participants.length} participants</span>
|
|
<span>{summary?.poll_summary.response_count || 0} responses</span>
|
|
</div>
|
|
<div className="scheduling-slot-table">
|
|
{selected.slots.map((slot) => {
|
|
const result = slot.poll_option_id ? optionResultById.get(slot.poll_option_id) : null;
|
|
return (
|
|
<div className="scheduling-slot-row" key={slot.id}>
|
|
<div>
|
|
<strong>{slot.label}</strong>
|
|
<small>{formatRange(slot.start_at, slot.end_at)}</small>
|
|
</div>
|
|
<span className={`scheduling-freebusy ${slot.freebusy_status || "unknown"}`}>{slot.freebusy_status || "unchecked"}</span>
|
|
<span>{result?.values.available || 0} yes</span>
|
|
<span>{result?.values.maybe || 0} maybe</span>
|
|
<span>{result?.values.unavailable || 0} no</span>
|
|
<Button disabled={saving || !canWrite} onClick={() => void runAction(() => decideSchedulingRequest(settings, selected.id, { slot_id: slot.id, handoff_to_calendar: selected.create_calendar_event_on_decision }))}>
|
|
<Check size={16} /> Decide
|
|
</Button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className="scheduling-columns">
|
|
<div>
|
|
<div className="scheduling-panel-heading"><strong>Participants</strong></div>
|
|
{selected.participants.map((participant) => (
|
|
<div className="scheduling-compact-row" key={participant.id}>
|
|
<span>{participant.display_name || participant.email || participant.id}</span>
|
|
<small>{participant.status}</small>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div>
|
|
<div className="scheduling-panel-heading"><strong>Outbox</strong></div>
|
|
{notifications.map((notification) => (
|
|
<div className="scheduling-compact-row" key={notification.id}>
|
|
<span>{notification.event_kind}</span>
|
|
<small>{notification.status}</small>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="scheduling-empty">No scheduling request</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</main>
|
|
);
|
|
|
|
function updateSlot(index: number, field: keyof SlotDraft, value: string) {
|
|
setSlots((items) => items.map((item, itemIndex) => (itemIndex === index ? { ...item, [field]: value } : item)));
|
|
}
|
|
|
|
function updateParticipant(index: number, field: keyof ParticipantDraft, value: string) {
|
|
setParticipants((items) => items.map((item, itemIndex) => (itemIndex === index ? { ...item, [field]: value } : item)));
|
|
}
|
|
}
|
|
|
|
function isRequestEnvelope(value: unknown): value is { request: SchedulingRequest } {
|
|
return Boolean(value && typeof value === "object" && "request" in value);
|
|
}
|
|
|
|
function formatRange(start: string, end: string): string {
|
|
const formatter = new Intl.DateTimeFormat(undefined, { dateStyle: "short", timeStyle: "short" });
|
|
return `${formatter.format(new Date(start))} - ${formatter.format(new Date(end))}`;
|
|
}
|
|
|
|
function errorMessage(error: unknown): string {
|
|
return error instanceof Error ? error.message : "Request failed";
|
|
}
|