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

2098 lines
86 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 { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import { useSearchParams } from "react-router-dom";
import {
Bell,
CalendarCheck,
Check,
Clock,
Copy,
Link2Off,
Pencil,
Plus,
RefreshCw,
Save,
Send,
XCircle
} from "lucide-react";
import {
Button,
Card,
ConfirmDialog,
DataGrid,
DataGridRowActions,
DateTimeField,
DismissibleAlert,
FormField,
MetricCard,
IconButton,
PageTitle,
PasswordField,
PeoplePicker,
formatDateTime,
SelectionList,
SelectionListItem,
TableActionGroup,
ToggleSwitch,
StatusBadge,
hasScope,
i18nMessage,
isApiError,
usePlatformLanguage,
usePlatformUiCapability,
useUnsavedChanges,
useUnsavedDraftGuard,
type ApiSettings,
type AuthInfo,
type CalendarPickerUiCapability,
type DataGridColumn,
type FormatDateTimeOptions,
type PeoplePickerItem,
type PeoplePickerSearch
} from "@govoplan/core-webui";
import {
closeSchedulingRequest,
createSchedulingCalendarEvent,
createSchedulingHolds,
createSchedulingNotifications,
createSchedulingRequest,
decideSchedulingRequest,
evaluateSchedulingFreeBusy,
getSchedulingAvailabilityResponse,
issueSchedulingParticipantInvitation,
listSchedulingNotifications,
listSchedulingRequests,
openSchedulingRequest,
revokeSchedulingParticipantInvitation,
searchSchedulingPeople,
schedulingSummary,
submitSchedulingAvailability,
updateSchedulingRequest,
type SchedulingCandidateSlot,
type SchedulingAvailabilityValue,
type SchedulingInvitationActionResponse,
type SchedulingNotification,
type SchedulingParticipant,
type SchedulingPollOptionResult,
type SchedulingRequest,
type SchedulingSummaryResponse
} from "../../api/scheduling";
import {
applySchedulingAvailabilityChoice,
groupSchedulingRequests,
schedulingParticipantForActor,
participantDraftFromResponse,
participantDraftsFromPicker,
participantPayload,
schedulingInvitationActionBlocks,
schedulingPublicInvitationUrl,
schedulingRelevantTimestamp,
schedulingRequestIsOwned,
schedulingSortPhase,
type SchedulingActor,
type SchedulingInvitationActionBlock,
type SchedulingParticipantDraft,
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 = SchedulingParticipantDraft;
type InvitationRevokeTarget = {
requestId: string;
participant: SchedulingParticipant;
};
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",
cancellationNoticeExpired: "i18n:govoplan-scheduling.the_cancellation_notice_has_expired_a_new_link_cannot_be_issued.9c6ccc7c",
checkFreeBusy: "i18n:govoplan-scheduling.check_free_busy.e9700e00",
chooseAvailability: "i18n:govoplan-scheduling.choose_availability.ac95b8f6",
clipboardUnavailable: "i18n:govoplan-scheduling.the_invitation_link_could_not_be_copied_check_browser_clipboard_permissions_and_try_again.a8b17cbc",
closePoll: "i18n:govoplan-scheduling.close_poll.a6a18916",
closed: "i18n:govoplan-scheduling.closed.88d86b77",
copyInvitationLink: "i18n:govoplan-scheduling.copy_a_fresh_invitation_link_for_value0.e3799c79",
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",
invitationDeliveryUnavailable: "i18n:govoplan-scheduling.automatic_invitation_delivery_is_unavailable_copy_the_link_instead.4e39d0b3",
invitationDeliveryFailed: "i18n:govoplan-scheduling.invitation_delivery_failed_the_link_was_created_but_was_not_delivered.8db0c306",
invitationDeliveryRequested: "i18n:govoplan-scheduling.invitation_delivery_requested.1aaa78ba",
invitationChanged: "i18n:govoplan-scheduling.this_invitation_changed_the_request_was_reloaded_try_again.c7095533",
invitedRequests: "i18n:govoplan-scheduling.scheduling_requests_for_me.1d521aba",
invitationLinkCopied: "i18n:govoplan-scheduling.invitation_link_copied.332973ec",
invitationLinkRevoked: "i18n:govoplan-scheduling.invitation_link_revoked.c7dd20d4",
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",
noActiveInvitation: "i18n:govoplan-scheduling.no_active_invitation_link_to_revoke.4ad0f0cc",
noDeliveryTarget: "i18n:govoplan-scheduling.this_participant_has_no_deliverable_email_address_or_account.dbe14180",
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",
participant: "i18n:govoplan-scheduling.participant.554f4235",
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",
participantPickerHelp: "i18n:govoplan-scheduling.search_visible_accounts_and_contacts_or_add_an_external_perso.877f6b44",
allowExternalParticipants: "i18n:govoplan-scheduling.allow_external_participants.a9efcb52",
allowExternalParticipantsHelp: "i18n:govoplan-scheduling.when_enabled_people_outside_the_configured_accounts_and.78829735",
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",
reloadInvitation: "i18n:govoplan-scheduling.reload_the_request_before_changing_this_invitation.9e685df4",
reminder: "i18n:govoplan-scheduling.send_reminder.cf5eb3bf",
revokeInvitation: "i18n:govoplan-scheduling.revoke_the_invitation_link_for_value0.15a9c9fa",
revokeInvitationConfirm: "i18n:govoplan-scheduling.revoke_the_current_invitation_link_for_value0_it_will_stop_working_immediately.3cdc5817",
revokeInvitationLabel: "i18n:govoplan-scheduling.revoke_invitation_link.87bf89cf",
revokeLink: "i18n:govoplan-scheduling.revoke_link.da371ee1",
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",
sendInvitation: "i18n:govoplan-scheduling.send_a_fresh_invitation_to_value0.fd8d9dea",
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[]>([]);
const [allowExternalParticipants, setAllowExternalParticipants] = useState(true);
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 [revokeInvitationTarget, setRevokeInvitationTarget] = useState<InvitationRevokeTarget | null>(null);
const [invitationActionClock, setInvitationActionClock] = useState(() => new Date());
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)
);
const participantSearch = useCallback<PeoplePickerSearch>(
(query, options) => searchSchedulingPeople(settings, query, options.limit, options.signal),
[settings]
);
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(() => {
setInvitationActionClock(new Date());
if (selected?.status !== "cancelled" || !selected.cancellation_notice_until) return undefined;
const expiresAt = Date.parse(selected.cancellation_notice_until);
if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) return undefined;
let timer: number | undefined;
const scheduleExpiryRefresh = () => {
const remaining = expiresAt - Date.now();
if (remaining <= 0) {
setInvitationActionClock(new Date());
return;
}
timer = window.setTimeout(
scheduleExpiryRefresh,
Math.min(remaining + 25, 2_147_483_647)
);
};
scheduleExpiryRefresh();
return () => {
if (timer !== undefined) window.clearTimeout(timer);
};
}, [selected?.cancellation_notice_until, selected?.status]);
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) => (
participantDraftFromResponse(participant, nextDraftId("participant"))
)));
setAllowExternalParticipants(request.allow_external_participants);
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,
allow_external_participants: allowExternalParticipants,
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.map(participantPayload)
});
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.map(participantPayload)
});
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 runParticipantInvitationAction(
requestId: string,
participant: SchedulingParticipant,
action: "copy" | "send" | "revoke"
): Promise<boolean> {
const request = requests.find((item) => item.id === requestId);
if (!request || !(canAdminister || (canWrite && schedulingRequestIsOwned(request, actor)))) return false;
if (!participant.revision) {
setError(I18N.reloadInvitation);
return false;
}
let succeeded = false;
let failureMessage = "";
let completionMessage = "";
setSaving(true);
setError("");
setSuccess("");
try {
let copied = true;
let result: SchedulingInvitationActionResponse;
if (action === "copy") {
const copyResult = await copySchedulingInvitationLink(
issueSchedulingParticipantInvitation(
settings,
requestId,
participant.id,
participant.revision,
action
)
);
result = copyResult.response;
copied = copyResult.copied;
} else if (action === "send") {
result = await issueSchedulingParticipantInvitation(
settings,
requestId,
participant.id,
participant.revision,
action
);
} else {
result = await revokeSchedulingParticipantInvitation(
settings,
requestId,
participant.id,
participant.revision
);
}
if (action === "copy") {
if (!copied) {
throw new Error(translateText(I18N.clipboardUnavailable));
}
completionMessage = I18N.invitationLinkCopied;
} else if (action === "send") {
if (["failed", "skipped"].includes(result.status)) {
throw new Error(translateText(I18N.invitationDeliveryFailed));
}
completionMessage = I18N.invitationDeliveryRequested;
} else {
completionMessage = I18N.invitationLinkRevoked;
}
succeeded = true;
} catch (err) {
if (isApiError(err, 409)) {
failureMessage = I18N.invitationChanged;
} else {
failureMessage = errorMessage(err, translateText(I18N.requestFailed));
}
} finally {
await loadRequests(requestId);
await loadDetails(requestId);
setSaving(false);
if (failureMessage) setError(failureMessage);
if (completionMessage) setSuccess(completionMessage);
}
return succeeded;
}
async function confirmRevokeInvitation() {
if (!revokeInvitationTarget) return;
const target = revokeInvitationTarget;
setRevokeInvitationTarget(null);
await runParticipantInvitationAction(target.requestId, target.participant, "revoke");
}
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 || saving} />
{canCreateOrWrite ? (
<Button type="button" variant="primary" onClick={beginCreate} disabled={saving}>
<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}
disabled={saving}
onSelect={selectRequest} />
<RequestGroup
title={I18N.invitedRequests}
requests={groups.invited}
selectedId={editorMode ? undefined : selected?.id}
actor={actor}
disabled={saving}
onSelect={selectRequest} />
{canAdminister && groups.other.length ? (
<RequestGroup
title={I18N.managedRequests}
requests={groups.other}
selectedId={editorMode ? undefined : selected?.id}
actor={actor}
disabled={saving}
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
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}
allowExternalParticipants={allowExternalParticipants}
search={participantSearch}
onAllowExternalParticipantsChange={(value) => {
setAllowExternalParticipants(value);
setDraftDirty(true);
}}
onChange={(value) => {
setParticipants((current) => participantDraftsFromPicker(
value,
current,
() => nextDraftId("participant")
));
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
request={selected}
participants={selected.participants}
canManage={canManageSelected}
saving={saving}
now={invitationActionClock}
onCopy={(participant) => void runParticipantInvitationAction(selected.id, participant, "copy")}
onSend={(participant) => void runParticipantInvitationAction(selected.id, participant, "send")}
onRevoke={(participant) => setRevokeInvitationTarget({
requestId: selected.id,
participant
})} />
) : <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>
<ConfirmDialog
open={Boolean(revokeInvitationTarget)}
title={I18N.revokeInvitationLabel}
message={i18nMessage(I18N.revokeInvitationConfirm, {
value0: participantDisplayLabel(revokeInvitationTarget?.participant ?? null)
})}
confirmLabel={I18N.revokeLink}
tone="danger"
busy={saving}
onCancel={() => setRevokeInvitationTarget(null)}
onConfirm={() => void confirmRevokeInvitation()} />
</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([]);
setAllowExternalParticipants(true);
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,
disabled,
onSelect
}: {
title: string;
requests: SchedulingRequest[];
selectedId?: string;
actor: SchedulingActor;
disabled: boolean;
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"
disabled={disabled}
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,
allowExternalParticipants,
search,
onAllowExternalParticipantsChange,
onChange
}: {
participants: ParticipantDraft[];
disabled: boolean;
allowExternalParticipants: boolean;
search: PeoplePickerSearch;
onAllowExternalParticipantsChange: (value: boolean) => void;
onChange: (participants: PeoplePickerItem[]) => void;
}) {
return (
<Card title={I18N.participants}>
{participants.some((participant) => participant.identityLocked) ? (
<p className="scheduling-capability-note">{I18N.invitedIdentityLocked}</p>
) : null}
<ToggleSwitch
label={I18N.allowExternalParticipants}
help={I18N.allowExternalParticipantsHelp}
checked={allowExternalParticipants}
disabled={disabled}
onChange={onAllowExternalParticipantsChange} />
<PeoplePicker
id="scheduling-participant-picker"
label={I18N.addParticipant}
help={I18N.participantPickerHelp}
selectedLabel={I18N.participants}
value={participants}
search={search}
disabled={disabled}
allowManualExternal={allowExternalParticipants}
manualEmailRequired
onChange={onChange} />
</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({
request,
participants,
canManage,
saving,
now,
onCopy,
onSend,
onRevoke
}: {
request: SchedulingRequest;
participants: SchedulingParticipant[];
canManage: boolean;
saving: boolean;
now: Date;
onCopy: (participant: SchedulingParticipant) => void;
onSend: (participant: SchedulingParticipant) => void;
onRevoke: (participant: SchedulingParticipant) => void;
}) {
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)} />
},
...(canManage ? [{
id: "actions",
header: I18N.actions,
width: 132,
sticky: "end",
align: "right",
render: (participant) => {
const label = participantDisplayLabel(participant);
const blocks = schedulingInvitationActionBlocks(request, participant, now);
const copyDisabledReason = invitationActionDisabledReason(blocks.copy, request);
const deliveryDisabledReason = invitationActionDisabledReason(blocks.send, request);
const revokeDisabledReason = invitationActionDisabledReason(blocks.revoke, request);
return (
<TableActionGroup
minimumSlots={3}
actions={[
{
id: "copy-invitation",
label: i18nMessage(I18N.copyInvitationLink, { value0: label }),
icon: <Copy aria-hidden="true" size={16} />,
disabled: saving || Boolean(copyDisabledReason),
disabledReason: copyDisabledReason,
onClick: () => onCopy(participant)
},
{
id: "send-invitation",
label: i18nMessage(I18N.sendInvitation, { value0: label }),
icon: <Send aria-hidden="true" size={16} />,
disabled: saving || Boolean(deliveryDisabledReason),
disabledReason: deliveryDisabledReason,
onClick: () => onSend(participant)
},
{
id: "revoke-invitation",
label: i18nMessage(I18N.revokeInvitation, { value0: label }),
icon: <Link2Off aria-hidden="true" size={16} />,
variant: "danger",
disabled: saving || Boolean(revokeDisabledReason),
disabledReason: revokeDisabledReason,
onClick: () => onRevoke(participant)
}
]} />
);
}
} satisfies DataGridColumn<SchedulingParticipant>] : [])
];
return (
<DataGrid
id="scheduling-participants-grid"
rows={participants}
columns={columns}
getRowKey={(participant) => participant.id}
emptyText={I18N.noParticipants}
initialFit="container" />
);
}
function invitationActionDisabledReason(
block: SchedulingInvitationActionBlock | null,
request: SchedulingRequest
): string | undefined {
if (block === "participation_policy_unavailable") {
return request.public_participation_policy_enforcement_reason || I18N.publicPolicyUnavailable;
}
if (block === "cancellation_notice_expired") return I18N.cancellationNoticeExpired;
if (block === "delivery_unavailable") return I18N.invitationDeliveryUnavailable;
if (block === "no_delivery_target") return I18N.noDeliveryTarget;
if (block === "no_active_invitation") return I18N.noActiveInvitation;
if (block === "participant_revision_unavailable") return I18N.reloadInvitation;
return undefined;
}
function participantDisplayLabel(participant: SchedulingParticipant | null): string {
return participant?.display_name?.trim() || participant?.email?.trim() || I18N.participant;
}
async function copySchedulingInvitationLink(
responsePromise: Promise<SchedulingInvitationActionResponse>
): Promise<{ response: SchedulingInvitationActionResponse; copied: boolean }> {
if (
typeof navigator !== "undefined" &&
navigator.clipboard?.write &&
typeof ClipboardItem !== "undefined"
) {
try {
const content = responsePromise.then((response) => {
const value = response.action_url
? schedulingPublicInvitationUrl(response.action_url, window.location.origin)
: null;
return new Blob([value ?? ""], { type: "text/plain" });
}).catch(() => new Blob([], { type: "text/plain" }));
await navigator.clipboard.write([new ClipboardItem({ "text/plain": content })]);
const response = await responsePromise;
const copied = Boolean(
response.action_url &&
schedulingPublicInvitationUrl(response.action_url, window.location.origin)
);
return { response, copied };
} catch {
// The request result is reused below; no second invitation is issued.
}
}
const response = await responsePromise;
const value = response.action_url
? schedulingPublicInvitationUrl(response.action_url, window.location.origin)
: null;
if (!value) return { response, copied: false };
try {
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value);
return { response, copied: true };
}
} catch {
// The caller reports the bounded clipboard failure without exposing the URL.
}
return { response, copied: false };
}
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",
participant_removed: "i18n:govoplan-scheduling.participant_removed.0cf4ec4c",
participant_replaced: "i18n:govoplan-scheduling.participant_replaced.2623752d"
} 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: {}
};
}
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;
}