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; }; 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("calendar.picker"); const CalendarPicker = calendarPickerCapability?.CalendarPicker; const [requests, setRequests] = useState([]); const [selectedId, setSelectedId] = useState(""); const [editorMode, setEditorMode] = useState(null); const [editingRequestId, setEditingRequestId] = useState(""); const [summary, setSummary] = useState(null); const [notifications, setNotifications] = useState([]); 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(() => initialSlots(translateText)); const [participants, setParticipants] = useState([]); 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>({}); const [savedAvailability, setSavedAvailability] = useState>({}); 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(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(() => ({ 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(); 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( (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 { 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 { 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) { event.preventDefault(); await persistAvailability(); } async function persistAvailability(): Promise { 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 (
)}> {loading ?

{I18N.loading}

: null} {canAdminister && groups.other.length ? ( ) : null}
{error ? {error} : null} {success ? {success} : null} {editorMode ? (
{ event.preventDefault(); void persistDraft(); }}>
changeDraft(setTitle, event.target.value)} />