Add scheduling calendar integration and WebUI
This commit is contained in:
31
webui/package.json
Normal file
31
webui/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@govoplan/scheduling-webui",
|
||||
"version": "0.1.8",
|
||||
"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/scheduling.css": "./src/styles/scheduling.css"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"lucide-react": "^1.23.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
|
||||
}
|
||||
}
|
||||
}
|
||||
210
webui/src/api/scheduling.ts
Normal file
210
webui/src/api/scheduling.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { apiFetch, type ApiSettings } from "@govoplan/core-webui";
|
||||
|
||||
export type SchedulingStatus = "draft" | "collecting" | "closed" | "decided" | "handed_off" | "cancelled" | "archived";
|
||||
|
||||
export type SchedulingCandidateSlot = {
|
||||
id: string;
|
||||
poll_option_id?: string | null;
|
||||
label: string;
|
||||
description?: string | null;
|
||||
start_at: string;
|
||||
end_at: string;
|
||||
timezone: string;
|
||||
location?: string | null;
|
||||
position: number;
|
||||
freebusy_checked_at?: string | null;
|
||||
freebusy_status?: string | null;
|
||||
freebusy_conflicts: Record<string, unknown>[];
|
||||
tentative_hold_event_id?: string | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type SchedulingParticipant = {
|
||||
id: string;
|
||||
respondent_id?: string | null;
|
||||
display_name?: string | null;
|
||||
email?: string | null;
|
||||
participant_type: string;
|
||||
required: boolean;
|
||||
status: string;
|
||||
poll_invitation_id?: string | null;
|
||||
invitation_token?: string | null;
|
||||
last_invited_at?: string | null;
|
||||
responded_at?: string | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type SchedulingRequest = {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
location?: string | null;
|
||||
timezone: string;
|
||||
status: SchedulingStatus;
|
||||
poll_id?: string | null;
|
||||
selected_slot_id?: string | null;
|
||||
organizer_user_id?: string | null;
|
||||
deadline_at?: string | null;
|
||||
allow_external_participants: boolean;
|
||||
allow_participant_updates: boolean;
|
||||
result_visibility: string;
|
||||
calendar_integration_enabled: boolean;
|
||||
calendar_id?: string | null;
|
||||
calendar_freebusy_enabled: boolean;
|
||||
calendar_hold_enabled: boolean;
|
||||
create_calendar_event_on_decision: boolean;
|
||||
calendar_event_id?: string | null;
|
||||
handed_off_at?: string | null;
|
||||
cancelled_at?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
slots: SchedulingCandidateSlot[];
|
||||
participants: SchedulingParticipant[];
|
||||
};
|
||||
|
||||
export type SchedulingRequestListResponse = { requests: SchedulingRequest[] };
|
||||
export type SchedulingStatusResponse = { request: SchedulingRequest };
|
||||
|
||||
export type SchedulingCandidateSlotPayload = {
|
||||
label?: string | null;
|
||||
description?: string | null;
|
||||
start_at: string;
|
||||
end_at: string;
|
||||
timezone?: string | null;
|
||||
location?: string | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type SchedulingParticipantPayload = {
|
||||
respondent_id?: string | null;
|
||||
display_name?: string | null;
|
||||
email?: string | null;
|
||||
participant_type?: "internal" | "external" | "resource";
|
||||
required?: boolean;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type SchedulingRequestCreatePayload = {
|
||||
title: string;
|
||||
description?: string | null;
|
||||
location?: string | null;
|
||||
timezone?: string;
|
||||
status?: "draft" | "collecting";
|
||||
deadline_at?: string | null;
|
||||
allow_external_participants?: boolean;
|
||||
allow_participant_updates?: boolean;
|
||||
result_visibility?: "organizer" | "after_response" | "after_close" | "public";
|
||||
calendar?: {
|
||||
enabled?: boolean;
|
||||
calendar_id?: string | null;
|
||||
freebusy_enabled?: boolean;
|
||||
tentative_holds_enabled?: boolean;
|
||||
create_event_on_decision?: boolean;
|
||||
};
|
||||
slots: SchedulingCandidateSlotPayload[];
|
||||
participants?: SchedulingParticipantPayload[];
|
||||
create_participant_invitations?: boolean;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type SchedulingDecisionPayload = {
|
||||
slot_id?: string | null;
|
||||
poll_option_id?: string | null;
|
||||
handoff_to_calendar?: boolean | null;
|
||||
};
|
||||
|
||||
export type SchedulingCalendarActionResponse = {
|
||||
request: SchedulingRequest;
|
||||
created_event_ids: string[];
|
||||
updated_slot_ids: string[];
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
export type SchedulingNotification = {
|
||||
id: string;
|
||||
request_id: string;
|
||||
participant_id?: string | null;
|
||||
event_kind: string;
|
||||
channel: string;
|
||||
recipient?: string | null;
|
||||
status: string;
|
||||
payload: Record<string, unknown>;
|
||||
error?: string | null;
|
||||
sent_at?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type SchedulingNotificationListResponse = { notifications: SchedulingNotification[] };
|
||||
|
||||
export type SchedulingPollOptionResult = {
|
||||
option_id: string;
|
||||
option_key: string;
|
||||
label: string;
|
||||
count: number;
|
||||
score: number;
|
||||
values: Record<string, number>;
|
||||
};
|
||||
|
||||
export type SchedulingSummaryResponse = {
|
||||
request: SchedulingRequest;
|
||||
poll_summary: {
|
||||
poll_id: string;
|
||||
kind: string;
|
||||
status: string;
|
||||
response_count: number;
|
||||
option_results: SchedulingPollOptionResult[];
|
||||
leading_option_ids: string[];
|
||||
};
|
||||
};
|
||||
|
||||
const json = (payload: unknown) => ({ method: "POST", body: JSON.stringify(payload ?? {}) });
|
||||
|
||||
export function listSchedulingRequests(settings: ApiSettings, status?: string): Promise<SchedulingRequestListResponse> {
|
||||
const query = status ? `?status=${encodeURIComponent(status)}` : "";
|
||||
return apiFetch<SchedulingRequestListResponse>(settings, `/api/v1/scheduling/requests${query}`);
|
||||
}
|
||||
|
||||
export function createSchedulingRequest(settings: ApiSettings, payload: SchedulingRequestCreatePayload): Promise<SchedulingRequest> {
|
||||
return apiFetch<SchedulingRequest>(settings, "/api/v1/scheduling/requests", json(payload));
|
||||
}
|
||||
|
||||
export function openSchedulingRequest(settings: ApiSettings, requestId: string): Promise<SchedulingStatusResponse> {
|
||||
return apiFetch<SchedulingStatusResponse>(settings, `/api/v1/scheduling/requests/${requestId}/open`, json({}));
|
||||
}
|
||||
|
||||
export function closeSchedulingRequest(settings: ApiSettings, requestId: string): Promise<SchedulingStatusResponse> {
|
||||
return apiFetch<SchedulingStatusResponse>(settings, `/api/v1/scheduling/requests/${requestId}/close`, json({}));
|
||||
}
|
||||
|
||||
export function decideSchedulingRequest(settings: ApiSettings, requestId: string, payload: SchedulingDecisionPayload): Promise<SchedulingStatusResponse> {
|
||||
return apiFetch<SchedulingStatusResponse>(settings, `/api/v1/scheduling/requests/${requestId}/decide`, json(payload));
|
||||
}
|
||||
|
||||
export function evaluateSchedulingFreeBusy(settings: ApiSettings, requestId: string): Promise<SchedulingCalendarActionResponse> {
|
||||
return apiFetch<SchedulingCalendarActionResponse>(settings, `/api/v1/scheduling/requests/${requestId}/calendar/freebusy`, json({}));
|
||||
}
|
||||
|
||||
export function createSchedulingHolds(settings: ApiSettings, requestId: string): Promise<SchedulingCalendarActionResponse> {
|
||||
return apiFetch<SchedulingCalendarActionResponse>(settings, `/api/v1/scheduling/requests/${requestId}/calendar/holds`, json({}));
|
||||
}
|
||||
|
||||
export function createSchedulingCalendarEvent(settings: ApiSettings, requestId: string): Promise<SchedulingCalendarActionResponse> {
|
||||
return apiFetch<SchedulingCalendarActionResponse>(settings, `/api/v1/scheduling/requests/${requestId}/calendar/event`, json({}));
|
||||
}
|
||||
|
||||
export function schedulingSummary(settings: ApiSettings, requestId: string): Promise<SchedulingSummaryResponse> {
|
||||
return apiFetch<SchedulingSummaryResponse>(settings, `/api/v1/scheduling/requests/${requestId}/summary`);
|
||||
}
|
||||
|
||||
export function createSchedulingNotifications(settings: ApiSettings, requestId: string, eventKind: string): Promise<SchedulingNotificationListResponse> {
|
||||
return apiFetch<SchedulingNotificationListResponse>(settings, `/api/v1/scheduling/requests/${requestId}/notifications`, json({ event_kind: eventKind }));
|
||||
}
|
||||
|
||||
export function listSchedulingNotifications(settings: ApiSettings, requestId?: string): Promise<SchedulingNotificationListResponse> {
|
||||
const query = requestId ? `?request_id=${encodeURIComponent(requestId)}` : "";
|
||||
return apiFetch<SchedulingNotificationListResponse>(settings, `/api/v1/scheduling/notifications${query}`);
|
||||
}
|
||||
349
webui/src/features/scheduling/SchedulingPage.tsx
Normal file
349
webui/src/features/scheduling/SchedulingPage.tsx
Normal file
@@ -0,0 +1,349 @@
|
||||
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";
|
||||
}
|
||||
4
webui/src/i18n/generatedTranslations.ts
Normal file
4
webui/src/i18n/generatedTranslations.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const generatedTranslations = {
|
||||
en: {},
|
||||
de: {}
|
||||
};
|
||||
2
webui/src/index.ts
Normal file
2
webui/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { schedulingModule as default, schedulingModule } from "./module";
|
||||
export * from "./api/scheduling";
|
||||
23
webui/src/module.ts
Normal file
23
webui/src/module.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { createElement, lazy } from "react";
|
||||
import type { PlatformWebModule } from "@govoplan/core-webui";
|
||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||
import "./styles/scheduling.css";
|
||||
|
||||
const SchedulingPage = lazy(() => import("./features/scheduling/SchedulingPage"));
|
||||
|
||||
const scheduleRead = ["scheduling:schedule:read"];
|
||||
|
||||
export const schedulingModule: PlatformWebModule = {
|
||||
id: "scheduling",
|
||||
label: "Scheduling",
|
||||
version: "1.0.0",
|
||||
dependencies: ["poll"],
|
||||
optionalDependencies: ["calendar", "mail", "notifications", "workflow", "appointments"],
|
||||
translations: generatedTranslations,
|
||||
navItems: [{ to: "/scheduling", label: "Scheduling", iconName: "calendar", anyOf: scheduleRead, order: 56 }],
|
||||
routes: [
|
||||
{ path: "/scheduling", anyOf: scheduleRead, order: 56, render: ({ settings, auth }) => createElement(SchedulingPage, { settings, auth }) }
|
||||
]
|
||||
};
|
||||
|
||||
export default schedulingModule;
|
||||
308
webui/src/styles/scheduling.css
Normal file
308
webui/src/styles/scheduling.css
Normal file
@@ -0,0 +1,308 @@
|
||||
.scheduling-page {
|
||||
box-sizing: border-box;
|
||||
height: calc(100vh - 115px);
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.scheduling-page *,
|
||||
.scheduling-page *::before,
|
||||
.scheduling-page *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.scheduling-shell {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(250px, 310px) minmax(0, 1fr);
|
||||
border: var(--border-line);
|
||||
background: var(--panel);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scheduling-sidebar {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: var(--border-line);
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.scheduling-sidebar-bar,
|
||||
.scheduling-topbar,
|
||||
.scheduling-panel-heading,
|
||||
.scheduling-mini-heading,
|
||||
.scheduling-status-row,
|
||||
.scheduling-actions,
|
||||
.scheduling-title-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.scheduling-sidebar-bar {
|
||||
justify-content: space-between;
|
||||
min-height: 54px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: var(--border-line);
|
||||
background: var(--panel-header);
|
||||
}
|
||||
|
||||
.scheduling-icon-button.btn {
|
||||
width: 34px;
|
||||
min-width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.scheduling-list {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.scheduling-list-item {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-height: 40px;
|
||||
margin: 0 0 4px;
|
||||
padding: 8px 9px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.scheduling-list-item:hover,
|
||||
.scheduling-list-item.is-selected {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.scheduling-list-item span,
|
||||
.scheduling-slot-row strong,
|
||||
.scheduling-compact-row span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.scheduling-list-item small,
|
||||
.scheduling-slot-row small,
|
||||
.scheduling-compact-row small,
|
||||
.scheduling-note {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.scheduling-workspace {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scheduling-topbar {
|
||||
justify-content: space-between;
|
||||
min-height: 54px;
|
||||
padding: 9px 12px;
|
||||
border-bottom: var(--border-line);
|
||||
background: var(--panel-header);
|
||||
}
|
||||
|
||||
.scheduling-title-line {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.scheduling-actions {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.scheduling-actions .btn,
|
||||
.scheduling-create-panel .btn,
|
||||
.scheduling-slot-row .btn {
|
||||
min-height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.scheduling-alert {
|
||||
margin: 8px 12px 0;
|
||||
padding: 8px 10px;
|
||||
border: var(--border-line);
|
||||
border-color: var(--danger);
|
||||
border-radius: 6px;
|
||||
color: var(--danger);
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
|
||||
.scheduling-content {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scheduling-create-panel,
|
||||
.scheduling-detail {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.scheduling-create-panel {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 10px;
|
||||
border-right: var(--border-line);
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.scheduling-create-panel label,
|
||||
.scheduling-form-section {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.scheduling-create-panel label span,
|
||||
.scheduling-mini-heading span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.scheduling-create-panel input {
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
padding: 6px 8px;
|
||||
border: var(--border-line);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
background: var(--input-bg);
|
||||
}
|
||||
|
||||
.scheduling-checkbox {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.scheduling-slot-draft,
|
||||
.scheduling-participant-draft {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.scheduling-slot-draft {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.scheduling-status-row {
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.scheduling-status {
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
color: var(--text-strong);
|
||||
background: var(--panel-soft);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.scheduling-status-collecting,
|
||||
.scheduling-status-handed_off {
|
||||
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
}
|
||||
|
||||
.scheduling-slot-table {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.scheduling-slot-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 1fr) auto auto auto auto auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-height: 46px;
|
||||
padding: 8px;
|
||||
border-bottom: var(--border-line);
|
||||
}
|
||||
|
||||
.scheduling-slot-row:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.scheduling-freebusy {
|
||||
min-width: 74px;
|
||||
padding: 3px 7px;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.scheduling-freebusy.free {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.scheduling-freebusy.busy,
|
||||
.scheduling-freebusy.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.scheduling-columns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.scheduling-compact-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
min-height: 34px;
|
||||
align-items: center;
|
||||
border-bottom: var(--border-line);
|
||||
}
|
||||
|
||||
.scheduling-empty {
|
||||
padding: 20px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.scheduling-shell,
|
||||
.scheduling-content,
|
||||
.scheduling-columns {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.scheduling-sidebar,
|
||||
.scheduling-create-panel {
|
||||
border-right: 0;
|
||||
border-bottom: var(--border-line);
|
||||
}
|
||||
|
||||
.scheduling-slot-row {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
}
|
||||
13
webui/src/vite-env.d.ts
vendored
Normal file
13
webui/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL?: string;
|
||||
readonly VITE_CSRF_COOKIE_NAME?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
declare module "virtual:govoplan-installed-modules" {
|
||||
const modules: any[];
|
||||
export default modules;
|
||||
}
|
||||
31
webui/tsconfig.json
Normal file
31
webui/tsconfig.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2020"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"preserveSymlinks": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@govoplan/core-webui": ["../../govoplan-core/webui/src/index.ts"],
|
||||
"@govoplan/core-webui/*": ["../../govoplan-core/webui/src/*"],
|
||||
"lucide-react": ["../../govoplan-core/webui/node_modules/lucide-react/dist/lucide-react.d.ts"],
|
||||
"react": ["../../govoplan-core/webui/node_modules/@types/react/index.d.ts"],
|
||||
"react/jsx-runtime": ["../../govoplan-core/webui/node_modules/@types/react/jsx-runtime.d.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user