2098 lines
86 KiB
TypeScript
2098 lines
86 KiB
TypeScript
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;
|
||
}
|