Files
govoplan-scheduling/webui/src/features/scheduling/SchedulingPage.tsx

1877 lines
77 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import { useSearchParams } from "react-router-dom";
import {
Bell,
CalendarCheck,
Check,
Clock,
Pencil,
Plus,
RefreshCw,
Save,
Send,
XCircle
} from "lucide-react";
import {
Button,
Card,
DataGrid,
DataGridEmptyAction,
DataGridRowActions,
DateTimeField,
DismissibleAlert,
FormField,
MetricCard,
IconButton,
PageTitle,
PasswordField,
formatDateTime,
SelectionList,
SelectionListItem,
TableActionGroup,
ToggleSwitch,
StatusBadge,
hasScope,
i18nMessage,
usePlatformLanguage,
usePlatformUiCapability,
useUnsavedChanges,
useUnsavedDraftGuard,
type ApiSettings,
type AuthInfo,
type CalendarPickerUiCapability,
type DataGridColumn,
type FormatDateTimeOptions
} from "@govoplan/core-webui";
import {
closeSchedulingRequest,
createSchedulingCalendarEvent,
createSchedulingHolds,
createSchedulingNotifications,
createSchedulingRequest,
decideSchedulingRequest,
evaluateSchedulingFreeBusy,
getSchedulingAvailabilityResponse,
listSchedulingNotifications,
listSchedulingRequests,
openSchedulingRequest,
schedulingSummary,
submitSchedulingAvailability,
updateSchedulingRequest,
type SchedulingCandidateSlot,
type SchedulingAvailabilityValue,
type SchedulingNotification,
type SchedulingParticipant,
type SchedulingPollOptionResult,
type SchedulingRequest,
type SchedulingSummaryResponse
} from "../../api/scheduling";
import {
applySchedulingAvailabilityChoice,
groupSchedulingRequests,
schedulingParticipantForActor,
schedulingRelevantTimestamp,
schedulingRequestIsOwned,
schedulingSortPhase,
type SchedulingActor,
type SchedulingRequestGroups
} from "./schedulingViewModel";
type EditorMode = "create" | "edit" | null;
type SlotDraft = {
draftId: string;
sourceId?: string;
revision?: string;
label: string;
description?: string | null;
start_at: string;
end_at: string;
timezone: string;
metadata?: Record<string, unknown>;
};
type ParticipantDraft = {
draftId: string;
sourceId?: string;
respondent_id?: string | null;
display_name: string;
email: string;
participant_type: "internal" | "external" | "resource";
required: boolean;
metadata?: Record<string, unknown>;
identityLocked?: boolean;
};
const I18N = {
actions: "i18n:govoplan-core.actions.c3cd636a",
add: "i18n:govoplan-core.add.61cc55aa",
addRequest: "i18n:govoplan-scheduling.add_scheduling_request.3c71be15",
addParticipant: "i18n:govoplan-scheduling.add_participant.6cff957d",
addSlot: "i18n:govoplan-scheduling.add_slot.9fcff3ed",
answered: "i18n:govoplan-scheduling.answered.e0aafffa",
available: "i18n:govoplan-scheduling.available.7c62a142",
awaitingResponse: "i18n:govoplan-scheduling.awaiting_response.ee646ea9",
basicInformation: "i18n:govoplan-scheduling.basic_information.d8bc7383",
busy: "i18n:govoplan-scheduling.busy.592a9d16",
calendar: "i18n:govoplan-scheduling.calendar.adab5090",
calendarCoordination: "i18n:govoplan-scheduling.calendar_coordination.291ab00a",
configuredCalendar: "i18n:govoplan-scheduling.configured_calendar.e2e8ebd5",
calendarDescription: "i18n:govoplan-scheduling.use_a_calendar_for_availability_checks_tentative_holds_a.20ccc1fa",
finalEventLabel: "i18n:govoplan-scheduling.create_calendar_event.0b87cfcf",
calendarIntegration: "i18n:govoplan-scheduling.calendar_integration.181ad18b",
calendarUnavailable: "i18n:govoplan-scheduling.calendar_integration_requires_the_calendar_module_plus_c.f892cb1e",
candidateAvailability: "i18n:govoplan-scheduling.candidate_availability.9541c4b5",
candidateSlots: "i18n:govoplan-scheduling.candidate_slots.c414946b",
checkFreeBusy: "i18n:govoplan-scheduling.check_free_busy.e9700e00",
chooseAvailability: "i18n:govoplan-scheduling.choose_availability.ac95b8f6",
closePoll: "i18n:govoplan-scheduling.close_poll.a6a18916",
closed: "i18n:govoplan-scheduling.closed.88d86b77",
description: "i18n:govoplan-scheduling.description.55f8ebc8",
determined: "i18n:govoplan-scheduling.determined.9f23293d",
decideUnavailable: "i18n:govoplan-scheduling.a_slot_can_be_selected_after_the_request_is_closed.f91ec02d",
discard: "i18n:govoplan-scheduling.discard.36fff63c",
discardConfirm: "i18n:govoplan-scheduling.discard_this_unsaved_scheduling_request.4a956be2",
edit: "i18n:govoplan-scheduling.edit_scheduling_request.7e749c19",
invitedIdentityLocked: "i18n:govoplan-scheduling.invited_participant_identity_is_locked_remove_and_add_the_participant_to_change_it.34e1201a",
end: "i18n:govoplan-scheduling.end.a2bb9d34",
generalSettings: "i18n:govoplan-scheduling.participation_settings.8dc6f62c",
free: "i18n:govoplan-scheduling.free.75f52718",
holds: "i18n:govoplan-scheduling.create_tentative_holds.51c4744e",
invitedRequests: "i18n:govoplan-scheduling.scheduling_requests_for_me.1d521aba",
loading: "i18n:govoplan-scheduling.loading_scheduling_requests.f42be95d",
location: "i18n:govoplan-scheduling.location.d219c681",
allowComments: "i18n:govoplan-scheduling.allow_comments.d63202a6",
allowCommentsHelp: "i18n:govoplan-scheduling.let_participants_add_a_comment_to_their_response.9dce8d17",
allowMaybe: "i18n:govoplan-scheduling.provide_a_maybe_option.e39da57a",
allowMaybeHelp: "i18n:govoplan-scheduling.add_maybe_between_yes_and_no_for_each_candidate_slot.74dc9db6",
anonymousEmailRequired: "i18n:govoplan-scheduling.require_an_email_address_from_guests.c2289a58",
anonymousEmailRequiredHelp: "i18n:govoplan-scheduling.people_responding_without_an_account_must_provide_an_email.19fd3dc8",
anonymousPassword: "i18n:govoplan-scheduling.guest_password.94545e82",
anonymousPasswordHelp: "i18n:govoplan-scheduling.use_at_least_8_characters_the_password_is_never_displayed.035708a2",
anonymousPasswordProtection: "i18n:govoplan-scheduling.password_protect_guest_access.13d7f08b",
anonymousPasswordProtectionHelp: "i18n:govoplan-scheduling.people_who_are_not_signed_in_must_enter_this_password.82bcc4ce",
comment: "i18n:govoplan-scheduling.comment.d03495b1",
maxParticipants: "i18n:govoplan-scheduling.limit_participants_per_option.1e9aa51d",
maxParticipantsCount: "i18n:govoplan-scheduling.maximum_participants_per_option.5abdfb27",
maxParticipantsHelp: "i18n:govoplan-scheduling.stop_accepting_yes_responses_for_an_option_when_its_limit.79af21db",
managedRequests: "i18n:govoplan-scheduling.managed_scheduling_requests.7a45972f",
maybe: "i18n:govoplan-scheduling.maybe.56dd8d0b",
myRequests: "i18n:govoplan-scheduling.my_scheduling_requests.d28ef235",
name: "i18n:govoplan-scheduling.name.709a2322",
newRequest: "i18n:govoplan-scheduling.new_scheduling_request.2080f675",
noNotifications: "i18n:govoplan-scheduling.no_notifications_have_been_created.c8d43ca3",
notificationsUnavailable: "i18n:govoplan-scheduling.notifications_could_not_be_loaded.f0e1b2c3",
noParticipants: "i18n:govoplan-scheduling.no_participants.73a89101",
noRequest: "i18n:govoplan-scheduling.no_scheduling_request_selected.ac940664",
noRequestsInGroup: "i18n:govoplan-scheduling.no_requests_in_this_group.a63c1b40",
notifications: "i18n:govoplan-scheduling.notifications.753a22b2",
notifyOnAnswers: "i18n:govoplan-scheduling.notify_me_about_each_answer.505749d6",
notifyOnAnswersHelp: "i18n:govoplan-scheduling.create_an_organizer_notification_whenever_a_response_is.253ddca8",
open: "i18n:govoplan-scheduling.open.cf9b7706",
openPoll: "i18n:govoplan-scheduling.open_poll.2beac9a7",
participantEmail: "i18n:govoplan-scheduling.participant_email.2cadfd9e",
participantRosterHidden: "i18n:govoplan-scheduling.participant_names_and_statuses_are_hidden_aggregate_counts_r.d811a69a",
participantPrivacy: "i18n:govoplan-scheduling.participant_privacy.108c470f",
participantRosterVisibility: "i18n:govoplan-scheduling.share_participant_names_and_response_statuses.df0bf9e0",
participantRosterVisibilityHelp: "i18n:govoplan-scheduling.when_enabled_participants_can_see_other_participants_names.3ac78361",
participants: "i18n:govoplan-scheduling.participants.cd56e083",
participation: "i18n:govoplan-scheduling.participation.9ad70cc4",
participationRate: "i18n:govoplan-scheduling.participation_rate.46bc5504",
responses: "i18n:govoplan-scheduling.responses.3427e3ab",
awaiting: "i18n:govoplan-scheduling.awaiting.42aa82e0",
capacity: "i18n:govoplan-scheduling.capacity.d3c375f8",
past: "i18n:govoplan-scheduling.past.405c12fb",
refresh: "i18n:govoplan-scheduling.refresh_requests.0a3ed7a1",
reminder: "i18n:govoplan-scheduling.send_reminder.cf5eb3bf",
requestFailed: "i18n:govoplan-scheduling.request_failed.9fcda32c",
publicPolicyUnavailable: "i18n:govoplan-scheduling.guest_links_are_not_issued_while_the_configured_participation.0794ebf0",
policyLocked: "i18n:govoplan-scheduling.participation_controls_are_locked_after_invitation_links_are_issued.66f5a740",
requests: "i18n:govoplan-scheduling.scheduling_requests.b3c12f4d",
requiresAvailabilityRead: "i18n:govoplan-scheduling.requires_calendar_availability_read_access.b48ed91b",
requiresEventWrite: "i18n:govoplan-scheduling.requires_calendar_event_write_access.887b0763",
responseRecorded: "i18n:govoplan-scheduling.your_response_has_been_recorded.b855088d",
responseReplace: "i18n:govoplan-scheduling.the_response_replaces_your_previous_availability_choices.74c16d53",
resultsUnavailable: "i18n:govoplan-scheduling.response_results_are_not_available_for_this_view.1e82db18",
invitationHelp: "i18n:govoplan-scheduling.you_can_respond_here_or_use_the_invitation_link_you_rece.1a25fd53",
save: "i18n:govoplan-scheduling.save.efc007a3",
saving: "i18n:govoplan-scheduling.saving.56a2285c",
selectCalendar: "i18n:govoplan-scheduling.select_a_calendar.f6af95bb",
selectResponse: "i18n:govoplan-scheduling.choose_availability_for_at_least_one_candidate_slot.28d2111f",
unsavedResponse: "i18n:govoplan-scheduling.save_or_discard_your_unsent_availability_changes_before_leaving.97e10df1",
singleChoice: "i18n:govoplan-scheduling.participants_can_choose_only_one_option.4311f51c",
singleChoiceHelp: "i18n:govoplan-scheduling.each_participant_can_answer_yes_to_at_most_one_candidate.5313a465",
sendResponse: "i18n:govoplan-scheduling.submit_response.a5f0c053",
start: "i18n:govoplan-scheduling.start.952f3754",
statusLabel: "i18n:govoplan-scheduling.status.bae7d5be",
title: "i18n:govoplan-scheduling.title.768e0c1c",
titleRequired: "i18n:govoplan-scheduling.scheduling_request_title_is_required.a27338be",
unchecked: "i18n:govoplan-scheduling.unchecked.1b927dec",
unavailable: "i18n:govoplan-scheduling.unavailable.2c9c1f79",
updateResponse: "i18n:govoplan-scheduling.update_response.346233cf",
updateResponseHelp: "i18n:govoplan-scheduling.responses_may_be_updated_while_this_request_remains_open.4faecbbe",
whatActionsDo: "i18n:govoplan-scheduling.what_do_these_actions_do.9a9aee0e"
} as const;
export default function SchedulingPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
const { translateText } = usePlatformLanguage();
const { requestDiscard, requestNavigation } = useUnsavedChanges();
const [searchParams, setSearchParams] = useSearchParams();
const requestedId = searchParams.get("request_id") ?? "";
const calendarPickerCapability = usePlatformUiCapability<CalendarPickerUiCapability>("calendar.picker");
const CalendarPicker = calendarPickerCapability?.CalendarPicker;
const [requests, setRequests] = useState<SchedulingRequest[]>([]);
const [selectedId, setSelectedId] = useState("");
const [editorMode, setEditorMode] = useState<EditorMode>(null);
const [editingRequestId, setEditingRequestId] = 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 [success, setSuccess] = useState("");
const [draftDirty, setDraftDirty] = useState(false);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [location, setLocation] = useState("");
const [calendarId, setCalendarId] = useState("");
const [calendarEnabled, setCalendarEnabled] = useState(false);
const [slots, setSlots] = useState<SlotDraft[]>(() => initialSlots(translateText));
const [participants, setParticipants] = useState<ParticipantDraft[]>(() => [emptyParticipant()]);
const [participantRosterVisible, setParticipantRosterVisible] = useState(false);
const [notifyOnAnswers, setNotifyOnAnswers] = useState(true);
const [singleChoice, setSingleChoice] = useState(false);
const [participantLimitEnabled, setParticipantLimitEnabled] = useState(false);
const [maxParticipantsPerOption, setMaxParticipantsPerOption] = useState(1);
const [allowMaybe, setAllowMaybe] = useState(true);
const [allowComments, setAllowComments] = useState(false);
const [participantEmailRequired, setParticipantEmailRequired] = useState(false);
const [anonymousPasswordProtectionEnabled, setAnonymousPasswordProtectionEnabled] = useState(false);
const [anonymousPassword, setAnonymousPassword] = useState("");
const [availability, setAvailability] = useState<Record<string, SchedulingAvailabilityValue | "">>({});
const [savedAvailability, setSavedAvailability] = useState<Record<string, SchedulingAvailabilityValue | "">>({});
const [availabilityComment, setAvailabilityComment] = useState("");
const [savedAvailabilityComment, setSavedAvailabilityComment] = useState("");
const [availabilityLoading, setAvailabilityLoading] = useState(false);
const [summaryUnavailable, setSummaryUnavailable] = useState(false);
const [notificationsUnavailable, setNotificationsUnavailable] = useState(false);
const [detailsLoading, setDetailsLoading] = useState(false);
const detailLoadSequence = useRef(0);
const canWrite = hasScope(auth, "scheduling:schedule:write");
const canAdminister = hasScope(auth, "scheduling:schedule:admin");
const canCreateOrWrite = canWrite || canAdminister;
const canRespond = hasScope(auth, "scheduling:availability:write");
const canReadCalendars = hasScope(auth, "calendar:calendar:read");
const canReadAvailability = hasScope(auth, "calendar:availability:read");
const canWriteCalendarEvent = hasScope(auth, "calendar:event:write");
const calendarIntegrationAvailable = Boolean(calendarPickerCapability) && canReadCalendars && canReadAvailability && canWriteCalendarEvent;
const actor = useMemo<SchedulingActor>(() => ({
accountId: auth.principal?.account_id || auth.user.account_id,
userId: auth.user.id,
membershipId: auth.principal?.membership_id,
identityId: auth.principal?.identity_id,
email: auth.principal?.email || auth.user.email
}), [auth.principal, auth.user.account_id, auth.user.email, auth.user.id]);
const groups = useMemo(() => groupSchedulingRequests(requests, actor), [actor, requests]);
const selected = useMemo(
() => requests.find((item) => item.id === selectedId) ?? firstRequest(groups, canAdminister),
[canAdminister, groups, requests, selectedId]
);
const selectedParticipant = useMemo(
() => selected ? schedulingParticipantForActor(selected, actor) : null,
[actor, selected]
);
const canManageSelected = Boolean(
selected && (canAdminister || (canWrite && schedulingRequestIsOwned(selected, actor)))
);
const canEditSelected = Boolean(
selected && canManageSelected && !["decided", "handed_off", "cancelled", "archived"].includes(selected.status)
);
const selectedPhase = selected ? schedulingSortPhase(selected, actor) : null;
const showPlanningCalendarActions = Boolean(
selected &&
selected.calendar_integration_enabled &&
canManageSelected &&
selectedPhase !== "past" &&
["draft", "collecting", "closed"].includes(selected.status) &&
!selected.selected_slot_id
);
const showFinalCalendarAction = Boolean(
selected &&
selected.calendar_integration_enabled &&
canManageSelected &&
selectedPhase !== "past" &&
selected.status === "decided" &&
selected.selected_slot_id &&
!selected.calendar_event_id &&
selected.create_calendar_event_on_decision
);
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);
map.set(result.option_key, result);
}
return map;
}, [summary]);
const responseDirty = useMemo(
() => availabilityComment !== savedAvailabilityComment || !availabilityValuesEqual(availability, savedAvailability),
[availability, availabilityComment, savedAvailability, savedAvailabilityComment]
);
const editorOriginal = editorMode === "edit"
? requests.find((request) => request.id === editingRequestId) ?? null
: null;
const participationPolicyLocked = Boolean(
editorOriginal?.participants.some((participant) => participant.poll_invitation_id)
);
useEffect(() => {
void loadRequests();
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
useEffect(() => {
if (!requestedId || requestedId === selectedId) return;
if (requests.some((request) => request.id === requestedId)) setSelectedId(requestedId);
}, [requestedId, requests, selectedId]);
useEffect(() => {
if (!selected?.id || editorMode) {
detailLoadSequence.current += 1;
setSummary(null);
setNotifications([]);
setSummaryUnavailable(false);
setNotificationsUnavailable(false);
setDetailsLoading(false);
return;
}
setAvailability({});
setSavedAvailability({});
setAvailabilityComment("");
setSavedAvailabilityComment("");
setSuccess("");
void loadDetails(selected.id);
}, [editorMode, selected?.id]);
useEffect(() => {
if (!selected?.id || !selectedParticipant || !canRespond || editorMode || selected.status !== "collecting") {
setAvailabilityLoading(false);
return undefined;
}
let cancelled = false;
setAvailabilityLoading(true);
void getSchedulingAvailabilityResponse(settings, selected.id)
.then((response) => {
if (cancelled) return;
const answers = Object.fromEntries(response.answers.map((answer) => [answer.slot_id, answer.value]));
const comment = response.comment ?? "";
setAvailability(answers);
setSavedAvailability(answers);
setAvailabilityComment(comment);
setSavedAvailabilityComment(comment);
})
.catch((err) => {
if (!cancelled) setError(errorMessage(err, translateText(I18N.requestFailed)));
})
.finally(() => {
if (!cancelled) setAvailabilityLoading(false);
});
return () => {
cancelled = true;
};
}, [
canRespond,
editorMode,
selected?.id,
selected?.status,
selectedParticipant?.id,
settings.accessToken,
settings.apiBaseUrl,
settings.apiKey,
translateText
]);
useUnsavedDraftGuard({
dirty: Boolean(editorMode && draftDirty),
message: I18N.discardConfirm,
onSave: persistDraft,
onDiscard: resetDraft,
enabled: Boolean(editorMode)
});
useUnsavedDraftGuard({
dirty: responseDirty,
message: I18N.unsavedResponse,
onSave: persistAvailability,
onDiscard: resetResponseDraft,
enabled: Boolean(!editorMode && selectedParticipant && selected?.status === "collecting")
});
async function loadRequests(preferredId?: string) {
setLoading(true);
setError("");
try {
const response = await listSchedulingRequests(settings);
setRequests(response.requests);
setSelectedId((current) => {
if (preferredId && response.requests.some((item) => item.id === preferredId)) return preferredId;
if (response.requests.some((item) => item.id === current)) return current;
const requested = response.requests.find((item) => item.id === requestedId);
if (requested) return requested.id;
const nextGroups = groupSchedulingRequests(response.requests, actor);
return firstRequest(nextGroups, canAdminister)?.id ?? "";
});
} catch (err) {
setError(errorMessage(err, translateText(I18N.requestFailed)));
} finally {
setLoading(false);
}
}
async function loadDetails(requestId: string) {
const sequence = detailLoadSequence.current + 1;
detailLoadSequence.current = sequence;
setDetailsLoading(true);
const [summaryResult, notificationResult] = await Promise.allSettled([
schedulingSummary(settings, requestId),
listSchedulingNotifications(settings, requestId)
]);
if (detailLoadSequence.current !== sequence) return;
if (summaryResult.status === "fulfilled") {
setSummary(summaryResult.value);
setSummaryUnavailable(false);
} else {
setSummary(null);
setSummaryUnavailable(true);
}
if (notificationResult.status === "fulfilled") {
setNotifications(notificationResult.value.notifications);
setNotificationsUnavailable(false);
} else {
setNotifications([]);
setNotificationsUnavailable(true);
}
setDetailsLoading(false);
}
function beginCreate() {
requestNavigation(startCreate);
}
function startCreate() {
resetDraft();
setError("");
setSuccess("");
setEditingRequestId("");
setEditorMode("create");
}
function beginEdit(request: SchedulingRequest) {
requestNavigation(() => {
setTitle(request.title);
setDescription(request.description ?? "");
setLocation(request.location ?? "");
setCalendarId(request.calendar_id ?? "");
setCalendarEnabled(Boolean(request.calendar_integration_enabled));
setSlots(request.slots.map((slot) => ({
draftId: nextDraftId("slot"),
sourceId: slot.id,
revision: slot.revision,
label: slot.label,
description: slot.description,
start_at: localValue(new Date(slot.start_at)),
end_at: localValue(new Date(slot.end_at)),
timezone: slot.timezone,
metadata: slot.metadata
})));
setParticipants(request.participants.map((participant) => ({
draftId: nextDraftId("participant"),
sourceId: participant.id,
respondent_id: participant.respondent_id,
display_name: participant.display_name ?? "",
email: participant.email ?? "",
participant_type: normalizeParticipantType(participant.participant_type),
required: participant.required ?? true,
metadata: participant.metadata,
identityLocked: Boolean(participant.poll_invitation_id)
})));
setParticipantRosterVisible(request.participant_visibility === "names_and_statuses");
setNotifyOnAnswers(request.notify_on_answers);
setSingleChoice(request.single_choice);
setParticipantLimitEnabled(request.max_participants_per_option !== null);
setMaxParticipantsPerOption(request.max_participants_per_option ?? 1);
setAllowMaybe(request.allow_maybe);
setAllowComments(request.allow_comments);
setParticipantEmailRequired(request.participant_email_required);
setAnonymousPasswordProtectionEnabled(request.anonymous_password_protection_enabled);
setAnonymousPassword("");
setError("");
setSuccess("");
setEditingRequestId(request.id);
setDraftDirty(false);
setEditorMode("edit");
});
}
function discardEditor() {
if (saving) return;
requestDiscard(exitEditor);
}
function exitEditor() {
resetDraft();
setEditingRequestId("");
setEditorMode(null);
}
async function persistDraft(): Promise<boolean> {
if (!canCreateOrWrite || !editorMode) return false;
setError("");
if (!title.trim()) {
setError(I18N.titleRequired);
return false;
}
if (calendarEnabled && !calendarId) {
setError("i18n:govoplan-scheduling.select_a_calendar_or_turn_off_calendar_integration.cc3652a9");
return false;
}
if (slots.some((slot) => !slot.start_at || !slot.end_at || new Date(slot.end_at) <= new Date(slot.start_at))) {
setError("i18n:govoplan-scheduling.every_candidate_slot_must_end_after_it_starts.47836010");
return false;
}
const original = editorMode === "edit"
? requests.find((request) => request.id === editingRequestId)
: null;
if (
anonymousPasswordProtectionEnabled &&
anonymousPassword.length < 8 &&
!(original?.anonymous_password_protection_enabled && anonymousPassword.length === 0)
) {
setError(I18N.anonymousPasswordHelp);
return false;
}
setSaving(true);
try {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
const commonPayload = {
title: title.trim(),
description: description.trim() || null,
location: location.trim() || null,
participant_visibility: participantRosterVisible ? "names_and_statuses" : "aggregates_only",
notify_on_answers: notifyOnAnswers,
single_choice: singleChoice,
max_participants_per_option: participantLimitEnabled ? maxParticipantsPerOption : null,
allow_maybe: allowMaybe,
allow_comments: allowComments,
participant_email_required: participantEmailRequired,
anonymous_password_protection_enabled: anonymousPasswordProtectionEnabled,
...(anonymousPassword ? { anonymous_password: anonymousPassword } : {}),
calendar: {
enabled: calendarEnabled,
calendar_id: calendarEnabled ? calendarId : null,
freebusy_enabled: calendarEnabled,
tentative_holds_enabled: calendarEnabled,
create_event_on_decision: calendarEnabled
}
} as const;
let request: SchedulingRequest;
if (editorMode === "create") {
request = await createSchedulingRequest(settings, {
...commonPayload,
timezone,
status: "draft",
slots: slots.map((slot) => ({
label: slot.label.trim() || null,
description: slot.description ?? null,
start_at: isoFromLocal(slot.start_at),
end_at: isoFromLocal(slot.end_at),
timezone: slot.timezone || timezone,
location: location.trim() || null,
metadata: slot.metadata ?? {}
})),
participants: participants
.filter((participant) => participant.email.trim() || participant.display_name.trim())
.map((participant) => ({
respondent_id: participant.respondent_id ?? null,
display_name: participant.display_name.trim() || null,
email: participant.email.trim() || null,
required: participant.required,
participant_type: participant.participant_type,
metadata: participant.metadata ?? {}
})),
create_participant_invitations: true
});
setRequests((items) => [request, ...items]);
} else {
if (!original) {
setError(I18N.requestFailed);
return false;
}
request = await updateSchedulingRequest(settings, original.id, {
...commonPayload,
slots: slots.map((slot) => ({
...(slot.sourceId ? { id: slot.sourceId, revision: slot.revision } : {}),
label: slot.label.trim(),
description: slot.description ?? null,
start_at: isoFromLocal(slot.start_at),
end_at: isoFromLocal(slot.end_at),
timezone: slot.timezone || timezone,
location: location.trim() || null,
metadata: slot.metadata ?? {}
})),
participants: participants
.filter((participant) => participant.sourceId || participant.email.trim() || participant.display_name.trim())
.map((participant) => ({
...(participant.sourceId ? { id: participant.sourceId } : {}),
respondent_id: participant.respondent_id ?? null,
display_name: participant.display_name.trim() || null,
email: participant.email.trim() || null,
participant_type: participant.participant_type,
required: participant.required,
metadata: participant.metadata ?? {}
})),
create_participant_invitations: true
});
replaceRequest(request);
}
setSelectedId(request.id);
setSelectedRequestSearchParam(request.id);
setDraftDirty(false);
setEditingRequestId("");
setEditorMode(null);
await loadRequests(request.id);
return true;
} catch (err) {
setError(errorMessage(err, translateText(I18N.requestFailed)));
return false;
} finally {
setSaving(false);
}
}
async function runAction(action: () => Promise<{ request?: SchedulingRequest } | unknown>) {
if (!selected || !canManageSelected) return;
setSaving(true);
setError("");
setSuccess("");
try {
const response = await action();
const updated = isRequestEnvelope(response) ? response.request : null;
if (updated) replaceRequest(updated);
await loadRequests(selected.id);
await loadDetails(selected.id);
} catch (err) {
setError(errorMessage(err, translateText(I18N.requestFailed)));
} finally {
setSaving(false);
}
}
async function sendAvailability(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
await persistAvailability();
}
async function persistAvailability(): Promise<boolean> {
if (!selected || !selectedParticipant || !canRespond || availabilityLoading) return false;
const answers = selected.slots
.map((slot) => ({
slot_id: slot.id,
value: availability[slot.id],
option_revision: slot.revision
}))
.filter((answer): answer is {
slot_id: string;
value: SchedulingAvailabilityValue;
option_revision: string;
} => Boolean(answer.value));
if (!answers.length) {
setError(I18N.selectResponse);
return false;
}
setSaving(true);
setError("");
setSuccess("");
try {
const response = await submitSchedulingAvailability(settings, selected.id, {
answers,
...(selected.allow_comments ? { comment: availabilityComment.trim() || null } : {})
});
replaceRequest(response.request);
const persistedAvailability = Object.fromEntries(answers.map((answer) => [answer.slot_id, answer.value]));
const persistedComment = selected.allow_comments ? availabilityComment.trim() : "";
setAvailability(persistedAvailability);
setSavedAvailability(persistedAvailability);
setAvailabilityComment(persistedComment);
setSavedAvailabilityComment(persistedComment);
setSuccess(I18N.responseRecorded);
await loadRequests(selected.id);
return true;
} catch (err) {
setError(errorMessage(err, translateText(I18N.requestFailed)));
return false;
} finally {
setSaving(false);
}
}
return (
<main className="scheduling-page">
<section className="scheduling-workspace">
<div className="scheduling-workspace-layout">
<aside className="scheduling-request-sidebar">
<Card
title={I18N.requests}
actions={(
<div className="scheduling-sidebar-actions">
<IconButton
label={I18N.refresh}
icon={<RefreshCw aria-hidden="true" size={16} />}
onClick={() => requestNavigation(() => void loadRequests(selected?.id))}
disabled={loading} />
{canCreateOrWrite ? (
<Button type="button" variant="primary" onClick={beginCreate}>
<Plus aria-hidden="true" size={16} /> {I18N.add}
</Button>
) : null}
</div>
)}>
{loading ? <p className="scheduling-note" role="status">{I18N.loading}</p> : null}
<RequestGroup
title={I18N.myRequests}
requests={groups.owned}
selectedId={editorMode ? undefined : selected?.id}
actor={actor}
onSelect={selectRequest} />
<RequestGroup
title={I18N.invitedRequests}
requests={groups.invited}
selectedId={editorMode ? undefined : selected?.id}
actor={actor}
onSelect={selectRequest} />
{canAdminister && groups.other.length ? (
<RequestGroup
title={I18N.managedRequests}
requests={groups.other}
selectedId={editorMode ? undefined : selected?.id}
actor={actor}
onSelect={selectRequest} />
) : null}
</Card>
</aside>
<section className="scheduling-main-panel">
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
{success ? <DismissibleAlert tone="success" resetKey={success}>{success}</DismissibleAlert> : null}
{editorMode ? (
<div className="scheduling-editor-surface">
<header className="scheduling-page-header">
<div className="scheduling-page-title">
<CalendarCheck aria-hidden="true" size={20} />
<div>
<PageTitle>{editorMode === "create" ? I18N.newRequest : I18N.edit}</PageTitle>
<p>{editorMode === "create" ? I18N.addRequest : title}</p>
</div>
</div>
<div className="scheduling-page-actions">
<Button type="button" onClick={discardEditor} disabled={saving}>{I18N.discard}</Button>
<Button type="submit" form="scheduling-editor-form" variant="primary" disabled={saving || !canCreateOrWrite}>
<Save aria-hidden="true" size={16} /> {saving ? I18N.saving : I18N.save}
</Button>
</div>
</header>
<form
id="scheduling-editor-form"
className="scheduling-editor-scroll"
onSubmit={(event) => {
event.preventDefault();
void persistDraft();
}}>
<Card title={I18N.basicInformation}>
<div className="form-grid">
<FormField label={I18N.title}>
<input required maxLength={500} value={title} onChange={(event) => changeDraft(setTitle, event.target.value)} />
</FormField>
<FormField label={I18N.description}>
<textarea rows={4} value={description} onChange={(event) => changeDraft(setDescription, event.target.value)} />
</FormField>
<FormField label={I18N.location}>
<input maxLength={500} value={location} onChange={(event) => changeDraft(setLocation, event.target.value)} />
</FormField>
</div>
</Card>
<Card title={I18N.calendarIntegration}>
<ToggleSwitch
label={I18N.calendar}
help={I18N.calendarDescription}
checked={calendarEnabled}
disabled={!calendarIntegrationAvailable}
onChange={(checked) => {
setCalendarEnabled(checked);
if (!checked) setCalendarId("");
setDraftDirty(true);
}} />
{!calendarIntegrationAvailable ? <p className="scheduling-capability-note">{I18N.calendarUnavailable}</p> : null}
{calendarEnabled && CalendarPicker ? (
<CalendarPicker
settings={settings}
auth={auth}
value={calendarId}
onChange={(value) => {
setCalendarId(value);
setDraftDirty(true);
}}
label={I18N.calendar}
emptyLabel={I18N.selectCalendar}
required />
) : null}
</Card>
<EditableSlots
slots={slots}
disabled={!canCreateOrWrite}
reorderable={editorMode === "create"}
onAddBelow={(index) => {
setSlots((items) => editorMode === "edit"
? [...items, newSlot(items.length + 1, translateText)]
: insertAfter(items, index, newSlot(items.length + 1, translateText)));
setDraftDirty(true);
}}
onChange={changeSlotDraft}
onMove={(from, to) => {
setSlots((items) => moveItem(items, from, to));
setDraftDirty(true);
}}
onRemove={(index) => {
setSlots((items) => items.filter((_item, itemIndex) => itemIndex !== index));
setDraftDirty(true);
}} />
<EditableParticipants
participants={participants}
disabled={!canCreateOrWrite}
onAddBelow={(index) => {
setParticipants((items) => insertAfter(items, index, emptyParticipant()));
setDraftDirty(true);
}}
onChange={(index, patch) => {
setParticipants((items) => items.map((item, itemIndex) => itemIndex === index ? { ...item, ...patch } : item));
setDraftDirty(true);
}}
onRemove={(index) => {
setParticipants((items) => items.filter((_item, itemIndex) => itemIndex !== index));
setDraftDirty(true);
}} />
<SchedulingPrivacySettings
disabled={!canCreateOrWrite}
participantRosterVisible={participantRosterVisible}
onChange={(value) => {
setParticipantRosterVisible(value);
setDraftDirty(true);
}} />
<SchedulingSettings
disabled={!canCreateOrWrite}
policyLocked={participationPolicyLocked}
notifyOnAnswers={notifyOnAnswers}
singleChoice={singleChoice}
participantLimitEnabled={participantLimitEnabled}
maxParticipantsPerOption={maxParticipantsPerOption}
allowMaybe={allowMaybe}
allowComments={allowComments}
participantEmailRequired={participantEmailRequired}
anonymousPasswordProtectionEnabled={anonymousPasswordProtectionEnabled}
anonymousPassword={anonymousPassword}
passwordRequired={anonymousPasswordProtectionEnabled && !(requests.find((request) => request.id === editingRequestId)?.anonymous_password_protection_enabled)}
onChange={(field, value) => {
setDraftDirty(true);
if (field === "notifyOnAnswers") setNotifyOnAnswers(Boolean(value));
if (field === "singleChoice") setSingleChoice(Boolean(value));
if (field === "participantLimitEnabled") setParticipantLimitEnabled(Boolean(value));
if (field === "maxParticipantsPerOption") setMaxParticipantsPerOption(Number(value));
if (field === "allowMaybe") setAllowMaybe(Boolean(value));
if (field === "allowComments") setAllowComments(Boolean(value));
if (field === "participantEmailRequired") setParticipantEmailRequired(Boolean(value));
if (field === "anonymousPasswordProtectionEnabled") {
setAnonymousPasswordProtectionEnabled(Boolean(value));
if (!value) setAnonymousPassword("");
}
if (field === "anonymousPassword") setAnonymousPassword(String(value));
}} />
</form>
</div>
) : selected ? (
<div className="scheduling-detail">
<Card
title={selected.title}
actions={(
<div className="scheduling-actions">
{canEditSelected ? (
<Button type="button" onClick={() => beginEdit(selected)} disabled={saving}>
<Pencil aria-hidden="true" size={16} /> {I18N.edit}
</Button>
) : null}
{canManageSelected ? (
<LifecycleActions
request={selected}
saving={saving}
onOpen={() => void runAction(() => openSchedulingRequest(settings, selected.id))}
onClose={() => requestNavigation(() => void runAction(() => closeSchedulingRequest(settings, selected.id)))}
onReminder={() => void runAction(() => createSchedulingNotifications(settings, selected.id, "reminder"))} />
) : null}
</div>
)}>
<div className="scheduling-status-row">
<StatusBadge status={selected.status} label={requestStatusLabel(selected, actor)} />
</div>
{selected.description ? <p>{selected.description}</p> : null}
{selected.location ? <p><strong>{I18N.location}:</strong> {selected.location}</p> : null}
</Card>
<ParticipationStats
request={selected}
responseCount={summary?.poll_summary.response_count
?? selected.participant_aggregate.status_counts.responded
?? null}
loading={detailsLoading} />
{summaryUnavailable ? (
<DismissibleAlert tone="info" dismissible={false} compact>
{I18N.resultsUnavailable}
</DismissibleAlert>
) : null}
{selected.public_participation_policy_enforcement_available === false ? (
<DismissibleAlert tone="warning" dismissible={false} compact>
{selected.public_participation_policy_enforcement_reason || I18N.publicPolicyUnavailable}
</DismissibleAlert>
) : null}
{selectedParticipant && selected.status === "collecting" ? (
<Card
title={I18N.candidateAvailability}
actions={(
<Button
variant="primary"
type="submit"
form="scheduling-response-form"
disabled={saving || availabilityLoading || !canRespond || (selectedParticipant.status === "responded" && !selected.allow_participant_updates)}>
<Send aria-hidden="true" size={16} />
{selectedParticipant.status === "responded" ? I18N.updateResponse : I18N.sendResponse}
</Button>
)}>
<form
id="scheduling-response-form"
className="scheduling-response-card"
aria-busy={availabilityLoading}
onSubmit={(event) => void sendAvailability(event)}>
<p>{selectedParticipant.status === "responded"
? (selected.allow_participant_updates ? I18N.responseReplace : I18N.responseRecorded)
: I18N.invitationHelp}</p>
{selectedParticipant.status === "responded" && selected.allow_participant_updates ? (
<p className="scheduling-capability-note">{I18N.updateResponseHelp}</p>
) : null}
<div className="scheduling-response-grid">
{selected.slots.map((slot) => (
<label key={slot.id}>
<span><strong>{slot.label}</strong><small>{formatRange(slot.start_at, slot.end_at)}</small></span>
<select
value={availability[slot.id] ?? ""}
disabled={availabilityLoading || (selectedParticipant.status === "responded" && !selected.allow_participant_updates)}
onChange={(event) => changeAvailability(selected, slot.id, event.target.value as SchedulingAvailabilityValue | "")}>
<option value="">{I18N.chooseAvailability}</option>
<option value="available">{I18N.available}</option>
{selected.allow_maybe ? <option value="maybe">{I18N.maybe}</option> : null}
<option value="unavailable">{I18N.unavailable}</option>
</select>
</label>
))}
</div>
{selected.allow_comments ? (
<FormField label={I18N.comment} help={I18N.allowCommentsHelp}>
<textarea
rows={3}
maxLength={4000}
value={availabilityComment}
disabled={availabilityLoading || (selectedParticipant.status === "responded" && !selected.allow_participant_updates)}
onChange={(event) => setAvailabilityComment(event.target.value)} />
</FormField>
) : null}
</form>
</Card>
) : null}
<Card title={I18N.candidateSlots}>
<CandidateSlotsGrid
slots={selected.slots}
optionResultById={optionResultById}
showDecisionAction={canManageSelected}
decisionEnabled={selected.status === "closed"}
resultsAvailable={!summaryUnavailable && !detailsLoading}
saving={saving}
allowMaybe={selected.allow_maybe}
maxParticipantsPerOption={selected.max_participants_per_option}
onDecide={(slot) => void runAction(() => decideSchedulingRequest(settings, selected.id, {
slot_id: slot.id,
handoff_to_calendar: selected.create_calendar_event_on_decision
}))} />
</Card>
{selected.calendar_integration_enabled && canManageSelected && (showPlanningCalendarActions || showFinalCalendarAction || selected.calendar_event_id) ? (
<Card
title={I18N.calendarCoordination}
actions={(
<div className="scheduling-card-actions">
{showPlanningCalendarActions ? (
<Button
type="button"
disabled={saving || !canReadAvailability}
disabledReason={!canReadAvailability ? I18N.requiresAvailabilityRead : undefined}
onClick={() => void runAction(() => evaluateSchedulingFreeBusy(settings, selected.id))}>
<RefreshCw aria-hidden="true" size={16} /> {I18N.checkFreeBusy}
</Button>
) : null}
{showPlanningCalendarActions && selected.calendar_hold_enabled ? (
<Button
type="button"
disabled={saving || !canWriteCalendarEvent}
disabledReason={!canWriteCalendarEvent ? I18N.requiresEventWrite : undefined}
onClick={() => void runAction(() => createSchedulingHolds(settings, selected.id))}>
<Clock aria-hidden="true" size={16} /> {I18N.holds}
</Button>
) : null}
{showFinalCalendarAction ? (
<Button
type="button"
disabled={saving || !canWriteCalendarEvent}
disabledReason={!canWriteCalendarEvent ? I18N.requiresEventWrite : undefined}
onClick={() => void runAction(() => createSchedulingCalendarEvent(settings, selected.id))}>
<CalendarCheck aria-hidden="true" size={16} /> {I18N.finalEventLabel}
</Button>
) : null}
</div>
)}>
{CalendarPicker && selected.calendar_id ? (
<CalendarPicker
className="scheduling-selected-calendar"
settings={settings}
auth={auth}
value={selected.calendar_id}
onChange={() => undefined}
label={I18N.configuredCalendar}
disabled />
) : <p>{I18N.configuredCalendar}</p>}
</Card>
) : null}
<div className="scheduling-columns">
<Card title={I18N.participants}>
<div className="scheduling-status-row">
<span>{i18nMessage("i18n:govoplan-scheduling.value_participants.e776b092", { value0: selected.participant_aggregate.total })}</span>
{Object.entries(selected.participant_aggregate.status_counts)
.filter(([, count]) => count > 0)
.map(([status, count]) => (
<span key={status}><strong>{participantStatusLabel(status)}</strong> {count}</span>
))}
</div>
{selected.effective_participant_visibility === "names_and_statuses" ? (
<ParticipantsGrid participants={selected.participants} />
) : <p className="scheduling-capability-note">{I18N.participantRosterHidden}</p>}
</Card>
<Card title={I18N.notifications}>
{notificationsUnavailable ? (
<p className="scheduling-note">{I18N.notificationsUnavailable}</p>
) : notifications.length ? notifications.map((notification) => (
<div className="scheduling-compact-row" key={notification.id}>
<span>{notificationKindLabel(notification.event_kind)}</span>
<StatusBadge status={notification.status} label={notificationStatusLabel(notification.status)} />
</div>
)) : <p className="scheduling-note">{I18N.noNotifications}</p>}
</Card>
</div>
<Card title={I18N.whatActionsDo} collapsible collapseKey="scheduling-action-help">
<ul className="scheduling-action-help">
<li>i18n:govoplan-scheduling.open_starts_collecting_availability_responses.c8ec34e5</li>
<li>i18n:govoplan-scheduling.close_stops_accepting_new_availability_responses.a1d57519</li>
<li>i18n:govoplan-scheduling.free_busy_checks_each_candidate_slot_against_the_selecte.50f0371a</li>
<li>i18n:govoplan-scheduling.tentative_holds_create_one_provisional_calendar_event_pe.ff3f1884</li>
<li>i18n:govoplan-scheduling.the_final_event_is_created_only_for_the_selected_slot.e3621252</li>
<li>i18n:govoplan-scheduling.reminder_creates_a_notification_job_for_every_active_par.7ec68797</li>
</ul>
</Card>
</div>
) : <div className="scheduling-empty">{I18N.noRequest}</div>}
</section>
</div>
</section>
</main>
);
function changeDraft(setter: (value: string) => void, value: string) {
setter(value);
setDraftDirty(true);
}
function changeSlotDraft(index: number, field: "label" | "start_at" | "end_at", value: string) {
setSlots((items) => items.map((item, itemIndex) => {
if (itemIndex !== index) return item;
if (field === "start_at" && value && (!item.end_at || new Date(item.end_at) <= new Date(value))) {
return { ...item, start_at: value, end_at: addLocalMinutes(value, 60) };
}
return { ...item, [field]: value };
}));
setDraftDirty(true);
}
function changeAvailability(
request: SchedulingRequest,
slotId: string,
value: SchedulingAvailabilityValue | ""
) {
setAvailability((current) => applySchedulingAvailabilityChoice(
request.slots.map((slot) => slot.id),
current,
slotId,
value,
request.single_choice
));
}
function selectRequest(requestId: string) {
if (!editorMode && requestId === selected?.id) return;
requestNavigation(() => {
exitEditor();
setSelectedId(requestId);
setSelectedRequestSearchParam(requestId);
setError("");
setSuccess("");
});
}
function setSelectedRequestSearchParam(requestId: string) {
const next = new URLSearchParams(searchParams);
next.set("request_id", requestId);
setSearchParams(next, { replace: true });
}
function resetResponseDraft() {
setAvailability({ ...savedAvailability });
setAvailabilityComment(savedAvailabilityComment);
}
function replaceRequest(updated: SchedulingRequest) {
setRequests((items) => items.map((item) => item.id === updated.id ? updated : item));
}
function resetDraft() {
setTitle("");
setDescription("");
setLocation("");
setCalendarId("");
setCalendarEnabled(false);
setSlots(initialSlots(translateText));
setParticipants([emptyParticipant()]);
setParticipantRosterVisible(false);
setNotifyOnAnswers(true);
setSingleChoice(false);
setParticipantLimitEnabled(false);
setMaxParticipantsPerOption(1);
setAllowMaybe(true);
setAllowComments(false);
setParticipantEmailRequired(false);
setAnonymousPasswordProtectionEnabled(false);
setAnonymousPassword("");
setDraftDirty(false);
}
}
function RequestGroup({
title,
requests,
selectedId,
actor,
onSelect
}: {
title: string;
requests: SchedulingRequest[];
selectedId?: string;
actor: SchedulingActor;
onSelect: (requestId: string) => void;
}) {
return (
<section className="scheduling-request-group" aria-label={title}>
<h2>{title}</h2>
<div className="scheduling-list-group">
{requests.length ? (
<SelectionList label={title} className="scheduling-request-list">
{requests.map((request) => (
<SelectionListItem
key={request.id}
selected={selectedId === request.id}
className="scheduling-list-item"
onClick={() => onSelect(request.id)}>
<span><strong>{request.title}</strong><small>{formatRelevantDate(request)}</small></span>
<StatusBadge status={request.status} label={requestStatusLabel(request, actor)} />
</SelectionListItem>
))}
</SelectionList>
) : <p className="scheduling-list-empty">{I18N.noRequestsInGroup}</p>}
</div>
</section>
);
}
type SchedulingSettingField =
| "notifyOnAnswers"
| "singleChoice"
| "participantLimitEnabled"
| "maxParticipantsPerOption"
| "allowMaybe"
| "allowComments"
| "participantEmailRequired"
| "anonymousPasswordProtectionEnabled"
| "anonymousPassword";
function SchedulingPrivacySettings({
disabled,
participantRosterVisible,
onChange
}: {
disabled: boolean;
participantRosterVisible: boolean;
onChange: (value: boolean) => void;
}) {
return (
<Card title={I18N.participantPrivacy}>
<ToggleSwitch
label={I18N.participantRosterVisibility}
help={I18N.participantRosterVisibilityHelp}
checked={participantRosterVisible}
disabled={disabled}
onChange={onChange} />
</Card>
);
}
function SchedulingSettings({
disabled,
policyLocked,
notifyOnAnswers,
singleChoice,
participantLimitEnabled,
maxParticipantsPerOption,
allowMaybe,
allowComments,
participantEmailRequired,
anonymousPasswordProtectionEnabled,
anonymousPassword,
passwordRequired,
onChange
}: {
disabled: boolean;
policyLocked: boolean;
notifyOnAnswers: boolean;
singleChoice: boolean;
participantLimitEnabled: boolean;
maxParticipantsPerOption: number;
allowMaybe: boolean;
allowComments: boolean;
participantEmailRequired: boolean;
anonymousPasswordProtectionEnabled: boolean;
anonymousPassword: string;
passwordRequired: boolean;
onChange: (field: SchedulingSettingField, value: boolean | number | string) => void;
}) {
return (
<Card title={I18N.generalSettings}>
<div className="settings-grid">
<ToggleSwitch
label={I18N.notifyOnAnswers}
help={I18N.notifyOnAnswersHelp}
checked={notifyOnAnswers}
disabled={disabled}
onChange={(value) => onChange("notifyOnAnswers", value)} />
<ToggleSwitch
label={I18N.singleChoice}
help={I18N.singleChoiceHelp}
checked={singleChoice}
disabled={disabled || policyLocked}
onChange={(value) => onChange("singleChoice", value)} />
<ToggleSwitch
label={I18N.allowMaybe}
help={I18N.allowMaybeHelp}
checked={allowMaybe}
disabled={disabled || policyLocked}
onChange={(value) => onChange("allowMaybe", value)} />
<ToggleSwitch
label={I18N.allowComments}
help={I18N.allowCommentsHelp}
checked={allowComments}
disabled={disabled || policyLocked}
onChange={(value) => onChange("allowComments", value)} />
<ToggleSwitch
label={I18N.anonymousEmailRequired}
help={I18N.anonymousEmailRequiredHelp}
checked={participantEmailRequired}
disabled={disabled || policyLocked}
onChange={(value) => onChange("participantEmailRequired", value)} />
<div className="scheduling-setting-with-field">
<ToggleSwitch
label={I18N.maxParticipants}
help={I18N.maxParticipantsHelp}
checked={participantLimitEnabled}
disabled={disabled || policyLocked}
onChange={(value) => onChange("participantLimitEnabled", value)} />
<FormField label={I18N.maxParticipantsCount} help={I18N.maxParticipantsHelp}>
<input
type="number"
min={1}
step={1}
required={participantLimitEnabled}
disabled={disabled || policyLocked || !participantLimitEnabled}
value={maxParticipantsPerOption}
onChange={(event) => onChange("maxParticipantsPerOption", Math.max(1, event.target.valueAsNumber || 1))} />
</FormField>
</div>
<div className="scheduling-setting-with-field">
<ToggleSwitch
label={I18N.anonymousPasswordProtection}
help={I18N.anonymousPasswordProtectionHelp}
checked={anonymousPasswordProtectionEnabled}
disabled={disabled || policyLocked}
onChange={(value) => onChange("anonymousPasswordProtectionEnabled", value)} />
<FormField label={I18N.anonymousPassword} help={I18N.anonymousPasswordHelp}>
<PasswordField
autoComplete="new-password"
minLength={8}
maxLength={1024}
required={passwordRequired}
disabled={disabled || policyLocked || !anonymousPasswordProtectionEnabled}
value={anonymousPassword}
saved={anonymousPasswordProtectionEnabled && !passwordRequired}
onValueChange={(value) => onChange("anonymousPassword", value)} />
</FormField>
</div>
</div>
{policyLocked ? (
<DismissibleAlert tone="info" dismissible={false} compact>
{I18N.policyLocked}
</DismissibleAlert>
) : null}
</Card>
);
}
function ParticipationStats({
request,
responseCount,
loading
}: {
request: SchedulingRequest;
responseCount: number | null;
loading: boolean;
}) {
const total = request.participant_aggregate.total;
const knownResponseCount = responseCount ?? 0;
const awaiting = responseCount === null ? null : Math.max(0, total - knownResponseCount);
const rate = responseCount === null ? null : total ? Math.round((knownResponseCount / total) * 100) : 0;
return (
<Card title={I18N.participation}>
<div className="metric-grid inside">
<MetricCard label={I18N.participants} value={total} />
<MetricCard label={I18N.responses} value={loading ? "…" : responseCount ?? "—"} tone="good" />
<MetricCard label={I18N.awaiting} value={loading ? "…" : awaiting ?? "—"} tone={awaiting ? "warning" : "neutral"} />
<MetricCard label={I18N.participationRate} value={loading ? "…" : rate === null ? "—" : `${rate}%`} tone="info" />
</div>
</Card>
);
}
function LifecycleActions({
request,
saving,
onOpen,
onClose,
onReminder
}: {
request: SchedulingRequest;
saving: boolean;
onOpen: () => void;
onClose: () => void;
onReminder: () => void;
}) {
if (request.status === "draft") {
return <Button type="button" variant="primary" disabled={saving} onClick={onOpen}><Send aria-hidden="true" size={16} /> {I18N.openPoll}</Button>;
}
if (request.status === "collecting") {
return (
<div className="scheduling-actions">
<Button type="button" disabled={saving} onClick={onReminder}><Bell aria-hidden="true" size={16} /> {I18N.reminder}</Button>
<Button type="button" variant="primary" disabled={saving} onClick={onClose}><XCircle aria-hidden="true" size={16} /> {I18N.closePoll}</Button>
</div>
);
}
return null;
}
function EditableSlots({
slots,
disabled,
reorderable,
onAddBelow,
onChange,
onMove,
onRemove
}: {
slots: SlotDraft[];
disabled: boolean;
reorderable: boolean;
onAddBelow: (index: number) => void;
onChange: (index: number, field: "label" | "start_at" | "end_at", value: string) => void;
onMove: (from: number, to: number) => void;
onRemove: (index: number) => void;
}) {
const columns: DataGridColumn<SlotDraft>[] = [
{
id: "title",
header: I18N.title,
minWidth: 180,
resizable: true,
value: (slot) => slot.label,
render: (slot, index) => (
<input
required
maxLength={500}
disabled={disabled}
aria-label={I18N.title}
value={slot.label}
onChange={(event) => onChange(index, "label", event.target.value)} />
)
},
{
id: "start",
header: I18N.start,
minWidth: 280,
resizable: true,
value: (slot) => slot.start_at,
render: (slot, index) => (
<DateTimeField
required
disabled={disabled}
aria-label={I18N.start}
value={slot.start_at}
onChange={(value) => onChange(index, "start_at", value)} />
)
},
{
id: "end",
header: I18N.end,
minWidth: 280,
resizable: true,
value: (slot) => slot.end_at,
render: (slot, index) => (
<DateTimeField
required
disabled={disabled}
aria-label={I18N.end}
value={slot.end_at}
min={addLocalMinutes(slot.start_at, 1)}
onChange={(value) => onChange(index, "end_at", value)} />
)
},
{
id: "actions",
header: I18N.actions,
width: 184,
sticky: "end",
render: (_slot, index) => (
<DataGridRowActions
disabled={disabled}
reorderable={reorderable}
onAddBelow={() => onAddBelow(index)}
onMoveUp={reorderable && index > 0 ? () => onMove(index, index - 1) : undefined}
onMoveDown={reorderable && index < slots.length - 1 ? () => onMove(index, index + 1) : undefined}
onRemove={() => onRemove(index)}
removeDisabled={slots.length === 1}
addLabel={I18N.addSlot}
removeLabel={i18nMessage("i18n:govoplan-scheduling.remove_slot_value.3adc7576", { value0: index + 1 })} />
)
}
];
return (
<Card title={I18N.candidateSlots}>
<DataGrid
id="scheduling-create-candidate-slots-grid"
rows={slots}
columns={columns}
getRowKey={(slot) => slot.draftId}
initialFit="container" />
</Card>
);
}
function EditableParticipants({
participants,
disabled,
onAddBelow,
onChange,
onRemove
}: {
participants: ParticipantDraft[];
disabled: boolean;
onAddBelow: (index: number) => void;
onChange: (index: number, patch: Partial<ParticipantDraft>) => void;
onRemove: (index: number) => void;
}) {
const lockedIdentityHelpId = "scheduling-invited-participant-identity-help";
const columns: DataGridColumn<ParticipantDraft>[] = [
{
id: "name",
header: I18N.name,
minWidth: 180,
resizable: true,
value: (participant) => participant.display_name,
render: (participant, index) => (
<input
disabled={disabled || participant.identityLocked}
maxLength={500}
aria-describedby={participant.identityLocked ? lockedIdentityHelpId : undefined}
aria-label={I18N.name}
value={participant.display_name}
onChange={(event) => onChange(index, { display_name: event.target.value })} />
)
},
{
id: "email",
header: I18N.participantEmail,
minWidth: 260,
resizable: true,
value: (participant) => participant.email,
render: (participant, index) => (
<input
disabled={disabled || participant.identityLocked}
type="email"
maxLength={320}
aria-describedby={participant.identityLocked ? lockedIdentityHelpId : undefined}
aria-label={I18N.participantEmail}
value={participant.email}
onChange={(event) => onChange(index, { email: event.target.value })} />
)
},
{
id: "actions",
header: I18N.actions,
width: 112,
sticky: "end",
render: (_participant, index) => (
<DataGridRowActions
disabled={disabled}
reorderable={false}
onAddBelow={() => onAddBelow(index)}
onRemove={() => onRemove(index)}
addLabel={I18N.addParticipant}
removeLabel={i18nMessage("i18n:govoplan-scheduling.remove_participant_value.e55f2b70", { value0: index + 1 })} />
)
}
];
return (
<Card title={I18N.participants}>
{participants.some((participant) => participant.identityLocked) ? (
<p id={lockedIdentityHelpId} className="scheduling-capability-note">{I18N.invitedIdentityLocked}</p>
) : null}
<DataGrid
id="scheduling-create-participants-grid"
rows={participants}
columns={columns}
getRowKey={(participant) => participant.draftId}
initialFit="container"
emptyText={I18N.noParticipants}
emptyActionColumnId="actions"
emptyAction={<DataGridEmptyAction disabled={disabled} reorderable={false} onAdd={() => onAddBelow(-1)} label={I18N.addParticipant} />} />
</Card>
);
}
function CandidateSlotsGrid({
slots,
optionResultById,
showDecisionAction,
decisionEnabled,
resultsAvailable,
saving,
allowMaybe,
maxParticipantsPerOption,
onDecide
}: {
slots: SchedulingCandidateSlot[];
optionResultById: Map<string, SchedulingPollOptionResult>;
showDecisionAction: boolean;
decisionEnabled: boolean;
resultsAvailable: boolean;
saving: boolean;
allowMaybe: boolean;
maxParticipantsPerOption: number | null;
onDecide: (slot: SchedulingCandidateSlot) => void;
}) {
const columns: DataGridColumn<SchedulingCandidateSlot>[] = [
{
id: "title",
header: I18N.title,
minWidth: 190,
resizable: true,
value: (slot) => slot.label,
render: (slot) => (
<span className="scheduling-slot-title">
<strong>{slot.label}</strong>
{slot.freebusy_status ? <StatusBadge status={slot.freebusy_status} label={freeBusyLabel(slot.freebusy_status)} /> : null}
</span>
)
},
{ id: "start", header: I18N.start, minWidth: 160, resizable: true, value: (slot) => slot.start_at, render: (slot) => formatDateTime(slot.start_at, SHORT_DATE_TIME_OPTIONS) },
{ id: "end", header: I18N.end, minWidth: 160, resizable: true, value: (slot) => slot.end_at, render: (slot) => formatDateTime(slot.end_at, SHORT_DATE_TIME_OPTIONS) },
{
id: "available",
header: I18N.available,
width: 100,
align: "right",
value: (slot) => resultsAvailable
? optionResultById.get(slot.poll_option_id ?? `slot-${slot.position + 1}`)?.values.available ?? 0
: "—"
},
...(allowMaybe ? [{
id: "maybe",
header: I18N.maybe,
width: 90,
align: "right",
value: (slot) => resultsAvailable
? optionResultById.get(slot.poll_option_id ?? `slot-${slot.position + 1}`)?.values.maybe ?? 0
: "—"
} satisfies DataGridColumn<SchedulingCandidateSlot>] : []),
...(maxParticipantsPerOption !== null ? [{
id: "capacity",
header: I18N.capacity,
width: 100,
align: "right",
value: () => maxParticipantsPerOption
} satisfies DataGridColumn<SchedulingCandidateSlot>] : []),
{
id: "unavailable",
header: I18N.unavailable,
width: 110,
align: "right",
value: (slot) => resultsAvailable
? optionResultById.get(slot.poll_option_id ?? `slot-${slot.position + 1}`)?.values.unavailable ?? 0
: "—"
},
...(showDecisionAction ? [{
id: "actions",
header: I18N.actions,
width: 72,
sticky: "end",
render: (slot) => <TableActionGroup actions={[{
id: "decide",
label: i18nMessage("i18n:govoplan-scheduling.decide_on_value.196409cd", { value0: slot.label }),
icon: <Check aria-hidden="true" size={16} />,
disabled: saving || !decisionEnabled,
disabledReason: !decisionEnabled ? I18N.decideUnavailable : undefined,
onClick: () => onDecide(slot)
}]} />
} satisfies DataGridColumn<SchedulingCandidateSlot>] : [])
];
return (
<DataGrid
id="scheduling-candidate-slots-grid"
rows={slots}
columns={columns}
getRowKey={(slot) => slot.id}
initialFit="container" />
);
}
function ParticipantsGrid({ participants }: { participants: SchedulingParticipant[] }) {
const columns: DataGridColumn<SchedulingParticipant>[] = [
{ id: "name", header: I18N.name, minWidth: 180, resizable: true, value: (participant) => participant.display_name ?? "" },
{ id: "email", header: I18N.participantEmail, minWidth: 220, resizable: true, value: (participant) => participant.email ?? "" },
{
id: "status",
header: I18N.statusLabel,
width: 140,
value: (participant) => participantStatusLabel(participant.status),
render: (participant) => <StatusBadge status={participant.status} label={participantStatusLabel(participant.status)} />
}
];
return (
<DataGrid
id="scheduling-participants-grid"
rows={participants}
columns={columns}
getRowKey={(participant) => participant.id}
emptyText={I18N.noParticipants}
initialFit="container" />
);
}
function firstRequest(groups: SchedulingRequestGroups, includeManaged: boolean): SchedulingRequest | null {
return groups.owned[0] ?? groups.invited[0] ?? (includeManaged ? groups.other[0] : null) ?? null;
}
function requestStatusLabel(request: SchedulingRequest, actor: SchedulingActor): string {
const phase = schedulingSortPhase(request, actor);
if (phase === "past") return I18N.past;
if (phase === "determined") return I18N.determined;
if (phase === "closed") return I18N.closed;
if (phase === "answered") return I18N.answered;
if (!schedulingRequestIsOwned(request, actor) && schedulingParticipantForActor(request, actor)) return I18N.awaitingResponse;
return request.status === "draft" ? "i18n:govoplan-scheduling.draft.23d33e22" : I18N.open;
}
function freeBusyLabel(status: string): string {
if (status === "free") return I18N.free;
if (status === "busy") return I18N.busy;
if (status === "error") return "i18n:govoplan-scheduling.error.7f2f6a15";
return I18N.unchecked;
}
function participantStatusLabel(status: string): string {
return ({
invited: "i18n:govoplan-scheduling.invited.53469df1",
responded: "i18n:govoplan-scheduling.responded.4f218211",
declined: "i18n:govoplan-scheduling.declined.ff59b80f",
removed: "i18n:govoplan-scheduling.removed.b5e77c5c",
draft: "i18n:govoplan-scheduling.draft.23d33e22"
} as Record<string, string>)[status] ?? status;
}
function notificationKindLabel(kind: string): string {
return ({
invitation: "i18n:govoplan-scheduling.invitation.6306ef74",
reminder: "i18n:govoplan-scheduling.reminder.b87a1929",
decision: "i18n:govoplan-scheduling.decision.7f59a1f1",
cancellation: "i18n:govoplan-scheduling.cancellation.319aaae4"
} as Record<string, string>)[kind] ?? kind;
}
function notificationStatusLabel(status: string): string {
return ({
pending: "i18n:govoplan-scheduling.pending.96f608c1",
queued: "i18n:govoplan-scheduling.queued.6a599877",
sending: "i18n:govoplan-scheduling.sending.ceafde86",
sent: "i18n:govoplan-scheduling.sent.35f49dcf",
failed: "i18n:govoplan-scheduling.failed.09fef5d8",
skipped: "i18n:govoplan-scheduling.skipped.5a000ad7"
} as Record<string, string>)[status] ?? status;
}
function isRequestEnvelope(value: unknown): value is { request: SchedulingRequest } {
return Boolean(value && typeof value === "object" && "request" in value);
}
function initialSlots(translateText: (value: string) => string): SlotDraft[] {
return [newSlot(1, translateText)];
}
function newSlot(position: number, translateText: (value: string) => string): SlotDraft {
const start = new Date(Date.now() + 24 * 60 * 60 * 1000);
start.setMinutes(0, 0, 0);
const end = new Date(start.getTime() + 60 * 60 * 1000);
return {
draftId: nextDraftId("slot"),
label: translateText(i18nMessage("i18n:govoplan-scheduling.option_value.3f643dc0", { value0: position })),
start_at: localValue(start),
end_at: localValue(end),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
metadata: {}
};
}
function emptyParticipant(): ParticipantDraft {
return {
draftId: nextDraftId("participant"),
display_name: "",
email: "",
participant_type: "external",
required: true,
metadata: {}
};
}
function normalizeParticipantType(value: string | null): ParticipantDraft["participant_type"] {
return value === "internal" || value === "resource" ? value : "external";
}
let draftIdSequence = 0;
function nextDraftId(prefix: string): string {
draftIdSequence += 1;
return `${prefix}-${draftIdSequence}`;
}
function insertAfter<T>(items: T[], index: number, item: T): T[] {
const insertionIndex = Math.min(items.length, Math.max(0, index + 1));
return [...items.slice(0, insertionIndex), item, ...items.slice(insertionIndex)];
}
function moveItem<T>(items: T[], from: number, to: number): T[] {
if (from === to || from < 0 || to < 0 || from >= items.length || to >= items.length) return items;
const next = [...items];
const [item] = next.splice(from, 1);
next.splice(to, 0, item);
return next;
}
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 addLocalMinutes(value: string, minutes: number): string {
if (!value) return "";
const date = new Date(value);
if (!Number.isFinite(date.getTime())) return value;
return localValue(new Date(date.getTime() + minutes * 60 * 1000));
}
function isoFromLocal(value: string): string {
return new Date(value).toISOString();
}
function availabilityValuesEqual(
left: Record<string, SchedulingAvailabilityValue | "">,
right: Record<string, SchedulingAvailabilityValue | "">
): boolean {
const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
return Array.from(keys).every((key) => (left[key] ?? "") === (right[key] ?? ""));
}
const SHORT_DATE_TIME_OPTIONS: FormatDateTimeOptions = {
year: undefined,
month: undefined,
day: undefined,
hour: undefined,
minute: undefined,
timeZoneName: undefined,
dateStyle: "short",
timeStyle: "short"
};
function formatRange(start: string, end: string): string {
return `${formatDateTime(start, SHORT_DATE_TIME_OPTIONS)} ${formatDateTime(end, SHORT_DATE_TIME_OPTIONS)}`;
}
function formatRelevantDate(request: SchedulingRequest): string {
const timestamp = schedulingRelevantTimestamp(request);
return Number.isFinite(timestamp) ? formatDateTime(new Date(timestamp).toISOString(), SHORT_DATE_TIME_OPTIONS) : "";
}
function errorMessage(error: unknown, fallback: string): string {
return error instanceof Error && error.message ? error.message : fallback;
}